Skip to content

Commit b1c1acf

Browse files
author
Miriad
committed
Merge branch 'phase1c/distribution-pipeline' into dev
# Conflicts: # package.json # pnpm-lock.yaml
2 parents ad16538 + d60c4dc commit b1c1acf

File tree

4 files changed

+145
-19381
lines changed

4 files changed

+145
-19381
lines changed
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { type NextRequest, NextResponse } from "next/server";
2+
import * as crypto from "node:crypto";
3+
import { writeClient } from "@/lib/sanity-write-client";
4+
import { generateWithGemini } from "@/lib/gemini";
5+
import { uploadVideo, uploadShort } from "@/lib/youtube-upload";
6+
import { notifySubscribers } from "@/lib/resend-notify";
7+
8+
function isValidSignature(body: string, signature: string | null): boolean {
9+
const secret = process.env.SANITY_WEBHOOK_SECRET;
10+
if (!secret) {
11+
console.warn("[sanity-distribute] SANITY_WEBHOOK_SECRET not set");
12+
return true;
13+
}
14+
if (!signature) return false;
15+
const hmac = crypto.createHmac("sha256", secret);
16+
hmac.update(body);
17+
const digest = hmac.digest("base64");
18+
try {
19+
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(digest));
20+
} catch {
21+
return false;
22+
}
23+
}
24+
25+
interface AutomatedVideoDoc {
26+
_id: string;
27+
_type: "automatedVideo";
28+
title: string;
29+
script: {
30+
hook: string;
31+
scenes: Array<{
32+
sceneNumber: number;
33+
narration: string;
34+
visualDescription: string;
35+
bRollKeywords: string[];
36+
durationEstimate: number;
37+
}>;
38+
cta: string;
39+
};
40+
status: "draft" | "script_ready" | "audio_gen" | "video_gen" | "flagged" | "uploading" | "published";
41+
videoUrl?: string;
42+
shortUrl?: string;
43+
audioUrl?: string;
44+
youtubeId?: string;
45+
youtubeShortId?: string;
46+
flaggedReason?: string;
47+
scheduledPublishAt?: string;
48+
}
49+
50+
interface YouTubeMetadata { title: string; description: string; tags: string[]; }
51+
52+
async function generateYouTubeMetadata(doc: AutomatedVideoDoc): Promise<YouTubeMetadata> {
53+
const scriptText = doc.script
54+
? [doc.script.hook, ...(doc.script.scenes?.map((s) => s.narration) ?? []), doc.script.cta].filter(Boolean).join("\n\n")
55+
: "";
56+
const prompt = `You are a YouTube SEO expert for CodingCat.dev, a developer education channel.\n\nVideo Title: ${doc.title}\nScript: ${scriptText}\n\nReturn JSON: {"title": "SEO title max 100 chars", "description": "500-1000 chars", "tags": ["10-15 tags"]}`;
57+
const raw = await generateWithGemini(prompt);
58+
try {
59+
const parsed = JSON.parse(raw.replace(/```json\n?|\n?```/g, "").trim()) as YouTubeMetadata;
60+
return { title: parsed.title?.slice(0, 100) || doc.title, description: parsed.description || doc.title, tags: Array.isArray(parsed.tags) ? parsed.tags.slice(0, 15) : [] };
61+
} catch {
62+
return { title: doc.title, description: doc.title, tags: [] };
63+
}
64+
}
65+
66+
async function updateStatus(docId: string, status: string, extra: Record<string, unknown> = {}): Promise<void> {
67+
await writeClient.patch(docId).set({ status, ...extra }).commit();
68+
console.log(`[sanity-distribute] ${docId} -> ${status}`);
69+
}
70+
71+
export async function POST(req: NextRequest): Promise<NextResponse> {
72+
const rawBody = await req.text();
73+
const signature = req.headers.get("sanity-webhook-signature");
74+
if (!isValidSignature(rawBody, signature)) {
75+
return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
76+
}
77+
78+
let payload: AutomatedVideoDoc;
79+
try { payload = JSON.parse(rawBody); } catch { return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); }
80+
81+
if (payload._type !== "automatedVideo") return NextResponse.json({ skipped: true, reason: "Not automatedVideo" });
82+
if (payload.status !== "video_gen") return NextResponse.json({ skipped: true, reason: `Status "${payload.status}" != "video_gen"` });
83+
if (payload.flaggedReason) return NextResponse.json({ skipped: true, reason: "Flagged" });
84+
85+
const docId = payload._id;
86+
console.log(`[sanity-distribute] Processing ${docId}: "${payload.title}"`);
87+
88+
try {
89+
await updateStatus(docId, "uploading");
90+
91+
// Step 1: Gemini metadata
92+
const metadata = await generateYouTubeMetadata(payload);
93+
94+
// Step 2: Upload main video
95+
let youtubeVideoId = "";
96+
if (payload.videoUrl) {
97+
const r = await uploadVideo({ videoUrl: payload.videoUrl, title: metadata.title, description: metadata.description, tags: metadata.tags });
98+
youtubeVideoId = r.videoId;
99+
}
100+
101+
// Step 3: Upload short
102+
let youtubeShortId = "";
103+
if (payload.shortUrl) {
104+
const r = await uploadShort({ videoUrl: payload.shortUrl, title: metadata.title, description: metadata.description, tags: metadata.tags });
105+
youtubeShortId = r.videoId;
106+
}
107+
108+
// Step 4: Email (non-fatal)
109+
const ytUrl = youtubeVideoId ? `https://www.youtube.com/watch?v=${youtubeVideoId}` : payload.videoUrl || "";
110+
try {
111+
await notifySubscribers({ videoTitle: metadata.title, videoUrl: ytUrl, shortDescription: metadata.description.slice(0, 280), thumbnailUrl: `https://img.youtube.com/vi/${youtubeVideoId}/maxresdefault.jpg` });
112+
} catch (e) { console.warn("[sanity-distribute] Email error:", e); }
113+
114+
// Step 5: Mark published
115+
await updateStatus(docId, "published", { youtubeId: youtubeVideoId || undefined, youtubeShortId: youtubeShortId || undefined });
116+
117+
return NextResponse.json({ success: true, docId, youtubeId: youtubeVideoId, youtubeShortId });
118+
} catch (err) {
119+
const msg = err instanceof Error ? err.message : String(err);
120+
console.error(`[sanity-distribute] Failed ${docId}: ${msg}`);
121+
try { await updateStatus(docId, "flagged", { flaggedReason: `Distribution error: ${msg}` }); } catch {}
122+
return NextResponse.json({ error: "Distribution failed", details: msg }, { status: 500 });
123+
}
124+
}

lib/gemini-metadata.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { generateWithGemini } from "@/lib/gemini";
2+
3+
export interface VideoMetadataInput { title: string; description: string; script: string; tags: string[]; }
4+
export interface VideoMetadataOutput { title: string; description: string; tags: string[]; }
5+
6+
export async function generateVideoMetadata(input: VideoMetadataInput): Promise<VideoMetadataOutput> {
7+
const prompt = `YouTube SEO expert for CodingCat.dev.\n\nTitle: ${input.title}\nDescription: ${input.description}\nScript: ${input.script.slice(0, 2000)}\nTags: ${input.tags.join(", ")}\n\nReturn JSON: {"title": "max 100 chars", "description": "500-1000 chars", "tags": ["10-15 tags"]}`;
8+
const raw = await generateWithGemini(prompt);
9+
try {
10+
const parsed = JSON.parse(raw.replace(/```json\n?|\n?```/g, "").trim()) as VideoMetadataOutput;
11+
return { title: parsed.title?.slice(0, 100) || input.title, description: parsed.description || input.title, tags: Array.isArray(parsed.tags) ? parsed.tags.slice(0, 15) : [] };
12+
} catch {
13+
return { title: input.title, description: input.description || input.title, tags: input.tags };
14+
}
15+
}

package.json

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,8 @@
7070
"cmdk": "^1.1.1",
7171
"date-fns": "^4.1.0",
7272
"embla-carousel-react": "^8.6.0",
73-
"feed": "^5.1.0",
73+
"feed": "^5.2.0",
74+
"googleapis": "^171.4.0",
7475
"input-otp": "^1.4.2",
7576
"instantsearch.js": "^4.80.0",
7677
"jwt-decode": "^4.0.0",
@@ -96,10 +97,11 @@
9697
"react-resizable-panels": "^3.0.6",
9798
"react-syntax-highlighter": "^15.6.6",
9899
"react-twitter-embed": "^4.0.4",
99-
"recharts": "2.15.4",
100+
"recharts": "3.7.0",
100101
"remotion": "^4.0.431",
101-
"sanity": "^4.8.1",
102-
"sanity-plugin-cloudinary": "^1.4.0",
102+
"resend": "^6.9.3",
103+
"sanity": "^5.12.0",
104+
"sanity-plugin-cloudinary": "^1.4.1",
103105
"server-only": "^0.0.1",
104106
"sonner": "^2.0.7",
105107
"styled-components": "^6.1.19",

0 commit comments

Comments
 (0)