b0ef27e914
gates / gates (push) Failing after 7m17s
The operator recorded the six scenes and ruled on their length: use them whole, "they had been cut as I liked them". Jellyfin, Tandoor, OnlyOffice, Immich, RomM, Paperless, 0.6s crossfades, 36.6s looping at 904x556. - hero-clip.mp4 (1 134 KB) + hero-clip.webm (1 295 KB) + hero-poster.webp (53 KB), one set for both languages: the recordings are the same on / and /en/. - The hero <video> sits in the frame's cut-out hole at z-index 1, the frame above it at 2. No autoplay attribute: the page's script starts it when the hero is on screen, pauses it when it leaves, and never starts it under prefers-reduced-motion. With JS off the poster stays. - make_hero_clip.py reads each scene's own length (ffprobe) instead of cutting every one to a fixed window, and the xfade offsets count from the running sum minus one fade per transition already made. - site.css ?v=26 on all 22 pages. NOT verified: playback in this workstation's Chrome, whose media pipeline loads nothing at all today (a 1 KB generated WAV blob stalls the same way, with no error) - which rules the clip out as the cause. Geometry, layering and the poster frame were verified in the browser. Jellyfin, Paperless and RomM were filmed on those projects' own public demo sites; the films in the Jellyfin scene are public-domain prints.
144 lines
6.3 KiB
Python
144 lines
6.3 KiB
Python
#!/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 N] [--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.
|
|
|
|
EACH SCENE PLAYS AT ITS FULL RECORDED LENGTH (operator ruling 2026-10-12: "use the full length for
|
|
all of them, they had been cut as I liked them"). The raw file IS the edit; this script does not
|
|
re-cut it. `--seconds N` caps every scene at N seconds, for trying a shorter loop.
|
|
|
|
OUTPUT (in <out-dir>, which is normally website/assets):
|
|
|
|
hero-clip.mp4 h264, the one every browser plays
|
|
hero-clip.webm VP9, preferred where offered
|
|
hero-poster.webp the first frame, which the <video> shows before it loads
|
|
|
|
ONE set serves both languages, because the recordings are the same on / and /en/. `--lang hu` puts
|
|
a `-hu` suffix back, for the day the two differ.
|
|
|
|
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.
|
|
|
|
SHIPPED, same day, the six real recordings at FULL length (36.6s after the fades):
|
|
hero-clip.mp4 1134 KB · hero-clip.webm 1295 KB · hero-poster.webp 53 KB.
|
|
"""
|
|
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 duration(path):
|
|
"""Seconds of a raw clip, read from the container."""
|
|
p = subprocess.run(["ffprobe", "-v", "error", "-show_entries", "format=duration",
|
|
"-of", "default=nw=1:nk=1", path], capture_output=True, text=True)
|
|
if p.returncode or not p.stdout.strip():
|
|
sys.exit("ffprobe could not read a duration from %s:\n%s" % (path, p.stdout + p.stderr))
|
|
return float(p.stdout.strip())
|
|
|
|
|
|
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="", help="suffix for a language-specific set; default: none")
|
|
ap.add_argument("--seconds", type=float, default=0.0,
|
|
help="cap every scene at N seconds; default 0 = each clip's full length")
|
|
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))
|
|
secs = []
|
|
for f in raws:
|
|
d = duration(os.path.join(a.raw_dir, f))
|
|
secs.append(min(d, a.seconds) if a.seconds else d)
|
|
n = len(raws)
|
|
print("%d scene(s) at full length, %.1fs fades -> %.1fs loop"
|
|
% (n, FADE, sum(secs) - (n - 1) * FADE))
|
|
for f, d in zip(raws, secs):
|
|
print(" %-24s %5.2fs" % (f, d))
|
|
|
|
ins, fc = [], ""
|
|
for i, f in enumerate(raws):
|
|
ins += ["-t", "%.3f" % secs[i], "-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):
|
|
# xfade measures its offset on the chain built SO FAR, which is shorter than the raw sum
|
|
# by one FADE per transition already made.
|
|
off = round(sum(secs[:i]) - i * 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(";")
|
|
|
|
sfx = ("-" + a.lang) if a.lang else ""
|
|
mp4 = os.path.join(a.out_dir, "hero-clip%s.mp4" % sfx)
|
|
webm = os.path.join(a.out_dir, "hero-clip%s.webm" % sfx)
|
|
poster = os.path.join(a.out_dir, "hero-poster%s.webp" % sfx)
|
|
|
|
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()
|