From 30c2a682cfd9e2d2691ca7a778b89942e953fdec Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Thu, 24 Sep 2026 13:12:03 +0000 Subject: [PATCH] Queue a pre-roll render when a campaign is saved or edited (package C) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Something finally calls the render pipeline. Saving a campaign now creates its video creative and queues a render; editing copy or regenerating bumps the revision and queues another; the campaign page shows progress and, once ready, a Download MP4 button. The rule this is built around is that a render is an extra output of saving a campaign, never a precondition for one. queueCampaignVideo returns null on every failure rather than throwing, the row is written before the enqueue is attempted so a missing REDIS_URL loses throughput rather than the request, and no path waits on an encode. An advertiser who saves a campaign gets the same response they always did. The snapshot is derived from a creative the advertiser already approved rather than generated afresh, so the pre-roll makes the same claim in the same palette as the display ads. The medium rectangle is preferred as the source and banner_320x50 avoided, for the same reason the feed backfill avoids it: that format carries the shortened mobile headline, and a 1920x1080 frame has no width problem that would justify truncated copy. Display headlines are capped by characters, which is a different constraint from what five seconds can hold, so trimHeadlineForVideo clips to eight words on a word boundary. Artwork is deliberately not carried yet. Recording a hero URL with a null content hash would be worse than recording neither: the dedupe key trusts the hash, and a URL sitting beside a null hash invites a later change to treat the URL as sufficient when the same URL can serve different bytes. The compositor renders its accent-tinted fallback until hashing lands. Dedupe is on the render hash, not the campaign, so a design previewed, saved, then regenerated without edits is one encode. The unique index on (render_hash, output_profile) is what makes that hold under concurrency; the select-then- insert is the fast path and the conflict re-select is the correct one. ensureVideoCreative bumps requested_revision on edits and leaves published_revision alone. That bump is what gives the worker's compare-and-swap something to compare: two quick edits produce revisions N and N+1, and a slow render of N publishes nothing when it lands second. Writing published_revision here would mark a creative servable before any bytes existed. Also fixes a leak this package would otherwise have introduced. The campaign detail page maps ad_creatives rows straight into , which renders markup — so the video creative row this package creates would have been drawn as a banner there. The dashboard forms already iterate DESIGN_FORMATS for that reason, but the detail page reads from the database and was not covered. Co-Authored-By: Claude Opus 5 (1M context) --- app/(app)/dashboard/ads/[id]/page.tsx | 14 +- app/actions/ads.ts | 128 ++++++ app/api/ads/v1/video/renders/[id]/route.ts | 70 ++++ components/ads/video-render-card.tsx | 137 +++++++ lib/ads/video/jobs.ts | 448 +++++++++++++++++++++ tests/ads-video-jobs.test.ts | 300 ++++++++++++++ 6 files changed, 1096 insertions(+), 1 deletion(-) create mode 100644 app/api/ads/v1/video/renders/[id]/route.ts create mode 100644 components/ads/video-render-card.tsx create mode 100644 lib/ads/video/jobs.ts create mode 100644 tests/ads-video-jobs.test.ts diff --git a/app/(app)/dashboard/ads/[id]/page.tsx b/app/(app)/dashboard/ads/[id]/page.tsx index e76a7d7..bc86c03 100644 --- a/app/(app)/dashboard/ads/[id]/page.tsx +++ b/app/(app)/dashboard/ads/[id]/page.tsx @@ -3,6 +3,9 @@ import { notFound } from "next/navigation"; import { createClient } from "@/lib/supabase/server"; import { formatSpec, type AdCreative, type AdFormatId } from "@/lib/ads/formats"; import { AdPreview } from "@/components/ads/ad-preview"; +import { VideoRenderCard } from "@/components/ads/video-render-card"; +import { latestJobForCampaign } from "@/lib/ads/video/jobs"; +import { isStreamingFormat } from "@/lib/ads/formats"; import { CampaignActions, RegenerateButton } from "@/components/ads/campaign-actions"; import { CampaignTrend } from "@/components/ads/campaign-trend"; import { BidHistory } from "@/components/ads/bid-history"; @@ -64,6 +67,11 @@ export default async function CampaignDetailPage({ .maybeSingle(); if (!campaign) notFound(); + // The campaign's newest pre-roll render, if it has one. Fetched alongside the + // rest rather than in the client component so the card renders with its job + // already known instead of flashing an empty state on every page load. + const videoJobId = await latestJobForCampaign(supabase, { campaignId: id, ownerId: user.id }); + const [{ data: stats }, { data: creativeRows }, series, { data: profile }] = await Promise.all([ supabase .from("ad_campaign_stats") @@ -265,11 +273,15 @@ export default async function CampaignDetailPage({ +
+ +
+ {creatives.length > 0 && (

Creatives

- {creatives.map((c) => ( + {creatives.filter((c) => !isStreamingFormat(c.format)).map((c) => (
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/); + } + }); +});