Migrate video

This commit is contained in:
Andras Schmelczer 2026-07-16 07:56:38 +01:00
parent 75c18a8eb4
commit 4428d6c50a
11 changed files with 310 additions and 80 deletions

View file

@ -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

View file

@ -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
View 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();

View file

@ -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
View 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;
}

View file

@ -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 });

View file

@ -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) {

View file

@ -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 };

View file

@ -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)],
}, },
{ {

View file

@ -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",

View file

@ -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)),