diff --git a/components/ads/video-render-card.tsx b/components/ads/video-render-card.tsx index 72da057d..47ceb8af 100644 --- a/components/ads/video-render-card.tsx +++ b/components/ads/video-render-card.tsx @@ -1,6 +1,7 @@ "use client"; import { useCallback, useEffect, useRef, useState } from "react"; +import { renderFailureMessage } from "@/lib/ads/video/errors"; import { videoRenderStatus } from "@/app/actions/ads"; type Status = Awaited>; @@ -95,8 +96,7 @@ export function VideoRenderCard({ {status?.state === "failed" && (

- Rendering failed{status.errorCode ? ` (${status.errorCode})` : ""}. Editing the campaign - queues a fresh attempt. + {renderFailureMessage(status.errorCode)}

)} diff --git a/lib/ads/video/errors.ts b/lib/ads/video/errors.ts new file mode 100644 index 00000000..b40b6176 --- /dev/null +++ b/lib/ads/video/errors.ts @@ -0,0 +1,8 @@ +/** Advertiser-safe explanations; never show raw provider responses. */ +export function renderFailureMessage(code: string | null): string { + if (code === "narration_payment_required" || code === "narration_voice_unavailable" || code === "narration_access_denied" || code === "narration_not_configured" || code === "missing_narration_audio") { + return "The voiceover service could not provide this campaign’s narration. Our team needs to correct the voice configuration before retrying. Your campaign copy is saved; editing it will not fix this service issue."; + } + if (code?.startsWith("narration_")) return "The voiceover service was unavailable after retrying. Your campaign copy is saved. Please contact support to retry the pre-roll."; + return "Rendering failed. Please contact support to retry this pre-roll; your campaign copy is saved."; +} diff --git a/lib/ads/video/narration.ts b/lib/ads/video/narration.ts index 348ba3bb..d97470ac 100644 --- a/lib/ads/video/narration.ts +++ b/lib/ads/video/narration.ts @@ -5,9 +5,8 @@ // 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. +// Narrated snapshots require real audio. Fail with a classified error so the +// worker can retry temporary provider failures without publishing a silent ad. import { writeFile } from "node:fs/promises"; import path from "node:path"; @@ -44,7 +43,15 @@ const API = "https://api.elevenlabs.io/v1/text-to-speech"; * 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"; +// Sarah is an available premade voice. The former Rachel default is now a +// library voice and returns payment_required for this deployment's free tier. +export const DEFAULT_VOICE = "EXAVITQu4vr4xnSDxMaL"; +export class NarrationError extends Error { + constructor(readonly code: string, readonly retryable: boolean) { + super(code); + this.name = "NarrationError"; + } +} /** * The line to speak. @@ -108,14 +115,7 @@ export type NarrationResult = { 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. - */ +/** Silent snapshots skip synthesis; narrated snapshots fail explicitly when audio is unavailable. */ export async function synthesiseNarration(input: { snapshot: VideoDesignSnapshot; workDir: string; @@ -123,23 +123,22 @@ export async function synthesiseNarration(input: { 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. + if (input.snapshot.audioMode === "silent") return null; + // Undefined uses deployment configuration; explicit null suppresses key lookup. const apiKey = input.apiKey === undefined ? (process.env.ELEVENLABS_API_KEY ?? null) : input.apiKey; - if (!apiKey) return null; + if (!apiKey) throw new NarrationError("narration_not_configured", false); - const script = narrationScript(input.snapshot); - if (!script.trim()) return null; + const script = input.snapshot.narration?.trim() || narrationScript(input.snapshot); + if (!script.trim()) throw new NarrationError("narration_script_missing", false); const doFetch = input.fetchImpl ?? fetch; - const voice = input.voiceId ?? DEFAULT_VOICE; + const voice = input.voiceId ?? process.env.ELEVENLABS_VOICE_ID ?? DEFAULT_VOICE; try { const res = await doFetch(`${API}/${voice}`, { method: "POST", + signal: AbortSignal.timeout(30_000), headers: { "xi-api-key": apiKey, "content-type": "application/json", @@ -151,17 +150,26 @@ export async function synthesiseNarration(input: { voice_settings: { stability: 0.5, similarity_boost: 0.75 }, }), }); - if (!res.ok) return null; + if (!res.ok) { + // Never return/log provider bodies, which may contain sensitive details. + const code = res.status === 402 ? "narration_payment_required" + : res.status === 401 || res.status === 403 ? "narration_access_denied" + : res.status === 404 ? "narration_voice_unavailable" + : res.status === 429 ? "narration_rate_limited" + : "narration_provider_failed"; + throw new NarrationError(code, res.status === 429 || res.status >= 500 || res.status === 408); + } 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; + if (buf.byteLength === 0) throw new NarrationError("narration_empty_audio", true); const filePath = path.join(input.workDir, "narration.mp3"); await writeFile(filePath, buf); return { filePath, script, byteSize: buf.byteLength }; - } catch { - return null; + } catch (error) { + if (error instanceof NarrationError) throw error; + throw new NarrationError("narration_unavailable", true); } } diff --git a/tests/ads-video-errors.test.ts b/tests/ads-video-errors.test.ts new file mode 100644 index 00000000..73d29421 --- /dev/null +++ b/tests/ads-video-errors.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from "vitest"; +import { renderFailureMessage } from "@/lib/ads/video/errors"; +describe("render error guidance", () => { + it("does not tell advertisers to edit copy to fix service configuration", () => { + for (const code of ["missing_narration_audio", "narration_payment_required", "narration_not_configured"]) { + expect(renderFailureMessage(code)).toContain("editing it will not fix"); + expect(renderFailureMessage(code)).toContain("copy is saved"); + } + }); +}); diff --git a/tests/ads-video-narration.test.ts b/tests/ads-video-narration.test.ts index d97f725b..ade31ea5 100644 --- a/tests/ads-video-narration.test.ts +++ b/tests/ads-video-narration.test.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; import { MAX_NARRATION_CHARS, + DEFAULT_VOICE, narrationScript, narrationVariants, synthesiseNarration, @@ -103,52 +104,52 @@ describe("the script is the approved copy, spoken", () => { }); }); -describe("synthesis never fails a render", () => { - it("returns null with no API key", async () => { +describe("synthesis enforces the narrated audio contract", () => { + it("reports missing configuration for narrated designs", async () => { const dir = await mkdtemp(path.join(tmpdir(), "narr-")); try { - expect(await synthesiseNarration({ snapshot: snapshot(), workDir: dir, apiKey: null })).toBeNull(); + await expect(synthesiseNarration({ snapshot: snapshot(), workDir: dir, apiKey: null })).rejects.toMatchObject({ code: "narration_not_configured", retryable: false }); } finally { await rm(dir, { recursive: true, force: true }); } }); - it("returns null on a non-200, rather than throwing", async () => { + it("classifies a rate limit as retryable", async () => { const dir = await mkdtemp(path.join(tmpdir(), "narr-")); try { - const res = await synthesiseNarration({ + const result = synthesiseNarration({ snapshot: snapshot(), workDir: dir, apiKey: "k", fetchImpl: (async () => new Response("rate limited", { status: 429 })) as typeof fetch, }); - expect(res).toBeNull(); + await expect(result).rejects.toMatchObject({ code: "narration_rate_limited", retryable: true }); } finally { await rm(dir, { recursive: true, force: true }); } }); - it("returns null on a 200 with an empty body", async () => { + it("retries an empty successful response", 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({ + const result = synthesiseNarration({ snapshot: snapshot(), workDir: dir, apiKey: "k", fetchImpl: (async () => new Response(new ArrayBuffer(0), { status: 200 })) as typeof fetch, }); - expect(res).toBeNull(); + await expect(result).rejects.toMatchObject({ code: "narration_empty_audio", retryable: true }); } finally { await rm(dir, { recursive: true, force: true }); } }); - it("returns null when the network throws", async () => { + it("retries a network failure", async () => { const dir = await mkdtemp(path.join(tmpdir(), "narr-")); try { - const res = await synthesiseNarration({ + const result = synthesiseNarration({ snapshot: snapshot(), workDir: dir, apiKey: "k", @@ -156,12 +157,22 @@ describe("synthesis never fails a render", () => { throw new Error("ECONNRESET"); }) as typeof fetch, }); - expect(res).toBeNull(); + await expect(result).rejects.toMatchObject({ code: "narration_unavailable", retryable: true }); } finally { await rm(dir, { recursive: true, force: true }); } }); + it("skips silent designs without calling the provider", async () => { + let called = false; + expect(await synthesiseNarration({ snapshot: snapshot({ audioMode: "silent" }), workDir: "/unused", apiKey: "fixture", fetchImpl: (async () => { called = true; throw Error(); }) as typeof fetch })).toBeNull(); + expect(called).toBe(false); + }); + + it("reports payment failures without leaking the provider body", async () => { + await expect(synthesiseNarration({ snapshot: snapshot(), workDir: "/unused", apiKey: "fixture", fetchImpl: (async () => new Response("sensitive provider body", { status: 402 })) as typeof fetch })).rejects.toMatchObject({ code: "narration_payment_required", message: "narration_payment_required", retryable: false }); + }); + it("writes the audio and reports the script it spoke", async () => { const dir = await mkdtemp(path.join(tmpdir(), "narr-")); try { @@ -170,7 +181,7 @@ describe("synthesis never fails a render", () => { snapshot: snapshot(), workDir: dir, apiKey: "k", - fetchImpl: (async () => new Response(bytes, { status: 200 })) as typeof fetch, + fetchImpl: (async (url: string) => { expect(url).toContain(DEFAULT_VOICE); return new Response(bytes, { status: 200 }); }) as typeof fetch, }); expect(res).not.toBeNull(); expect(res!.script).toBe("All fixtures, one feed. View feeds at niche d b dot dev."); @@ -180,3 +191,14 @@ describe("synthesis never fails a render", () => { } }); }); + +it("speaks the immutable approved narration instead of regenerating the script", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "narr-approved-")); + try { + await synthesiseNarration({ snapshot: snapshot({ narration: "Approved voiceover." }), workDir: dir, apiKey: "fixture", fetchImpl: (async (_url: string, init: RequestInit) => { + expect(JSON.parse(String(init.body)).text).toBe("Approved voiceover."); + expect(init.signal).toBeDefined(); + return new Response(new Uint8Array([1,2,3])); + }) as typeof fetch }); + } finally { await rm(dir, { recursive: true, force: true }); } +}); diff --git a/tests/ads-video-worker-narration.test.ts b/tests/ads-video-worker-narration.test.ts new file mode 100644 index 00000000..1493abb2 --- /dev/null +++ b/tests/ads-video-worker-narration.test.ts @@ -0,0 +1,36 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +const mocks = vi.hoisted(() => ({ synth: vi.fn(), render: vi.fn(), capture: vi.fn() })); +vi.mock("@/lib/ads/video/narration", async (original) => ({ ...await original(), synthesiseNarration: mocks.synth })); +vi.mock("@/lib/ads/video/render", async (original) => ({ ...await original(), renderPreroll: mocks.render })); +vi.mock("../worker/frames", () => ({ captureFrames: mocks.capture })); +import { processRenderJob } from "../worker/video"; +import { NarrationError } from "@/lib/ads/video/narration"; +const snapshot = { audioMode: "narrated", narration: "Approved copy." }; +function fixture(attemptsMade = 0) { + const states: Record[] = []; + const db = { from: () => ({ update: (patch: Record) => ({ eq: async () => { states.push(patch); return { error: null }; } }) }) }; + const job = { data: { jobRowId: "fixture", snapshot, creativeId: null, campaignId: null }, attemptsMade, opts: { attempts: 3 } }; + return { states, db, job }; +} +beforeEach(() => vi.clearAllMocks()); +describe("worker narration failures", () => { + it("stops retrying permanent payment errors before capturing any frames", async () => { + const f = fixture();mocks.synth.mockRejectedValue(new NarrationError("narration_payment_required", false)); + await expect(processRenderJob(f.db as never, f.job as never)).rejects.toMatchObject({ name: "UnrecoverableError", message: "narration_payment_required" }); + expect(f.states.at(-1)).toEqual({ state: "failed", error_code: "narration_payment_required" }); + expect(mocks.render).not.toHaveBeenCalled(); + }); + it("keeps temporary failures queued until retries are exhausted", async () => { + mocks.synth.mockRejectedValue(new NarrationError("narration_rate_limited", true)); + const first = fixture();await expect(processRenderJob(first.db as never, first.job as never)).rejects.toMatchObject({ code: "narration_rate_limited" }); + expect(first.states.at(-1)?.state).toBe("queued"); + const last = fixture(2);await expect(processRenderJob(last.db as never, last.job as never)).rejects.toMatchObject({ code: "narration_rate_limited" }); + expect(last.states.at(-1)?.state).toBe("failed"); + }); + it("passes the generated track and unchanged approved snapshot to the renderer", async () => { + const f = fixture();mocks.synth.mockResolvedValue({ filePath: "/fixture/audio.mp3", byteSize: 100, script: "Approved copy." });mocks.render.mockResolvedValue({ assets: [], problems: [] }); + await processRenderJob(f.db as never, f.job as never); + expect(mocks.render).toHaveBeenCalledWith(expect.objectContaining({ snapshot, audioPath: "/fixture/audio.mp3" })); + expect(f.states.at(-1)?.state).toBe("ready"); + }); +}); diff --git a/worker/video.ts b/worker/video.ts index a78596ff..9f84be19 100644 --- a/worker/video.ts +++ b/worker/video.ts @@ -1,7 +1,7 @@ // The render worker: consumes ad-video-render jobs, produces validated media, // publishes a revision atomically. -import { Worker, type Job } from "bullmq"; +import { Worker, UnrecoverableError, type Job } from "bullmq"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -10,7 +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 { synthesiseNarration, NarrationError } from "../lib/ads/video/narration"; import { uploadRenderedAssets } from "../lib/ads/video/storage"; import { captureFrames } from "./frames"; @@ -76,14 +76,13 @@ 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. + // A narrated design must keep its audio contract. Provider failures are + // classified below, before expensive frame capture begins. 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"); + console.log("[render] narration: skipped for a silent design"); } const { assets, problems } = await renderPreroll({ @@ -191,8 +190,10 @@ export async function processRenderJob( return { published, assets: uploaded.length }; } catch (err) { - const code = err instanceof RenderError ? err.code : "render_failed"; - await setJobState(supabase, d.jobRowId, { state: "failed", error_code: code }); + const code = err instanceof RenderError || err instanceof NarrationError ? err.code : "render_failed"; + const retrying = err instanceof NarrationError && err.retryable && job.attemptsMade + 1 < (job.opts.attempts ?? 1); + await setJobState(supabase, d.jobRowId, { state: retrying ? "queued" : "failed", error_code: code }); + if (err instanceof NarrationError && !err.retryable) throw new UnrecoverableError(code); throw err; } finally { await rm(workDir, { recursive: true, force: true }).catch(() => {});