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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion app/(app)/dashboard/ads/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -265,11 +273,15 @@ export default async function CampaignDetailPage({
<BidHistory data={history.days} events={history.events} autobid={autobid} failed={history.failed} />
</div>

<div className="mt-6">
<VideoRenderCard jobId={videoJobId} />
</div>

{creatives.length > 0 && (
<div className="mt-6">
<h2 className="mb-3 font-semibold">Creatives</h2>
<div className="flex flex-wrap gap-4">
{creatives.map((c) => (
{creatives.filter((c) => !isStreamingFormat(c.format)).map((c) => (
<div key={c.id} className="card p-3">
<AdPreview creative={c} />
<div className="mt-2 text-center text-xs text-[var(--color-muted)]">
Expand Down
128 changes: 128 additions & 0 deletions app/actions/ads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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: {
Expand Down Expand Up @@ -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<ReturnType<typeof createClient>>,
args: { campaignId: string; ownerId: string },
): Promise<void> {
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<AdCreative> & { id: string })[];
Expand Down Expand Up @@ -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 };
}
Expand Down Expand Up @@ -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 };
Expand Down
70 changes: 70 additions & 0 deletions app/api/ads/v1/video/renders/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -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,
})),
});
}
Loading