website: prepare the moving laptop screen - the cut-out frame and the clip builder
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:
2026-10-12 10:40:26 +02:00
parent 42c2d84c7c
commit 7733d7b754
5 changed files with 157 additions and 1 deletions
+115
View File
@@ -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()