perfect-postcode/video/render.sh

330 lines
13 KiB
Bash
Executable file

#!/usr/bin/env bash
#
# End-to-end re-render of the dashboard demo video.
#
# 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 --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
# TTS_SPEAKER=aiden ./render.sh # override CustomVoice speaker
#
# 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
# -- config (override via env) -------------------------------------------------
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}"
# When in the output timeline to grab the poster frame.
# Right-pane inspection (~16s output) is the clearest paused-state preview:
# Manchester map, filters applied, right pane populated, larger narration
# caption visible.
POSTER_TIME_S="${POSTER_TIME_S:-16}"
# Recorder/encoder knobs read by src/config.ts. config.ts treats these as
# required, so they live here (the only entry point) rather than as defaults
# scattered across TS modules. Override per-run via env.
export ASPECT="${ASPECT:-16x9}"
export CAPTURE_SCALE="${CAPTURE_SCALE:-1}"
export WEBM_BITRATE="${WEBM_BITRATE:-$(awk -v s="$CAPTURE_SCALE" 'BEGIN{print (s+0>1)?"18M":"8M"}')}"
export PROMPT_TEXT="${PROMPT_TEXT:-Flats or terraces <£450k, 35 min to Manchester, low crime}"
export AI_ZOOM_SCALE="${AI_ZOOM_SCALE:-2.4}"
export MAX_DURATION_S="${MAX_DURATION_S:-60}"
export MIN_DURATION_S="${MIN_DURATION_S:-10}"
export OUTPUT_FPS="${OUTPUT_FPS:-50}"
FRESH_AUTH="${FORCE_AUTH:-0}"
DO_ENCODE=1
DO_AUDIO=1
for arg in "${@:-}"; do
[ -z "$arg" ] && continue
case "$arg" in
--fresh-auth) FRESH_AUTH=1 ;;
--no-encode) DO_ENCODE=0 ;;
--no-audio) DO_AUDIO=0 ;;
-h|--help)
sed -n '3,30p' "$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 + synth (Qwen3-TTS) -------------------------------------------
# Synth runs BEFORE recording: one batched generate_custom_voice call across
# all cues so the voice stays consistent. The recorder reads
# output/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.
mkdir -p output
# Wipe last run's leaking artifacts so the rename step picks up *this* run.
rm -f output/recording.webm output/recording.mp4 output/page@*.webm output/page@*.webm.untrimmed
rm -f output/narration-script.json output/narration.json
# output/audio/ is preserved; tts/synth.py decides whether the cached WAVs
# still match the script and skips generation when they do.
say "Preflight: emitting narration script"
node dist/preflight.js
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 "Synthesising narration with Qwen3-TTS (speaker=${TTS_SPEAKER:-ryan}) — one batched call"
uv sync --project tts ${uv_sync_extras[@]+"${uv_sync_extras[@]}"} || fail "uv sync failed in video/tts"
uv run --project tts python tts/synth.py || fail "tts/synth.py failed"
if [ ! -s output/audio/index.json ]; then
fail "synth did not produce output/audio/index.json"
fi
fi
# -- record -------------------------------------------------------------------
say "Recording"
APP_URL="$APP_URL" node dist/record.js
if [ ! -s output/recording.webm ]; then
fail "recording.webm missing or empty"
fi
node dist/verify.js output/recording.webm
# -- encode -------------------------------------------------------------------
if [ "$DO_ENCODE" = "1" ]; then
if ! command -v ffmpeg >/dev/null 2>&1; then
fail "ffmpeg not on PATH; rerun with --no-encode if you only need the WebM"
fi
say "Encoding to MP4"
ffmpeg -y -loglevel warning -i output/recording.webm \
-c:v libx264 -pix_fmt yuv420p -crf 14 -preset fast \
-movflags +faststart \
output/recording.mp4
# Poster: a single high-quality JPEG extracted from a representative
# moment in the output timeline. Used as the homepage <video poster=...>,
# which is what the visitor sees before pressing play.
# - -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.
say "Extracting poster frame at ${POSTER_TIME_S}s"
ffmpeg -y -loglevel warning -i output/recording.mp4 -ss "$POSTER_TIME_S" \
-frames:v 1 -update 1 -q:v 2 \
output/poster.jpg
node dist/verify.js output/recording.mp4 output/poster.jpg
fi
# -- mux narration ------------------------------------------------------------
# Synth already produced per-cue WAVs (in output/audio/); the recorder logged
# each cue's videoTime against the trimmed timeline. Drop the WAVs onto the
# mp4 with one ffmpeg adelay+amix and replace the silent recording in place.
if [ "$DO_ENCODE" = "1" ] && [ "$DO_AUDIO" = "1" ]; then
if [ ! -s output/narration.json ]; then
fail "narration.json missing — recorder did not log cues"
fi
say "Muxing narration into output/recording.mp4"
uv run --project tts python tts/mux.py --replace \
|| fail "tts/mux.py failed"
node dist/verify.js output/recording.mp4
fi
# -- publish to homepage ------------------------------------------------------
# Only publish when we did the encode (otherwise we'd be copying a stale
# mp4 next to a fresh webm). --no-encode skips this whole block.
if [ "$DO_ENCODE" = "1" ]; then
if [ ! -d "$PUBLISH_DIR" ]; then
say "Creating $PUBLISH_DIR"
mkdir -p "$PUBLISH_DIR"
fi
say "Publishing to $PUBLISH_DIR"
cp output/recording.mp4 "$PUBLISH_DIR/recording.mp4"
cp output/poster.jpg "$PUBLISH_DIR/poster.jpg"
node dist/verify.js "$PUBLISH_DIR/recording.mp4" "$PUBLISH_DIR/poster.jpg"
fi
# -- report -------------------------------------------------------------------
say "Done"
if command -v ffprobe >/dev/null 2>&1; then
for f in output/recording.webm output/recording.mp4 output/poster.jpg \
"$PUBLISH_DIR/recording.mp4" "$PUBLISH_DIR/poster.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
else
ls -la output/recording.* output/poster.jpg \
"$PUBLISH_DIR/recording.mp4" "$PUBLISH_DIR/poster.jpg" 2>/dev/null || true
fi