website: prepare the moving laptop screen - the cut-out frame and the clip builder
gates / gates (push) Failing after 14m28s
gates / gates (push) Failing after 14m28s
The operator asked for the laptop's screen to show a few seconds of real apps
running. His decision: he records the raw clips, the assembly is built here. So no
Playwright and no new dependency - ffmpeg 7.1.5 is already on DooPlex.
NOT ON THE PAGE YET. The live hero still shows the still picture; this commit is the
two finished pieces plus the measurements behind the format choice.
scripts/make_hero_clip.py
NN-appname.ext in -> scaled and centre-cropped to the screen's 1.626:1, crossfaded,
out as mp4 (h264) + webm (VP9) + a webp poster. Runs on DooPlex; Windows has no
ffmpeg. Not part of any build, gate or deploy: the site has no build step, so the
output is committed like any other picture. Proven end to end against six
placeholder scenes built from the repo's own app screenshots (21s loop -> mp4
480 KB, webm 609 KB, poster 83 KB).
WHY NOT A GIF - measured, six crossfaded scenes:
904x556 mp4 331 KB | webm 411 KB | GIF 2676 KB for HALF the length
1130x696 mp4 775 KB | webm 1196 KB | webp 2551 KB
A GIF is ~11x the mp4 for the same seconds and dithers to 256 colours on a dark UI.
website/assets/hero-laptop-frame.webp
The laptop render with its SCREEN CUT OUT to transparency, so a clip plays under the
bezel with the glass reflection over it. The reflection is PAINTED: lifting the
bright pixels out of the AI-drawn screen dragged a ghost of the drawn UI along.
Hole: left 11.041% top 12.345% width 79.817% height 64.305%, ratio 1.626:1. Two
pixels of slack - a tight rect showed the drawn sidebar's highlight, a loose one
showed page background at the top left; both were seen in the browser.
The page will load it as <video muted loop playsinline preload="none"> with the first
frame as poster, started only on scroll-into-view and never under
prefers-reduced-motion. First paint: frame 59 KB + poster ~80 KB against today's single
57 KB still; the ~330 KB clip comes after, or never if the visitor does not scroll
there. No "loading" graphic needed - the poster IS the loading state.
The recording brief is in E:\felhom-video\README.txt (outside the repo: the raw files
are large and the repo is public).
This commit is contained in:
@@ -172,6 +172,7 @@
|
||||
| Workspace installer | scripts/install_workspace.py | Lays down the workspace-root `CLAUDE.md` (canonical source: `documentation/runbooks/workspace-CLAUDE.md`) and the `InstructionsLoaded` hook from `workspace/hooks/`. **Unlike `install_skills.py` the targets are LIVE CONFIG, not disposable**: timestamped backup before every write, `settings.json` MERGED (owns exactly one key, `hooks.InstructionsLoaded`), a diverged CLAUDE.md reported not silently resolved, an unparseable settings.json REFUSED. Idempotent — a second run writes nothing. |
|
||||
| Rule-firing report | scripts/rules_report.py | Reads the `InstructionsLoaded` hook log and cross-references every `.claude/rules/*.md` in the workspace. **The column that matters is the empty one** — a rule that has never fired is mis-globbed or dead (built-but-never-wired, applied to instructions). Prints each silent rule's `paths:` so "wrong glob" is distinguishable from "quiet month". Caveat: the log only covers since the hook was armed. |
|
||||
| Gate script | scripts/site_gates.py | Byte-level mechanical gates (BOM, emoji codepoint ranges, nav/footer diff, analytics, banned tokens, cache-bust); run `python scripts/site_gates.py` after ANY website change; non-zero exit on failure. |
|
||||
| Hero clip builder | scripts/make_hero_clip.py | Assembles the home page's laptop clip from the operator's raw app recordings: scale+crop each to the screen's 1.626:1, crossfade, encode mp4 (h264) + webm (VP9) + a webp poster. **Runs on DooPlex** - Windows has no ffmpeg. NOT part of any build, gate or deploy: the site has no build step, so the output is committed like any other picture. Measured 2026-10-12: GIF is 11x the mp4 for the same seconds and dithers to 256 colours, hence mp4+webm. The frame it plays under is `website/assets/hero-laptop-frame.webp`, whose screen is cut out; the hole's position is in the CSS as percentages. |
|
||||
| Fetch-validate-install (shell) | scripts/felhom-host-install.sh `step_agent_install` (~L1108) | `fetch_raw` to mktemp → syntax-check (`bash -n`) → `install -m0755 -o root -g root` → only then activate; guarded-mkfs wrapper installed BEFORE the sudoers that references it (ordering is the safety property). All mutations through `run()` (dry-run aware). |
|
||||
| Install-profile gate (shell) | scripts/felhom-host-install.sh `--mode appliance\|byo` (GL-2, v1.10.0) | Mandatory-flag profile (no default), refusals at argv time BEFORE any prompt/step, risky step gated at its CALL SITE (one auditable place — never a branch inside the step), mode persisted to state.json + resume-mismatch refusal, `FELHOM_INSTALL_STATE_DIR` override for harness isolation. Harness: scripts/hostinstall-mode-harness.sh (static refusal matrix + grep-invariants + PVE dry-transcript tier; red-proofs run against a mutated scratch copy). |
|
||||
| Disclosure↔uninstall parity (shell) | scripts/felhom-host-install.sh `_uninstall_statement` + harness GL4-D (v1.11.0) | Every host artifact the byo disclosure names must be removed OR explicitly listed KEPT by `run_uninstall`; the harness greps the parity (token list). New install-time artifact ⇒ add its removal + disclosure line + parity token in the SAME commit. Drive data rule: plain `umount` only, never `-l`/`-f`, never any format op under /mnt/felhom-drives. |
|
||||
|
||||
@@ -1,3 +1,23 @@
|
||||
## 2026-10-12 (evening) — make_hero_clip.py: the home page's laptop clip
|
||||
|
||||
- `scripts/make_hero_clip.py` assembles the hero clip from the operator's raw app recordings:
|
||||
`NN-appname.ext` in, scaled and centre-cropped to the laptop screen's 1.626:1, crossfaded, out as
|
||||
**mp4 (h264) + webm (VP9) + a webp poster**. Runs on DooPlex; Windows has no ffmpeg. It is not part
|
||||
of any build, gate or deploy — the site has no build step, so its output is committed like any
|
||||
other picture.
|
||||
- **Why mp4+webm and not a GIF**, measured on DooPlex 2026-10-12 with six crossfaded scenes:
|
||||
|
||||
| | 11.4s @ 904×556 | 11.4s @ 1130×696 |
|
||||
|---|---:|---:|
|
||||
| mp4 (h264 crf30) | **331 KB** | 775 KB |
|
||||
| webm (VP9 crf44) | 411 KB | 1 196 KB |
|
||||
| animated webp | — | 2 551 KB |
|
||||
| GIF | **2 676 KB for 6s** | — |
|
||||
|
||||
A GIF is ~11× the mp4 for the same seconds, and dithers to 256 colours on a dark UI.
|
||||
- Proven end to end against six placeholder scenes built from the repo's own app screenshots:
|
||||
21s loop → mp4 480 KB, webm 609 KB, poster 83 KB.
|
||||
|
||||
## 2026-10-12 (afternoon) — gate 22 also refuses an unscoped bare element rule
|
||||
|
||||
- `site_gates.py` gate 22 gains a second half: a rule in `site.css` whose selector starts with a bare
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""make_hero_clip.py — assemble the home page's laptop clip from raw app recordings.
|
||||
|
||||
python3 scripts/make_hero_clip.py <raw-dir> <out-dir> [--lang hu|en] [--seconds 4] [--dry-run]
|
||||
|
||||
WHAT IT IS FOR. The hero on / and /en/ is a laptop whose SCREEN is a short, silent, looping clip of
|
||||
real apps running on a real box. The operator records the raw clips; this turns them into the two
|
||||
files the page loads, and into nothing else — it is NOT part of any build, gate or deploy. The site
|
||||
has no build step: the output is committed like any other picture.
|
||||
|
||||
WHERE IT RUNS. On DooPlex, which has ffmpeg (7.1.5, libx264 + libvpx-vp9). Windows has no ffmpeg.
|
||||
|
||||
INPUT. One file per scene in <raw-dir>, named `NN-appname.<ext>` so the order is the file order:
|
||||
|
||||
01-jellyfin.mp4 02-tandoor.mkv 03-onlyoffice.webm 04-immich.mp4 ...
|
||||
|
||||
Any container ffmpeg reads. Audio is dropped. Each clip is scaled to cover the laptop's screen and
|
||||
centre-cropped to 1.626:1 — record at 16:10 (1440x900 is ideal) and almost nothing is lost.
|
||||
|
||||
OUTPUT (in <out-dir>, which is normally website/assets):
|
||||
|
||||
hero-clip-<lang>.mp4 h264, the one every browser plays
|
||||
hero-clip-<lang>.webm VP9, preferred where offered
|
||||
hero-poster-<lang>.webp the first frame, which the <video> shows before it loads
|
||||
|
||||
MEASURED, 2026-10-12, six scenes of 2.4s with 0.6s fades at 904x556:
|
||||
mp4 331 KB · webm 411 KB · animated webp 2.5 MB · GIF 2.7 MB for HALF of it.
|
||||
GIF is 11x the size of the mp4 and dithers to 256 colours on a dark UI. Hence mp4 + webm.
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
for _s in (sys.stdout, sys.stderr):
|
||||
try:
|
||||
_s.reconfigure(encoding="utf-8", errors="replace")
|
||||
except (AttributeError, ValueError, OSError):
|
||||
pass
|
||||
|
||||
W, H = 904, 556 # 2x what the screen renders at on a 1440px-wide desktop; ratio 1.626
|
||||
FADE = 0.6
|
||||
FPS = 20
|
||||
EXTS = (".mp4", ".mkv", ".webm", ".mov", ".m4v", ".avi")
|
||||
|
||||
|
||||
def ffmpeg(args, dry):
|
||||
print(" ffmpeg " + " ".join(a if " " not in a else '"%s"' % a for a in args[:6]) + " …")
|
||||
if dry:
|
||||
return
|
||||
p = subprocess.run(["ffmpeg", "-y", "-hide_banner", "-loglevel", "error"] + args,
|
||||
capture_output=True, text=True)
|
||||
if p.returncode:
|
||||
sys.exit("ffmpeg failed:\n" + (p.stdout + p.stderr)[-2000:])
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("raw_dir")
|
||||
ap.add_argument("out_dir")
|
||||
ap.add_argument("--lang", default="hu")
|
||||
ap.add_argument("--seconds", type=float, default=4.0, help="how much of each raw clip to keep")
|
||||
ap.add_argument("--dry-run", action="store_true")
|
||||
a = ap.parse_args()
|
||||
|
||||
raws = sorted(f for f in os.listdir(a.raw_dir) if f.lower().endswith(EXTS))
|
||||
if not raws:
|
||||
sys.exit("no clips in %s (looked for %s)" % (a.raw_dir, ", ".join(EXTS)))
|
||||
unnumbered = [f for f in raws if not re.match(r"^\d+[-_]", f)]
|
||||
if unnumbered:
|
||||
sys.exit("name every clip NN-appname.ext so the order is explicit; these are not: %s"
|
||||
% ", ".join(unnumbered))
|
||||
print("%d scene(s), %.1fs each, %.1fs fades -> %.1fs loop"
|
||||
% (len(raws), a.seconds, FADE, len(raws) * (a.seconds - FADE) + FADE))
|
||||
for f in raws:
|
||||
print(" %s" % f)
|
||||
|
||||
ins, fc, n = [], "", len(raws)
|
||||
for i, f in enumerate(raws):
|
||||
ins += ["-t", "%.3f" % a.seconds, "-i", os.path.join(a.raw_dir, f)]
|
||||
fc += ("[%d:v]scale=%d:%d:force_original_aspect_ratio=increase,crop=%d:%d,"
|
||||
"setsar=1,fps=%d,format=yuv420p[v%d];" % (i, W, H, W, H, FPS, i))
|
||||
prev = "[v0]"
|
||||
for i in range(1, n):
|
||||
off = round(i * (a.seconds - FADE), 3)
|
||||
out = "[out]" if i == n - 1 else "[x%d]" % i
|
||||
fc += "%s[v%d]xfade=transition=fade:duration=%s:offset=%s%s;" % (prev, i, FADE, off, out)
|
||||
prev = "[x%d]" % i
|
||||
fc = fc.rstrip(";")
|
||||
if n == 1:
|
||||
fc = fc.replace("[v0];", "[out];").rstrip(";")
|
||||
|
||||
mp4 = os.path.join(a.out_dir, "hero-clip-%s.mp4" % a.lang)
|
||||
webm = os.path.join(a.out_dir, "hero-clip-%s.webm" % a.lang)
|
||||
poster = os.path.join(a.out_dir, "hero-poster-%s.webp" % a.lang)
|
||||
|
||||
ffmpeg(ins + ["-filter_complex", fc, "-map", "[out]", "-an",
|
||||
"-c:v", "libx264", "-crf", "30", "-preset", "slow",
|
||||
"-pix_fmt", "yuv420p", "-movflags", "+faststart", mp4], a.dry_run)
|
||||
ffmpeg(ins + ["-filter_complex", fc, "-map", "[out]", "-an",
|
||||
"-c:v", "libvpx-vp9", "-b:v", "0", "-crf", "44",
|
||||
"-deadline", "good", "-cpu-used", "2", "-row-mt", "1", webm], a.dry_run)
|
||||
ffmpeg(["-i", mp4, "-frames:v", "1", "-c:v", "libwebp", "-q:v", "82", poster], a.dry_run)
|
||||
|
||||
if not a.dry_run:
|
||||
for p in (mp4, webm, poster):
|
||||
print(" %-34s %7.1f KB" % (os.path.basename(p), os.path.getsize(p) / 1024))
|
||||
print("Bump the ?v= on the <video> sources in index.html and en/index.html, then run "
|
||||
"scripts/site_gates.py.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+21
-1
@@ -1,4 +1,24 @@
|
||||
## 2026-10-12 (afternoon) — four more notes, and the leak shape gate 22 could not see
|
||||
## 2026-10-12 (evening) — the laptop frame, cut open for a moving screen
|
||||
|
||||
Prepared, **not yet on the page**: the live hero still shows the still. The clip itself is the
|
||||
operator's to record (his decision, 2026-10-12: he films the apps, the assembly is built here).
|
||||
|
||||
- **`website/assets/hero-laptop-frame.webp`** (59 KB) — the same laptop render, with the **screen cut
|
||||
out to transparency** so a clip can play underneath while the bezel, the keyboard and the glass
|
||||
reflection stay on top. The reflection is painted, not lifted: lifting the bright pixels out of the
|
||||
AI-drawn screen dragged a ghost of the drawn UI with it.
|
||||
- The hole is `left 11.041% · top 12.345% · width 79.817% · height 64.305%` of the frame, ratio
|
||||
**1.626:1**. Two pixels of slack: the lid keystones slightly, so a tight rectangle let the drawn
|
||||
sidebar's highlight show and a loose one showed page background at the top left. Both were seen.
|
||||
- **What will load, and when:** `<video muted loop playsinline preload="none">` with the first frame
|
||||
as its poster, started only when the hero scrolls into view, and never started under
|
||||
`prefers-reduced-motion`. First paint costs the frame (59 KB) + the poster (~80 KB) against today's
|
||||
single still (57 KB); the clip itself (~330 KB) is fetched after, or not at all if the visitor
|
||||
never reaches it. **No „loading" graphic is needed** — the poster is the loading state.
|
||||
- Layering proven in the browser against a placeholder clip: content inside the bezel, frame on top,
|
||||
and a magenta backdrop behind the frame showed the cut-out is clean all the way round.
|
||||
|
||||
## 2026-10-12 (afternoon) — four more notes, and the leak shape gate 22 could not see
|
||||
|
||||
`site.css?v=25`.
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 60 KiB |
Reference in New Issue
Block a user