diff --git a/app/actions/ads.ts b/app/actions/ads.ts
index f70d0d9..0ecb841 100644
--- a/app/actions/ads.ts
+++ b/app/actions/ads.ts
@@ -5,6 +5,14 @@ import { createClient } from "@/lib/supabase/server";
import { serviceClient } from "@/lib/supabase/service";
import { isAllowedTargetUrl } from "@/lib/rateLimit";
import { getOrCreateDefaultOrg } from "@/lib/orgs";
+import {
+ downloadableAsset,
+ getRenderStatus,
+ queueCampaignVideo,
+ renderStateLabel,
+ streamingReady,
+ type RenderState,
+} from "@/lib/ads/video/jobs";
import {
generateAdCreatives,
DESIGN_FORMAT_IDS,
@@ -278,6 +286,18 @@ export async function saveCampaign(input: {
const { error: cErr } = await supabase.from("ad_creatives").insert(rows);
if (cErr) return { ok: false, error: cErr.message };
+ // Queue the five-second pre-roll from the design that was just approved.
+ // Deliberately fire-and-forget on failure: a render is an extra output of
+ // saving a campaign, never a precondition for one, and holding this request
+ // open until an encode finished would be a minute of spinner on a save.
+ await queueCampaignVideo(supabase, {
+ campaignId: campaign.data.id,
+ ownerId: user.id,
+ domain: domainOf(check.url),
+ creatives,
+ bumpRevision: false,
+ });
+
// Turning trending targeting on is what earns the 90 days, and the grant is
// idempotent — a campaign saved twice does not get a second window. A
// failure to grant never fails the save: the campaign runs and bills
@@ -292,6 +312,60 @@ export async function saveCampaign(input: {
return { ok: true, id: campaign.data.id, refSlug: campaign.data.ref_slug, promoDays };
}
+/**
+ * Poll one pre-roll render.
+ *
+ * The dashboard calls this on an interval while a render is in flight. It is a
+ * status read and nothing more: no request in this application ever waits on an
+ * encode, which is why saving a campaign returns immediately and the video
+ * catches up behind it.
+ *
+ * Reads through the service client because ad_video_assets rows are written by
+ * the worker, but scopes every query to the signed-in user's id rather than
+ * relying on RLS that the service client bypasses.
+ */
+export async function videoRenderStatus(input: { jobId: string }): Promise<
+ | {
+ ok: true;
+ state: RenderState;
+ label: string;
+ revision: number;
+ streamingReady: boolean;
+ downloadUrl: string | null;
+ downloadBytes: number | null;
+ posterUrl: string | null;
+ errorCode: string | null;
+ }
+ | { ok: false; error: string }
+> {
+ const supabase = await createClient();
+ const {
+ data: { user },
+ } = await supabase.auth.getUser();
+ if (!user) return { ok: false, error: "Not authenticated." };
+
+ const sb = serviceClient();
+ const status = await getRenderStatus(sb, {
+ jobId: input.jobId,
+ ownerId: user.id,
+ publicUrlFor: (key) => sb.storage.from(ASSET_BUCKET).getPublicUrl(key).data.publicUrl,
+ });
+ if (!status) return { ok: false, error: "No such render." };
+
+ const master = downloadableAsset(status);
+ return {
+ ok: true,
+ state: status.state,
+ label: renderStateLabel(status.state),
+ revision: status.revision,
+ streamingReady: streamingReady(status),
+ downloadUrl: master?.url ?? null,
+ downloadBytes: master?.byteSize ?? null,
+ posterUrl: status.assets.find((a) => a.profile === "poster")?.url ?? null,
+ errorCode: status.errorCode,
+ };
+}
+
// --- Campaign editing (advertiser) ---
export async function updateCampaign(input: {
@@ -370,6 +444,58 @@ export async function updateCampaign(input: {
return { ok: true };
}
+/**
+ * Re-queue a campaign's pre-roll after its design changed.
+ *
+ * Reads the campaign's current creatives back rather than trusting the payload
+ * that was just written: an edit may touch one format, and the snapshot has to
+ * be built from whichever source format the video prefers, which may not be
+ * the one that changed.
+ *
+ * bumpRevision is always true here - that is what distinguishes this from the
+ * initial save, and what makes the worker's compare-and-swap meaningful when
+ * two edits land in quick succession.
+ */
+async function requeueCampaignVideo(
+ supabase: Awaited
>,
+ args: { campaignId: string; ownerId: string },
+): Promise {
+ const { data: campaign } = await supabase
+ .from("ad_campaigns")
+ .select("destination_domain, destination_url")
+ .eq("id", args.campaignId)
+ .eq("owner_id", args.ownerId)
+ .maybeSingle();
+ if (!campaign) return;
+
+ const { data: rows } = await supabase
+ .from("ad_creatives")
+ .select("format, headline, cta_text, bg_color, fg_color, accent_color, font_family, logo_url, image_url")
+ .eq("campaign_id", args.campaignId)
+ .eq("owner_id", args.ownerId);
+ if (!rows?.length) return;
+
+ await queueCampaignVideo(supabase, {
+ campaignId: args.campaignId,
+ ownerId: args.ownerId,
+ domain:
+ (campaign.destination_domain as string | null) ??
+ domainOf(campaign.destination_url as string),
+ creatives: rows.map((r) => ({
+ format: r.format,
+ headline: r.headline ?? "",
+ ctaText: r.cta_text ?? "",
+ bgColor: r.bg_color,
+ fgColor: r.fg_color,
+ accentColor: r.accent_color,
+ fontFamily: r.font_family,
+ logoUrl: r.logo_url,
+ imageUrl: r.image_url,
+ })),
+ bumpRevision: true,
+ });
+}
+
export async function updateCreatives(input: {
campaignId: string;
creatives: (Partial & { id: string })[];
@@ -402,6 +528,7 @@ export async function updateCreatives(input: {
.eq("owner_id", user.id);
if (error) return { ok: false, error: error.message };
}
+ await requeueCampaignVideo(supabase, { campaignId: input.campaignId, ownerId: user.id });
revalidatePath(`/dashboard/ads/${input.campaignId}`);
return { ok: true };
}
@@ -500,6 +627,7 @@ export async function regenerateCampaign(input: {
}
}
+ await requeueCampaignVideo(supabase, { campaignId: input.id, ownerId: user.id });
revalidatePath("/dashboard/ads");
revalidatePath(`/dashboard/ads/${input.id}`);
return { ok: true };
diff --git a/app/api/ads/v1/video/renders/[id]/route.ts b/app/api/ads/v1/video/renders/[id]/route.ts
new file mode 100644
index 0000000..54bc1e2
--- /dev/null
+++ b/app/api/ads/v1/video/renders/[id]/route.ts
@@ -0,0 +1,70 @@
+// /api/ads/v1/video/renders/[id] — one five-second pre-roll render.
+//
+// GET state (queued | rendering | validating | ready | failed), the revision
+// it is rendering, and — once ready — the downloadable master plus the
+// delivery renditions, HLS playlist and poster.
+//
+// Owner-scoped: the service client bypasses RLS, so the owner id from the
+// bearer token is applied to the query itself rather than relied on from the
+// policy. A job id is a uuid somebody may still hold after losing access to
+// the campaign it belongs to.
+//
+// This is a status read, deliberately cheap and pollable. Rendering happens on
+// the worker; no request ever waits for an encode.
+
+import { NextResponse, type NextRequest } from "next/server";
+import { serviceClient } from "@/lib/supabase/service";
+import { authenticateBearer } from "@/lib/sp/apiAuth";
+import {
+ downloadableAsset,
+ getRenderStatus,
+ renderStateLabel,
+ streamingReady,
+} from "@/lib/ads/video/jobs";
+import { ASSET_BUCKET } from "@/lib/ads/video/storage";
+
+export const runtime = "nodejs";
+export const dynamic = "force-dynamic";
+
+type Ctx = { params: Promise<{ id: string }> };
+
+export async function GET(req: NextRequest, ctx: Ctx) {
+ const auth = await authenticateBearer(req);
+ if (!auth.ok) return NextResponse.json({ error: auth.error }, { status: auth.status });
+
+ const { id } = await ctx.params;
+ const sb = serviceClient();
+
+ const status = await getRenderStatus(sb, {
+ jobId: id,
+ ownerId: auth.userId,
+ publicUrlFor: (key) => sb.storage.from(ASSET_BUCKET).getPublicUrl(key).data.publicUrl,
+ });
+
+ if (!status) return NextResponse.json({ error: "No such render." }, { status: 404 });
+
+ const master = downloadableAsset(status);
+
+ return NextResponse.json({
+ id: status.jobId,
+ state: status.state,
+ state_label: renderStateLabel(status.state),
+ revision: status.revision,
+ campaign_id: status.campaignId,
+ error_code: status.errorCode,
+ // An advertiser may download a draft render before the campaign is ever
+ // activated; what they may not do is have it served. Those are different
+ // permissions and this flag is only the second one.
+ streaming_ready: streamingReady(status),
+ download_url: master?.url ?? null,
+ download_bytes: master?.byteSize ?? null,
+ assets: status.assets.map((a) => ({
+ profile: a.profile,
+ url: a.url,
+ byte_size: a.byteSize,
+ width: a.width,
+ height: a.height,
+ duration_ms: a.durationMs,
+ })),
+ });
+}
diff --git a/components/ads/video-render-card.tsx b/components/ads/video-render-card.tsx
new file mode 100644
index 0000000..692e545
--- /dev/null
+++ b/components/ads/video-render-card.tsx
@@ -0,0 +1,137 @@
+"use client";
+
+import { useCallback, useEffect, useRef, useState } from "react";
+import { videoRenderStatus } from "@/app/actions/ads";
+
+type Status = Awaited>;
+type Ready = Extract;
+
+/**
+ * Progress and download for a campaign's five-second pre-roll.
+ *
+ * Polls while the render is in flight and stops the moment it settles — a
+ * finished render never changes again, so a timer that keeps running is just a
+ * request every few seconds for a row that will not move. The interval is
+ * deliberately unhurried: an encode takes tens of seconds, and polling faster
+ * would only make the queue look busier than it is.
+ */
+export function VideoRenderCard({ jobId }: { jobId: string | null }) {
+ const [status, setStatus] = useState(null);
+ const [error, setError] = useState(null);
+ const timer = useRef | null>(null);
+
+ const settled = status?.state === "ready" || status?.state === "failed";
+
+ const poll = useCallback(async () => {
+ if (!jobId) return;
+ const res = await videoRenderStatus({ jobId });
+ if (res.ok) {
+ setStatus(res);
+ setError(null);
+ } else {
+ setError(res.error);
+ }
+ }, [jobId]);
+
+ useEffect(() => {
+ if (!jobId || settled) return;
+ let cancelled = false;
+
+ const tick = async () => {
+ if (cancelled) return;
+ await poll();
+ if (!cancelled) timer.current = setTimeout(tick, 5000);
+ };
+ void tick();
+
+ return () => {
+ cancelled = true;
+ if (timer.current) clearTimeout(timer.current);
+ };
+ }, [jobId, settled, poll]);
+
+ // No job means this campaign predates the video pipeline, or its render was
+ // never queued. Saying nothing beats showing a broken-looking empty card on
+ // every older campaign.
+ if (!jobId) return null;
+
+ return (
+
+
+
Streaming pre-roll
+
+
+
+
+ Five seconds, 1920×1080, built from this campaign’s approved copy and palette.
+
+
+ {error &&
{error}
}
+
+ {status?.state === "failed" && (
+
+ Rendering failed{status.errorCode ? ` (${status.errorCode})` : ""}. Editing the campaign
+ queues a fresh attempt.
+
+ )}
+
+ {status?.state === "ready" && status.downloadUrl && (
+
+ {/* The poster is what a player shows before the first frame, so it is
+ the honest still to preview here. */}
+ {status.posterUrl && (
+ // eslint-disable-next-line @next/next/no-img-element
+

+ )}
+
+
+ Download MP4
+
+
+ {formatBytes(status.downloadBytes)} · revision {status.revision}
+
+
+
+ {status.streamingReady
+ ? "Ready to stream. Downloading works whether or not the campaign is active."
+ : "Downloadable, but not yet complete for streaming."}
+
+
+ )}
+
+ {status && !settled && (
+
+ This runs in the background. You can leave the page; saving and editing the campaign
+ never waits on it.
+
+ )}
+
+ );
+}
+
+function StateBadge({ state, label }: { state: string; label: string }) {
+ const tone =
+ state === "ready"
+ ? "var(--color-accent, #6ee7b7)"
+ : state === "failed"
+ ? "var(--color-danger, #f87171)"
+ : "var(--color-muted, #98a2b3)";
+ return (
+
+ {label}
+
+ );
+}
+
+function formatBytes(n: number | null): string {
+ if (!n) return "—";
+ const mb = n / (1024 * 1024);
+ return mb >= 1 ? `${mb.toFixed(1)} MB` : `${Math.round(n / 1024)} KB`;
+}
diff --git a/lib/ads/video/jobs.ts b/lib/ads/video/jobs.ts
new file mode 100644
index 0000000..9653adf
--- /dev/null
+++ b/lib/ads/video/jobs.ts
@@ -0,0 +1,448 @@
+// Turning a generated design into a render job.
+//
+// This is the seam between the ad pipeline the dashboard already has and the
+// encoder package B built. Everything here is written so that a video render
+// can fail, stall, or be unavailable entirely without any of it reaching the
+// advertiser's campaign: a render is an additional output of saving a campaign,
+// never a precondition for one.
+
+import type { SupabaseClient } from "@supabase/supabase-js";
+import type { AdCreative } from "../formats";
+import { VIDEO_FORMAT_ID } from "../formats";
+import { MAX_HEADLINE_WORDS, renderHash, validateSnapshot, type VideoDesignSnapshot } from "./snapshot";
+import { enqueueRender } from "./queue";
+import { VIDEO_PROFILES, type VideoProfileId } from "./profiles";
+
+/** The one output profile a job is keyed on; a job renders the whole set. */
+export const DEFAULT_OUTPUT_PROFILE = "default";
+
+export type RenderState = "queued" | "rendering" | "validating" | "ready" | "failed";
+
+/**
+ * Clip a display headline down to something a five-second ad can hold.
+ *
+ * Display headlines are capped at 48 characters, which is a different
+ * constraint entirely: a banner can set eight words in two lines and still
+ * read, where a pre-roll has to be legible at 480p from across a room in about
+ * three seconds. Clipping on a word boundary rather than mid-word, and
+ * returning the original when it already fits, means most campaigns are
+ * unaffected and the ones that aren't lose a trailing clause rather than half
+ * a word.
+ */
+export function trimHeadlineForVideo(headline: string): string {
+ const words = headline.trim().split(/\s+/).filter(Boolean);
+ if (words.length <= MAX_HEADLINE_WORDS) return words.join(" ");
+ return words.slice(0, MAX_HEADLINE_WORDS).join(" ");
+}
+
+/**
+ * Build the render input from the design the generator already produced.
+ *
+ * Deliberately derived from an existing creative rather than generated afresh:
+ * the video must make the same claim, in the same palette, as the display ads
+ * the advertiser reviewed and approved. A separate generation pass would drift,
+ * and a campaign whose banner and pre-roll say different things is a
+ * compliance problem rather than a design inconsistency.
+ *
+ * The medium rectangle is preferred as the source because it carries the full
+ * headline and the hero artwork. banner_320x50 is avoided for the same reason
+ * the feed backfill avoids it — it holds the *shortened* mobile headline, and a
+ * 1920x1080 frame has no width problem that would justify truncated copy.
+ */
+export function snapshotFromCreatives(args: {
+ creatives: Pick<
+ AdCreative,
+ "format" | "headline" | "ctaText" | "bgColor" | "fgColor" | "accentColor" | "fontFamily" | "logoUrl" | "imageUrl"
+ >[];
+ domain: string;
+ locale?: string;
+ reducedMotion?: boolean;
+}): VideoDesignSnapshot | null {
+ const preference: string[] = [
+ "banner_300x250",
+ "banner_728x90",
+ "text_link",
+ "terminal_ascii",
+ "feed_item",
+ "banner_320x50",
+ ];
+ const source = preference
+ .map((f) => args.creatives.find((c) => c.format === f))
+ .find((c): c is NonNullable => !!c);
+ if (!source) return null;
+
+ return {
+ headline: trimHeadlineForVideo(source.headline),
+ ctaText: source.ctaText || "Learn more",
+ domain: args.domain,
+ bgColor: source.bgColor,
+ fgColor: source.fgColor,
+ accentColor: source.accentColor,
+ fontFamily: source.fontFamily,
+ logoUrl: source.logoUrl ?? null,
+ // Package C does not fetch and hash the artwork yet, so the compositor
+ // renders its accent-tinted fallback rather than the hero. Recording the
+ // URL with a null content hash would be worse than recording neither: the
+ // hash is what the dedupe key trusts, and a null one alongside a real URL
+ // invites a later change to treat the URL as sufficient.
+ logoSha256: null,
+ heroUrl: null,
+ heroSha256: null,
+ audioMode: "silent",
+ narration: null,
+ locale: args.locale ?? "en",
+ reducedMotion: args.reducedMotion ?? false,
+ };
+}
+
+export type RenderHandle = {
+ jobId: string;
+ state: RenderState;
+ revision: number;
+ /** True when an identical design already had a job; no new work was queued. */
+ reused: boolean;
+ /** False when Redis is unavailable — the row exists and a sweep can pick it up. */
+ enqueued: boolean;
+};
+
+type EnsureArgs = {
+ ownerId: string;
+ /** Null for a preview render started before any campaign exists. */
+ campaignId: string | null;
+ creativeId: string | null;
+ snapshot: VideoDesignSnapshot;
+ revision: number;
+ audioSlotSupported?: boolean;
+};
+
+/**
+ * Create a render job for this design, or hand back the one that already
+ * exists.
+ *
+ * Dedupe is on the render hash rather than on the campaign, so the same design
+ * previewed twice, saved, and then regenerated without edits is one encode.
+ * The unique index on (render_hash, output_profile) is what makes that true
+ * under concurrency; the select-then-insert below is the fast path, and the
+ * conflict re-select is the correct one.
+ */
+export async function ensureRenderJob(
+ supabase: SupabaseClient,
+ args: EnsureArgs,
+): Promise {
+ const problems = validateSnapshot(args.snapshot);
+ if (problems.length > 0) {
+ return { error: problems.map((p) => `${p.field}: ${p.reason}`).join(", ") };
+ }
+
+ const hash = renderHash(args.snapshot, DEFAULT_OUTPUT_PROFILE as VideoProfileId);
+
+ const existing = await supabase
+ .from("ad_video_jobs")
+ .select("id, state, revision")
+ .eq("render_hash", hash)
+ .eq("output_profile", DEFAULT_OUTPUT_PROFILE)
+ .maybeSingle();
+
+ if (existing.data) {
+ return {
+ jobId: existing.data.id as string,
+ state: existing.data.state as RenderState,
+ revision: existing.data.revision as number,
+ reused: true,
+ enqueued: true,
+ };
+ }
+
+ const inserted = await supabase
+ .from("ad_video_jobs")
+ .insert({
+ owner_id: args.ownerId,
+ campaign_id: args.campaignId,
+ creative_id: args.creativeId,
+ revision: args.revision,
+ render_hash: hash,
+ output_profile: DEFAULT_OUTPUT_PROFILE,
+ design: args.snapshot,
+ state: "queued",
+ })
+ .select("id, state, revision")
+ .single();
+
+ if (inserted.error) {
+ // Another request inserted the identical design between our select and our
+ // insert. That is the unique index doing its job, not a failure.
+ const retry = await supabase
+ .from("ad_video_jobs")
+ .select("id, state, revision")
+ .eq("render_hash", hash)
+ .eq("output_profile", DEFAULT_OUTPUT_PROFILE)
+ .maybeSingle();
+ if (retry.data) {
+ return {
+ jobId: retry.data.id as string,
+ state: retry.data.state as RenderState,
+ revision: retry.data.revision as number,
+ reused: true,
+ enqueued: true,
+ };
+ }
+ return { error: inserted.error.message };
+ }
+
+ const jobId = inserted.data.id as string;
+
+ // Enqueue failures are not save failures. The row is the durable record; a
+ // worker sweep can pick up a `queued` row whose BullMQ job never existed,
+ // which is the whole reason the row is written first.
+ let enqueued = false;
+ try {
+ enqueued = await enqueueRender({
+ renderHash: hash,
+ profile: DEFAULT_OUTPUT_PROFILE,
+ data: {
+ jobRowId: jobId,
+ ownerId: args.ownerId,
+ campaignId: args.campaignId,
+ creativeId: args.creativeId,
+ revision: args.revision,
+ snapshot: args.snapshot,
+ profile: DEFAULT_OUTPUT_PROFILE as VideoProfileId,
+ audioSlotSupported: !!args.audioSlotSupported,
+ },
+ });
+ } catch (err) {
+ console.warn("[ads] video render enqueue failed", (err as Error).message);
+ }
+
+ return { jobId, state: "queued", revision: args.revision, reused: false, enqueued };
+}
+
+/**
+ * Ensure the campaign has a video creative, and return the revision this edit
+ * should render as.
+ *
+ * The revision is bumped on every call that follows a design change, which is
+ * what makes the worker's compare-and-swap meaningful: two quick edits produce
+ * revisions N and N+1, and whichever render finishes second only publishes if
+ * its revision is still the requested one.
+ *
+ * published_revision is deliberately left alone. It is set by the worker once
+ * ffprobe has validated the encode, and writing it here would mark a creative
+ * servable before any bytes existed.
+ */
+export async function ensureVideoCreative(
+ supabase: SupabaseClient,
+ args: { campaignId: string; ownerId: string; bumpRevision: boolean },
+): Promise<{ creativeId: string; revision: number } | { error: string }> {
+ const existing = await supabase
+ .from("ad_creatives")
+ .select("id, requested_revision")
+ .eq("campaign_id", args.campaignId)
+ .eq("format", VIDEO_FORMAT_ID)
+ .maybeSingle();
+
+ if (existing.data) {
+ const current = (existing.data.requested_revision as number | null) ?? 1;
+ const revision = args.bumpRevision ? current + 1 : current;
+ if (revision !== current) {
+ const { error } = await supabase
+ .from("ad_creatives")
+ .update({ requested_revision: revision })
+ .eq("id", existing.data.id)
+ .eq("owner_id", args.ownerId);
+ if (error) return { error: error.message };
+ }
+ return { creativeId: existing.data.id as string, revision };
+ }
+
+ // A fresh video creative carries no copy of its own: the design lives in the
+ // snapshot, and the row exists to hold the revision pointers and to give
+ // reporting something stable to attribute against. status stays 'generating'
+ // so nothing treats it as ready, and published_revision stays null so
+ // selection cannot pick it.
+ const inserted = await supabase
+ .from("ad_creatives")
+ .insert({
+ campaign_id: args.campaignId,
+ owner_id: args.ownerId,
+ format: VIDEO_FORMAT_ID,
+ requested_revision: 1,
+ status: "generating",
+ })
+ .select("id")
+ .single();
+
+ if (inserted.error) return { error: inserted.error.message };
+ return { creativeId: inserted.data.id as string, revision: 1 };
+}
+
+/**
+ * The whole flow for a campaign: creative row, revision, render job.
+ *
+ * Returns null rather than throwing on any failure. Every caller is a path an
+ * advertiser is already waiting on — saving a campaign, editing copy — and none
+ * of them should fail because a video could not be queued.
+ */
+export async function queueCampaignVideo(
+ supabase: SupabaseClient,
+ args: {
+ campaignId: string;
+ ownerId: string;
+ domain: string;
+ creatives: Parameters[0]["creatives"];
+ bumpRevision: boolean;
+ },
+): Promise {
+ try {
+ const snapshot = snapshotFromCreatives({ creatives: args.creatives, domain: args.domain });
+ if (!snapshot) return null;
+
+ const creative = await ensureVideoCreative(supabase, {
+ campaignId: args.campaignId,
+ ownerId: args.ownerId,
+ bumpRevision: args.bumpRevision,
+ });
+ if ("error" in creative) {
+ console.warn("[ads] video creative failed", creative.error);
+ return null;
+ }
+
+ const handle = await ensureRenderJob(supabase, {
+ ownerId: args.ownerId,
+ campaignId: args.campaignId,
+ creativeId: creative.creativeId,
+ snapshot,
+ revision: creative.revision,
+ });
+ if ("error" in handle) {
+ console.warn("[ads] video render job failed", handle.error);
+ return null;
+ }
+ return handle;
+ } catch (err) {
+ console.warn("[ads] video render skipped", (err as Error).message);
+ return null;
+ }
+}
+
+/**
+ * The most recent render job for a campaign.
+ *
+ * Newest first by revision then creation: an edit bumps the revision, so the
+ * highest revision is the design the advertiser last asked for, and that is the
+ * one whose progress they are watching. An older revision's job may still be
+ * running and will publish nothing when it finishes (the worker's
+ * compare-and-swap discards it), so showing it would be showing progress
+ * towards an outcome that is already void.
+ */
+export async function latestJobForCampaign(
+ supabase: SupabaseClient,
+ args: { campaignId: string; ownerId: string },
+): Promise {
+ const { data } = await supabase
+ .from("ad_video_jobs")
+ .select("id, revision, created_at")
+ .eq("campaign_id", args.campaignId)
+ .eq("owner_id", args.ownerId)
+ .order("revision", { ascending: false })
+ .order("created_at", { ascending: false })
+ .limit(1)
+ .maybeSingle();
+ return (data?.id as string | undefined) ?? null;
+}
+
+export type RenderStatus = {
+ jobId: string;
+ state: RenderState;
+ revision: number;
+ errorCode: string | null;
+ campaignId: string | null;
+ assets: {
+ profile: VideoProfileId;
+ url: string;
+ byteSize: number;
+ width: number | null;
+ height: number | null;
+ durationMs: number | null;
+ }[];
+};
+
+/**
+ * Read a render's state and its assets, scoped to the owner.
+ *
+ * Owner-scoped on the query rather than trusting RLS alone: this is read by a
+ * server action and an API route, and a job id is a uuid somebody could hold
+ * from a previous session after losing access to the campaign.
+ */
+export async function getRenderStatus(
+ supabase: SupabaseClient,
+ args: { jobId: string; ownerId: string; publicUrlFor: (objectKey: string) => string },
+): Promise {
+ const job = await supabase
+ .from("ad_video_jobs")
+ .select("id, state, revision, error_code, campaign_id, creative_id")
+ .eq("id", args.jobId)
+ .eq("owner_id", args.ownerId)
+ .maybeSingle();
+
+ if (!job.data) return null;
+
+ const status: RenderStatus = {
+ jobId: job.data.id as string,
+ state: job.data.state as RenderState,
+ revision: job.data.revision as number,
+ errorCode: (job.data.error_code as string | null) ?? null,
+ campaignId: (job.data.campaign_id as string | null) ?? null,
+ assets: [],
+ };
+
+ // Only a ready job has assets worth listing. Asking for them earlier would
+ // return a partial set mid-upload and invite a UI that plays half a render.
+ if (status.state !== "ready" || !job.data.creative_id) return status;
+
+ const assets = await supabase
+ .from("ad_video_assets")
+ .select("profile, object_key, byte_size, width, height, duration_ms")
+ .eq("creative_id", job.data.creative_id)
+ .eq("revision", status.revision);
+
+ status.assets = (assets.data ?? []).map((a: Record) => ({
+ profile: a.profile as VideoProfileId,
+ url: args.publicUrlFor(a.object_key as string),
+ byteSize: Number(a.byte_size),
+ width: (a.width as number | null) ?? null,
+ height: (a.height as number | null) ?? null,
+ durationMs: (a.duration_ms as number | null) ?? null,
+ }));
+
+ return status;
+}
+
+/** Advertiser-facing label for a render state. */
+export function renderStateLabel(state: RenderState): string {
+ switch (state) {
+ case "queued":
+ return "Queued";
+ case "rendering":
+ return "Rendering";
+ case "validating":
+ return "Validating";
+ case "ready":
+ return "Ready";
+ case "failed":
+ return "Failed";
+ }
+}
+
+/** The profile an advertiser downloads: the 1080p master. */
+export const DOWNLOAD_PROFILE: VideoProfileId = "master_1080p";
+
+export function downloadableAsset(status: RenderStatus) {
+ return status.assets.find((a) => a.profile === DOWNLOAD_PROFILE) ?? null;
+}
+
+/** Profiles that must be present before a revision counts as streamable. */
+export function streamingReady(status: RenderStatus): boolean {
+ const required = VIDEO_PROFILES.filter((p) => p.required).map((p) => p.id);
+ return required.every((id) => status.assets.some((a) => a.profile === id));
+}
diff --git a/tests/ads-video-jobs.test.ts b/tests/ads-video-jobs.test.ts
new file mode 100644
index 0000000..2aca088
--- /dev/null
+++ b/tests/ads-video-jobs.test.ts
@@ -0,0 +1,300 @@
+import { describe, expect, it } from "vitest";
+import {
+ DEFAULT_OUTPUT_PROFILE,
+ downloadableAsset,
+ ensureRenderJob,
+ ensureVideoCreative,
+ renderStateLabel,
+ snapshotFromCreatives,
+ streamingReady,
+ trimHeadlineForVideo,
+ type RenderStatus,
+} from "@/lib/ads/video/jobs";
+import { MAX_HEADLINE_WORDS, renderHash, validateSnapshot } from "@/lib/ads/video/snapshot";
+import { VIDEO_FORMAT_ID } from "@/lib/ads/formats";
+
+const design = (format: string, over: Record = {}) => ({
+ format,
+ headline: "Sources in, feeds out",
+ ctaText: "Start free",
+ bgColor: "#12161f",
+ fgColor: "#e7e9ee",
+ accentColor: "#6ee7b7",
+ fontFamily: "system-ui, sans-serif",
+ logoUrl: null,
+ imageUrl: null,
+ ...over,
+}) as Parameters[0]["creatives"][number];
+
+describe("the snapshot is derived from approved copy", () => {
+ it("prefers the rectangle, which carries the full headline", () => {
+ const snap = snapshotFromCreatives({
+ creatives: [
+ design("banner_320x50", { headline: "Short one" }),
+ design("banner_300x250", { headline: "Sources in, feeds out" }),
+ ],
+ domain: "nichedb.dev",
+ });
+ // banner_320x50 holds the *shortened* mobile headline, and a 1920x1080
+ // frame has no width problem that would justify truncated copy.
+ expect(snap?.headline).toBe("Sources in, feeds out");
+ });
+
+ it("falls back down the preference list rather than giving up", () => {
+ const snap = snapshotFromCreatives({
+ creatives: [design("feed_item", { headline: "Only a feed item" })],
+ domain: "nichedb.dev",
+ });
+ expect(snap?.headline).toBe("Only a feed item");
+ });
+
+ it("returns null when there is no design to derive from", () => {
+ expect(snapshotFromCreatives({ creatives: [], domain: "nichedb.dev" })).toBeNull();
+ });
+
+ it("produces a snapshot that passes validation", () => {
+ const snap = snapshotFromCreatives({
+ creatives: [design("banner_300x250")],
+ 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();
+ });
+
+ it("records no artwork URL while it records no content hash", () => {
+ const snap = snapshotFromCreatives({
+ creatives: [design("banner_300x250", { imageUrl: "https://cdn/hero.png" })],
+ domain: "nichedb.dev",
+ })!;
+ // The dedupe key trusts the hash. A URL with a null hash would invite a
+ // later change to treat the URL as sufficient, and the same URL can serve
+ // different bytes.
+ expect(snap.heroUrl).toBeNull();
+ expect(snap.heroSha256).toBeNull();
+ });
+});
+
+describe("headlines are clipped to what five seconds can hold", () => {
+ it("leaves a short headline exactly as it is", () => {
+ expect(trimHeadlineForVideo("Sources in, feeds out")).toBe("Sources in, feeds out");
+ });
+
+ it("clips on a word boundary, never mid-word", () => {
+ const long = "one two three four five six seven eight nine ten";
+ const out = trimHeadlineForVideo(long);
+ expect(out.split(" ")).toHaveLength(MAX_HEADLINE_WORDS);
+ expect(out).toBe("one two three four five six seven eight");
+ expect(long.startsWith(out)).toBe(true);
+ });
+
+ it("produces a headline the snapshot validator accepts", () => {
+ const snap = snapshotFromCreatives({
+ creatives: [design("banner_300x250", { headline: "a b c d e f g h i j k" })],
+ domain: "nichedb.dev",
+ })!;
+ // The clip has to actually satisfy the rule, or every long-headline
+ // campaign would queue a job that the renderer then refuses.
+ expect(validateSnapshot(snap)).toEqual([]);
+ });
+});
+
+// A very small Supabase stand-in: enough query-builder surface for the calls
+// jobs.ts makes, and it records what was written.
+function fakeDb(opts: {
+ existingJob?: Record | null;
+ existingCreative?: Record | null;
+ insertError?: string;
+}) {
+ const inserts: { table: string; row: Record }[] = [];
+ const updates: { table: string; patch: Record }[] = [];
+
+ const client = {
+ from(table: string) {
+ const builder: Record = {};
+ const chain = () => builder;
+ Object.assign(builder, {
+ select: chain,
+ eq: chain,
+ order: chain,
+ limit: chain,
+ maybeSingle: async () => ({
+ data:
+ table === "ad_video_jobs"
+ ? (opts.existingJob ?? null)
+ : (opts.existingCreative ?? null),
+error: null,
+ }),
+ insert(row: Record) {
+ inserts.push({ table, row });
+ return {
+ select: () => ({
+ single: async () =>
+ opts.insertError
+ ? { data: null, error: { message: opts.insertError } }
+ : { data: { id: "new-id", state: "queued", revision: row.revision ?? 1 }, error: null },
+ }),
+ };
+ },
+ update(patch: Record) {
+ updates.push({ table, patch });
+ const u: Record = {};
+ Object.assign(u, { eq: () => u, select: async () => ({ data: [{ id: "x" }] }) });
+ return u;
+ },
+ });
+ return builder;
+ },
+ };
+ return { client, inserts, updates };
+}
+
+describe("render jobs dedupe on the design, not the campaign", () => {
+ const snapshot = snapshotFromCreatives({
+ creatives: [design("banner_300x250")],
+ domain: "nichedb.dev",
+ })!;
+
+ it("hands back an existing job for an identical design", async () => {
+ const db = fakeDb({ existingJob: { id: "job-1", state: "rendering", revision: 2 } });
+ const res = await ensureRenderJob(db.client as never, {
+ ownerId: "o",
+ campaignId: "c",
+ creativeId: "cr",
+ snapshot,
+ revision: 2,
+ });
+ expect(res).toMatchObject({ jobId: "job-1", state: "rendering", reused: true });
+ // Nothing queued: the same design previewed, saved and regenerated without
+ // edits is one encode.
+ expect(db.inserts).toHaveLength(0);
+ });
+
+ it("writes the row before trying to enqueue", async () => {
+ const db = fakeDb({ existingJob: null });
+ const res = await ensureRenderJob(db.client as never, {
+ ownerId: "o",
+ campaignId: "c",
+ creativeId: "cr",
+ snapshot,
+ revision: 1,
+ });
+ expect("jobId" in res && res.jobId).toBe("new-id");
+ const row = db.inserts[0].row;
+ expect(db.inserts[0].table).toBe("ad_video_jobs");
+ expect(row.state).toBe("queued");
+ expect(row.output_profile).toBe(DEFAULT_OUTPUT_PROFILE);
+ expect(row.render_hash).toBe(renderHash(snapshot, DEFAULT_OUTPUT_PROFILE as never));
+ });
+
+ it("survives Redis being unavailable", async () => {
+ // No REDIS_URL in the test env, so enqueueRender returns false. The row
+ // still exists and a sweep can pick it up — which is the whole reason the
+ // row is written first.
+ const db = fakeDb({ existingJob: null });
+ const res = await ensureRenderJob(db.client as never, {
+ ownerId: "o",
+ campaignId: "c",
+ creativeId: "cr",
+ snapshot,
+ revision: 1,
+ });
+ expect("enqueued" in res && res.enqueued).toBe(false);
+ expect("state" in res && res.state).toBe("queued");
+ });
+
+ it("refuses a snapshot that cannot render", async () => {
+ const db = fakeDb({ existingJob: null });
+ const res = await ensureRenderJob(db.client as never, {
+ ownerId: "o",
+ campaignId: "c",
+ creativeId: "cr",
+ snapshot: { ...snapshot, headline: "" },
+ revision: 1,
+ });
+ expect("error" in res).toBe(true);
+ expect(db.inserts).toHaveLength(0);
+ });
+});
+
+describe("the video creative row", () => {
+ it("starts unservable: generating, and no published revision", async () => {
+ const db = fakeDb({ existingCreative: null });
+ const res = await ensureVideoCreative(db.client as never, {
+ campaignId: "c",
+ ownerId: "o",
+ bumpRevision: false,
+ });
+ expect("creativeId" in res).toBe(true);
+ const row = db.inserts[0].row;
+ expect(row.format).toBe(VIDEO_FORMAT_ID);
+ expect(row.requested_revision).toBe(1);
+ expect(row.status).toBe("generating");
+ // published_revision is the worker's to set, once ffprobe has validated an
+ // encode. Setting it here would mark a creative servable with no bytes.
+ expect(row.published_revision).toBeUndefined();
+ });
+
+ it("bumps the revision on an edit so the worker's CAS means something", async () => {
+ const db = fakeDb({ existingCreative: { id: "cr-1", requested_revision: 4 } });
+ const res = await ensureVideoCreative(db.client as never, {
+ campaignId: "c",
+ ownerId: "o",
+ bumpRevision: true,
+ });
+ expect(res).toMatchObject({ creativeId: "cr-1", revision: 5 });
+ expect(db.updates[0].patch).toEqual({ requested_revision: 5 });
+ });
+
+ it("does not bump on a plain save", async () => {
+ const db = fakeDb({ existingCreative: { id: "cr-1", requested_revision: 4 } });
+ const res = await ensureVideoCreative(db.client as never, {
+ campaignId: "c",
+ ownerId: "o",
+ bumpRevision: false,
+ });
+ expect(res).toMatchObject({ revision: 4 });
+ expect(db.updates).toHaveLength(0);
+ });
+});
+
+describe("status presentation", () => {
+ const status = (assets: string[]): RenderStatus => ({
+ jobId: "j",
+ state: "ready",
+ revision: 1,
+ errorCode: null,
+ campaignId: "c",
+ assets: assets.map((profile) => ({
+ profile: profile as never,
+ url: `https://cdn/${profile}`,
+ byteSize: 1000,
+ width: null,
+ height: null,
+ durationMs: null,
+ })),
+ });
+
+ it("offers the 1080p master as the download", () => {
+ expect(downloadableAsset(status(["master_1080p", "mp4_720p"]))?.url).toBe(
+ "https://cdn/master_1080p",
+ );
+ expect(downloadableAsset(status(["mp4_720p"]))).toBeNull();
+ });
+
+ it("only calls a revision streamable when every required profile is present", () => {
+ // Downloadable and streamable are different permissions: an advertiser may
+ // download a draft long before anything may serve it.
+ expect(streamingReady(status(["master_1080p"]))).toBe(false);
+ expect(
+ streamingReady(status(["master_1080p", "mp4_720p", "mp4_480p", "hls", "poster"])),
+ ).toBe(true);
+ });
+
+ it("labels every state", () => {
+ for (const s of ["queued", "rendering", "validating", "ready", "failed"] as const) {
+ expect(renderStateLabel(s)).toMatch(/\w/);
+ }
+ });
+});