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
|
||||
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.ts iterates over storyboards in-process and writes per-storyboard
|
||||
# recording.webm + narration.json. One Node invocation handles all of them
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
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';
|
||||
|
||||
/**
|
||||
|
|
@ -27,7 +27,7 @@ async function programmatic() {
|
|||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({ viewport: { width: 1280, height: 800 } });
|
||||
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
|
||||
// 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 context = await browser.newContext({ viewport: { width: 1280, height: 800 } });
|
||||
const page = await context.newPage();
|
||||
await page.goto(APP_URL);
|
||||
await page.goto(appUrl());
|
||||
|
||||
console.log('');
|
||||
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
|
||||
// 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';
|
||||
|
||||
// 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 { 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 { 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 page.screenshot({ path: 'output/probe-1-initial.png', fullPage: false });
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
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';
|
||||
|
||||
export async function installDemoRoutes(page: Page, storyboard: Storyboard) {
|
||||
|
|
@ -23,9 +23,14 @@ export function dashboardUrl(storyboard: Storyboard): string {
|
|||
params.append('filter', entry);
|
||||
}
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
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 {
|
||||
clearVignette,
|
||||
hideAdScene,
|
||||
|
|
@ -26,15 +30,6 @@ export interface RunnerResult {
|
|||
}
|
||||
|
||||
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.
|
||||
|
|
@ -116,14 +111,11 @@ async function runCue(
|
|||
}
|
||||
|
||||
const during = cue.during ?? [];
|
||||
const declaredSum = during.reduce((s, a) => s + a.durationMs, 0);
|
||||
if (declaredSum > measuredAudioMs + 50) {
|
||||
throw new Error(
|
||||
`Cue ${synth.cueIndex} "${cue.text.slice(0, 40)}…" has ${declaredSum}ms of ` +
|
||||
`during activities but the measured audio is only ${measuredAudioMs}ms. ` +
|
||||
`Trim a during step, lengthen the cue text, or move work into tail.`
|
||||
);
|
||||
}
|
||||
// Backstop only: dist/check-timing.js has already vetted every cue of every
|
||||
// storyboard against this same rule, before recording started. Reaching a
|
||||
// throw here means record.js was run outside render.sh.
|
||||
const timing = cueTiming(cue, synth.cueIndex, measuredAudioMs);
|
||||
if (timing.over) throw new Error(overrunMessage(cue, timing));
|
||||
// Time the during block as a whole: individual steps may overrun their
|
||||
// budgets, but what matters at the cue boundary is total wall-clock.
|
||||
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 };
|
||||
|
|
|
|||
|
|
@ -59,7 +59,14 @@ const SCHOOL_OUTSTANDING_PRIMARY = 'Outstanding primary school catchments';
|
|||
const AI_ZOOM_SCALE_DESKTOP = 1.45;
|
||||
|
||||
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;
|
||||
// 25 (not 20): tight enough that the drag visibly prunes the map, loose
|
||||
// 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`,
|
||||
trackSelector: `${TT_CARD_SELECTOR} [data-orientation="horizontal"] >> nth=0`,
|
||||
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,
|
||||
},
|
||||
{ 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,
|
||||
gapBeforeMs: 300,
|
||||
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
|
||||
// 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.',
|
||||
caption: 'Same commute, cheaper postcode',
|
||||
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.",
|
||||
caption: 'What 20 minutes buys',
|
||||
during: [sheetUp(700), touchShow(), ttDragAct(20, 1700)],
|
||||
during: [touchShow(), ttDragAct(20, 1700)],
|
||||
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
|
||||
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",
|
||||
f"{filter_complex};[aout]loudnorm={LOUDNORM_TARGET}:print_format=json[anorm]",
|
||||
"-map",
|
||||
|
|
|
|||
|
|
@ -427,7 +427,7 @@ def resolve_voice(script_path: Path, block: dict) -> VoiceSettings:
|
|||
voice=voice,
|
||||
language=language,
|
||||
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)),
|
||||
speed_factor=float(block.get("speedFactor", 1.0)),
|
||||
seed=int(block.get("seed", 42)),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue