Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions components/ads/video-render-card.tsx
Original file line number Diff line number Diff line change
@@ -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<ReturnType<typeof videoRenderStatus>>;
Expand Down Expand Up @@ -95,8 +96,7 @@ export function VideoRenderCard({

{status?.state === "failed" && (
<p className="mt-3 text-sm text-[var(--color-danger,#f87171)]">
Rendering failed{status.errorCode ? ` (${status.errorCode})` : ""}. Editing the campaign
queues a fresh attempt.
{renderFailureMessage(status.errorCode)}
</p>
)}

Expand Down
8 changes: 8 additions & 0 deletions lib/ads/video/errors.ts
Original file line number Diff line number Diff line change
@@ -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.";
}
56 changes: 32 additions & 24 deletions lib/ads/video/narration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -108,38 +115,30 @@ 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;
apiKey?: string | null;
voiceId?: string;
fetchImpl?: typeof fetch;
}): Promise<NarrationResult | null> {
// `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",
Expand All @@ -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);
}
}
10 changes: 10 additions & 0 deletions tests/ads-video-errors.test.ts
Original file line number Diff line number Diff line change
@@ -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");
}
});
});
48 changes: 35 additions & 13 deletions tests/ads-video-narration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import path from "node:path";
import { describe, expect, it } from "vitest";
import {
MAX_NARRATION_CHARS,
DEFAULT_VOICE,
narrationScript,
narrationVariants,
synthesiseNarration,
Expand Down Expand Up @@ -103,65 +104,75 @@ 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",
fetchImpl: (async () => {
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 {
Expand All @@ -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.");
Expand All @@ -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 }); }
});
36 changes: 36 additions & 0 deletions tests/ads-video-worker-narration.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import("@/lib/ads/video/narration")>(), synthesiseNarration: mocks.synth }));
vi.mock("@/lib/ads/video/render", async (original) => ({ ...await original<typeof import("@/lib/ads/video/render")>(), 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<string, unknown>[] = [];
const db = { from: () => ({ update: (patch: Record<string, unknown>) => ({ 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");
});
});
17 changes: 9 additions & 8 deletions worker/video.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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";

Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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(() => {});
Expand Down