Migrate video
This commit is contained in:
parent
75c18a8eb4
commit
4428d6c50a
11 changed files with 310 additions and 80 deletions
|
|
@ -372,6 +372,16 @@ if [ "$DO_AUDIO" = "1" ]; then
|
||||||
done
|
done
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# -- cue timing gate ---------------------------------------------------------
|
||||||
|
# Every cue's `during` block has to fit inside the narration line it plays
|
||||||
|
# under, and synth has just measured every one of those lines. Check the whole
|
||||||
|
# set here, while the run has cost nothing but TTS. The recorder enforces the
|
||||||
|
# same rule, but it hits it one cue at a time, mid-take, after re-recording
|
||||||
|
# every storyboard that came before: a set of mistuned cues costs one full
|
||||||
|
# render cycle each to discover. This reports all of them at once.
|
||||||
|
say "Checking cue timings against the measured narration"
|
||||||
|
node dist/check-timing.js || fail "cue timing check failed (see the list above)"
|
||||||
|
|
||||||
# -- record ------------------------------------------------------------------
|
# -- record ------------------------------------------------------------------
|
||||||
# record.ts iterates over storyboards in-process and writes per-storyboard
|
# record.ts iterates over storyboards in-process and writes per-storyboard
|
||||||
# recording.webm + narration.json. One Node invocation handles all of them
|
# recording.webm + narration.json. One Node invocation handles all of them
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { chromium } from 'playwright';
|
import { chromium } from 'playwright';
|
||||||
import { APP_URL, AUTH_STATE_PATH } from './config.js';
|
import { appUrl, AUTH_STATE_PATH } from './config.js';
|
||||||
import { ensureRecorderAdminUser } from './pb-admin.js';
|
import { ensureRecorderAdminUser } from './pb-admin.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -27,7 +27,7 @@ async function programmatic() {
|
||||||
const browser = await chromium.launch({ headless: true });
|
const browser = await chromium.launch({ headless: true });
|
||||||
const context = await browser.newContext({ viewport: { width: 1280, height: 800 } });
|
const context = await browser.newContext({ viewport: { width: 1280, height: 800 } });
|
||||||
const page = await context.newPage();
|
const page = await context.newPage();
|
||||||
await page.goto(APP_URL);
|
await page.goto(appUrl());
|
||||||
|
|
||||||
// Drive the dashboard's actual login modal. Scoping to <header> avoids the
|
// Drive the dashboard's actual login modal. Scoping to <header> avoids the
|
||||||
// modal's own "Log in" submit button (same text, different element).
|
// modal's own "Log in" submit button (same text, different element).
|
||||||
|
|
@ -62,7 +62,7 @@ async function interactive() {
|
||||||
const browser = await chromium.launch({ headless: false });
|
const browser = await chromium.launch({ headless: false });
|
||||||
const context = await browser.newContext({ viewport: { width: 1280, height: 800 } });
|
const context = await browser.newContext({ viewport: { width: 1280, height: 800 } });
|
||||||
const page = await context.newPage();
|
const page = await context.newPage();
|
||||||
await page.goto(APP_URL);
|
await page.goto(appUrl());
|
||||||
|
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log(' → Log in via the UI in the opened browser window.');
|
console.log(' → Log in via the UI in the opened browser window.');
|
||||||
|
|
|
||||||
89
video/src/check-timing.ts
Normal file
89
video/src/check-timing.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
import {
|
||||||
|
SLACK_WARN_MS,
|
||||||
|
cueTiming,
|
||||||
|
overrunMessage,
|
||||||
|
readSynthIndex,
|
||||||
|
} from './cue-timing.js';
|
||||||
|
import { storyboards } from './storyboard.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fail the render before recording if any cue's `during` block is longer than
|
||||||
|
* the narration line it plays under.
|
||||||
|
*
|
||||||
|
* Runs after synth, when every measured duration is known and nothing
|
||||||
|
* expensive has happened yet. Reports EVERY offending cue across EVERY
|
||||||
|
* storyboard in one pass: the point is to hand over the whole list, because
|
||||||
|
* the alternative (the runner's mid-take throw) reveals exactly one cue per
|
||||||
|
* render cycle and each cycle costs a full re-record of everything before it.
|
||||||
|
*
|
||||||
|
* Also warns about cues that fit only barely. Those are the ones that break
|
||||||
|
* next time someone touches the copy or the voice, and a warning here is
|
||||||
|
* cheaper than finding out from a failed run.
|
||||||
|
*
|
||||||
|
* Skips silently on --no-audio runs, where there are no measured durations to
|
||||||
|
* check against and the runner falls back to estimates.
|
||||||
|
*/
|
||||||
|
function main(): void {
|
||||||
|
const errors: string[] = [];
|
||||||
|
const warnings: string[] = [];
|
||||||
|
let checkedCues = 0;
|
||||||
|
let checkedStoryboards = 0;
|
||||||
|
|
||||||
|
for (const storyboard of storyboards) {
|
||||||
|
// A storyboard whose manifest no longer matches its cues (a cue added
|
||||||
|
// without a re-synth) throws out of readSynthIndex. Collect that as a
|
||||||
|
// finding rather than letting it abort the sweep: one unreadable
|
||||||
|
// storyboard must not hide the other 22.
|
||||||
|
let synth;
|
||||||
|
try {
|
||||||
|
synth = readSynthIndex(storyboard);
|
||||||
|
} catch (err) {
|
||||||
|
errors.push(`[${storyboard.name}] ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!synth) continue;
|
||||||
|
checkedStoryboards++;
|
||||||
|
|
||||||
|
for (let i = 0; i < storyboard.cues.length; i++) {
|
||||||
|
const cue = storyboard.cues[i];
|
||||||
|
const timing = cueTiming(cue, i, synth[i].durationMs);
|
||||||
|
checkedCues++;
|
||||||
|
if (timing.over) {
|
||||||
|
errors.push(`[${storyboard.name}] ${overrunMessage(cue, timing)}`);
|
||||||
|
} else if (timing.slackMs < SLACK_WARN_MS) {
|
||||||
|
warnings.push(
|
||||||
|
`[${storyboard.name}] cue ${i} fits with only ${timing.slackMs}ms to spare ` +
|
||||||
|
`(during=${timing.declaredMs}ms, audio=${timing.measuredMs}ms): ` +
|
||||||
|
`"${cue.text.slice(0, 40)}…"`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only a clean sweep that found nothing to read is a --no-audio run. Test
|
||||||
|
// errors first: every storyboard failing to load is the loudest possible
|
||||||
|
// signal, and reporting it as "nothing to check" would wave the render
|
||||||
|
// through on the strength of its own broken manifests.
|
||||||
|
if (checkedStoryboards === 0 && errors.length === 0) {
|
||||||
|
console.log('[check-timing] no synthesised audio found, skipping (--no-audio run)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const warning of warnings) console.warn(`[check-timing] WARN: ${warning}`);
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
for (const error of errors) console.error(`[check-timing] FAIL: ${error}`);
|
||||||
|
console.error(
|
||||||
|
`\n[check-timing] ${errors.length} cue(s) declare more visual activity than their ` +
|
||||||
|
`narration covers. Fix them all now: recording would hit them one at a time.`
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[check-timing] ${checkedCues} cues across ${checkedStoryboards} storyboards fit their ` +
|
||||||
|
`narration${warnings.length > 0 ? ` (${warnings.length} with under ${SLACK_WARN_MS}ms of slack)` : ''}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
|
|
@ -10,7 +10,14 @@ function requiredEnv(name: string): string {
|
||||||
// voice, prompts, brand…) lives on the Storyboard object itself. See
|
// voice, prompts, brand…) lives on the Storyboard object itself. See
|
||||||
// src/storyboard.ts.
|
// src/storyboard.ts.
|
||||||
|
|
||||||
export const APP_URL = requiredEnv('APP_URL');
|
/**
|
||||||
|
* Read lazily, not at import time. This module also holds pure constants
|
||||||
|
* (OUTPUT_DIR, LEAD_IN_S) that the offline steps need, and a top-level
|
||||||
|
* `requiredEnv` made importing any of them fail without a frontend URL: it
|
||||||
|
* put a live-stack dependency on dist/check-timing.js, which only reads JSON
|
||||||
|
* off disk. Only the steps that actually open a browser call this.
|
||||||
|
*/
|
||||||
|
export const appUrl = (): string => requiredEnv('APP_URL');
|
||||||
export const DASHBOARD_PATH = '/dashboard';
|
export const DASHBOARD_PATH = '/dashboard';
|
||||||
|
|
||||||
// Per-target storage state. render.sh sets AUTH_STATE_FILE to auth.local.json
|
// Per-target storage state. render.sh sets AUTH_STATE_FILE to auth.local.json
|
||||||
|
|
|
||||||
132
video/src/cue-timing.ts
Normal file
132
video/src/cue-timing.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
import { existsSync, readFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { OUTPUT_DIR } from './config.js';
|
||||||
|
import type { Cue, Storyboard } from './script.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The cue/audio fit rule, shared by the runner (which enforces it per cue,
|
||||||
|
* mid-take) and dist/check-timing.js (which enforces it for every cue of
|
||||||
|
* every storyboard before a single frame is recorded).
|
||||||
|
*
|
||||||
|
* Both call sites used to be one call site: the runner threw the moment it
|
||||||
|
* reached a cue whose `during` block was longer than its measured audio. That
|
||||||
|
* is a true invariant but a terrible place to learn about it. Recording is the
|
||||||
|
* expensive stage, storyboards record one after another, and the throw kills
|
||||||
|
* the whole run, so a set of N mistuned cues cost N full re-runs to find, one
|
||||||
|
* per cycle, each one paying for the storyboards that already passed. The
|
||||||
|
* durations that decide the rule are all known straight after synth, so the
|
||||||
|
* check belongs there. The runner keeps its throw as a backstop for anyone
|
||||||
|
* running dist/record.js directly.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface SynthCue {
|
||||||
|
cueIndex: number;
|
||||||
|
text: string;
|
||||||
|
durationMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FALLBACK_MS_PER_WORD = 750;
|
||||||
|
const FALLBACK_TAIL_BUFFER_MS = 800;
|
||||||
|
const CJK_CHARS_PER_FALLBACK_WORD = 2;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Slop allowed on the fit rule. A `during` block this much over its cue's
|
||||||
|
* audio is treated as authored-to-fit rather than a mistake: the runner pads
|
||||||
|
* each step up to its declared budget but never trims one, so a cue that
|
||||||
|
* overshoots by less than a frame or two of scheduler jitter would produce an
|
||||||
|
* identical cut either way.
|
||||||
|
*/
|
||||||
|
export const DURING_TOLERANCE_MS = 50;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cues with less slack than this fit today only because the TTS happened to
|
||||||
|
* speak the line slowly enough. They are not errors, but any re-synth (a
|
||||||
|
* reworded line, a new voice, a re-rolled seed) can push them over, so
|
||||||
|
* check-timing surfaces them while the render is still cheap to fix.
|
||||||
|
*/
|
||||||
|
export const SLACK_WARN_MS = 250;
|
||||||
|
|
||||||
|
export interface CueTiming {
|
||||||
|
cueIndex: number;
|
||||||
|
/** Sum of the cue's declared `during` budgets. */
|
||||||
|
declaredMs: number;
|
||||||
|
/** Measured length of the cue's synthesised audio. */
|
||||||
|
measuredMs: number;
|
||||||
|
/** measured - declared. Negative means the visuals outlast the line. */
|
||||||
|
slackMs: number;
|
||||||
|
/** True when the overrun is past DURING_TOLERANCE_MS, i.e. a hard error. */
|
||||||
|
over: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function declaredDuringMs(cue: Cue): number {
|
||||||
|
return (cue.during ?? []).reduce((sum, activity) => sum + activity.durationMs, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cueTiming(cue: Cue, cueIndex: number, measuredMs: number): CueTiming {
|
||||||
|
const declaredMs = declaredDuringMs(cue);
|
||||||
|
return {
|
||||||
|
cueIndex,
|
||||||
|
declaredMs,
|
||||||
|
measuredMs,
|
||||||
|
slackMs: measuredMs - declaredMs,
|
||||||
|
over: declaredMs > measuredMs + DURING_TOLERANCE_MS,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The operator-facing explanation of an over-budget cue. */
|
||||||
|
export function overrunMessage(cue: Cue, timing: CueTiming): string {
|
||||||
|
return (
|
||||||
|
`Cue ${timing.cueIndex} "${cue.text.slice(0, 40)}…" has ${timing.declaredMs}ms of ` +
|
||||||
|
`during activities but the measured audio is only ${timing.measuredMs}ms ` +
|
||||||
|
`(${-timing.slackMs}ms over). Trim a during step, move staging into the ` +
|
||||||
|
`previous cue's tail, or lengthen the cue text.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read synth's measured cue durations for one storyboard, or null when the
|
||||||
|
* storyboard has no audio yet (a --no-audio run).
|
||||||
|
*/
|
||||||
|
export function readSynthIndex(storyboard: Storyboard): SynthCue[] | null {
|
||||||
|
const path = join(OUTPUT_DIR, storyboard.name, 'audio', 'index.json');
|
||||||
|
if (!existsSync(path)) return null;
|
||||||
|
const raw = JSON.parse(readFileSync(path, 'utf-8')) as { items: SynthCue[] };
|
||||||
|
const byIndex = new Map(raw.items.map((item) => [item.cueIndex, item] as const));
|
||||||
|
return storyboard.cues.map((cue, i) => {
|
||||||
|
const measured = byIndex.get(i);
|
||||||
|
if (!measured) {
|
||||||
|
throw new Error(
|
||||||
|
`[${storyboard.name}] synth manifest is missing cue ${i} ("${cue.text.slice(0, 40)}…"). ` +
|
||||||
|
`Re-run preflight + synth so the audio matches the storyboard.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return measured;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Measured durations when synth has run, worst-case estimates when it has
|
||||||
|
* not. The estimate path only serves --no-audio runs, where the visual flow
|
||||||
|
* still has to play without speech to time against.
|
||||||
|
*/
|
||||||
|
export function loadSynthIndex(storyboard: Storyboard): SynthCue[] {
|
||||||
|
const measured = readSynthIndex(storyboard);
|
||||||
|
if (measured) return measured;
|
||||||
|
console.log(
|
||||||
|
`[runner] no ${join(OUTPUT_DIR, storyboard.name, 'audio', 'index.json')} found. ` +
|
||||||
|
`Using worst-case fallback durations (${FALLBACK_MS_PER_WORD}ms/word + ` +
|
||||||
|
`${FALLBACK_TAIL_BUFFER_MS}ms buffer). Audio will be missing.`
|
||||||
|
);
|
||||||
|
return storyboard.cues.map((cue, cueIndex) => ({
|
||||||
|
cueIndex,
|
||||||
|
text: cue.text,
|
||||||
|
durationMs: estimateFallbackDurationMs(cue.text),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function estimateFallbackDurationMs(text: string): number {
|
||||||
|
const wordCount = text.split(/\s+/).filter(Boolean).length;
|
||||||
|
const cjkCount = text.match(/\p{Script=Han}/gu)?.length ?? 0;
|
||||||
|
const units = Math.max(wordCount, Math.ceil(cjkCount / CJK_CHARS_PER_FALLBACK_WORD), 1);
|
||||||
|
return units * FALLBACK_MS_PER_WORD + FALLBACK_TAIL_BUFFER_MS;
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { chromium } from 'playwright';
|
import { chromium } from 'playwright';
|
||||||
import { APP_URL, AUTH_STATE_PATH, DASHBOARD_PATH } from './config.js';
|
import { appUrl, AUTH_STATE_PATH, DASHBOARD_PATH } from './config.js';
|
||||||
import { viewportFor } from './script.js';
|
import { viewportFor } from './script.js';
|
||||||
import { storyboards } from './storyboard.js';
|
import { storyboards } from './storyboard.js';
|
||||||
|
|
||||||
|
|
@ -24,7 +24,7 @@ async function main() {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.goto(`${APP_URL}${DASHBOARD_PATH}`, { waitUntil: 'networkidle' });
|
await page.goto(`${appUrl()}${DASHBOARD_PATH}`, { waitUntil: 'networkidle' });
|
||||||
await new Promise((r) => setTimeout(r, 1500));
|
await new Promise((r) => setTimeout(r, 1500));
|
||||||
|
|
||||||
await page.screenshot({ path: 'output/probe-1-initial.png', fullPage: false });
|
await page.screenshot({ path: 'output/probe-1-initial.png', fullPage: false });
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { Page } from 'playwright';
|
import type { Page } from 'playwright';
|
||||||
import { APP_URL, DASHBOARD_PATH } from './config.js';
|
import { appUrl, DASHBOARD_PATH } from './config.js';
|
||||||
import type { Storyboard } from './script.js';
|
import type { Storyboard } from './script.js';
|
||||||
|
|
||||||
export async function installDemoRoutes(page: Page, storyboard: Storyboard) {
|
export async function installDemoRoutes(page: Page, storyboard: Storyboard) {
|
||||||
|
|
@ -23,9 +23,14 @@ export function dashboardUrl(storyboard: Storyboard): string {
|
||||||
params.append('filter', entry);
|
params.append('filter', entry);
|
||||||
}
|
}
|
||||||
for (const tt of storyboard.content.stubbedTravelTimeFilters) {
|
for (const tt of storyboard.content.stubbedTravelTimeFilters) {
|
||||||
params.append('tt', `${tt.mode}:${tt.slug}:${tt.label}:${tt.min ?? 0}:${tt.max ?? 120}`);
|
// The fallback must track MAX_TRAVEL_MINUTES (frontend/src/hooks/useTravelTime.ts).
|
||||||
|
// It read 120 against a live 90: dead, because every storyboard sets `max`,
|
||||||
|
// but the same stale number that made the commute drag land on 19 when the
|
||||||
|
// storyboard said 25. Reached, it would ask for a cap the dashboard clamps
|
||||||
|
// away, and the take would film a filter the storyboard never declared.
|
||||||
|
params.append('tt', `${tt.mode}:${tt.slug}:${tt.label}:${tt.min ?? 0}:${tt.max ?? TRAVEL_TIME_MAX_MIN}`);
|
||||||
}
|
}
|
||||||
return `${APP_URL}${DASHBOARD_PATH}?${params}`;
|
return `${appUrl()}${DASHBOARD_PATH}?${params}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function stubAiFilters(page: Page, storyboard: Storyboard) {
|
async function stubAiFilters(page: Page, storyboard: Storyboard) {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,11 @@
|
||||||
import { existsSync, readFileSync } from 'node:fs';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
import type { Page } from 'playwright';
|
import type { Page } from 'playwright';
|
||||||
import { LEAD_IN_S, OUTPUT_DIR } from './config.js';
|
import { LEAD_IN_S } from './config.js';
|
||||||
|
import {
|
||||||
|
cueTiming,
|
||||||
|
loadSynthIndex,
|
||||||
|
overrunMessage,
|
||||||
|
type SynthCue,
|
||||||
|
} from './cue-timing.js';
|
||||||
import {
|
import {
|
||||||
clearVignette,
|
clearVignette,
|
||||||
hideAdScene,
|
hideAdScene,
|
||||||
|
|
@ -26,15 +30,6 @@ export interface RunnerResult {
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAP_ZOOM_WHEEL_DELTA = -120;
|
const MAP_ZOOM_WHEEL_DELTA = -120;
|
||||||
const FALLBACK_MS_PER_WORD = 750;
|
|
||||||
const FALLBACK_TAIL_BUFFER_MS = 800;
|
|
||||||
const CJK_CHARS_PER_FALLBACK_WORD = 2;
|
|
||||||
|
|
||||||
interface SynthCue {
|
|
||||||
cueIndex: number;
|
|
||||||
text: string;
|
|
||||||
durationMs: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Drive the recording from a cue-anchored storyboard.
|
* Drive the recording from a cue-anchored storyboard.
|
||||||
|
|
@ -116,14 +111,11 @@ async function runCue(
|
||||||
}
|
}
|
||||||
|
|
||||||
const during = cue.during ?? [];
|
const during = cue.during ?? [];
|
||||||
const declaredSum = during.reduce((s, a) => s + a.durationMs, 0);
|
// Backstop only: dist/check-timing.js has already vetted every cue of every
|
||||||
if (declaredSum > measuredAudioMs + 50) {
|
// storyboard against this same rule, before recording started. Reaching a
|
||||||
throw new Error(
|
// throw here means record.js was run outside render.sh.
|
||||||
`Cue ${synth.cueIndex} "${cue.text.slice(0, 40)}…" has ${declaredSum}ms of ` +
|
const timing = cueTiming(cue, synth.cueIndex, measuredAudioMs);
|
||||||
`during activities but the measured audio is only ${measuredAudioMs}ms. ` +
|
if (timing.over) throw new Error(overrunMessage(cue, timing));
|
||||||
`Trim a during step, lengthen the cue text, or move work into tail.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// Time the during block as a whole: individual steps may overrun their
|
// Time the during block as a whole: individual steps may overrun their
|
||||||
// budgets, but what matters at the cue boundary is total wall-clock.
|
// budgets, but what matters at the cue boundary is total wall-clock.
|
||||||
const duringStart = Date.now();
|
const duringStart = Date.now();
|
||||||
|
|
@ -514,45 +506,4 @@ async function tryResolveTarget(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Load synth's measured cue durations. Falls back to a worst-case estimate
|
|
||||||
* if the manifest is missing: that path is only used for ``--no-audio``
|
|
||||||
* runs, where the visual flow needs to play even without speech to time
|
|
||||||
* against.
|
|
||||||
*/
|
|
||||||
function loadSynthIndex(storyboard: Storyboard): SynthCue[] {
|
|
||||||
const path = join(OUTPUT_DIR, storyboard.name, 'audio', 'index.json');
|
|
||||||
if (existsSync(path)) {
|
|
||||||
const raw = JSON.parse(readFileSync(path, 'utf-8')) as {
|
|
||||||
items: SynthCue[];
|
|
||||||
};
|
|
||||||
const byIndex = new Map(raw.items.map((it) => [it.cueIndex, it] as const));
|
|
||||||
return storyboard.cues.map((cue, i) => {
|
|
||||||
const m = byIndex.get(i);
|
|
||||||
if (!m) {
|
|
||||||
throw new Error(
|
|
||||||
`Synth manifest is missing cue ${i} ("${cue.text.slice(0, 40)}…"). ` +
|
|
||||||
`Re-run preflight + synth so the audio matches the storyboard.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return m;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
console.log(
|
|
||||||
`[runner] no ${path} found. Using worst-case fallback durations (${FALLBACK_MS_PER_WORD}ms/word + ${FALLBACK_TAIL_BUFFER_MS}ms buffer). Audio will be missing.`
|
|
||||||
);
|
|
||||||
return storyboard.cues.map((cue, cueIndex) => ({
|
|
||||||
cueIndex,
|
|
||||||
text: cue.text,
|
|
||||||
durationMs: estimateFallbackDurationMs(cue.text),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
function estimateFallbackDurationMs(text: string): number {
|
|
||||||
const wordCount = text.split(/\s+/).filter(Boolean).length;
|
|
||||||
const cjkCount = text.match(/\p{Script=Han}/gu)?.length ?? 0;
|
|
||||||
const units = Math.max(wordCount, Math.ceil(cjkCount / CJK_CHARS_PER_FALLBACK_WORD), 1);
|
|
||||||
return units * FALLBACK_MS_PER_WORD + FALLBACK_TAIL_BUFFER_MS;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type { Page };
|
export type { Page };
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,14 @@ const SCHOOL_OUTSTANDING_PRIMARY = 'Outstanding primary school catchments';
|
||||||
const AI_ZOOM_SCALE_DESKTOP = 1.45;
|
const AI_ZOOM_SCALE_DESKTOP = 1.45;
|
||||||
|
|
||||||
const TT_CARD_SELECTOR = '[data-filter-name="tt_0"]';
|
const TT_CARD_SELECTOR = '[data-filter-name="tt_0"]';
|
||||||
const TT_SLIDER_MAX = 120;
|
// Must track MAX_TRAVEL_MINUTES in frontend/src/hooks/useTravelTime.ts, which
|
||||||
|
// is what TravelTimeCard hands the slider as its max. Only `toFraction` maths
|
||||||
|
// reads this, so drift does not throw: it silently drops the thumb at
|
||||||
|
// `target × (90/this)` of the way along and the take looks plausible. It was
|
||||||
|
// 120 against a live 90, which is why ttDragAct's comment below records a drop
|
||||||
|
// landing on 15 when the story said 20 (20/120 × 90 = 15 exactly). That was
|
||||||
|
// papered over with snapToValue rather than traced back to here.
|
||||||
|
const TT_SLIDER_MAX = 90;
|
||||||
const TT_DRAG_FROM_MIN = 35;
|
const TT_DRAG_FROM_MIN = 35;
|
||||||
// 25 (not 20): tight enough that the drag visibly prunes the map, loose
|
// 25 (not 20): tight enough that the drag visibly prunes the map, loose
|
||||||
// enough that street-level central London keeps plenty of matching
|
// enough that street-level central London keeps plenty of matching
|
||||||
|
|
@ -421,20 +428,34 @@ function createCues(locale: RecordingLocale, formFactor: FormFactor): Storyboard
|
||||||
thumbSelector: `${TT_CARD_SELECTOR} [role="slider"] >> nth=1`,
|
thumbSelector: `${TT_CARD_SELECTOR} [role="slider"] >> nth=1`,
|
||||||
trackSelector: `${TT_CARD_SELECTOR} [data-orientation="horizontal"] >> nth=0`,
|
trackSelector: `${TT_CARD_SELECTOR} [data-orientation="horizontal"] >> nth=0`,
|
||||||
toFraction: TT_DRAG_TO_MIN / TT_SLIDER_MAX,
|
toFraction: TT_DRAG_TO_MIN / TT_SLIDER_MAX,
|
||||||
|
// Land on the exact minute, like the ads' ttDragAct does. Without
|
||||||
|
// it this drag inherited the TT_SLIDER_MAX drift and dropped the
|
||||||
|
// thumb at 19, under the 20 that the TT_DRAG_TO_MIN note above
|
||||||
|
// records as emptying central London and leaving the postcode tap
|
||||||
|
// in the next cue with nothing to open.
|
||||||
|
snapToValue: TT_DRAG_TO_MIN,
|
||||||
durationMs: 1600,
|
durationMs: 1600,
|
||||||
},
|
},
|
||||||
{ kind: 'click', target: colourTravelTime, durationMs: 700 },
|
{ kind: 'click', target: colourTravelTime, durationMs: 700 },
|
||||||
],
|
],
|
||||||
tail: [{ kind: 'wait', durationMs: 300 }],
|
// Clearing the sheet off the map and growing the cursor are staging for
|
||||||
|
// the zoom in the next cue, not part of any spoken line, so they run in
|
||||||
|
// this cue's tail. Inside the next cue's `during` they had to share the
|
||||||
|
// window with the zoom itself, and this cue builder is shared by all six
|
||||||
|
// locales: the budget has to fit the SHORTEST narration of the six (hi),
|
||||||
|
// not the one it was tuned against.
|
||||||
|
tail: [
|
||||||
|
...(isMobile
|
||||||
|
? [{ kind: 'dragSheet', toHeightFrac: SHEET_DOWN, durationMs: 800 } as Activity]
|
||||||
|
: []),
|
||||||
|
{ kind: 'cursorScale', scale: isMobile ? 1 : 1.4, durationMs: 200 },
|
||||||
|
{ kind: 'wait', durationMs: 300 },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: copy.cues.streets,
|
text: copy.cues.streets,
|
||||||
gapBeforeMs: 300,
|
gapBeforeMs: 300,
|
||||||
during: [
|
during: [
|
||||||
...(isMobile
|
|
||||||
? [{ kind: 'dragSheet', toHeightFrac: SHEET_DOWN, durationMs: 800 } as Activity]
|
|
||||||
: []),
|
|
||||||
{ kind: 'cursorScale', scale: isMobile ? 1 : 1.4, durationMs: 200 },
|
|
||||||
{
|
{
|
||||||
// Zoom AROUND the strongest visible match (hex()) so the
|
// Zoom AROUND the strongest visible match (hex()) so the
|
||||||
// destination area is guaranteed to contain matching postcodes.
|
// destination area is guaranteed to contain matching postcodes.
|
||||||
|
|
@ -1120,12 +1141,15 @@ const AD_CONFIGS: DemoAdStoryboardConfig[] = [
|
||||||
text: 'Every colour on this map is a commute to central London.',
|
text: 'Every colour on this map is a commute to central London.',
|
||||||
caption: 'Same commute, cheaper postcode',
|
caption: 'Same commute, cheaper postcode',
|
||||||
during: [wait(400)],
|
during: [wait(400)],
|
||||||
tail: [wait(150)],
|
// Raise the sheet here rather than under the next line. Bringing the
|
||||||
|
// slider on screen is staging, and the next cue is a short one: with
|
||||||
|
// the sheet in it, the drag had to be rushed to fit the words.
|
||||||
|
tail: [wait(150), sheetUp(700)],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: "Here's what twenty minutes actually leaves you.",
|
text: "Here's what twenty minutes actually leaves you.",
|
||||||
caption: 'What 20 minutes buys',
|
caption: 'What 20 minutes buys',
|
||||||
during: [sheetUp(700), touchShow(), ttDragAct(20, 1700)],
|
during: [touchShow(), ttDragAct(20, 1700)],
|
||||||
tail: [touchHide(), sheetDown(650)],
|
tail: [touchHide(), sheetDown(650)],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -38,8 +38,20 @@ def measure_loudness(cmd_head: list[str], filter_complex: str) -> dict[str, str]
|
||||||
|
|
||||||
Returns the measured values for the linear second pass, or None if the
|
Returns the measured values for the linear second pass, or None if the
|
||||||
measurement failed (in which case the caller falls back to one-pass).
|
measurement failed (in which case the caller falls back to one-pass).
|
||||||
|
|
||||||
|
loudnorm prints its JSON summary at ffmpeg's `info` level, so this pass
|
||||||
|
cannot inherit the caller's `-loglevel warning`: that suppressed the very
|
||||||
|
stats parsed below, the probe returned None every single time, and every
|
||||||
|
render silently took the one-pass fallback the docstring treats as the
|
||||||
|
exception. Nothing surfaced it because the fallback sounds fine until you
|
||||||
|
compare it: one-pass is the dynamic mode, which pumps on speech.
|
||||||
"""
|
"""
|
||||||
probe_cmd = cmd_head + [
|
probe_head = list(cmd_head)
|
||||||
|
if "-loglevel" in probe_head:
|
||||||
|
probe_head[probe_head.index("-loglevel") + 1] = "info"
|
||||||
|
else:
|
||||||
|
probe_head[1:1] = ["-loglevel", "info"]
|
||||||
|
probe_cmd = probe_head + [
|
||||||
"-filter_complex",
|
"-filter_complex",
|
||||||
f"{filter_complex};[aout]loudnorm={LOUDNORM_TARGET}:print_format=json[anorm]",
|
f"{filter_complex};[aout]loudnorm={LOUDNORM_TARGET}:print_format=json[anorm]",
|
||||||
"-map",
|
"-map",
|
||||||
|
|
|
||||||
|
|
@ -427,7 +427,7 @@ def resolve_voice(script_path: Path, block: dict) -> VoiceSettings:
|
||||||
voice=voice,
|
voice=voice,
|
||||||
language=language,
|
language=language,
|
||||||
temperature=float(block.get("temperature", 0.8)),
|
temperature=float(block.get("temperature", 0.8)),
|
||||||
exaggeration=float(block.get("exaggeration", 0.5)),
|
exaggeration=float(block.get("exaggeration", 0.65)),
|
||||||
cfg_weight=float(block.get("cfgWeight", 0.5)),
|
cfg_weight=float(block.get("cfgWeight", 0.5)),
|
||||||
speed_factor=float(block.get("speedFactor", 1.0)),
|
speed_factor=float(block.get("speedFactor", 1.0)),
|
||||||
seed=int(block.get("seed", 42)),
|
seed=int(block.get("seed", 42)),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue