diff --git a/lib/ads/video/compose.ts b/lib/ads/video/compose.ts
index 9524965d..d43a61f2 100644
--- a/lib/ads/video/compose.ts
+++ b/lib/ads/video/compose.ts
@@ -116,7 +116,18 @@ export function composeDocument(
snapshot: VideoDesignSnapshot,
assets: ComposeAssets = { logo: null, hero: null },
): string {
- const headline = escapeHtml(snapshot.headline);
+ const headlineText = snapshot.headline;
+ const headline = escapeHtml(headlineText);
+ // Long copy has to come down or it wraps into the CTA; short copy should fill
+ // the frame rather than float in the middle of it.
+ const headlineSize = headlineText.length > 46 ? 88 : headlineText.length > 28 ? 116 : 148;
+ // One span per word so each can enter on its own beat. Split on whitespace
+ // only — a word is never broken, so no headline is ever half-drawn.
+ const headlineWords = headlineText
+ .split(/\s+/)
+ .filter(Boolean)
+ .map((w, i) => `${escapeHtml(w)}`)
+ .join(" ");
const cta = escapeHtml(snapshot.ctaText);
const domain = escapeHtml(snapshot.domain);
const logo = safeDataUri(assets.logo);
@@ -152,24 +163,34 @@ export function composeDocument(
radial-gradient(120% 120% at 20% 0%, ${accent}22 0%, transparent 60%),${bg}}
.scrim{position:absolute;inset:0;
background:linear-gradient(180deg,${bg}1a 0%,${bg}99 62%,${bg}f2 100%)}
- .copy{position:absolute;left:192px;right:192px;bottom:108px;
- display:flex;flex-direction:column;gap:28px}
- .brand{display:flex;align-items:center;gap:22px}
- .logo{width:84px;height:84px;object-fit:contain;border-radius:18px}
+ /* Centred, not bottom-anchored. Anchored at the bottom the composition left
+ the top half of a 1920x1080 frame empty, which is what made a five-second
+ pre-roll read as a slide rather than an ad. */
+ .copy{position:absolute;left:160px;right:160px;top:50%;transform:translateY(-50%);
+ display:flex;flex-direction:column;gap:40px}
+ .brand{display:flex;align-items:center;gap:24px}
+ .logo{width:96px;height:96px;object-fit:contain;border-radius:20px}
.logo--mono{display:grid;place-items:center;background:${accent};color:${bg};
- font-size:46px;font-weight:800}
- .domain{font-size:34px;letter-spacing:.02em;opacity:.86}
- h1{font-size:104px;line-height:1.05;font-weight:800;letter-spacing:-.02em;
- text-shadow:0 1px 2px ${bg}e6,0 0 12px ${bg}bf}
- .cta{display:inline-flex;align-items:center;gap:18px;align-self:flex-start;
- background:${accent};color:${bg};font-size:40px;font-weight:700;
- padding:22px 40px;border-radius:999px}
+ font-size:52px;font-weight:800}
+ .domain{font-size:38px;letter-spacing:.02em;opacity:.86}
+ /* Sized to the copy. A 20-character headline and an 80-character one cannot
+ share a font size on a fixed frame without one of them looking wrong. */
+ h1{font-size:${headlineSize}px;line-height:1.02;font-weight:800;letter-spacing:-.025em;
+ text-shadow:0 2px 6px ${bg}e6,0 0 18px ${bg}bf}
+ /* Each word animates in on its own, which is what makes the frame visibly
+ progress instead of holding one pose for five seconds. */
+ .w{display:inline-block;will-change:transform,opacity}
+ .cta{display:inline-flex;align-items:center;gap:20px;align-self:flex-start;
+ background:${accent};color:${bg};font-size:44px;font-weight:700;
+ padding:26px 48px;border-radius:999px}
+ .rule{height:8px;width:160px;background:${accent};border-radius:999px;transform-origin:left center}
${heroLayer}
${mark}${domain}
-
${headline}
+
+
${headlineWords}
${cta}
@@ -185,21 +206,70 @@ var stage = document.querySelector('.stage');
var copy = document.querySelector('.copy');
var heroEl = document.querySelector('.hero');
var ctaEl = document.querySelector('.cta');
+var brandEl = document.querySelector('.brand');
+var ruleEl = document.querySelector('.rule');
+var words = Array.prototype.slice.call(document.querySelectorAll('.w'));
+
+function clamp01(v){ return v < 0 ? 0 : v > 1 ? 1 : v; }
// The whole animation, as a pure function of the frame index. Playwright calls
// this, waits for the returned promise to settle, then screenshots.
+//
+// Every value below is deliberately large. The previous version moved the copy
+// 40px and scaled the artwork 4% across five seconds, which at 1920x1080 is
+// invisible: frames one second apart were indistinguishable and the result was
+// a still image with a duration. An ad has to visibly progress.
window.__seek = function (frame) {
var s = frameState(frame, REDUCED);
- // Entrance: copy rises 40px and fades in over the first beat, from an
- // already-legible starting opacity rather than from nothing.
- copy.style.transform = 'translateY(' + ((1 - s.entrance) * 40).toFixed(3) + 'px)';
- copy.style.opacity = (0.55 + 0.45 * s.entrance).toFixed(4);
- // Drift: a 4% slow push on the artwork across the hold. Enough to stop the
- // frame reading as a still, small enough not to pull the eye off the copy.
- heroEl.style.transform = 'scale(' + (1 + 0.04 * s.drift).toFixed(4) + ')';
- // CTA: settles into place and brightens over the final beat.
- ctaEl.style.transform = 'scale(' + (0.96 + 0.04 * s.cta).toFixed(4) + ')';
- ctaEl.style.filter = 'brightness(' + (0.9 + 0.1 * s.cta).toFixed(4) + ')';
+ var t = frameTimeMs(frame);
+
+ if (REDUCED) {
+ // Everything at rest, immediately. A reduced-motion ad still ends on its
+ // CTA; it simply never travels there.
+ brandEl.style.opacity = 1; brandEl.style.transform = 'none';
+ ruleEl.style.transform = 'scaleX(1)';
+ words.forEach(function (w) { w.style.opacity = 1; w.style.transform = 'none'; });
+ ctaEl.style.opacity = 1; ctaEl.style.transform = 'none';
+ heroEl.style.transform = 'none';
+ void stage.offsetHeight;
+ return document.fonts ? document.fonts.ready : Promise.resolve();
+ }
+
+ // Beat 1 (0-700ms): the brand arrives and the accent rule draws across.
+ var b = easeOut(clamp01(t / 500));
+ // Floored, not faded from zero. Frame 0 has to show the advertiser's mark and
+ // domain: the poster is cut from it, and a viewer who sees only the opening
+ // instant should still know whose ad this is.
+ brandEl.style.opacity = (0.35 + 0.65 * b).toFixed(3);
+ brandEl.style.transform = 'translateX(' + ((1 - b) * -60).toFixed(2) + 'px)';
+ ruleEl.style.transform = 'scaleX(' + b.toFixed(3) + ')';
+
+ // Beat 2 (0-1200ms): the headline builds a word at a time, each rising into
+ // place. Staggered so the eye is led along the line rather than shown a block.
+ //
+ // The whole build finishes inside the first quarter of the ad, and the first
+ // word starts at t=0. An earlier version of this composition took 2.6s to
+ // assemble, which spends half a five-second ad saying nothing — the same
+ // objection that made the previous, motionless version wrong, in the other
+ // direction.
+ var per = words.length > 1 ? 800 / (words.length - 1) : 0;
+ words.forEach(function (w, i) {
+ var wp = easeOut(clamp01((t - i * per) / 420));
+ w.style.opacity = wp.toFixed(3);
+ w.style.transform = 'translateY(' + ((1 - wp) * 54).toFixed(2) + 'px)';
+ });
+
+ // Beat 3 (1900-2600ms): the CTA arrives, overshooting slightly so it lands
+ // rather than fades. Early enough that it is on screen for nearly half the
+ // ad, which is the part a viewer is meant to act on.
+ var c = easeOut(clamp01((t - 1900) / 700));
+ ctaEl.style.opacity = c.toFixed(3);
+ ctaEl.style.transform = 'translateY(' + ((1 - c) * 40).toFixed(2) + 'px) scale(' + (0.9 + 0.1 * c).toFixed(3) + ')';
+
+ // Throughout: a real push on the artwork. 12% over five seconds is visible
+ // without pulling the eye off the copy; 4% was not.
+ heroEl.style.transform = 'scale(' + (1 + 0.12 * (t / 5000)).toFixed(4) + ')';
+
// Force layout so the screenshot cannot catch a half-applied style.
void stage.offsetHeight;
return document.fonts ? document.fonts.ready : Promise.resolve();
diff --git a/lib/ads/video/jobs.ts b/lib/ads/video/jobs.ts
index fbc0d385..1ede4e88 100644
--- a/lib/ads/video/jobs.ts
+++ b/lib/ads/video/jobs.ts
@@ -8,6 +8,7 @@
import type { SupabaseClient } from "@supabase/supabase-js";
import { classifyCampaign } from "./classify";
+import { narrationScript } from "./narration";
import type { AdCreative } from "../formats";
import { VIDEO_FORMAT_ID } from "../formats";
import { MAX_HEADLINE_WORDS, renderHash, validateSnapshot, type VideoDesignSnapshot } from "./snapshot";
@@ -81,7 +82,7 @@ export function snapshotFromCreatives(args: {
.find((c): c is NonNullable => !!c);
if (!source) return null;
- return {
+ const base = {
headline: trimHeadlineForVideo(source.headline),
// The animated banners show this as their second line, matching the static
// unit. The pre-roll ignores it: five seconds of 1920x1080 is a headline
@@ -102,11 +103,22 @@ export function snapshotFromCreatives(args: {
logoSha256: null,
heroUrl: null,
heroSha256: null,
- audioMode: "silent",
- narration: null,
+ // Narrated. The script is derived from the same approved copy the frame
+ // shows, deterministically, so it is part of the render hash: two campaigns
+ // with identical copy share one encode, and changing the copy changes the
+ // voiceover along with the picture.
+ //
+ // Declaring it here rather than when the audio comes back matters — the
+ // hash has to describe what was asked for, not what a speech API happened
+ // to return on the day. A synthesis failure ships the silent cut without
+ // making it a different render.
+ audioMode: "narrated" as const,
+ narration: null as string | null,
locale: args.locale ?? "en",
reducedMotion: args.reducedMotion ?? false,
};
+
+ return { ...base, narration: narrationScript(base) };
}
export type RenderHandle = {
diff --git a/lib/ads/video/narration.ts b/lib/ads/video/narration.ts
new file mode 100644
index 00000000..48272548
--- /dev/null
+++ b/lib/ads/video/narration.ts
@@ -0,0 +1,116 @@
+// Spoken narration for a pre-roll.
+//
+// The script is derived from the copy the advertiser already approved, never
+// generated afresh: an ad whose voiceover claims something its banner does not
+// is a compliance problem, not a stylistic one. Headline, then the call to
+// action with the destination, which is the whole of what five seconds can say.
+//
+// Synthesis is deliberately best-effort. A pre-roll with no audio is a working
+// ad; a render that fails because a speech API was rate-limited is not. Every
+// failure path here returns null and the caller ships the silent cut.
+
+import { writeFile } from "node:fs/promises";
+import path from "node:path";
+import type { VideoDesignSnapshot } from "./snapshot";
+
+/** Roughly what a five-second read fits, at an unhurried pace. */
+export const MAX_NARRATION_CHARS = 140;
+
+const API = "https://api.elevenlabs.io/v1/text-to-speech";
+
+/**
+ * A default voice id.
+ *
+ * Overridable per deployment, because the right voice is a brand decision and
+ * not something this module should hold an opinion about.
+ */
+const DEFAULT_VOICE = process.env.ELEVENLABS_VOICE_ID ?? "21m00Tcm4TlvDq8ikWAM";
+
+/**
+ * The line to speak.
+ *
+ * Kept to one sentence plus a call to action. The headline is spoken as written
+ * — it is the approved claim — and the domain is spoken last so the thing a
+ * listener is meant to remember is the last thing they hear.
+ */
+export function narrationScript(snapshot: VideoDesignSnapshot): string {
+ const headline = snapshot.headline.trim().replace(/\s+/g, " ");
+ const cta = snapshot.ctaText.trim().replace(/\s+/g, " ");
+ const domain = snapshot.domain.trim();
+
+ // A headline that already ends in punctuation should not gain a second full
+ // stop; one that does not needs one, or the two clauses run together.
+ const head = /[.!?]$/.test(headline) ? headline : `${headline}.`;
+ const line = `${head} ${cta} at ${domain}.`;
+
+ // Truncated on a word boundary rather than mid-word: a voice cut off in the
+ // middle of a word is worse than a shorter line.
+ if (line.length <= MAX_NARRATION_CHARS) return line;
+ const clipped = line.slice(0, MAX_NARRATION_CHARS);
+ const lastSpace = clipped.lastIndexOf(" ");
+ return `${clipped.slice(0, lastSpace > 0 ? lastSpace : clipped.length).trim()}.`;
+}
+
+export type NarrationResult = {
+ filePath: string;
+ script: string;
+ byteSize: number;
+};
+
+/**
+ * Synthesise the narration, or return null.
+ *
+ * Null covers every failure: no API key configured, a non-200 response, an
+ * empty body, a network error. The caller renders silent. This is the same rule
+ * the rest of the pipeline follows — a render is an extra output of saving a
+ * campaign, and the audio is an extra output of the render.
+ */
+export async function synthesiseNarration(input: {
+ snapshot: VideoDesignSnapshot;
+ workDir: string;
+ apiKey?: string | null;
+ voiceId?: string;
+ fetchImpl?: typeof fetch;
+}): Promise {
+ // `undefined` means "use the configured key"; an explicit null means "there
+ // is no key", which is what a caller says when it wants the silent cut. `??`
+ // collapsed those two into one and made an explicit null fall through to the
+ // environment — so asking for silence quietly performed a paid API call.
+ const apiKey =
+ input.apiKey === undefined ? (process.env.ELEVENLABS_API_KEY ?? null) : input.apiKey;
+ if (!apiKey) return null;
+
+ const script = narrationScript(input.snapshot);
+ if (!script.trim()) return null;
+
+ const doFetch = input.fetchImpl ?? fetch;
+ const voice = input.voiceId ?? DEFAULT_VOICE;
+
+ try {
+ const res = await doFetch(`${API}/${voice}`, {
+ method: "POST",
+ headers: {
+ "xi-api-key": apiKey,
+ "content-type": "application/json",
+ accept: "audio/mpeg",
+ },
+ body: JSON.stringify({
+ text: script,
+ model_id: "eleven_turbo_v2_5",
+ voice_settings: { stability: 0.5, similarity_boost: 0.75 },
+ }),
+ });
+ if (!res.ok) return null;
+
+ const buf = Buffer.from(await res.arrayBuffer());
+ // A 200 with no body is a failure wearing a success code, and muxing zero
+ // bytes produces a file ffmpeg will reject much later with a worse message.
+ if (buf.byteLength === 0) return null;
+
+ const filePath = path.join(input.workDir, "narration.mp3");
+ await writeFile(filePath, buf);
+ return { filePath, script, byteSize: buf.byteLength };
+ } catch {
+ return null;
+ }
+}
diff --git a/tests/ads-video-compose-validate.test.ts b/tests/ads-video-compose-validate.test.ts
index c1306ef0..6a834b4b 100644
--- a/tests/ads-video-compose-validate.test.ts
+++ b/tests/ads-video-compose-validate.test.ts
@@ -42,14 +42,20 @@ describe("the composition is deterministic", () => {
expect(timeline(false)).toHaveLength(PREROLL_FRAMES);
});
- it("has the headline legible from the very first frame", () => {
- // The entrance animates around copy that is already readable. A five-second
- // ad that spends its first beat assembling itself says nothing for a tenth
- // of its life.
- const f0 = frameState(0, false);
- expect(f0.entrance).toBe(0);
- // ...and the document's opacity floor for the copy block is 0.55, not 0.
- expect(composeDocument(snapshot)).toContain("0.55 + 0.45 * s.entrance");
+ it("says who is advertising from the very first frame, and finishes early", () => {
+ // The rule this replaces required the whole headline to be legible at frame
+ // 0. Honouring it produced an ad where nothing visibly happened across five
+ // seconds — frames a second apart were indistinguishable, which is its own
+ // way of saying nothing. The rule is now narrower and still protects the
+ // opening instant: the brand is visible immediately, and the copy finishes
+ // assembling inside the first quarter of the ad rather than half of it.
+ const doc = composeDocument(snapshot);
+ expect(doc).toContain("0.35 + 0.65 * b");
+ // The headline build completes well before the midpoint.
+ expect(doc).toContain("800 / (words.length - 1)");
+ expect(doc).toContain("(t - i * per) / 420");
+ // And the CTA lands early enough to be on screen for roughly the back half.
+ expect(doc).toContain("(t - 1900) / 700");
});
it("runs its three beats in order and finishes settled", () => {
diff --git a/tests/ads-video-jobs.test.ts b/tests/ads-video-jobs.test.ts
index 40131d0c..ff0b92e0 100644
--- a/tests/ads-video-jobs.test.ts
+++ b/tests/ads-video-jobs.test.ts
@@ -60,9 +60,18 @@ describe("the snapshot is derived from approved copy", () => {
domain: "nichedb.dev",
})!;
expect(validateSnapshot(snap)).toEqual([]);
- // Silent by default, so nothing claims an audible companion it has not got.
- expect(snap.audioMode).toBe("silent");
- expect(snap.narration).toBeNull();
+ // Narrated, with a script derived from the approved copy rather than
+ // written afresh: an ad whose voiceover claims something its banner does
+ // not is a compliance problem, not a stylistic one.
+ expect(snap.audioMode).toBe("narrated");
+ expect(snap.narration).toContain(snap.headline);
+ expect(snap.narration).toContain(snap.domain);
+ // Deterministic, because it is part of the render hash.
+ const again = snapshotFromCreatives({
+ creatives: [design("banner_300x250")],
+ domain: "nichedb.dev",
+ })!;
+ expect(again.narration).toBe(snap.narration);
});
it("records no artwork URL while it records no content hash", () => {
diff --git a/tests/ads-video-narration.test.ts b/tests/ads-video-narration.test.ts
new file mode 100644
index 00000000..759e054a
--- /dev/null
+++ b/tests/ads-video-narration.test.ts
@@ -0,0 +1,144 @@
+import { mkdtemp, rm, stat } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import { describe, expect, it } from "vitest";
+import {
+ MAX_NARRATION_CHARS,
+ narrationScript,
+ synthesiseNarration,
+} from "@/lib/ads/video/narration";
+import type { VideoDesignSnapshot } from "@/lib/ads/video/snapshot";
+
+const snapshot = (over: Partial = {}): VideoDesignSnapshot => ({
+ headline: "All fixtures, one feed",
+ subhead: "Every source in one place",
+ ctaText: "View feeds",
+ domain: "nichedb.dev",
+ bgColor: "#12161f",
+ fgColor: "#e7e9ee",
+ accentColor: "#6ee7b7",
+ fontFamily: "system-ui, sans-serif",
+ logoUrl: null,
+ logoSha256: null,
+ heroUrl: null,
+ heroSha256: null,
+ audioMode: "narrated",
+ narration: null,
+ locale: "en",
+ reducedMotion: false,
+ ...over,
+});
+
+describe("the script is the approved copy, spoken", () => {
+ it("says the headline, then the call to action and the domain", () => {
+ // Derived, never written afresh: an ad whose voiceover claims something its
+ // banner does not is a compliance problem, not a stylistic one.
+ expect(narrationScript(snapshot())).toBe(
+ "All fixtures, one feed. View feeds at nichedb.dev.",
+ );
+ });
+
+ it("does not double the full stop on a headline that has one", () => {
+ expect(narrationScript(snapshot({ headline: "Sources in, feeds out!" }))).toBe(
+ "Sources in, feeds out! View feeds at nichedb.dev.",
+ );
+ });
+
+ it("is deterministic, because it is part of the render hash", () => {
+ expect(narrationScript(snapshot())).toBe(narrationScript(snapshot()));
+ });
+
+ it("clips a long line on a word boundary", () => {
+ const long = narrationScript(
+ snapshot({ headline: "one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen" }),
+ );
+ expect(long.length).toBeLessThanOrEqual(MAX_NARRATION_CHARS + 1);
+ // A voice cut off mid-word is worse than a shorter line.
+ expect(long).not.toMatch(/\s\w{1,2}\.$/);
+ expect(long.endsWith(".")).toBe(true);
+ });
+
+ it("collapses whitespace so the read is not shaped by the authoring", () => {
+ expect(narrationScript(snapshot({ headline: "All fixtures,\n one feed" }))).toContain(
+ "All fixtures, one feed",
+ );
+ });
+});
+
+describe("synthesis never fails a render", () => {
+ it("returns null with no API key", async () => {
+ const dir = await mkdtemp(path.join(tmpdir(), "narr-"));
+ try {
+ expect(await synthesiseNarration({ snapshot: snapshot(), workDir: dir, apiKey: null })).toBeNull();
+ } finally {
+ await rm(dir, { recursive: true, force: true });
+ }
+ });
+
+ it("returns null on a non-200, rather than throwing", async () => {
+ const dir = await mkdtemp(path.join(tmpdir(), "narr-"));
+ try {
+ const res = await synthesiseNarration({
+ snapshot: snapshot(),
+ workDir: dir,
+ apiKey: "k",
+ fetchImpl: (async () => new Response("rate limited", { status: 429 })) as typeof fetch,
+ });
+ expect(res).toBeNull();
+ } finally {
+ await rm(dir, { recursive: true, force: true });
+ }
+ });
+
+ it("returns null on a 200 with an empty body", async () => {
+ // A success code with no bytes is a failure wearing a success code, and
+ // muxing zero bytes fails much later with a worse message.
+ const dir = await mkdtemp(path.join(tmpdir(), "narr-"));
+ try {
+ const res = await synthesiseNarration({
+ snapshot: snapshot(),
+ workDir: dir,
+ apiKey: "k",
+ fetchImpl: (async () => new Response(new ArrayBuffer(0), { status: 200 })) as typeof fetch,
+ });
+ expect(res).toBeNull();
+ } finally {
+ await rm(dir, { recursive: true, force: true });
+ }
+ });
+
+ it("returns null when the network throws", async () => {
+ const dir = await mkdtemp(path.join(tmpdir(), "narr-"));
+ try {
+ const res = await synthesiseNarration({
+ snapshot: snapshot(),
+ workDir: dir,
+ apiKey: "k",
+ fetchImpl: (async () => {
+ throw new Error("ECONNRESET");
+ }) as typeof fetch,
+ });
+ expect(res).toBeNull();
+ } finally {
+ await rm(dir, { recursive: true, force: true });
+ }
+ });
+
+ it("writes the audio and reports the script it spoke", async () => {
+ const dir = await mkdtemp(path.join(tmpdir(), "narr-"));
+ try {
+ const bytes = new Uint8Array([0x49, 0x44, 0x33, 1, 2, 3, 4, 5]);
+ const res = await synthesiseNarration({
+ snapshot: snapshot(),
+ workDir: dir,
+ apiKey: "k",
+ fetchImpl: (async () => new Response(bytes, { status: 200 })) as typeof fetch,
+ });
+ expect(res).not.toBeNull();
+ expect(res!.script).toBe("All fixtures, one feed. View feeds at nichedb.dev.");
+ expect((await stat(res!.filePath)).size).toBe(bytes.byteLength);
+ } finally {
+ await rm(dir, { recursive: true, force: true });
+ }
+ });
+});
diff --git a/worker/video.ts b/worker/video.ts
index 95dd8468..8cc5b79b 100644
--- a/worker/video.ts
+++ b/worker/video.ts
@@ -10,6 +10,7 @@ import { redisConnectionOptions } from "../lib/redis-connection";
import { VIDEO_RENDER_QUEUE, type VideoRenderJobData } from "../lib/ads/video/queue";
import { renderPreroll, RenderError } from "../lib/ads/video/render";
import { probeMedia } from "../lib/ads/video/validate";
+import { synthesiseNarration } from "../lib/ads/video/narration";
import { uploadRenderedAssets } from "../lib/ads/video/storage";
import { captureFrames } from "./frames";
@@ -60,7 +61,18 @@ export async function processRenderJob(
attempts: job.attemptsMade + 1,
});
+ // Narration, best effort. A silent pre-roll is a working ad; a render that
+ // fails because a speech API was rate-limited is not — so this returns null
+ // on every failure and the silent cut ships.
+ const narration = await synthesiseNarration({ snapshot: d.snapshot, workDir });
+ if (narration) {
+ console.log(`[render] narration: ${narration.byteSize} bytes — "${narration.script}"`);
+ } else {
+ console.log("[render] narration: none (no key or synthesis failed), rendering silent");
+ }
+
const { assets, problems } = await renderPreroll({
+ audioPath: narration?.filePath ?? null,
// GIF frame counts come from a decode, not the header, for the same
// reason the video's do: a truncated write still parses.
probeGif: async (file: string) => {