417 lines
18 KiB
Bash
Executable file
417 lines
18 KiB
Bash
Executable file
#!/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
|