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
91 changes: 91 additions & 0 deletions lib/ads/agent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
// ad-generator-agent: the c0mpute plugin that makes every ad type on
// /dashboard/ads/new (c0mpute plugins/ad-generator-agent, DIP 0029).
//
// On by default. While it is on, each generated asset comes from c0mpute or
// from no AI at all, never from a paid API the operator did not opt into:
//
// copy (every format, summaries) Infernet inference on c0mpute, through
// lib/lx/backendAi (the fleet-wide router;
// its OpenAI/Anthropic fallback is the
// standing 2026-11-02 rule, not ours to add)
// narration (video + audio ads) c0mpute tts.synthesize via the speech
// gateway (`c0mpute speech serve`)
// hero image (image ads) the advertiser's og:image; c0mpute has no
// live diffusion workers yet, so otherwise
// none. gpt-image only with
// AD_GENERATOR_PAID_IMAGES=1
// static / gif / video / audio rendered by our own worker (Chromium
// assembly frames + ffmpeg), no model involved
//
// AD_GENERATOR_AGENT=off (or 0/false/no) restores the previous providers
// (gpt-image heroes, ElevenLabs narration).

export const AD_GENERATOR_AGENT = "ad-generator-agent";

type Env = Record<string, string | undefined>;

// Read through a variable so nothing here can be inlined at build time.
function runtimeEnv(): Env {
const e: Env = process.env;
return e;
}

const OFF = /^(0|off|false|no|disabled?)$/i;
const ON = /^(1|on|true|yes|enabled?)$/i;

export function adGeneratorAgentEnabled(env: Env = runtimeEnv()): boolean {
return !OFF.test((env.AD_GENERATOR_AGENT ?? "").trim());
}

/** gpt-image heroes: always when the agent is off, only by opt-in when it is on. */
export function paidHeroImagesAllowed(env: Env = runtimeEnv()): boolean {
if (!adGeneratorAgentEnabled(env)) return true;
return ON.test((env.AD_GENERATOR_PAID_IMAGES ?? "").trim());
}

export type NarrationRoute = "c0mpute" | "elevenlabs";

export function narrationRoute(env: Env = runtimeEnv()): NarrationRoute {
return adGeneratorAgentEnabled(env) ? "c0mpute" : "elevenlabs";
}

export type C0mputeSpeechTarget = {
/** Full URL of the synthesis endpoint. */
url: string;
/** Bearer for a private gateway; the public Infernet route needs none. */
token: string | null;
};

const DEFAULT_INFERNET_URL = "https://infernetprotocol.com";

/**
* Where narration is synthesised.
*
* A private `c0mpute speech serve` gateway (C0MPUTE_SPEECH_URL + _TOKEN) when
* one is configured: it has no rate limit and is the buyer this agent is meant
* to run on. Otherwise Infernet's public voice route, which forwards to the
* same kind of gateway, so a fresh deployment still narrates on c0mpute.
*/
export function c0mputeSpeechTarget(env: Env = runtimeEnv()): C0mputeSpeechTarget {
const gateway = (env.C0MPUTE_SPEECH_URL ?? "").trim().replace(/\/+$/, "");
const token = (env.C0MPUTE_SPEECH_TOKEN ?? "").trim();
if (gateway && token) return { url: `${gateway}/v1/tts`, token };
const base = (env.C0MPUTE_URL ?? DEFAULT_INFERNET_URL).trim().replace(/\/+$/, "");
return { url: `${base}/api/chat/voice/tts`, token: null };
}

export function c0mputeTtsVoice(env: Env = runtimeEnv()): { engine: string; voice: string } {
return {
engine: (env.AD_GENERATOR_TTS_ENGINE ?? "").trim() || "kokoro",
// Sarah, the same voice the ElevenLabs default used.
voice: (env.AD_GENERATOR_TTS_VOICE ?? "").trim() || "af_sarah",
};
}

/**
* The provider label shown on the form and stored with a generation, so it is
* visible which agent made the ad: `ad-generator-agent/c0mpute`.
*/
export function agentProviderLabel(provider: string, env: Env = runtimeEnv()): string {
return adGeneratorAgentEnabled(env) ? `${AD_GENERATOR_AGENT}/${provider}` : provider;
}
15 changes: 13 additions & 2 deletions lib/ads/creative.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { env } from "@/lib/env";
import { generateStructuredOutput } from "@/lib/lx/backendAi";
import { extractSiteBrand, type SiteBrand } from "./brand";
import { resolveAdHeroImage } from "./heroImage";
import { agentProviderLabel, paidHeroImagesAllowed } from "./agent";
import { renderCreativeText, renderTerminalHtml } from "./terminal";
import { renderFeedHtml } from "./feeditem";
import { NO_MEDIA, type AdMediaKind, type MediaAssets } from "./media";
Expand Down Expand Up @@ -523,12 +524,17 @@ export async function generateAdCreatives(
// Hero image: the advertiser's og:image when it exists, else a gpt-image-2.5
// fallback (only when we have a Supabase client to host the upload). Best-
// effort — a failure just leaves the accent-tint background.
//
// The ad-generator-agent (./agent, on by default) keeps this off paid image
// generation: c0mpute has no live diffusion workers yet, so without an
// og:image the ad ships on its accent tint unless the operator opted into
// gpt-image with AD_GENERATOR_PAID_IMAGES=1.
let heroUrl: string | null = null;
if (opts.supabase) {
const hero = await resolveAdHeroImage({
brand,
copy: { headline: output.headline, body: output.body, bgColor: output.bgColor, accentColor: output.accentColor },
openai,
openai: paidHeroImagesAllowed() ? openai : null,
supabase: opts.supabase,
}).catch(() => null);
heroUrl = hero?.url ?? null;
Expand All @@ -543,7 +549,12 @@ export async function generateAdCreatives(
domain: summaryDomain(brand.url || rawUrl),
};

return { brand, creatives: copyToCreatives(brand, output, heroUrl), provider, summary };
return {
brand,
creatives: copyToCreatives(brand, output, heroUrl),
provider: agentProviderLabel(provider),
summary,
};
}

// Append the campaign ref for click attribution, preserving any existing query.
Expand Down
81 changes: 81 additions & 0 deletions lib/ads/video/narration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@
NARRATION_CHARS_PER_SEC,
} from "./profiles";
import type { VideoDesignSnapshot } from "./snapshot";
import {
c0mputeSpeechTarget,
c0mputeTtsVoice,
narrationRoute,
type C0mputeSpeechTarget,
type NarrationRoute,
} from "../agent";
import { spokenDomain, spokenText } from "./spoken";

/**
Expand Down Expand Up @@ -119,11 +126,16 @@
export async function synthesiseNarration(input: {
snapshot: VideoDesignSnapshot;
workDir: string;
/** Default: c0mpute while the ad-generator-agent is on (../agent). */
route?: NarrationRoute;
/** c0mpute speech endpoint; default from C0MPUTE_SPEECH_URL/_TOKEN. */
speech?: C0mputeSpeechTarget;
apiKey?: string | null;
voiceId?: string;
fetchImpl?: typeof fetch;
}): Promise<NarrationResult | null> {
if (input.snapshot.audioMode === "silent") return null;
if ((input.route ?? narrationRoute()) === "c0mpute") return synthesiseOnC0mpute(input);
// Undefined uses deployment configuration; explicit null suppresses key lookup.
const apiKey =
input.apiKey === undefined ? (process.env.ELEVENLABS_API_KEY ?? null) : input.apiKey;
Expand Down Expand Up @@ -173,3 +185,72 @@
throw new NarrationError("narration_unavailable", true);
}
}

// ── c0mpute (ad-generator-agent) ─────────────────────────────────────────────

/** CPU synthesis of a one-line read is seconds; a cold worker or a queue is not. */
const C0MPUTE_TTS_TIMEOUT_MS = 180_000;

/** A WAV container: what tts.synthesize returns (the gateway validates it too). */
function isWav(buf: Buffer): boolean {
return (
buf.byteLength > 44 &&
buf.toString("ascii", 0, 4) === "RIFF" &&
buf.toString("ascii", 8, 12) === "WAVE"
);
}

/**
* Narration on c0mpute: one `tts.synthesize` job through a speech gateway.
*
* Never falls back to a paid voice. A gateway with no worker answering (503),
* a rate limit or a network failure is retryable, so the render job retries
* rather than publishing a silent ad; a rejected request or bad credentials
* is not, because the same request will be rejected again.
*/
async function synthesiseOnC0mpute(input: {
snapshot: VideoDesignSnapshot;
workDir: string;
speech?: C0mputeSpeechTarget;
fetchImpl?: typeof fetch;
}): Promise<NarrationResult> {
const script = input.snapshot.narration?.trim() || narrationScript(input.snapshot);
if (!script.trim()) throw new NarrationError("narration_script_missing", false);

const target = input.speech ?? c0mputeSpeechTarget();
const { engine, voice } = c0mputeTtsVoice();
const doFetch = input.fetchImpl ?? fetch;

try {
const res = await doFetch(target.url, {
method: "POST",
signal: AbortSignal.timeout(C0MPUTE_TTS_TIMEOUT_MS),
headers: {
"content-type": "application/json",
accept: "audio/wav",
...(target.token ? { authorization: `Bearer ${target.token}` } : {}),
},
body: JSON.stringify({ text: script, engine, voice, locale: "en-US", speed: 1 }),
});
if (!res.ok) {
const code =
res.status === 401 || res.status === 403 ? "narration_access_denied"
: res.status === 400 || res.status === 413 ? "narration_rejected"
: res.status === 429 ? "narration_rate_limited"
: res.status === 503 ? "narration_no_c0mpute_worker"
: "narration_provider_failed";
throw new NarrationError(code, res.status === 429 || res.status >= 500 || res.status === 408);
}

const buf = Buffer.from(await res.arrayBuffer());
if (buf.byteLength === 0) throw new NarrationError("narration_empty_audio", true);
if (!isWav(buf)) throw new NarrationError("narration_bad_audio", true);

const filePath = path.join(input.workDir, "narration.wav");
await writeFile(filePath, buf);
return { filePath, script, byteSize: buf.byteLength };
} catch (error) {
if (error instanceof NarrationError) throw error;
throw new NarrationError("narration_unavailable", true);
}
}
Loading