Skip to content

Commit 7405df2

Browse files
ralyodioclaude
andauthored
Backfill pre-roll videos for existing product campaigns (#277)
Adds the sweep the rest of this package kept promising, a classifier for what counts as a product ad, and a backfill script that queues a render for each one. The sweep first, because without it the backfill would not work and several comments in this package were writing cheques nothing cashed. ad_video_jobs is the durable record and the BullMQ job is the transient work item, which is the right way round, but it leaves a gap: if Redis is down at save time, or the process dies between the insert and the enqueue, the row sits `queued` with nothing scheduled to look at it. processDueVideoRenders reschedules those after a minute's grace, and fails a row outright if it carries no design snapshot so a broken row cannot become a hot loop against Redis. It also means a backfill can simply insert rows from wherever it runs and let the worker schedule them. The classifier exists because "generate videos for the ads" is not the same set as "every campaign row". Of 450 campaigns, 269 point at blog posts and 4 at social links; only 177 advertise a product. Getting that wrong is expensive in one direction specifically — rendering videos for the posts that were excluded — so the shapes are taken from the live table rather than imagined. Most blog campaigns have no /blog/ path prefix at all: dev.profullstack.com/~anthony/blog/126-post.html 152 campaigns, ~user dir dev.to/chovy/<slug> 117 campaigns, /user/slug A naive /^\/blog/ match catches neither, which is why the publishing platforms are matched on the domain. Affiliate and referral links are kept as products: they advertise something a person can buy, which is the distinction that matters. An unparseable destination is deliberately not classified as a product — something that cannot be shown to be one should not get a render. The script calls the same queueCampaignVideo the dashboard calls, so backfilled videos cannot differ from freshly saved ones with nothing to explain why. It is re-runnable: jobs dedupe on a hash of the design, so a campaign that already has a job for its current copy is skipped rather than re-encoded. It passes bumpRevision false, because a backfill is not an edit and bumping would invalidate media an earlier pass already rendered for the same unchanged design. It never rewrites campaign copy and never changes campaign status. Dry run against production: 450 campaigns, 177 product, 269 blog, 4 social, 177 queueable with no campaign lacking a usable creative. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 9ea4cd9 commit 7405df2

5 files changed

Lines changed: 437 additions & 0 deletions

File tree

‎lib/ads/video/classify.ts‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
// What kind of thing a campaign advertises.
2+
//
3+
// ad_campaigns records no kind, so this is derived from the destination. It
4+
// exists because "generate videos for the ads" does not mean every campaign
5+
// row: a large share of them point at blog posts, and rendering a pre-roll for
6+
// an article nobody asked to promote that way is the expensive mistake.
7+
//
8+
// The shapes below are taken from the live table rather than imagined. In
9+
// particular, most blog campaigns do NOT have a /blog/ path prefix:
10+
//
11+
// dev.profullstack.com/~anthony/blog/126-post.html (under a ~user dir)
12+
// dev.to/chovy/<slug> (path is /user/slug)
13+
//
14+
// A naive /^\/blog/ match catches neither, which is why the domain families
15+
// are matched explicitly.
16+
17+
const SOCIAL_DOMAIN =
18+
/^(twitter\.com|x\.com|.*mastodon.*|bsky\.app|threads\.net|linkedin\.com|facebook\.com|instagram\.com|reddit\.com|youtube\.com|youtu\.be|t\.me|discord\.(gg|com)|tiktok\.com)$/i;
19+
20+
const BLOG_PLATFORM =
21+
/^(dev\.to|medium\.com|.*\.medium\.com|.*\.hashnode\.dev|.*\.substack\.com|hackernoon\.com|lobste\.rs)$/i;
22+
23+
// Path segments, so /newsletter-tool stays a product while /news/thing does not.
24+
const BLOG_PATH = /(^|\/)(blog|posts?|article|articles|news)(\/|$)|-post\.html$/i;
25+
26+
export type CampaignKind = "product" | "blog" | "social";
27+
28+
/**
29+
* Classify a campaign by where it points.
30+
*
31+
* Affiliate and referral links count as product: they advertise something a
32+
* person can buy, which is the distinction that matters. An unparseable
33+
* destination is deliberately not classified as a product — something we cannot
34+
* show to be one should not get a render.
35+
*/
36+
export function classifyCampaign(destinationUrl: string): CampaignKind {
37+
let host = "";
38+
let path = "/";
39+
try {
40+
const u = new URL(destinationUrl);
41+
host = u.hostname.replace(/^www\./, "").toLowerCase();
42+
path = u.pathname || "/";
43+
} catch {
44+
return "blog";
45+
}
46+
if (SOCIAL_DOMAIN.test(host)) return "social";
47+
if (BLOG_PLATFORM.test(host)) return "blog";
48+
if (BLOG_PATH.test(path)) return "blog";
49+
return "product";
50+
}

‎lib/ads/video/sweep.ts‎

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
// Drain render jobs whose BullMQ job never materialised.
2+
//
3+
// ad_video_jobs is the durable record and the BullMQ job is the transient work
4+
// item, which is the right way round — but it leaves a gap. If Redis is down
5+
// when a campaign is saved, or the process dies between the insert and the
6+
// enqueue, the row sits `queued` with nothing scheduled to look at it. Several
7+
// comments in this package promise "a sweep can pick it up"; this is that
8+
// sweep, and without it those rows are simply lost.
9+
//
10+
// It also makes a backfill trivial: a script can insert rows and let the sweep
11+
// schedule them, instead of needing to reach Redis itself from wherever it runs.
12+
13+
import type { SupabaseClient } from "@supabase/supabase-js";
14+
import { enqueueRender, getVideoRenderQueue, renderJobId } from "./queue";
15+
import type { VideoDesignSnapshot } from "./snapshot";
16+
import type { VideoProfileId } from "./profiles";
17+
18+
/**
19+
* Grace period before a queued row is considered stranded.
20+
*
21+
* Long enough that the ordinary path — insert, then enqueue milliseconds later
22+
* — is never second-guessed by the sweep racing it. A row that is genuinely
23+
* only a few seconds old is almost certainly mid-save.
24+
*/
25+
const STRANDED_AFTER_MS = 60_000;
26+
27+
/** Rows to schedule per pass. Keeps a backfill from flooding the queue at once. */
28+
const BATCH = Number(process.env.VIDEO_SWEEP_BATCH ?? "25");
29+
30+
export async function processDueVideoRenders(
31+
supabase: SupabaseClient,
32+
): Promise<{ scheduled: number; skipped: number }> {
33+
const queue = getVideoRenderQueue();
34+
if (!queue) return { scheduled: 0, skipped: 0 };
35+
36+
const cutoff = new Date(Date.now() - STRANDED_AFTER_MS).toISOString();
37+
const { data: rows } = await supabase
38+
.from("ad_video_jobs")
39+
.select("id, owner_id, campaign_id, creative_id, revision, render_hash, output_profile, design, attempts")
40+
.eq("state", "queued")
41+
.lt("created_at", cutoff)
42+
.order("created_at", { ascending: true })
43+
.limit(BATCH);
44+
45+
let scheduled = 0;
46+
let skipped = 0;
47+
48+
for (const row of rows ?? []) {
49+
const hash = row.render_hash as string;
50+
const profile = (row.output_profile as string) ?? "default";
51+
52+
// Already scheduled: re-adding is harmless because BullMQ dedupes on the
53+
// job id, but checking first keeps the log honest about what this did.
54+
const existing = await queue.getJob(renderJobId(hash, profile)).catch(() => null);
55+
if (existing) {
56+
skipped++;
57+
continue;
58+
}
59+
60+
// A row with no design cannot be rendered and re-queueing it forever would
61+
// be a hot loop against Redis. Fail it so it stops being swept and shows up
62+
// in the dashboard as something that needs attention.
63+
const snapshot = row.design as VideoDesignSnapshot | null;
64+
if (!snapshot || !snapshot.headline) {
65+
await supabase
66+
.from("ad_video_jobs")
67+
.update({ state: "failed", error_code: "no_design_snapshot" })
68+
.eq("id", row.id);
69+
skipped++;
70+
continue;
71+
}
72+
73+
try {
74+
const ok = await enqueueRender({
75+
renderHash: hash,
76+
profile,
77+
data: {
78+
jobRowId: row.id as string,
79+
ownerId: row.owner_id as string,
80+
campaignId: (row.campaign_id as string | null) ?? null,
81+
creativeId: (row.creative_id as string | null) ?? null,
82+
revision: row.revision as number,
83+
snapshot,
84+
profile: profile as VideoProfileId,
85+
audioSlotSupported: false,
86+
},
87+
});
88+
if (ok) scheduled++;
89+
else skipped++;
90+
} catch (err) {
91+
// Leave the row `queued`. The next pass tries again — which is the entire
92+
// point of the row outliving the queue.
93+
console.warn(`[worker] video sweep could not schedule ${row.id}: ${(err as Error).message}`);
94+
skipped++;
95+
}
96+
}
97+
98+
return { scheduled, skipped };
99+
}

‎scripts/backfill-ad-videos.ts‎

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
// Queue a five-second pre-roll render for existing PRODUCT campaigns.
2+
//
3+
// npx tsx scripts/backfill-ad-videos.ts --env ~/crawlproof-env-backup-2026-07-28.txt --dry-run
4+
// npx tsx scripts/backfill-ad-videos.ts --env ~/crawlproof-env-backup-2026-07-28.txt --limit 3
5+
// npx tsx scripts/backfill-ad-videos.ts --env ~/crawlproof-env-backup-2026-07-28.txt
6+
//
7+
// TypeScript through tsx, like backfill-ad-summaries, so it can call the same
8+
// queueCampaignVideo the dashboard calls. Reimplementing the snapshot here
9+
// would mean backfilled videos differing from freshly saved ones with nothing
10+
// to explain why.
11+
//
12+
// Safe to re-run. Render jobs dedupe on a hash of the design, so a campaign
13+
// that already has a job for its current copy is skipped rather than
14+
// re-encoded, and an interrupted pass continues where it stopped.
15+
//
16+
// It never rewrites campaign copy and never changes campaign status: the only
17+
// writes are the video creative row and its render job.
18+
19+
import { readFileSync } from "node:fs";
20+
import { createClient } from "@supabase/supabase-js";
21+
22+
const args = process.argv.slice(2);
23+
const flag = (name: string): string | null => {
24+
const i = args.indexOf(`--${name}`);
25+
return i >= 0 ? (args[i + 1] ?? "") : null;
26+
};
27+
const has = (name: string) => args.includes(`--${name}`);
28+
29+
const envPath = flag("env") ?? `${process.env.HOME}/crawlproof-env-backup-2026-07-28.txt`;
30+
const dryRun = has("dry-run");
31+
const limit = Number(flag("limit") ?? "0") || 0;
32+
/** Pause between queued campaigns, so a backfill does not spike the queue. */
33+
const DELAY_MS = Number(flag("delay") ?? "150") || 150;
34+
35+
for (const [k, v] of Object.entries(readEnvFile(envPath))) {
36+
if (!process.env[k]) process.env[k] = v;
37+
}
38+
39+
const { queueCampaignVideo } = await import("../lib/ads/video/jobs");
40+
const { classifyCampaign } = await import("../lib/ads/video/classify");
41+
type CampaignKind = "product" | "blog" | "social";
42+
43+
function readEnvFile(path: string): Record<string, string> {
44+
const out: Record<string, string> = {};
45+
let text = "";
46+
try {
47+
text = readFileSync(path, "utf8");
48+
} catch {
49+
return out;
50+
}
51+
for (const line of text.split(/\r?\n/)) {
52+
const m = line.match(/^\s*(?:export\s+)?([A-Z0-9_]+)\s*=\s*(.*)\s*$/);
53+
if (!m) continue;
54+
out[m[1]] = m[2].replace(/^["']|["']$/g, "");
55+
}
56+
return out;
57+
}
58+
59+
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
60+
const key = process.env.SUPABASE_SERVICE_ROLE_KEY;
61+
if (!url || !key) {
62+
console.error("Missing NEXT_PUBLIC_SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY.");
63+
process.exit(1);
64+
}
65+
66+
const supabase = createClient(url, key, {
67+
auth: { autoRefreshToken: false, persistSession: false },
68+
});
69+
70+
type CampaignRow = {
71+
id: string;
72+
owner_id: string;
73+
name: string;
74+
status: string;
75+
destination_url: string;
76+
destination_domain: string | null;
77+
};
78+
79+
const { data: campaigns, error } = await supabase
80+
.from("ad_campaigns")
81+
.select("id, owner_id, name, status, destination_url, destination_domain")
82+
// A rejected campaign must not gain new servable media.
83+
.neq("status", "rejected")
84+
.order("created_at", { ascending: true });
85+
86+
if (error) {
87+
console.error("Could not read campaigns:", error.message);
88+
process.exit(1);
89+
}
90+
91+
const all = (campaigns ?? []) as CampaignRow[];
92+
const tally: Record<CampaignKind, number> = { product: 0, blog: 0, social: 0 };
93+
const products: CampaignRow[] = [];
94+
for (const c of all) {
95+
const kind = classifyCampaign(c.destination_url);
96+
tally[kind]++;
97+
if (kind === "product") products.push(c);
98+
}
99+
100+
console.log(
101+
`${all.length} campaigns: ${tally.product} product, ${tally.blog} blog, ${tally.social} social.`,
102+
);
103+
104+
const targets = limit > 0 ? products.slice(0, limit) : products;
105+
console.log(`${targets.length} to queue${dryRun ? " (dry run — nothing written)" : ""}.\n`);
106+
107+
let queued = 0;
108+
let reused = 0;
109+
let skipped = 0;
110+
let failed = 0;
111+
112+
for (const [i, c] of targets.entries()) {
113+
const label = `${i + 1}/${targets.length} ${c.destination_url.slice(0, 70)}`;
114+
115+
const { data: creatives } = await supabase
116+
.from("ad_creatives")
117+
.select("format, headline, cta_text, bg_color, fg_color, accent_color, font_family, logo_url, image_url")
118+
.eq("campaign_id", c.id)
119+
.neq("format", "video_preroll_5s")
120+
.neq("status", "rejected");
121+
122+
const usable = (creatives ?? []).filter((r) => (r.headline ?? "").trim().length > 0);
123+
if (usable.length === 0) {
124+
console.log(` skip ${label} — no usable creative`);
125+
skipped++;
126+
continue;
127+
}
128+
129+
if (dryRun) {
130+
console.log(` would ${label}`);
131+
queued++;
132+
continue;
133+
}
134+
135+
const handle = await queueCampaignVideo(supabase, {
136+
campaignId: c.id,
137+
ownerId: c.owner_id,
138+
domain: c.destination_domain ?? new URL(c.destination_url).hostname.replace(/^www\./, ""),
139+
creatives: usable.map((r) => ({
140+
format: r.format,
141+
headline: r.headline ?? "",
142+
ctaText: r.cta_text ?? "",
143+
bgColor: r.bg_color,
144+
fgColor: r.fg_color,
145+
accentColor: r.accent_color,
146+
fontFamily: r.font_family,
147+
logoUrl: r.logo_url,
148+
imageUrl: r.image_url,
149+
})),
150+
// A backfill is not an edit. Bumping the revision would invalidate media
151+
// that a previous pass already rendered for the same unchanged design.
152+
bumpRevision: false,
153+
});
154+
155+
if (!handle) {
156+
console.log(` FAIL ${label}`);
157+
failed++;
158+
} else if (handle.reused) {
159+
console.log(` have ${label} — job ${handle.jobId} (${handle.state})`);
160+
reused++;
161+
} else {
162+
console.log(` queue ${label} — job ${handle.jobId}${handle.enqueued ? "" : " (row only; sweep will schedule)"}`);
163+
queued++;
164+
}
165+
166+
if (DELAY_MS > 0) await new Promise((r) => setTimeout(r, DELAY_MS));
167+
}
168+
169+
console.log(
170+
`\nDone. queued=${queued} already-had=${reused} skipped=${skipped} failed=${failed}`,
171+
);

0 commit comments

Comments
 (0)