LGTM
This commit is contained in:
parent
9248e26af2
commit
f2a2651b8a
95 changed files with 3993 additions and 1471 deletions
248
video/render.sh
248
video/render.sh
|
|
@ -1,6 +1,11 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# End-to-end re-render of the dashboard demo video.
|
||||
# End-to-end re-render of the dashboard demo videos.
|
||||
#
|
||||
# All per-storyboard knobs (aspect, fps, bitrate, prompt text, voice persona,
|
||||
# poster timestamp, brand strings…) live on the Storyboard objects in
|
||||
# src/storyboard.ts. To add a vertical cut or change the voice, edit that
|
||||
# file — this script only handles target/auth/transport concerns.
|
||||
#
|
||||
# Two targets:
|
||||
# local (default) — assumes the docker-compose stack on host.docker.internal,
|
||||
|
|
@ -17,7 +22,6 @@
|
|||
# ./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
|
||||
|
|
@ -48,7 +52,7 @@ case "$TARGET" in
|
|||
*) echo "Unknown --target: $TARGET (expected: local, prod)" >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
# -- config (override via env) -------------------------------------------------
|
||||
# -- 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}"
|
||||
|
|
@ -81,23 +85,6 @@ AUTH_TTL_HOURS="${AUTH_TTL_HOURS:-24}" # re-auth if cache older than this
|
|||
# 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
|
||||
|
|
@ -109,7 +96,7 @@ for arg in "${@:-}"; do
|
|||
--no-encode) DO_ENCODE=0 ;;
|
||||
--no-audio) DO_AUDIO=0 ;;
|
||||
-h|--help)
|
||||
sed -n '3,30p' "$0"
|
||||
sed -n '3,32p' "$0"
|
||||
exit 0 ;;
|
||||
*) echo "Unknown arg: $arg" >&2; exit 2 ;;
|
||||
esac
|
||||
|
|
@ -207,22 +194,57 @@ 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.
|
||||
# -- 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
|
||||
# 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"
|
||||
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"
|
||||
}
|
||||
|
||||
# -- 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.
|
||||
for sb in "${STORYBOARDS[@]}"; do
|
||||
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"
|
||||
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."
|
||||
|
|
@ -236,95 +258,103 @@ if [ "$DO_AUDIO" = "1" ]; then
|
|||
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"
|
||||
say "Synchronising tts/ Python deps"
|
||||
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
|
||||
|
||||
for sb in "${STORYBOARDS[@]}"; do
|
||||
say "Synthesising narration for [$sb] — one batched call"
|
||||
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
|
||||
done
|
||||
fi
|
||||
|
||||
# -- record -------------------------------------------------------------------
|
||||
say "Recording"
|
||||
# -- 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"
|
||||
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"
|
||||
for sb in "${STORYBOARDS[@]}"; do
|
||||
if [ ! -s "output/$sb/recording.webm" ]; then
|
||||
fail "[$sb] recording.webm missing or empty"
|
||||
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
|
||||
node dist/verify.js "$sb" "output/$sb/recording.webm"
|
||||
done
|
||||
|
||||
# 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
|
||||
# -- 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
|
||||
|
||||
# -- 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"
|
||||
for sb in "${STORYBOARDS[@]}"; do
|
||||
if [ "$DO_ENCODE" = "1" ]; then
|
||||
say "[$sb] Encoding to MP4"
|
||||
ffmpeg -y -loglevel warning -i "output/$sb/recording.webm" \
|
||||
-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
|
||||
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"
|
||||
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
|
||||
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 -------------------------------------------------------------------
|
||||
# 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 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
|
||||
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
|
||||
else
|
||||
ls -la output/recording.* output/poster.jpg \
|
||||
"$PUBLISH_DIR/recording.mp4" "$PUBLISH_DIR/poster.jpg" 2>/dev/null || true
|
||||
fi
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue