Skip to content

Commit ac3beb0

Browse files
ralyodioclaude
andauthored
Queue a pre-roll render when a campaign is saved or edited (package C) (#275)
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 <AdPreview>, 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) <noreply@anthropic.com>
1 parent 84e868a commit ac3beb0

6 files changed

Lines changed: 1096 additions & 1 deletion

File tree

‎app/(app)/dashboard/ads/[id]/page.tsx‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ import { notFound } from "next/navigation";
33
import { createClient } from "@/lib/supabase/server";
44
import { formatSpec, type AdCreative, type AdFormatId } from "@/lib/ads/formats";
55
import { AdPreview } from "@/components/ads/ad-preview";
6+
import { VideoRenderCard } from "@/components/ads/video-render-card";
7+
import { latestJobForCampaign } from "@/lib/ads/video/jobs";
8+
import { isStreamingFormat } from "@/lib/ads/formats";
69
import { CampaignActions, RegenerateButton } from "@/components/ads/campaign-actions";
710
import { CampaignTrend } from "@/components/ads/campaign-trend";
811
import { BidHistory } from "@/components/ads/bid-history";
@@ -64,6 +67,11 @@ export default async function CampaignDetailPage({
6467
.maybeSingle();
6568
if (!campaign) notFound();
6669

70+
// The campaign's newest pre-roll render, if it has one. Fetched alongside the
71+
// rest rather than in the client component so the card renders with its job
72+
// already known instead of flashing an empty state on every page load.
73+
const videoJobId = await latestJobForCampaign(supabase, { campaignId: id, ownerId: user.id });
74+
6775
const [{ data: stats }, { data: creativeRows }, series, { data: profile }] = await Promise.all([
6876
supabase
6977
.from("ad_campaign_stats")
@@ -265,11 +273,15 @@ export default async function CampaignDetailPage({
265273
<BidHistory data={history.days} events={history.events} autobid={autobid} failed={history.failed} />
266274
</div>
267275

276+
<div className="mt-6">
277+
<VideoRenderCard jobId={videoJobId} />
278+
</div>
279+
268280
{creatives.length > 0 && (
269281
<div className="mt-6">
270282
<h2 className="mb-3 font-semibold">Creatives</h2>
271283
<div className="flex flex-wrap gap-4">
272-
{creatives.map((c) => (
284+
{creatives.filter((c) => !isStreamingFormat(c.format)).map((c) => (
273285
<div key={c.id} className="card p-3">
274286
<AdPreview creative={c} />
275287
<div className="mt-2 text-center text-xs text-[var(--color-muted)]">

‎app/actions/ads.ts‎

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,14 @@ import { createClient } from "@/lib/supabase/server";
55
import { serviceClient } from "@/lib/supabase/service";
66
import { isAllowedTargetUrl } from "@/lib/rateLimit";
77
import { getOrCreateDefaultOrg } from "@/lib/orgs";
8+
import {
9+
downloadableAsset,
10+
getRenderStatus,
11+
queueCampaignVideo,
12+
renderStateLabel,
13+
streamingReady,
14+
type RenderState,
15+
} from "@/lib/ads/video/jobs";
816
import {
917
generateAdCreatives,
1018
DESIGN_FORMAT_IDS,
@@ -278,6 +286,18 @@ export async function saveCampaign(input: {
278286
const { error: cErr } = await supabase.from("ad_creatives").insert(rows);
279287
if (cErr) return { ok: false, error: cErr.message };
280288

289+
// Queue the five-second pre-roll from the design that was just approved.
290+
// Deliberately fire-and-forget on failure: a render is an extra output of
291+
// saving a campaign, never a precondition for one, and holding this request
292+
// open until an encode finished would be a minute of spinner on a save.
293+
await queueCampaignVideo(supabase, {
294+
campaignId: campaign.data.id,
295+
ownerId: user.id,
296+
domain: domainOf(check.url),
297+
creatives,
298+
bumpRevision: false,
299+
});
300+
281301
// Turning trending targeting on is what earns the 90 days, and the grant is
282302
// idempotent — a campaign saved twice does not get a second window. A
283303
// failure to grant never fails the save: the campaign runs and bills
@@ -292,6 +312,60 @@ export async function saveCampaign(input: {
292312
return { ok: true, id: campaign.data.id, refSlug: campaign.data.ref_slug, promoDays };
293313
}
294314

315+
/**
316+
* Poll one pre-roll render.
317+
*
318+
* The dashboard calls this on an interval while a render is in flight. It is a
319+
* status read and nothing more: no request in this application ever waits on an
320+
* encode, which is why saving a campaign returns immediately and the video
321+
* catches up behind it.
322+
*
323+
* Reads through the service client because ad_video_assets rows are written by
324+
* the worker, but scopes every query to the signed-in user's id rather than
325+
* relying on RLS that the service client bypasses.
326+
*/
327+
export async function videoRenderStatus(input: { jobId: string }): Promise<
328+
| {
329+
ok: true;
330+
state: RenderState;
331+
label: string;
332+
revision: number;
333+
streamingReady: boolean;
334+
downloadUrl: string | null;
335+
downloadBytes: number | null;
336+
posterUrl: string | null;
337+
errorCode: string | null;
338+
}
339+
| { ok: false; error: string }
340+
> {
341+
const supabase = await createClient();
342+
const {
343+
data: { user },
344+
} = await supabase.auth.getUser();
345+
if (!user) return { ok: false, error: "Not authenticated." };
346+
347+
const sb = serviceClient();
348+
const status = await getRenderStatus(sb, {
349+
jobId: input.jobId,
350+
ownerId: user.id,
351+
publicUrlFor: (key) => sb.storage.from(ASSET_BUCKET).getPublicUrl(key).data.publicUrl,
352+
});
353+
if (!status) return { ok: false, error: "No such render." };
354+
355+
const master = downloadableAsset(status);
356+
return {
357+
ok: true,
358+
state: status.state,
359+
label: renderStateLabel(status.state),
360+
revision: status.revision,
361+
streamingReady: streamingReady(status),
362+
downloadUrl: master?.url ?? null,
363+
downloadBytes: master?.byteSize ?? null,
364+
posterUrl: status.assets.find((a) => a.profile === "poster")?.url ?? null,
365+
errorCode: status.errorCode,
366+
};
367+
}
368+
295369
// --- Campaign editing (advertiser) ---
296370

297371
export async function updateCampaign(input: {
@@ -370,6 +444,58 @@ export async function updateCampaign(input: {
370444
return { ok: true };
371445
}
372446

447+
/**
448+
* Re-queue a campaign's pre-roll after its design changed.
449+
*
450+
* Reads the campaign's current creatives back rather than trusting the payload
451+
* that was just written: an edit may touch one format, and the snapshot has to
452+
* be built from whichever source format the video prefers, which may not be
453+
* the one that changed.
454+
*
455+
* bumpRevision is always true here - that is what distinguishes this from the
456+
* initial save, and what makes the worker's compare-and-swap meaningful when
457+
* two edits land in quick succession.
458+
*/
459+
async function requeueCampaignVideo(
460+
supabase: Awaited<ReturnType<typeof createClient>>,
461+
args: { campaignId: string; ownerId: string },
462+
): Promise<void> {
463+
const { data: campaign } = await supabase
464+
.from("ad_campaigns")
465+
.select("destination_domain, destination_url")
466+
.eq("id", args.campaignId)
467+
.eq("owner_id", args.ownerId)
468+
.maybeSingle();
469+
if (!campaign) return;
470+
471+
const { data: rows } = await supabase
472+
.from("ad_creatives")
473+
.select("format, headline, cta_text, bg_color, fg_color, accent_color, font_family, logo_url, image_url")
474+
.eq("campaign_id", args.campaignId)
475+
.eq("owner_id", args.ownerId);
476+
if (!rows?.length) return;
477+
478+
await queueCampaignVideo(supabase, {
479+
campaignId: args.campaignId,
480+
ownerId: args.ownerId,
481+
domain:
482+
(campaign.destination_domain as string | null) ??
483+
domainOf(campaign.destination_url as string),
484+
creatives: rows.map((r) => ({
485+
format: r.format,
486+
headline: r.headline ?? "",
487+
ctaText: r.cta_text ?? "",
488+
bgColor: r.bg_color,
489+
fgColor: r.fg_color,
490+
accentColor: r.accent_color,
491+
fontFamily: r.font_family,
492+
logoUrl: r.logo_url,
493+
imageUrl: r.image_url,
494+
})),
495+
bumpRevision: true,
496+
});
497+
}
498+
373499
export async function updateCreatives(input: {
374500
campaignId: string;
375501
creatives: (Partial<AdCreative> & { id: string })[];
@@ -402,6 +528,7 @@ export async function updateCreatives(input: {
402528
.eq("owner_id", user.id);
403529
if (error) return { ok: false, error: error.message };
404530
}
531+
await requeueCampaignVideo(supabase, { campaignId: input.campaignId, ownerId: user.id });
405532
revalidatePath(`/dashboard/ads/${input.campaignId}`);
406533
return { ok: true };
407534
}
@@ -500,6 +627,7 @@ export async function regenerateCampaign(input: {
500627
}
501628
}
502629

630+
await requeueCampaignVideo(supabase, { campaignId: input.id, ownerId: user.id });
503631
revalidatePath("/dashboard/ads");
504632
revalidatePath(`/dashboard/ads/${input.id}`);
505633
return { ok: true };
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
// /api/ads/v1/video/renders/[id] — one five-second pre-roll render.
2+
//
3+
// GET state (queued | rendering | validating | ready | failed), the revision
4+
// it is rendering, and — once ready — the downloadable master plus the
5+
// delivery renditions, HLS playlist and poster.
6+
//
7+
// Owner-scoped: the service client bypasses RLS, so the owner id from the
8+
// bearer token is applied to the query itself rather than relied on from the
9+
// policy. A job id is a uuid somebody may still hold after losing access to
10+
// the campaign it belongs to.
11+
//
12+
// This is a status read, deliberately cheap and pollable. Rendering happens on
13+
// the worker; no request ever waits for an encode.
14+
15+
import { NextResponse, type NextRequest } from "next/server";
16+
import { serviceClient } from "@/lib/supabase/service";
17+
import { authenticateBearer } from "@/lib/sp/apiAuth";
18+
import {
19+
downloadableAsset,
20+
getRenderStatus,
21+
renderStateLabel,
22+
streamingReady,
23+
} from "@/lib/ads/video/jobs";
24+
import { ASSET_BUCKET } from "@/lib/ads/video/storage";
25+
26+
export const runtime = "nodejs";
27+
export const dynamic = "force-dynamic";
28+
29+
type Ctx = { params: Promise<{ id: string }> };
30+
31+
export async function GET(req: NextRequest, ctx: Ctx) {
32+
const auth = await authenticateBearer(req);
33+
if (!auth.ok) return NextResponse.json({ error: auth.error }, { status: auth.status });
34+
35+
const { id } = await ctx.params;
36+
const sb = serviceClient();
37+
38+
const status = await getRenderStatus(sb, {
39+
jobId: id,
40+
ownerId: auth.userId,
41+
publicUrlFor: (key) => sb.storage.from(ASSET_BUCKET).getPublicUrl(key).data.publicUrl,
42+
});
43+
44+
if (!status) return NextResponse.json({ error: "No such render." }, { status: 404 });
45+
46+
const master = downloadableAsset(status);
47+
48+
return NextResponse.json({
49+
id: status.jobId,
50+
state: status.state,
51+
state_label: renderStateLabel(status.state),
52+
revision: status.revision,
53+
campaign_id: status.campaignId,
54+
error_code: status.errorCode,
55+
// An advertiser may download a draft render before the campaign is ever
56+
// activated; what they may not do is have it served. Those are different
57+
// permissions and this flag is only the second one.
58+
streaming_ready: streamingReady(status),
59+
download_url: master?.url ?? null,
60+
download_bytes: master?.byteSize ?? null,
61+
assets: status.assets.map((a) => ({
62+
profile: a.profile,
63+
url: a.url,
64+
byte_size: a.byteSize,
65+
width: a.width,
66+
height: a.height,
67+
duration_ms: a.durationMs,
68+
})),
69+
});
70+
}

0 commit comments

Comments
 (0)