perfect-postcode/video/render.sh
2026-05-14 08:09:19 +01:00

417 lines
18 KiB
Bash
Executable file
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env bash
#
# End-to-end re-render of the dashboard demo videos.
#
# All per-storyboard knobs (aspect, fps, bitrate, prompt text, localized
# narration, voice persona, poster timestamp, brand strings…) live in
# src/storyboard.ts. A single visual storyboard can expand into multiple
# language variants there; this script renders every emitted slug.
#
# Two targets:
# local (default) — assumes the docker-compose stack on host.docker.internal,
# bootstraps a recorder admin user automatically.
# prod — points at https://perfect-postcode.co.uk and skips the
# bootstrap step; you supply real account credentials.
#
# Usage:
# ./render.sh # local stack
# ./render.sh --prod # prod (requires LOGIN_EMAIL/LOGIN_PASSWORD)
# ./render.sh --target prod # same as --prod
# ./render.sh --fresh-auth # force re-auth even if cache is fresh
# ./render.sh --resume # preserve completed recordings and continue
# ./render.sh --no-encode # stop at WebM, skip MP4 encode
# ./render.sh --no-audio # skip Qwen3-TTS narration; silent MP4
# FORCE_AUTH=1 ./render.sh # same as --fresh-auth
# APP_URL=http://localhost:3001 ./render.sh # override frontend URL
#
# Cred env vars (read for both targets, but prod has no fallback defaults):
# LOGIN_EMAIL, LOGIN_PASSWORD — the dashboard account to record as
# (same email/password you'd type into
# the login modal)
# PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD — PocketBase superuser, only used by
# --target local to bootstrap the
# recorder user; ignored on --prod
set -euo pipefail
# -- target -------------------------------------------------------------------
TARGET="${TARGET:-local}"
parsed_args=()
while [ $# -gt 0 ]; do
case "$1" in
--prod) TARGET=prod; shift ;;
--local) TARGET=local; shift ;;
--target) TARGET="$2"; shift 2 ;;
--target=*) TARGET="${1#--target=}"; shift ;;
*) parsed_args+=("$1"); shift ;;
esac
done
set -- "${parsed_args[@]+"${parsed_args[@]}"}"
case "$TARGET" in
local|prod) ;;
*) echo "Unknown --target: $TARGET (expected: local, prod)" >&2; exit 2 ;;
esac
# -- environment (target-specific URLs and credentials) ----------------------
if [ "$TARGET" = "prod" ]; then
# Prod serves frontend, /api/*, and /pb/* off the same domain.
export APP_URL="${APP_URL:-https://perfect-postcode.co.uk}"
export PB_URL="${PB_URL:-https://perfect-postcode.co.uk/pb}"
export API_URL="${API_URL:-https://perfect-postcode.co.uk}"
# Prod has no recorder-bootstrap path: the user supplies a real account.
PB_BOOTSTRAP_ADMIN="${PB_BOOTSTRAP_ADMIN:-0}"
if [ -z "${LOGIN_EMAIL:-}" ] || [ -z "${LOGIN_PASSWORD:-}" ]; then
echo "FAIL: --prod requires LOGIN_EMAIL and LOGIN_PASSWORD (your dashboard login)." >&2
echo " Example: LOGIN_EMAIL=you@example.com LOGIN_PASSWORD='...' $0 --prod" >&2
exit 2
fi
else
export APP_URL="${APP_URL:-http://host.docker.internal:3001}"
export PB_URL="${PB_URL:-http://host.docker.internal:8090}"
export API_URL="${API_URL:-http://host.docker.internal:8001}"
PB_ADMIN_EMAIL="${PB_ADMIN_EMAIL:-admin@propertymap.local}"
PB_ADMIN_PASSWORD="${PB_ADMIN_PASSWORD:-propertymap-dev-2024}"
LOGIN_EMAIL="${LOGIN_EMAIL:-demo-video@local.test}"
LOGIN_PASSWORD="${LOGIN_PASSWORD:-DemoVideoPass123!}"
PB_BOOTSTRAP_ADMIN="${PB_BOOTSTRAP_ADMIN:-1}"
fi
export PB_BOOTSTRAP_ADMIN
export LOGIN_EMAIL LOGIN_PASSWORD
# Per-target storage state — switching targets must not reuse a stale token.
# config.ts reads AUTH_STATE_FILE for AUTH_STATE_PATH.
export AUTH_STATE_FILE="${AUTH_STATE_FILE:-auth.${TARGET}.json}"
AUTH_TTL_HOURS="${AUTH_TTL_HOURS:-24}" # re-auth if cache older than this
# Where the homepage <video> source lives. Vite copies frontend/public/* into
# the built bundle, so updating this path is what makes the new clip appear
# on the homepage. Override if the dashboard ever moves.
PUBLISH_DIR="${PUBLISH_DIR:-../frontend/public/video}"
FRESH_AUTH="${FORCE_AUTH:-0}"
RESUME_RECORDINGS="${RESUME_RECORDINGS:-0}"
DO_ENCODE=1
DO_AUDIO=1
for arg in "${@:-}"; do
[ -z "$arg" ] && continue
case "$arg" in
--fresh-auth) FRESH_AUTH=1 ;;
--resume) RESUME_RECORDINGS=1 ;;
--no-encode) DO_ENCODE=0 ;;
--no-audio) DO_AUDIO=0 ;;
-h|--help)
sed -n '3,32p' "$0"
exit 0 ;;
*) echo "Unknown arg: $arg" >&2; exit 2 ;;
esac
done
cd "$(dirname "$0")"
# -- helpers ------------------------------------------------------------------
say() { printf '\n[render] %s\n' "$*"; }
fail() { printf '\n[render] FAIL: %s\n' "$*" >&2; exit 1; }
http_code() {
curl -s -o /dev/null -w "%{http_code}" --connect-timeout 3 --max-time 5 "$1" || echo "000"
}
wait_for() {
local url="$1" desc="$2" timeout="${3:-90}"
say "Waiting for $desc ($url)"
for i in $(seq 1 "$timeout"); do
if [ "$(http_code "$url")" = "200" ]; then
say " ready after ${i}s"
return 0
fi
sleep 1
done
fail "$desc not reachable after ${timeout}s"
}
# -- stack health -------------------------------------------------------------
say "Checking stack health"
fe_code="$(http_code "$APP_URL/")"
api_code="$(http_code "$API_URL/api/features")"
pb_code="$(http_code "$PB_URL/api/health")"
echo " frontend=$fe_code api=$api_code pocketbase=$pb_code"
if [ "$fe_code" != "200" ] || [ "$pb_code" != "200" ]; then
if [ "$TARGET" = "prod" ]; then
fail "Cannot reach prod ($APP_URL / $PB_URL). Check the URLs and your network."
else
fail "Stack down. From the repo root run: docker compose up -d"
fi
fi
if [ "$api_code" != "200" ]; then
wait_for "$API_URL/api/features" "Rust API" 120
fi
# -- node deps ----------------------------------------------------------------
if [ ! -d node_modules ]; then
say "Installing npm deps"
npm install --no-audit --no-fund
fi
# Chromium binary lives in Playwright's cache; install if missing.
if ! npx --no-install playwright --version >/dev/null 2>&1 \
|| [ ! -d "$HOME/.cache/ms-playwright" ] \
|| ! find "$HOME/.cache/ms-playwright" -maxdepth 1 -name "chromium-*" -print -quit | grep -q .; then
say "Installing Playwright Chromium"
npx playwright install chromium
fi
# System libs Chromium dlopens (libnspr4, libnss3, libasound2…). Detect via
# the canonical missing one and shell out to playwright's apt wrapper. Needs
# sudo; gated on ldconfig so we don't prompt unnecessarily on every render.
if ! ldconfig -p 2>/dev/null | grep -q 'libnspr4\.so'; then
say "Installing Chromium system deps (sudo)"
sudo npx playwright install-deps chromium
fi
# -- build --------------------------------------------------------------------
say "Compiling TypeScript"
./node_modules/.bin/tsc
# -- auth ---------------------------------------------------------------------
need_auth=0
if [ "$FRESH_AUTH" = "1" ] || [ ! -f "$AUTH_STATE_FILE" ]; then
need_auth=1
else
# File mtime check, portable: if older than TTL, refresh.
if [ "$(find "$AUTH_STATE_FILE" -mmin "+$((AUTH_TTL_HOURS * 60))" -print 2>/dev/null)" ]; then
say "$AUTH_STATE_FILE is older than ${AUTH_TTL_HOURS}h, will refresh"
need_auth=1
fi
fi
if [ "$need_auth" = "1" ]; then
say "Minting fresh $AUTH_STATE_FILE (target: $TARGET, user: $LOGIN_EMAIL)"
PB_URL="$PB_URL" \
LOGIN_EMAIL="$LOGIN_EMAIL" LOGIN_PASSWORD="$LOGIN_PASSWORD" \
PB_ADMIN_EMAIL="${PB_ADMIN_EMAIL:-}" PB_ADMIN_PASSWORD="${PB_ADMIN_PASSWORD:-}" \
PB_BOOTSTRAP_ADMIN="$PB_BOOTSTRAP_ADMIN" \
AUTH_STATE_FILE="$AUTH_STATE_FILE" \
APP_URL="$APP_URL" \
node dist/auth.js
else
say "Reusing existing $AUTH_STATE_FILE"
fi
# -- preflight ---------------------------------------------------------------
# preflight emits per-storyboard narration scripts AND output/storyboards.json
# (the index this script loops over below). Run it BEFORE wiping per-storyboard
# files so we know what slugs to target.
mkdir -p output
say "Preflight: emitting narration scripts and storyboard index"
node dist/preflight.js
if [ ! -s output/storyboards.json ]; then
fail "preflight did not produce output/storyboards.json"
fi
# Pull the storyboard slugs out of the index. Use Node so we don't grow a jq
# dependency just for one read.
mapfile -t STORYBOARDS < <(node -e '
const idx = JSON.parse(require("fs").readFileSync("output/storyboards.json","utf8"));
for (const s of idx.storyboards) console.log(s.name);
')
if [ "${#STORYBOARDS[@]}" -eq 0 ]; then
fail "storyboards.json contains no storyboards"
fi
say "Storyboards to render: ${STORYBOARDS[*]}"
# Per-storyboard poster timestamp lookup (slug → seconds), set once so each
# loop body can read it without re-parsing the index.
poster_time_for() {
node -e '
const idx = JSON.parse(require("fs").readFileSync("output/storyboards.json","utf8"));
const sb = idx.storyboards.find(s => s.name === process.argv[1]);
if (!sb) { process.exit(1); }
process.stdout.write(String(sb.posterTimeS));
' "$1"
}
# Resolve the FINAL published video dimensions for a storyboard. The
# recording happens at the CSS viewport, but the encode pass upscales to
# `captureScale x viewport` via lanczos so the published mp4 is true
# 1080x1920 on mobile rather than a soft 540x960. Returns "WxH".
published_size_for() {
node -e '
const idx = JSON.parse(require("fs").readFileSync("output/storyboards.json","utf8"));
const sb = idx.storyboards.find(s => s.name === process.argv[1]);
if (!sb || !sb.publishedSize) { process.exit(1); }
process.stdout.write(`${sb.publishedSize.width}x${sb.publishedSize.height}`);
' "$1"
}
# -- per-storyboard wipe of leaking artefacts --------------------------------
# output/<sb>/audio/ is preserved; tts/synth.py decides whether the cached
# WAVs still match the script and skips generation when they do. In resume
# mode, keep successful recording.webm + narration.json pairs and let record.ts
# skip them, but still clear raw/intermediate files that may be left by a
# failed Playwright save.
for sb in "${STORYBOARDS[@]}"; do
if [ "$RESUME_RECORDINGS" = "1" ]; then
rm -f "output/$sb/page@"*.webm "output/$sb/page@"*.webm.untrimmed \
"output/$sb/recording.raw.webm" "output/$sb/recording.raw.webm.untrimmed" \
"output/$sb/recording.narrated.mp4"
else
rm -f "output/$sb/recording.webm" "output/$sb/recording.mp4" \
"output/$sb/page@"*.webm "output/$sb/page@"*.webm.untrimmed \
"output/$sb/recording.raw.webm" "output/$sb/recording.raw.webm.untrimmed" \
"output/$sb/recording.narrated.mp4" "output/$sb/poster.jpg" \
"output/$sb/narration.json"
fi
done
# -- synth (Qwen3-TTS) -------------------------------------------------------
# Synth runs BEFORE recording: one batched generate_voice_clone call per
# storyboard so the voice stays consistent within each video. The recorder
# reads output/<sb>/audio/index.json for measured per-cue durations and
# sizes each cue's wall-clock to fit; --no-audio skips synth and the recorder
# falls back to a worst-case estimate.
if [ "$DO_AUDIO" = "1" ]; then
if ! command -v uv >/dev/null 2>&1; then
fail "uv not on PATH (required for Qwen3-TTS synth). Install uv or rerun with --no-audio."
fi
# The torch/cudnn wheels are ~700MB; uv's 30s default chokes on first sync.
export UV_HTTP_TIMEOUT="${UV_HTTP_TIMEOUT:-600}"
# Pull in the flash-attn prebuilt wheel (defined as the `gpu` extra) when
# the host actually has a GPU. The wheel is bound to torch 2.6 + cu12 +
# cp312 — see tts/pyproject.toml.
uv_sync_extras=()
if command -v nvidia-smi >/dev/null 2>&1 && nvidia-smi -L >/dev/null 2>&1; then
uv_sync_extras+=(--extra gpu)
fi
say "Synchronising tts/ Python deps"
uv sync --project tts ${uv_sync_extras[@]+"${uv_sync_extras[@]}"} || fail "uv sync failed in video/tts"
# Voice consistency: every ad in this set declares the same AD_VOICE
# (instruct/seed/temperature/topP/referenceText). Even with seed-locked
# VoiceDesign, independent invocations across processes can produce
# mildly different reference waveforms — different enough that a
# listener notices the timbre shift across ads. To avoid that, we
# mint the reference WAV ONCE (from the first storyboard) and reuse
# it across the rest of the storyboards by copying _reference.wav +
# _reference.meta.json into their audio dirs before their synth runs.
# synth.py's _resolve_reference() reuses a matching cached reference
# as long as the meta block (instruct/language/seed/etc.) matches —
# which it always does, because every ad shares AD_VOICE.
shared_ref_wav=""
shared_ref_meta=""
for sb in "${STORYBOARDS[@]}"; do
if [ -n "$shared_ref_wav" ] && [ -f "$shared_ref_wav" ] && [ -f "$shared_ref_meta" ]; then
mkdir -p "output/$sb/audio"
cp -f "$shared_ref_wav" "output/$sb/audio/_reference.wav"
cp -f "$shared_ref_meta" "output/$sb/audio/_reference.meta.json"
fi
say "Synthesising narration for [$sb]"
uv run --project tts python tts/synth.py --storyboard "$sb" \
|| fail "tts/synth.py failed for $sb"
if [ ! -s "output/$sb/audio/index.json" ]; then
fail "synth did not produce output/$sb/audio/index.json"
fi
if [ -z "$shared_ref_wav" ] && [ -f "output/$sb/audio/_reference.wav" ]; then
shared_ref_wav="output/$sb/audio/_reference.wav"
shared_ref_meta="output/$sb/audio/_reference.meta.json"
say "Locked voice reference to $shared_ref_wav — reusing for the rest of the set"
fi
done
fi
# -- record ------------------------------------------------------------------
# record.ts iterates over storyboards in-process and writes per-storyboard
# recording.webm + narration.json. One Node invocation handles all of them
# so we don't spin up Playwright + GPU/WebGL + auth more than necessary.
say "Recording all storyboards"
RESUME_RECORDINGS="$RESUME_RECORDINGS" APP_URL="$APP_URL" node dist/record.js
for sb in "${STORYBOARDS[@]}"; do
if [ ! -s "output/$sb/recording.webm" ]; then
fail "[$sb] recording.webm missing or empty"
fi
node dist/verify.js "$sb" "output/$sb/recording.webm"
done
# -- encode + mux + publish (per storyboard) ---------------------------------
if [ "$DO_ENCODE" = "1" ] && ! command -v ffmpeg >/dev/null 2>&1; then
fail "ffmpeg not on PATH; rerun with --no-encode if you only need the WebM"
fi
for sb in "${STORYBOARDS[@]}"; do
if [ "$DO_ENCODE" = "1" ]; then
say "[$sb] Encoding to MP4"
# Lanczos upscale the recording to its published dimensions
# (captureScale × viewport). For captureScale=1 the filter is a
# no-op and ffmpeg copies the size through; for captureScale=2
# mobile cuts go 540x960 → 1080x1920 sharply because Chromium
# already rasterised internally at DPR=2.
pub_size="$(published_size_for "$sb")"
pub_w="${pub_size%x*}"
pub_h="${pub_size#*x}"
ffmpeg -y -loglevel warning -i "output/$sb/recording.webm" \
-vf "scale=${pub_w}:${pub_h}:flags=lanczos" \
-c:v libx264 -pix_fmt yuv420p -crf 14 -preset fast \
-movflags +faststart \
"output/$sb/recording.mp4"
# Poster: a single high-quality JPEG extracted from a representative
# moment in the output timeline. Used as the homepage <video poster=...>.
# - -ss AFTER -i = output-side seek, frame-accurate (input-side seek
# would land on the nearest keyframe, drifting back up to ~2s).
# - -update 1 tells ffmpeg the output is a single image, not a sequence.
# - -q:v 2 = high JPEG quality (~95%); poster file is ~120KB at 1080p.
poster_t="$(poster_time_for "$sb")"
say "[$sb] Extracting poster frame at ${poster_t}s"
ffmpeg -y -loglevel warning -i "output/$sb/recording.mp4" -ss "$poster_t" \
-frames:v 1 -update 1 -q:v 2 \
"output/$sb/poster.jpg"
node dist/verify.js "$sb" "output/$sb/recording.mp4" "output/$sb/poster.jpg"
fi
if [ "$DO_ENCODE" = "1" ] && [ "$DO_AUDIO" = "1" ]; then
if [ ! -s "output/$sb/narration.json" ]; then
fail "[$sb] narration.json missing — recorder did not log cues"
fi
say "[$sb] Muxing narration into output/$sb/recording.mp4"
uv run --project tts python tts/mux.py --storyboard "$sb" --replace \
|| fail "tts/mux.py failed for $sb"
node dist/verify.js "$sb" "output/$sb/recording.mp4"
fi
# Only publish when we did the encode (otherwise we'd be copying a stale
# mp4 next to a fresh webm). --no-encode skips publish.
if [ "$DO_ENCODE" = "1" ]; then
if [ ! -d "$PUBLISH_DIR" ]; then
say "Creating $PUBLISH_DIR"
mkdir -p "$PUBLISH_DIR"
fi
say "[$sb] Publishing to $PUBLISH_DIR/$sb.{mp4,jpg}"
cp "output/$sb/recording.mp4" "$PUBLISH_DIR/$sb.mp4"
cp "output/$sb/poster.jpg" "$PUBLISH_DIR/$sb.jpg"
node dist/verify.js "$sb" "$PUBLISH_DIR/$sb.mp4" "$PUBLISH_DIR/$sb.jpg"
fi
done
# -- report ------------------------------------------------------------------
say "Done"
if command -v ffprobe >/dev/null 2>&1; then
for sb in "${STORYBOARDS[@]}"; do
for f in "output/$sb/recording.webm" "output/$sb/recording.mp4" \
"output/$sb/poster.jpg" \
"$PUBLISH_DIR/$sb.mp4" "$PUBLISH_DIR/$sb.jpg"; do
[ -f "$f" ] || continue
size=$(stat -c '%s' "$f" 2>/dev/null || stat -f '%z' "$f")
case "$f" in
*.mp4|*.webm)
dur=$(ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "$f")
printf ' %s %ss %s bytes\n' "$f" "$(printf '%.2f' "$dur")" "$size"
;;
*)
printf ' %s %s bytes\n' "$f" "$size"
;;
esac
done
done
fi