diff --git a/app/(dashboard)/dashboard/page.tsx b/app/(dashboard)/dashboard/page.tsx index 2e689fc5..c7b8eb90 100644 --- a/app/(dashboard)/dashboard/page.tsx +++ b/app/(dashboard)/dashboard/page.tsx @@ -2,6 +2,7 @@ export const dynamic = "force-dynamic"; import { SectionCardsLive } from "@/components/section-cards-live"; import { RecentActivity } from "@/components/recent-activity"; +import { PipelineStatus } from "@/components/pipeline-status"; export default function DashboardPage() { return ( @@ -11,7 +12,7 @@ export default function DashboardPage() { Content Ops Dashboard

- Overview of your automated content engine \u2014 videos, sponsors, + Overview of your automated content engine — videos, sponsors, and pipeline health.

@@ -25,7 +26,9 @@ export default function DashboardPage() {

Real-time view of content moving through the pipeline.

- {/* Pipeline status will be added here */} +
+ +
diff --git a/app/(dashboard)/dashboard/settings/page.tsx b/app/(dashboard)/dashboard/settings/page.tsx index cf7748e6..4094974b 100644 --- a/app/(dashboard)/dashboard/settings/page.tsx +++ b/app/(dashboard)/dashboard/settings/page.tsx @@ -5,46 +5,8 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; import { Badge } from "@/components/ui/badge"; -import { Separator } from "@/components/ui/separator"; - -const PUBLISH_DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; -const DEFAULT_PUBLISH_DAYS = ["Mon", "Wed", "Fri"]; - -const CONTENT_CATEGORIES = [ - "JavaScript", - "TypeScript", - "React", - "Next.js", - "Angular", - "Svelte", - "Node.js", - "CSS", - "DevOps", - "AI / ML", - "Web Performance", - "Tooling", -]; - -const RATE_CARD_TIERS = [ - { - name: "Pre-roll Mention", - description: "15-second sponsor mention at the start of the video", - price: "$200", - }, - { - name: "Mid-roll Segment", - description: "60-second dedicated sponsor segment mid-video", - price: "$500", - }, - { - name: "Dedicated Video", - description: "Full sponsored video with product deep-dive", - price: "$1,500", - }, -]; +import { SettingsForm } from "./settings-form"; const INTEGRATIONS = [ { @@ -112,150 +74,42 @@ export default function SettingsPage() {

-
- Note: Settings are currently read-only. Editing will be - enabled in a future phase once a settings schema is added to Sanity. -
- -
- {/* Publishing Cadence */} - - - Publishing Cadence - - Control how often videos are published and on which days. - - - -
- - -
- -
- -
- {PUBLISH_DAYS.map((day) => ( - - {day} - - ))} -
-
-

- Settings will be stored in Sanity once a settings schema is - created. -

-
-
+ - {/* Content Categories */} - - - Content Categories - - Categories used for content idea classification and YouTube - metadata. - - - -
- {CONTENT_CATEGORIES.map((category) => ( - - {category} - - ))} -
-

- Custom category management will be available in a future phase. -

-
-
- - {/* Sponsor Rate Card */} - - - Sponsor Rate Card - - Sponsorship tiers and pricing used by the sponsor portal and - pipeline. - - - -
- {RATE_CARD_TIERS.map((tier) => ( -
-
-

{tier.name}

-

- {tier.description} -

-
- {tier.price} + {/* Integrations Status — server-rendered */} + + + Integrations Status + + Connection status for external services. Green indicates the + environment variable is configured. + + + +
+ {integrationStatus.map((integration) => ( +
+ +
+

{integration.name}

+

+ {integration.description} +

- ))} -
-

- Rate card editing will be available once the sponsor rate card - schema is finalized. -

- - - - {/* Integrations Status */} - - - Integrations Status - - Connection status for external services. Green indicates the - environment variable is configured. - - - -
- {integrationStatus.map((integration) => ( -
- -
-

{integration.name}

-

- {integration.description} -

-
- - {integration.connected ? "Connected" : "Not configured"} - -
- ))} -
-
-
-
+ {integration.connected ? "Connected" : "Not configured"} + +
+ ))} +
+
+
); } diff --git a/app/(dashboard)/dashboard/settings/settings-form.tsx b/app/(dashboard)/dashboard/settings/settings-form.tsx new file mode 100644 index 00000000..74c4d4a7 --- /dev/null +++ b/app/(dashboard)/dashboard/settings/settings-form.tsx @@ -0,0 +1,370 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { toast } from "sonner"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Separator } from "@/components/ui/separator"; +import { Loader2, Plus, Save, Trash2, X } from "lucide-react"; + +interface RateCardTier { + name: string; + description: string; + price: number; +} + +interface DashboardSettings { + videosPerWeek: number; + publishDays: string[]; + contentCategories: string[]; + rateCardTiers: RateCardTier[]; +} + +const ALL_DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; + +const DEFAULT_SETTINGS: DashboardSettings = { + videosPerWeek: 3, + publishDays: ["Mon", "Wed", "Fri"], + contentCategories: [ + "JavaScript", "TypeScript", "React", "Next.js", "Angular", + "Svelte", "Node.js", "CSS", "DevOps", "AI / ML", + "Web Performance", "Tooling", + ], + rateCardTiers: [ + { name: "Pre-roll Mention", description: "15-second sponsor mention at the start of the video", price: 200 }, + { name: "Mid-roll Segment", description: "60-second dedicated sponsor segment mid-video", price: 500 }, + { name: "Dedicated Video", description: "Full sponsored video with product deep-dive", price: 1500 }, + ], +}; + +export function SettingsForm() { + const [settings, setSettings] = useState(DEFAULT_SETTINGS); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [newCategory, setNewCategory] = useState(""); + + const fetchSettings = useCallback(async () => { + try { + const res = await fetch("/api/dashboard/settings"); + if (res.ok) { + const data = await res.json(); + setSettings({ + videosPerWeek: data.videosPerWeek ?? DEFAULT_SETTINGS.videosPerWeek, + publishDays: data.publishDays ?? DEFAULT_SETTINGS.publishDays, + contentCategories: data.contentCategories ?? DEFAULT_SETTINGS.contentCategories, + rateCardTiers: data.rateCardTiers ?? DEFAULT_SETTINGS.rateCardTiers, + }); + } + } catch { + // Use defaults on error + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchSettings(); + }, [fetchSettings]); + + const handleSave = async () => { + setSaving(true); + try { + const res = await fetch("/api/dashboard/settings", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(settings), + }); + if (res.ok) { + toast.success("Settings saved successfully"); + } else { + const data = await res.json(); + toast.error(data.error || "Failed to save settings"); + } + } catch { + toast.error("Failed to save settings"); + } finally { + setSaving(false); + } + }; + + const toggleDay = (day: string) => { + setSettings((prev) => ({ + ...prev, + publishDays: prev.publishDays.includes(day) + ? prev.publishDays.filter((d) => d !== day) + : [...prev.publishDays, day], + })); + }; + + const addCategory = () => { + const trimmed = newCategory.trim(); + if (!trimmed || settings.contentCategories.includes(trimmed)) return; + setSettings((prev) => ({ + ...prev, + contentCategories: [...prev.contentCategories, trimmed], + })); + setNewCategory(""); + }; + + const removeCategory = (category: string) => { + setSettings((prev) => ({ + ...prev, + contentCategories: prev.contentCategories.filter((c) => c !== category), + })); + }; + + const updateTier = (index: number, field: keyof RateCardTier, value: string | number) => { + setSettings((prev) => ({ + ...prev, + rateCardTiers: prev.rateCardTiers.map((tier, i) => + i === index ? { ...tier, [field]: value } : tier + ), + })); + }; + + const addTier = () => { + setSettings((prev) => ({ + ...prev, + rateCardTiers: [...prev.rateCardTiers, { name: "", description: "", price: 0 }], + })); + }; + + const removeTier = (index: number) => { + setSettings((prev) => ({ + ...prev, + rateCardTiers: prev.rateCardTiers.filter((_, i) => i !== index), + })); + }; + + if (loading) { + return ( +
+ {[1, 2, 3].map((i) => ( + + +
+
+ + +
+
+
+
+ + + ))} +
+ ); + } + + return ( + <> +
+ {/* Publishing Cadence */} + + + Publishing Cadence + + Control how often videos are published and on which days. + + + +
+ + + setSettings((prev) => ({ + ...prev, + videosPerWeek: Number.parseInt(e.target.value, 10) || 1, + })) + } + className="w-24" + /> +
+ +
+ +
+ {ALL_DAYS.map((day) => ( + toggleDay(day)} + > + {day} + + ))} +
+
+
+
+ + {/* Content Categories */} + + + Content Categories + + Categories used for content idea classification and YouTube + metadata. + + + +
+ {settings.contentCategories.map((category) => ( + + {category} + + + ))} +
+
+ setNewCategory(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + addCategory(); + } + }} + className="flex-1" + /> + +
+
+
+ + {/* Sponsor Rate Card */} + + + Sponsor Rate Card + + Sponsorship tiers and pricing used by the sponsor portal and + pipeline. + + + +
+ {settings.rateCardTiers.map((tier, index) => ( +
+
+
+
+ + + updateTier(index, "name", e.target.value) + } + placeholder="Tier name" + /> +
+
+ + + updateTier(index, "description", e.target.value) + } + placeholder="Description" + /> +
+
+ + + updateTier( + index, + "price", + Number.parseInt(e.target.value, 10) || 0, + ) + } + placeholder="0" + min={0} + /> +
+
+
+ +
+ ))} +
+ +
+
+
+ +
+ +
+ + ); +} diff --git a/app/api/cron/ingest/route.ts b/app/api/cron/ingest/route.ts index c8fac8ba..69b0105b 100644 --- a/app/api/cron/ingest/route.ts +++ b/app/api/cron/ingest/route.ts @@ -4,22 +4,39 @@ import type { NextRequest } from "next/server"; import { generateWithGemini, stripCodeFences } from "@/lib/gemini"; import { writeClient } from "@/lib/sanity-write-client"; +import { discoverTrends, type TrendResult } from "@/lib/services/trend-discovery"; +import { conductResearch, type ResearchPayload } from "@/lib/services/research"; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- -interface RSSItem { - title: string; - url: string; -} - interface ScriptScene { sceneNumber: number; + sceneType: "narration" | "code" | "list" | "comparison" | "mockup"; narration: string; visualDescription: string; bRollKeywords: string[]; durationEstimate: number; + // Scene-type-specific data + code?: { + snippet: string; + language: string; + highlightLines?: number[]; + }; + list?: { + items: string[]; + icon?: string; + }; + comparison?: { + leftLabel: string; + rightLabel: string; + rows: { left: string; right: string }[]; + }; + mockup?: { + deviceType: "browser" | "phone" | "terminal"; + screenContent: string; + }; } interface GeneratedScript { @@ -42,124 +59,131 @@ interface CriticResult { } // --------------------------------------------------------------------------- -// RSS Feed Helpers +// Fallback topics (used when discoverTrends returns empty) // --------------------------------------------------------------------------- -const RSS_FEEDS = [ - "https://hnrss.org/newest?q=javascript+OR+react+OR+nextjs+OR+typescript&points=50", - "https://dev.to/feed/tag/webdev", -]; - -const FALLBACK_TOPICS: RSSItem[] = [ +const FALLBACK_TRENDS: TrendResult[] = [ { - title: "React Server Components: The Future of Web Development", - url: "https://react.dev/blog", + topic: "React Server Components: The Future of Web Development", + slug: "react-server-components", + score: 80, + signals: [{ source: "blog", title: "React Server Components", url: "https://react.dev/blog", score: 80 }], + whyTrending: "Major shift in React architecture", + suggestedAngle: "Explain what RSC changes for everyday React developers", }, { - title: "TypeScript 5.x: New Features Every Developer Should Know", - url: "https://devblogs.microsoft.com/typescript/", + topic: "TypeScript 5.x: New Features Every Developer Should Know", + slug: "typescript-5x-features", + score: 75, + signals: [{ source: "blog", title: "TypeScript 5.x", url: "https://devblogs.microsoft.com/typescript/", score: 75 }], + whyTrending: "New TypeScript release with major DX improvements", + suggestedAngle: "Walk through the top 5 new features with code examples", }, { - title: "Next.js App Router Best Practices for 2025", - url: "https://nextjs.org/blog", + topic: "Next.js App Router Best Practices for 2025", + slug: "nextjs-app-router-2025", + score: 70, + signals: [{ source: "blog", title: "Next.js App Router", url: "https://nextjs.org/blog", score: 70 }], + whyTrending: "App Router adoption is accelerating", + suggestedAngle: "Common pitfalls and how to avoid them", }, { - title: "The State of CSS in 2025: Container Queries, Layers, and More", - url: "https://web.dev/blog", + topic: "The State of CSS in 2025: Container Queries, Layers, and More", + slug: "css-2025-state", + score: 65, + signals: [{ source: "blog", title: "CSS 2025", url: "https://web.dev/blog", score: 65 }], + whyTrending: "CSS has gained powerful new features", + suggestedAngle: "Demo the top 3 CSS features you should be using today", }, { - title: "WebAssembly is Changing How We Build Web Apps", - url: "https://webassembly.org/", + topic: "WebAssembly is Changing How We Build Web Apps", + slug: "webassembly-web-apps", + score: 60, + signals: [{ source: "blog", title: "WebAssembly", url: "https://webassembly.org/", score: 60 }], + whyTrending: "WASM adoption growing in production apps", + suggestedAngle: "Real-world use cases where WASM outperforms JS", }, ]; -function extractRSSItems(xml: string): RSSItem[] { - const items: RSSItem[] = []; - const itemRegex = /([\s\S]*?)<\/item>/gi; - let itemMatch: RegExpExecArray | null; +// --------------------------------------------------------------------------- +// Gemini Script Generation +// --------------------------------------------------------------------------- - while ((itemMatch = itemRegex.exec(xml)) !== null) { - const block = itemMatch[1]; +const SYSTEM_INSTRUCTION = + "You are a content strategist for CodingCat.dev, a web development education channel. You create engaging, Cleo Abram-style explainer video scripts that are educational, energetic, and concise (60-90 seconds)."; - const titleMatch = block.match(/<!\[CDATA\[(.*?)\]\]><\/title>/); - const titleAlt = block.match(/<title>(.*?)<\/title>/); - const title = titleMatch?.[1] ?? titleAlt?.[1] ?? ""; +function buildPrompt(trends: TrendResult[], research?: ResearchPayload): string { + const topicList = trends + .map((t, i) => `${i + 1}. "${t.topic}" (score: ${t.score}) — ${t.whyTrending}\n Sources: ${t.signals.map(s => s.url).join(", ")}`) + .join("\n"); - const linkMatch = block.match(/<link>(.*?)<\/link>/); - const url = linkMatch?.[1] ?? ""; + // If we have research data, include it as enrichment + let researchContext = ""; + if (research) { + researchContext = `\n\n## Research Data (use this to create an informed, accurate script)\n\n`; + researchContext += `### Briefing\n${research.briefing}\n\n`; - if (title && url) { - items.push({ title: title.trim(), url: url.trim() }); + if (research.talkingPoints.length > 0) { + researchContext += `### Key Talking Points\n${research.talkingPoints.map((tp, i) => `${i + 1}. ${tp}`).join("\n")}\n\n`; } - } - - return items; -} -async function fetchTrendingTopics(): Promise<RSSItem[]> { - const allItems: RSSItem[] = []; + if (research.codeExamples.length > 0) { + researchContext += `### Code Examples (use these in "code" scenes)\n`; + for (const ex of research.codeExamples.slice(0, 5)) { + researchContext += `\`\`\`${ex.language}\n${ex.snippet}\n\`\`\`\nContext: ${ex.context}\n\n`; + } + } - const results = await Promise.allSettled( - RSS_FEEDS.map(async (feedUrl) => { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 10_000); - try { - const res = await fetch(feedUrl, { signal: controller.signal }); - if (!res.ok) { - console.warn( - `[CRON/ingest] RSS fetch failed for ${feedUrl}: ${res.status}`, - ); - return []; + if (research.comparisonData && research.comparisonData.length > 0) { + researchContext += `### Comparison Data (use in "comparison" scenes)\n`; + for (const comp of research.comparisonData) { + researchContext += `${comp.leftLabel} vs ${comp.rightLabel}:\n`; + for (const row of comp.rows) { + researchContext += ` - ${row.left} | ${row.right}\n`; } - const xml = await res.text(); - return extractRSSItems(xml); - } finally { - clearTimeout(timeout); + researchContext += "\n"; } - }), - ); + } - for (const result of results) { - if (result.status === "fulfilled") { - allItems.push(...result.value); - } else { - console.warn("[CRON/ingest] RSS feed error:", result.reason); + if (research.sceneHints.length > 0) { + researchContext += `### Scene Type Suggestions\n`; + for (const hint of research.sceneHints) { + researchContext += `- ${hint.suggestedSceneType}: ${hint.reason}\n`; + } + } + + if (research.infographicPath) { + researchContext += `\n### Infographic Available\nAn infographic has been generated for this topic. Use sceneType "narration" with bRollUrl pointing to the infographic for at least one scene.\n`; } } - const seen = new Set<string>(); - const unique = allItems.filter((item) => { - const key = item.title.toLowerCase(); - if (seen.has(key)) return false; - seen.add(key); - return true; - }); + return `Here are today's trending web development topics: - if (unique.length === 0) { - console.warn("[CRON/ingest] No RSS items fetched, using fallback topics"); - return FALLBACK_TOPICS; - } +${topicList}${researchContext} - return unique.slice(0, 10); -} +Pick the MOST interesting and timely topic for a short explainer video (60-90 seconds). Then generate a complete video script as JSON. -// --------------------------------------------------------------------------- -// Gemini Script Generation -// --------------------------------------------------------------------------- +## Scene Types -const SYSTEM_INSTRUCTION = - "You are a content strategist for CodingCat.dev, a web development education channel. You create engaging, Cleo Abram-style explainer video scripts that are educational, energetic, and concise (60-90 seconds)."; +Each scene MUST have a "sceneType" that determines its visual treatment. Choose the best type for the content: -function buildPrompt(topics: RSSItem[]): string { - const topicList = topics - .map((t, i) => `${i + 1}. "${t.title}" — ${t.url}`) - .join("\n"); +- **"code"** — Use when explaining code snippets, API usage, config files, or CLI commands. Provide the actual code in the "code" field. +- **"list"** — Use for enumerated content: "Top 5 features", "3 reasons why", key takeaways. Provide items in the "list" field. +- **"comparison"** — Use for A-vs-B content: "React vs Vue", "SQL vs NoSQL", pros/cons. Provide structured data in the "comparison" field. +- **"mockup"** — Use when showing a UI, website, app screen, or terminal output. Provide device type and content description in the "mockup" field. +- **"narration"** — Use for conceptual explanations, introductions, or transitions where B-roll footage is appropriate. This is the default/fallback. - return `Here are today's trending web development topics: +**Guidelines:** +- A good video uses 2-3 different scene types for visual variety +- Code-heavy topics should have at least one "code" scene +- Always include "bRollKeywords" and "visualDescription" as fallbacks even for non-narration scenes +- For "code" scenes, provide REAL, working code snippets (not pseudocode) +- For "list" scenes, provide 3-6 concise items +- For "comparison" scenes, provide 2-4 rows -${topicList} +## JSON Schema -Pick the MOST interesting and timely topic for a short explainer video (60-90 seconds). Then generate a complete video script as JSON matching this exact schema: +Return ONLY a JSON object matching this exact schema: { "title": "string - catchy video title", @@ -171,10 +195,31 @@ Pick the MOST interesting and timely topic for a short explainer video (60-90 se "scenes": [ { "sceneNumber": 1, + "sceneType": "code | list | comparison | mockup | narration", "narration": "string - what the narrator says", - "visualDescription": "string - what to show on screen", + "visualDescription": "string - what to show on screen (fallback for all types)", "bRollKeywords": ["keyword1", "keyword2"], - "durationEstimate": 15 + "durationEstimate": 15, + "code": { + "snippet": "string - actual code to display (only for sceneType: code)", + "language": "typescript | javascript | jsx | tsx | css | html | json | bash", + "highlightLines": [1, 3] + }, + "list": { + "items": ["Item 1", "Item 2", "Item 3"], + "icon": "🚀" + }, + "comparison": { + "leftLabel": "Option A", + "rightLabel": "Option B", + "rows": [ + { "left": "Feature of A", "right": "Feature of B" } + ] + }, + "mockup": { + "deviceType": "browser | phone | terminal", + "screenContent": "Description of what appears on the device screen" + } } ], "cta": "string - call to action (subscribe, check link, etc.)" @@ -185,7 +230,9 @@ Pick the MOST interesting and timely topic for a short explainer video (60-90 se Requirements: - The script should have 3-5 scenes totaling 60-90 seconds - The hook should be punchy and curiosity-driven -- Each scene should have clear visual direction +- Use at least 2 different scene types for visual variety +- Only include the type-specific field that matches the sceneType (e.g., only include "code" when sceneType is "code") +- For "code" scenes, provide real, syntactically correct code - The qualityScore should be your honest self-assessment (0-100) - Return ONLY the JSON object, no markdown or extra text`; } @@ -269,6 +316,8 @@ Respond with ONLY the JSON object.`, async function createSanityDocuments( script: GeneratedScript, criticResult: CriticResult, + trends: TrendResult[], + research?: ResearchPayload, ) { const isFlagged = criticResult.score < 50; @@ -304,6 +353,9 @@ async function createSanityDocuments( ...(isFlagged && { flaggedReason: `Quality score ${criticResult.score}/100. Issues: ${criticResult.issues.join("; ") || "Low quality score"}`, }), + trendScore: trends[0]?.score, + trendSources: trends[0]?.signals.map(s => s.source).join(", "), + researchNotebookId: research?.notebookId, }); console.log(`[CRON/ingest] Created automatedVideo: ${automatedVideo._id}`); @@ -329,12 +381,38 @@ export async function GET(request: NextRequest) { } try { - console.log("[CRON/ingest] Fetching trending topics..."); - const topics = await fetchTrendingTopics(); - console.log(`[CRON/ingest] Found ${topics.length} topics`); + // Step 1: Discover trending topics (replaces fetchTrendingTopics) + console.log("[CRON/ingest] Discovering trending topics..."); + let trends: TrendResult[]; + try { + trends = await discoverTrends({ lookbackDays: 7, maxTopics: 10 }); + console.log(`[CRON/ingest] Found ${trends.length} trending topics`); + } catch (err) { + console.warn("[CRON/ingest] Trend discovery failed, using fallback topics:", err); + trends = []; + } + + // Fall back to hardcoded topics if discovery returns empty or failed + if (trends.length === 0) { + console.warn("[CRON/ingest] No trends discovered, using fallback topics"); + trends = FALLBACK_TRENDS; + } + + // Step 2: Optional deep research on top topic + let research: ResearchPayload | undefined; + if (process.env.ENABLE_NOTEBOOKLM_RESEARCH === "true") { + console.log(`[CRON/ingest] Conducting research on: "${trends[0].topic}"...`); + try { + research = await conductResearch(trends[0].topic); + console.log(`[CRON/ingest] Research complete: ${research.sources.length} sources, ${research.sceneHints.length} scene hints`); + } catch (err) { + console.warn("[CRON/ingest] Research failed, continuing without:", err); + } + } + // Step 3: Generate script with Gemini (enriched with research) console.log("[CRON/ingest] Generating script with Gemini..."); - const prompt = buildPrompt(topics); + const prompt = buildPrompt(trends, research); const rawResponse = await generateWithGemini(prompt, SYSTEM_INSTRUCTION); let script: GeneratedScript; @@ -365,7 +443,7 @@ export async function GET(request: NextRequest) { ); console.log("[CRON/ingest] Creating Sanity documents..."); - const result = await createSanityDocuments(script, criticResult); + const result = await createSanityDocuments(script, criticResult, trends, research); console.log("[CRON/ingest] Done!", result); @@ -374,7 +452,9 @@ export async function GET(request: NextRequest) { ...result, title: script.title, criticScore: criticResult.score, - topicCount: topics.length, + trendCount: trends.length, + trendScore: trends[0]?.score, + researchEnabled: !!research, }); } catch (err) { console.error("[CRON/ingest] Unexpected error:", err); diff --git a/app/api/dashboard/activity/route.ts b/app/api/dashboard/activity/route.ts index 5dbacfc1..80437e99 100644 --- a/app/api/dashboard/activity/route.ts +++ b/app/api/dashboard/activity/route.ts @@ -10,14 +10,17 @@ export async function GET() { process.env.NEXT_PUBLIC_SUPABASE_URL && process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; - if (hasSupabase) { - const supabase = await createClient(); - const { - data: { user }, - } = await supabase.auth.getUser(); - if (!user) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } + if (!hasSupabase) { + return NextResponse.json({ error: "Auth not configured" }, { status: 503 }); + } + + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } try { diff --git a/app/api/dashboard/metrics/route.ts b/app/api/dashboard/metrics/route.ts index 6f4d3519..36d74064 100644 --- a/app/api/dashboard/metrics/route.ts +++ b/app/api/dashboard/metrics/route.ts @@ -10,37 +10,31 @@ export async function GET() { process.env.NEXT_PUBLIC_SUPABASE_URL && process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; - if (hasSupabase) { - const supabase = await createClient(); - const { - data: { user }, - } = await supabase.auth.getUser(); - if (!user) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } + if (!hasSupabase) { + return NextResponse.json({ error: "Auth not configured" }, { status: 503 }); + } + + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } try { - const [videosPublished, flaggedVideos, newIdeas, sponsorPipeline] = - await Promise.all([ - dashboardQuery<number>( - `count(*[_type == "automatedVideo" && status == "published"])`, - ), - dashboardQuery<number>( - `count(*[_type == "automatedVideo" && status == "flagged"])`, - ), - dashboardQuery<number>( - `count(*[_type == "contentIdea" && status == "new"])`, - ), - dashboardQuery<number>( - `count(*[_type == "sponsorLead" && status != "paid"])`, - ), - ]); + const counts = await dashboardQuery<Record<string, number>>(`{ + "videosPublished": count(*[_type == "automatedVideo" && status == "published"]), + "flaggedVideos": count(*[_type == "automatedVideo" && status == "flagged"]), + "newIdeas": count(*[_type == "contentIdea" && status == "new"]), + "sponsorPipeline": count(*[_type == "sponsorLead" && status != "paid"]) + }`); const metrics: DashboardMetrics = { - videosPublished: videosPublished ?? 0, - flaggedForReview: (flaggedVideos ?? 0) + (newIdeas ?? 0), - sponsorPipeline: sponsorPipeline ?? 0, + videosPublished: counts?.videosPublished ?? 0, + flaggedForReview: (counts?.flaggedVideos ?? 0) + (counts?.newIdeas ?? 0), + sponsorPipeline: counts?.sponsorPipeline ?? 0, revenue: null, }; diff --git a/app/api/dashboard/pipeline/route.ts b/app/api/dashboard/pipeline/route.ts new file mode 100644 index 00000000..8836d991 --- /dev/null +++ b/app/api/dashboard/pipeline/route.ts @@ -0,0 +1,48 @@ +import { NextResponse } from "next/server"; +import { createClient } from "@/lib/supabase/server"; +import { dashboardQuery } from "@/lib/sanity/dashboard"; + +export const dynamic = "force-dynamic"; + +export async function GET() { + const hasSupabase = + process.env.NEXT_PUBLIC_SUPABASE_URL && + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; + + if (!hasSupabase) { + return NextResponse.json({ error: "Auth not configured" }, { status: 503 }); + } + + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + // Single consolidated GROQ query for all pipeline stages + const counts = await dashboardQuery<Record<string, number>>(`{ + "draft": count(*[_type == "automatedVideo" && status == "draft"]), + "scriptReady": count(*[_type == "automatedVideo" && status == "script_ready"]), + "audioGen": count(*[_type == "automatedVideo" && status == "audio_gen"]), + "rendering": count(*[_type == "automatedVideo" && status == "rendering"]), + "videoGen": count(*[_type == "automatedVideo" && status == "video_gen"]), + "flagged": count(*[_type == "automatedVideo" && status == "flagged"]), + "uploading": count(*[_type == "automatedVideo" && status == "uploading"]), + "published": count(*[_type == "automatedVideo" && status == "published"]) + }`); + + const total = Object.values(counts ?? {}).reduce((sum, n) => sum + (n ?? 0), 0); + + return NextResponse.json({ + ...counts, + total, + }); + } catch (error) { + console.error("Failed to fetch pipeline status:", error); + return NextResponse.json({ error: "Failed" }, { status: 500 }); + } +} diff --git a/app/api/dashboard/settings/route.ts b/app/api/dashboard/settings/route.ts new file mode 100644 index 00000000..fb8259a8 --- /dev/null +++ b/app/api/dashboard/settings/route.ts @@ -0,0 +1,152 @@ +import { NextResponse } from "next/server"; +import { createClient } from "@/lib/supabase/server"; +import { dashboardQuery, dashboardClient } from "@/lib/sanity/dashboard"; + +export const dynamic = "force-dynamic"; + +const SETTINGS_DOC_ID = "dashboardSettings"; + +const DEFAULT_SETTINGS = { + videosPerWeek: 3, + publishDays: ["Mon", "Wed", "Fri"], + contentCategories: [ + "JavaScript", "TypeScript", "React", "Next.js", "Angular", + "Svelte", "Node.js", "CSS", "DevOps", "AI / ML", + "Web Performance", "Tooling", + ], + rateCardTiers: [ + { name: "Pre-roll Mention", description: "15-second sponsor mention", price: 200 }, + { name: "Mid-roll Segment", description: "60-second dedicated segment", price: 500 }, + { name: "Dedicated Video", description: "Full sponsored video", price: 1500 }, + ], +}; + +async function requireAuth() { + const hasSupabase = + process.env.NEXT_PUBLIC_SUPABASE_URL && + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; + + if (!hasSupabase) { + return { error: NextResponse.json({ error: "Auth not configured" }, { status: 503 }) }; + } + + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + + if (!user) { + return { error: NextResponse.json({ error: "Unauthorized" }, { status: 401 }) }; + } + + return { user }; +} + +const VALID_DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; + +function validateSettings(body: unknown): { valid: boolean; data?: Record<string, unknown>; error?: string } { + if (!body || typeof body !== "object") { + return { valid: false, error: "Invalid request body" }; + } + + const input = body as Record<string, unknown>; + const sanitized: Record<string, unknown> = {}; + + if ("videosPerWeek" in input) { + const v = Number(input.videosPerWeek); + if (!Number.isInteger(v) || v < 1 || v > 14) { + return { valid: false, error: "videosPerWeek must be an integer between 1 and 14" }; + } + sanitized.videosPerWeek = v; + } + + if ("publishDays" in input) { + if (!Array.isArray(input.publishDays) || !input.publishDays.every((d: unknown) => typeof d === "string" && VALID_DAYS.includes(d as string))) { + return { valid: false, error: "publishDays must be an array of valid day abbreviations" }; + } + sanitized.publishDays = input.publishDays; + } + + if ("contentCategories" in input) { + if (!Array.isArray(input.contentCategories) || !input.contentCategories.every((c: unknown) => typeof c === "string" && (c as string).length <= 50)) { + return { valid: false, error: "contentCategories must be an array of strings (max 50 chars each)" }; + } + sanitized.contentCategories = input.contentCategories; + } + + if ("rateCardTiers" in input) { + if (!Array.isArray(input.rateCardTiers)) { + return { valid: false, error: "rateCardTiers must be an array" }; + } + for (const tier of input.rateCardTiers as Record<string, unknown>[]) { + if (typeof tier.name !== "string" || typeof tier.description !== "string" || typeof tier.price !== "number") { + return { valid: false, error: "Each rate card tier must have name (string), description (string), and price (number)" }; + } + } + sanitized.rateCardTiers = (input.rateCardTiers as Record<string, unknown>[]).map((t) => ({ + _type: "object", + _key: crypto.randomUUID().slice(0, 8), + name: t.name, + description: t.description, + price: t.price, + })); + } + + if (Object.keys(sanitized).length === 0) { + return { valid: false, error: "No valid fields provided" }; + } + + return { valid: true, data: sanitized }; +} + +export async function GET() { + const auth = await requireAuth(); + if (auth.error) return auth.error; + + try { + const settings = await dashboardQuery( + `*[_type == "dashboardSettings"][0] { + videosPerWeek, + publishDays, + contentCategories, + rateCardTiers[] { name, description, price } + }` + ); + return NextResponse.json(settings ?? DEFAULT_SETTINGS); + } catch (error) { + console.error("Failed to fetch settings:", error); + return NextResponse.json({ error: "Failed to fetch settings" }, { status: 500 }); + } +} + +export async function PUT(request: Request) { + const auth = await requireAuth(); + if (auth.error) return auth.error; + + if (!dashboardClient) { + return NextResponse.json({ error: "Sanity client not available" }, { status: 503 }); + } + + try { + const body = await request.json(); + const validation = validateSettings(body); + + if (!validation.valid) { + return NextResponse.json({ error: validation.error }, { status: 400 }); + } + + // Use createIfNotExists with deterministic ID to prevent race conditions + await dashboardClient.createIfNotExists({ + _id: SETTINGS_DOC_ID, + _type: "dashboardSettings", + ...DEFAULT_SETTINGS, + }); + + await dashboardClient.patch(SETTINGS_DOC_ID).set(validation.data!).commit(); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("Failed to update settings:", error); + return NextResponse.json({ error: "Failed to update settings" }, { status: 500 }); + } +} diff --git a/app/api/webhooks/sanity-distribute/route.ts b/app/api/webhooks/sanity-distribute/route.ts index 5758e829..bfe3c7c5 100644 --- a/app/api/webhooks/sanity-distribute/route.ts +++ b/app/api/webhooks/sanity-distribute/route.ts @@ -1,4 +1,4 @@ -import { type NextRequest, NextResponse } from "next/server"; +import { NextResponse, after } from "next/server"; import { isValidSignature, SIGNATURE_HEADER_NAME } from "@sanity/webhook"; import { writeClient } from "@/lib/sanity-write-client"; import { generateWithGemini } from "@/lib/gemini"; @@ -41,10 +41,35 @@ interface AutomatedVideoDoc { youtubeShortId?: string; flaggedReason?: string; scheduledPublishAt?: string; + distributionLog?: DistributionLogEntry[]; +} + +interface DistributionLogEntry { + _key: string; + step: string; + status: "success" | "failed" | "skipped"; + error?: string; + timestamp: string; + result?: string; } interface YouTubeMetadata { title: string; description: string; tags: string[]; } +// --------------------------------------------------------------------------- +// Distribution log helpers +// --------------------------------------------------------------------------- + +function logEntry(step: string, status: "success" | "failed" | "skipped", opts?: { error?: string; result?: string }): DistributionLogEntry { + return { + _key: `${step}-${Date.now()}`, + step, + status, + error: opts?.error, + timestamp: new Date().toISOString(), + result: opts?.result, + }; +} + // --------------------------------------------------------------------------- // Gemini metadata generation for long-form videos // --------------------------------------------------------------------------- @@ -71,8 +96,7 @@ Return JSON: Include in the description: - Brief summary of what viewers will learn - Key topics covered -- Links section placeholder (🔗 Links mentioned in this video:) -- Social links placeholder +- Links section placeholder - Relevant hashtags at the end`; const raw = await generateWithGemini(prompt); @@ -97,79 +121,80 @@ async function updateStatus(docId: string, status: string, extra: Record<string, console.log(`[sanity-distribute] ${docId} -> ${status}`); } -// --------------------------------------------------------------------------- -// POST handler -// --------------------------------------------------------------------------- - -export async function POST(req: NextRequest): Promise<NextResponse> { - const rawBody = await req.text(); - const signature = req.headers.get(SIGNATURE_HEADER_NAME); - - if (!WEBHOOK_SECRET) { - console.error("[sanity-distribute] Missing SANITY_WEBHOOK_SECRET"); - return NextResponse.json({ error: "Server misconfigured" }, { status: 500 }); - } - - if (!signature || !(await isValidSignature(rawBody, signature, WEBHOOK_SECRET))) { - console.log("[sanity-distribute] Invalid signature"); - return NextResponse.json({ error: "Invalid signature" }, { status: 401 }); - } - - let webhookPayload: WebhookPayload; - try { webhookPayload = JSON.parse(rawBody); } catch { return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); } - - if (webhookPayload._type !== "automatedVideo") return NextResponse.json({ skipped: true, reason: "Not automatedVideo" }); - if (webhookPayload.status !== "video_gen") return NextResponse.json({ skipped: true, reason: `Status "${webhookPayload.status}" != "video_gen"` }); - - const docId = webhookPayload._id; - - // Fetch the full document from Sanity - const doc = await writeClient.fetch<AutomatedVideoDoc | null>( - `*[_id == $id][0]`, - { id: docId } - ); - - if (!doc) { - console.error(`[sanity-distribute] Document ${docId} not found`); - return NextResponse.json({ error: "Document not found" }, { status: 404 }); +async function appendDistributionLog(docId: string, entries: DistributionLogEntry[]): Promise<void> { + const ops = entries.map((entry) => ({ + insert: { after: "distributionLog[-1]", items: [entry] }, + })); + // Use setIfMissing to create the array if it doesn't exist, then append + let patch = writeClient.patch(docId).setIfMissing({ distributionLog: [] }); + for (const entry of entries) { + patch = patch.append("distributionLog", [entry]); } + await patch.commit(); +} - if (doc.status !== "video_gen") { - return NextResponse.json({ skipped: true, reason: `Document status is "${doc.status}", not "video_gen"` }); - } - if (doc.flaggedReason) { - return NextResponse.json({ skipped: true, reason: "Flagged" }); - } +// --------------------------------------------------------------------------- +// Core distribution pipeline (runs inside after()) +// --------------------------------------------------------------------------- - console.log(`[sanity-distribute] Processing ${docId}: "${doc.title}"`); +async function runDistribution(docId: string, doc: AutomatedVideoDoc): Promise<void> { + const log: DistributionLogEntry[] = []; try { await updateStatus(docId, "uploading"); // Step 1: Generate long-form YouTube metadata via Gemini console.log("[sanity-distribute] Step 1/6 - Generating long-form metadata"); - const metadata = await generateYouTubeMetadata(doc); + let metadata: YouTubeMetadata; + try { + metadata = await generateYouTubeMetadata(doc); + log.push(logEntry("gemini-metadata", "success")); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + console.error("[sanity-distribute] Gemini metadata failed:", msg); + log.push(logEntry("gemini-metadata", "failed", { error: msg })); + // Fallback metadata so we can still upload + metadata = { title: doc.title, description: doc.title, tags: [] }; + } // Step 2: Upload main video to YouTube let youtubeVideoId = ""; if (doc.videoUrl) { console.log("[sanity-distribute] Step 2/6 - Uploading main video"); - const r = await uploadVideo({ videoUrl: doc.videoUrl, title: metadata.title, description: metadata.description, tags: metadata.tags }); - youtubeVideoId = r.videoId; + try { + const r = await uploadVideo({ videoUrl: doc.videoUrl, title: metadata.title, description: metadata.description, tags: metadata.tags }); + youtubeVideoId = r.videoId; + log.push(logEntry("youtube-upload", "success", { result: youtubeVideoId })); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + console.error("[sanity-distribute] YouTube upload failed:", msg); + log.push(logEntry("youtube-upload", "failed", { error: msg })); + } + } else { + log.push(logEntry("youtube-upload", "skipped", { error: "No videoUrl" })); } // Step 3: Generate Shorts-optimized metadata + upload Short let youtubeShortId = ""; if (doc.shortUrl) { console.log("[sanity-distribute] Step 3/6 - Generating Shorts metadata + uploading"); - const shortsMetadata = await generateShortsMetadata(generateWithGemini, doc); - const r = await uploadShort({ - videoUrl: doc.shortUrl, - title: shortsMetadata.title, - description: shortsMetadata.description, - tags: shortsMetadata.tags, - }); - youtubeShortId = r.videoId; + try { + const shortsMetadata = await generateShortsMetadata(generateWithGemini, doc); + const r = await uploadShort({ + videoUrl: doc.shortUrl, + title: shortsMetadata.title, + description: shortsMetadata.description, + tags: shortsMetadata.tags, + }); + youtubeShortId = r.videoId; + log.push(logEntry("youtube-short", "success", { result: youtubeShortId })); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + console.error("[sanity-distribute] Short upload failed:", msg); + log.push(logEntry("youtube-short", "failed", { error: msg })); + } + } else { + log.push(logEntry("youtube-short", "skipped", { error: "No shortUrl" })); } // Step 4: Email notification (non-fatal) @@ -182,7 +207,12 @@ export async function POST(req: NextRequest): Promise<NextResponse> { videoUrl: ytUrl, description: metadata.description.slice(0, 280), }); - } catch (e) { console.warn("[sanity-distribute] Email error:", e); } + log.push(logEntry("email", "success")); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + console.warn("[sanity-distribute] Email error:", msg); + log.push(logEntry("email", "failed", { error: msg })); + } // Step 5: Post to X/Twitter (non-fatal) console.log("[sanity-distribute] Step 5/6 - Posting to X/Twitter"); @@ -192,24 +222,175 @@ export async function POST(req: NextRequest): Promise<NextResponse> { youtubeUrl: ytUrl, tags: metadata.tags, }); - if (!tweetResult.success) { - console.warn(`[sanity-distribute] Tweet failed: ${tweetResult.error}`); + if (tweetResult.success) { + log.push(logEntry("x-twitter", "success", { result: tweetResult.tweetId })); + } else { + log.push(logEntry("x-twitter", "failed", { error: tweetResult.error })); } - } catch (e) { console.warn("[sanity-distribute] X/Twitter error:", e); } + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + console.warn("[sanity-distribute] X/Twitter error:", msg); + log.push(logEntry("x-twitter", "failed", { error: msg })); + } - // Step 6: Mark published in Sanity + // Step 6: Mark published in Sanity + save distribution log console.log("[sanity-distribute] Step 6/6 - Marking published"); - await updateStatus(docId, "published", { - youtubeId: youtubeVideoId || undefined, - youtubeShortId: youtubeShortId || undefined, - }); + await writeClient + .patch(docId) + .set({ + status: "published", + youtubeId: youtubeVideoId || undefined, + youtubeShortId: youtubeShortId || undefined, + }) + .setIfMissing({ distributionLog: [] }) + .append("distributionLog", log) + .commit(); console.log(`[sanity-distribute] ✅ Distribution complete for ${docId}`); - return NextResponse.json({ success: true, docId, youtubeId: youtubeVideoId, youtubeShortId }); } catch (err) { const msg = err instanceof Error ? err.message : String(err); console.error(`[sanity-distribute] ❌ Failed ${docId}: ${msg}`); - try { await updateStatus(docId, "flagged", { flaggedReason: `Distribution error: ${msg}` }); } catch {} - return NextResponse.json({ error: "Distribution failed", details: msg }, { status: 500 }); + log.push(logEntry("pipeline", "failed", { error: msg })); + + try { + await writeClient + .patch(docId) + .set({ + status: "flagged", + flaggedReason: `Distribution error: ${msg}`, + }) + .setIfMissing({ distributionLog: [] }) + .append("distributionLog", log) + .commit(); + } catch { + // Last resort — at least try to save the log + console.error("[sanity-distribute] Failed to save error state"); + } + } +} + +// --------------------------------------------------------------------------- +// POST handler +// --------------------------------------------------------------------------- + +/** + * Sanity webhook handler for the distribution pipeline. + * + * Listens for automatedVideo documents transitioning to "video_gen" status + * and triggers YouTube upload, email notification, and social posting. + * + * Uses after() to return 200 immediately and run the heavy pipeline work + * in the background — prevents Vercel from killing the function mid-upload. + * + * Configure in Sanity: Webhook → POST → filter: `_type == "automatedVideo"` + * with projection: `{ _id, _type, status }` + */ +export async function POST(request: Request) { + try { + if (!WEBHOOK_SECRET) { + console.log("[sanity-distribute] Missing SANITY_WEBHOOK_SECRET environment variable"); + return NextResponse.json( + { error: "Server misconfigured: missing webhook secret" }, + { status: 500 } + ); + } + + // Read the raw body as text for signature verification + const rawBody = await request.text(); + const signature = request.headers.get(SIGNATURE_HEADER_NAME); + + if (!signature) { + console.log("[sanity-distribute] Missing signature header"); + return NextResponse.json( + { error: "Missing signature" }, + { status: 401 } + ); + } + + // Verify the webhook signature (same as sanity-content route) + const isValid = await isValidSignature(rawBody, signature, WEBHOOK_SECRET); + + if (!isValid) { + console.log("[sanity-distribute] Invalid signature received"); + return NextResponse.json( + { error: "Invalid signature" }, + { status: 401 } + ); + } + + // Parse the verified body + let webhookPayload: WebhookPayload; + try { + webhookPayload = JSON.parse(rawBody); + } catch { + console.log("[sanity-distribute] Failed to parse webhook body"); + return NextResponse.json( + { skipped: true, reason: "Invalid JSON body" }, + { status: 200 } + ); + } + + console.log(`[sanity-distribute] Received: type=${webhookPayload._type}, id=${webhookPayload._id}, status=${webhookPayload.status}`); + + if (webhookPayload._type !== "automatedVideo") { + return NextResponse.json( + { skipped: true, reason: `Not automatedVideo` }, + { status: 200 } + ); + } + + if (webhookPayload.status !== "video_gen") { + return NextResponse.json( + { skipped: true, reason: `Status "${webhookPayload.status}" is not "video_gen"` }, + { status: 200 } + ); + } + + const docId = webhookPayload._id; + + // Fetch the full document from Sanity (webhook only sends minimal projection) + const doc = await writeClient.fetch<AutomatedVideoDoc | null>( + `*[_id == $id][0]`, + { id: docId } + ); + + if (!doc) { + console.error(`[sanity-distribute] Document ${docId} not found`); + return NextResponse.json({ error: "Document not found" }, { status: 404 }); + } + + // Re-check status from the actual document (race condition guard) + if (doc.status !== "video_gen") { + return NextResponse.json( + { skipped: true, reason: `Document status is "${doc.status}", not "video_gen"` }, + { status: 200 } + ); + } + if (doc.flaggedReason) { + return NextResponse.json( + { skipped: true, reason: "Flagged" }, + { status: 200 } + ); + } + + // Use after() to run the distribution pipeline after the response is sent. + // On Vercel, serverless functions terminate after the response — fire-and-forget + // (promise.catch() without await) silently dies. after() keeps the function alive. + console.log(`[sanity-distribute] Triggering distribution for: ${docId}`); + after(async () => { + try { + await runDistribution(docId, doc); + } catch (error) { + console.error(`[sanity-distribute] Background processing error for ${docId}:`, error); + } + }); + + return NextResponse.json({ triggered: true, docId }, { status: 200 }); + } catch (error) { + console.log("[sanity-distribute] Unexpected error processing webhook:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); } } diff --git a/components/pipeline-status.tsx b/components/pipeline-status.tsx new file mode 100644 index 00000000..fa20588b --- /dev/null +++ b/components/pipeline-status.tsx @@ -0,0 +1,139 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { Loader2 } from "lucide-react"; +import { POLL_INTERVAL_MS } from "@/lib/types/dashboard"; + +interface PipelineData { + draft: number; + scriptReady: number; + audioGen: number; + rendering: number; + videoGen: number; + flagged: number; + uploading: number; + published: number; + total: number; +} + +const STAGES: { + key: keyof Omit<PipelineData, "total">; + label: string; + color: string; + bg: string; + ring: string; +}[] = [ + { key: "draft", label: "Draft", color: "text-gray-700 dark:text-gray-300", bg: "bg-gray-200 dark:bg-gray-700", ring: "ring-gray-300 dark:ring-gray-600" }, + { key: "scriptReady", label: "Script", color: "text-yellow-700 dark:text-yellow-300", bg: "bg-yellow-200 dark:bg-yellow-800", ring: "ring-yellow-300 dark:ring-yellow-600" }, + { key: "audioGen", label: "Audio", color: "text-orange-700 dark:text-orange-300", bg: "bg-orange-200 dark:bg-orange-800", ring: "ring-orange-300 dark:ring-orange-600" }, + { key: "rendering", label: "Render", color: "text-cyan-700 dark:text-cyan-300", bg: "bg-cyan-200 dark:bg-cyan-800", ring: "ring-cyan-300 dark:ring-cyan-600" }, + { key: "videoGen", label: "Video", color: "text-blue-700 dark:text-blue-300", bg: "bg-blue-200 dark:bg-blue-800", ring: "ring-blue-300 dark:ring-blue-600" }, + { key: "flagged", label: "Flagged", color: "text-red-700 dark:text-red-300", bg: "bg-red-200 dark:bg-red-800", ring: "ring-red-300 dark:ring-red-600" }, + { key: "uploading", label: "Upload", color: "text-purple-700 dark:text-purple-300", bg: "bg-purple-200 dark:bg-purple-800", ring: "ring-purple-300 dark:ring-purple-600" }, + { key: "published", label: "Published", color: "text-green-700 dark:text-green-300", bg: "bg-green-200 dark:bg-green-800", ring: "ring-green-300 dark:ring-green-600" }, +]; + +export function PipelineStatus() { + const [data, setData] = useState<PipelineData | null>(null); + const [loading, setLoading] = useState(true); + const abortRef = useRef<AbortController | null>(null); + + const fetchPipeline = useCallback(async () => { + abortRef.current?.abort(); + const controller = new AbortController(); + abortRef.current = controller; + + try { + const res = await fetch("/api/dashboard/pipeline", { + signal: controller.signal, + }); + if (res.ok) { + setData(await res.json()); + } + } catch (error) { + if (error instanceof DOMException && error.name === "AbortError") return; + // Silently fail — will retry on next poll + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchPipeline(); + const interval = setInterval(fetchPipeline, POLL_INTERVAL_MS); + return () => { + clearInterval(interval); + abortRef.current?.abort(); + }; + }, [fetchPipeline]); + + if (loading) { + return ( + <div className="flex items-center justify-center py-8"> + <Loader2 className="h-5 w-5 animate-spin text-muted-foreground" /> + </div> + ); + } + + if (!data) { + return ( + <p className="py-4 text-center text-sm text-muted-foreground"> + Unable to load pipeline data. + </p> + ); + } + + return ( + <div className="space-y-4"> + {/* Stage indicators */} + <div className="flex items-end gap-1.5"> + {STAGES.map((stage) => { + const count = data[stage.key]; + const maxCount = Math.max(...STAGES.map((s) => data[s.key]), 1); + const heightPct = Math.max((count / maxCount) * 100, 8); + + return ( + <div + key={stage.key} + className="flex flex-1 flex-col items-center gap-1" + > + {/* Count */} + <span className={`text-xs font-semibold ${stage.color}`}> + {count} + </span> + {/* Bar */} + <div className="relative w-full" style={{ height: "48px" }}> + <div + className={`absolute bottom-0 w-full rounded-sm ${stage.bg} ring-1 ${stage.ring} transition-all duration-500`} + style={{ height: `${heightPct}%`, minHeight: "4px" }} + /> + </div> + {/* Label */} + <span className="text-[10px] leading-tight text-muted-foreground"> + {stage.label} + </span> + </div> + ); + })} + </div> + + {/* Connector arrows */} + <div className="flex items-center gap-1.5 px-2"> + {STAGES.map((stage, i) => ( + <div key={stage.key} className="flex flex-1 items-center"> + <div className={`h-0.5 flex-1 ${stage.bg}`} /> + {i < STAGES.length - 1 && ( + <span className="text-[10px] text-muted-foreground">\u2192</span> + )} + </div> + ))} + </div> + + {/* Total */} + <div className="flex items-center justify-between border-t pt-3"> + <span className="text-xs text-muted-foreground">Total in pipeline</span> + <span className="text-sm font-semibold">{data.total}</span> + </div> + </div> + ); +} diff --git a/lib/services/elevenlabs.ts b/lib/services/elevenlabs.ts index 4aeb8d4c..faae42f3 100644 --- a/lib/services/elevenlabs.ts +++ b/lib/services/elevenlabs.ts @@ -6,6 +6,13 @@ * script text → ElevenLabs TTS → MP3 audio → upload to GCS → Remotion render */ +import { + aggregateToWordTimestamps, + type CharacterAlignment, + type WordTimestamp, + type SceneAudioResult, +} from "@/lib/utils/audio-timestamps"; + const ELEVENLABS_API_BASE = "https://api.elevenlabs.io/v1"; /** Configuration for the ElevenLabs TTS service. */ @@ -43,6 +50,12 @@ export interface VideoScript { cta: string; } +/** Response from ElevenLabs /with-timestamps endpoint */ +interface TTSWithTimestampsResponse { + audio_base64: string; + alignment: CharacterAlignment; +} + /** * Reads the ElevenLabs configuration from environment variables. * @@ -213,3 +226,181 @@ export async function generateSpeechFromScript( return generateSpeech(combinedText); } + +/** + * Generate speech with word-level timestamps using the ElevenLabs + * `/text-to-speech/{voiceId}/with-timestamps` endpoint. + * + * Returns both the audio buffer and word-level timing data that can be + * used to sync Remotion visuals to the narration. + * + * @param text - The text to convert to speech. + * @returns Audio buffer + word-level timestamps. + */ +export async function generateSpeechWithTimestamps( + text: string +): Promise<SceneAudioResult> { + if (!text || text.trim().length === 0) { + throw new Error("Cannot generate speech from empty text."); + } + + const { apiKey, voiceId } = getConfig(); + + const url = `${ELEVENLABS_API_BASE}/text-to-speech/${voiceId}/with-timestamps`; + + const body: TTSRequestBody = { + text, + model_id: "eleven_multilingual_v2", + voice_settings: { + stability: 0.5, + similarity_boost: 0.75, + style: 0.5, + }, + }; + + let response: Response; + + try { + response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + "xi-api-key": apiKey, + }, + body: JSON.stringify(body), + }); + } catch (error) { + throw new Error( + `ElevenLabs timestamps API request failed: ${error instanceof Error ? error.message : String(error)}` + ); + } + + if (!response.ok) { + let errorDetail: string; + try { + const errorBody = await response.json(); + errorDetail = + errorBody?.detail?.message || + errorBody?.detail || + JSON.stringify(errorBody); + } catch { + errorDetail = response.statusText || "Unknown error"; + } + throw new Error( + `ElevenLabs timestamps API error (${response.status}): ${errorDetail}` + ); + } + + const data = (await response.json()) as TTSWithTimestampsResponse; + + if (!data.audio_base64) { + throw new Error("ElevenLabs timestamps API returned no audio data."); + } + + const audioBuffer = Buffer.from(data.audio_base64, "base64"); + const wordTimestamps = aggregateToWordTimestamps(data.alignment); + + // Calculate duration from the last word's end time, or estimate from buffer + const durationMs = + wordTimestamps.length > 0 + ? wordTimestamps[wordTimestamps.length - 1].endMs + : Math.round((audioBuffer.length / 32000) * 1000); // rough estimate for MP3 + + return { + audioBase64: data.audio_base64, + audioBuffer, + wordTimestamps, + durationMs, + }; +} + +/** + * Generate per-scene audio with timestamps from a structured video script. + * + * Instead of concatenating everything into one blob, this generates + * separate audio for each section (hook, scenes, CTA) with word-level + * timestamps. This enables: + * - Precise scene boundary timing + * - Per-scene word timestamps for visual sync + * - Fault isolation (retry one scene instead of all) + * + * @param script - The video script + * @returns Array of SceneAudioResult, one per section (hook + scenes + CTA) + */ +export async function generatePerSceneAudio( + script: VideoScript +): Promise<{ + hook: SceneAudioResult; + scenes: SceneAudioResult[]; + cta: SceneAudioResult; + totalDurationMs: number; +}> { + const sections: { label: string; text: string }[] = []; + + if (script.hook?.trim()) { + sections.push({ label: "hook", text: script.hook.trim() }); + } else { + throw new Error("Script must have a hook."); + } + + if (!script.scenes?.length) { + throw new Error("Script must have at least one scene."); + } + + for (const scene of script.scenes) { + if (scene.narration?.trim()) { + sections.push({ + label: `scene-${scene.sceneNumber ?? sections.length}`, + text: scene.narration.trim(), + }); + } + } + + if (script.cta?.trim()) { + sections.push({ label: "cta", text: script.cta.trim() }); + } else { + throw new Error("Script must have a CTA."); + } + + console.log( + `[elevenlabs] Generating per-scene audio for ${sections.length} sections...` + ); + + // Generate audio for all sections concurrently (with a concurrency limit) + const CONCURRENCY = 3; + const results: SceneAudioResult[] = []; + + for (let i = 0; i < sections.length; i += CONCURRENCY) { + const batch = sections.slice(i, i + CONCURRENCY); + const batchResults = await Promise.all( + batch.map(async (section) => { + console.log( + `[elevenlabs] Generating audio for ${section.label} (${section.text.length} chars)...` + ); + return generateSpeechWithTimestamps(section.text); + }) + ); + results.push(...batchResults); + } + + const totalDurationMs = results.reduce((sum, r) => sum + r.durationMs, 0); + + console.log( + `[elevenlabs] Per-scene audio complete: ${results.length} sections, ${Math.round(totalDurationMs / 1000)}s total` + ); + + // Split results back into hook, scenes, CTA + const hookResult = results[0]; + const sceneResults = results.slice(1, results.length - 1); + const ctaResult = results[results.length - 1]; + + return { + hook: hookResult, + scenes: sceneResults, + cta: ctaResult, + totalDurationMs, + }; +} + +// Re-export timestamp types for consumers +export type { WordTimestamp, SceneAudioResult, CharacterAlignment } from "@/lib/utils/audio-timestamps"; diff --git a/lib/services/research.ts b/lib/services/research.ts new file mode 100644 index 00000000..6c8d6c42 --- /dev/null +++ b/lib/services/research.ts @@ -0,0 +1,807 @@ +/** + * NotebookLM Research Service + * + * Wraps the `notebooklm` Python CLI (v0.3.3) to conduct deep research on a + * topic and produce a structured payload for Gemini script generation. + * + * Pipeline: + * topic → NotebookLM deep research → artifacts + Q&A → ResearchPayload + * + * TEMPORARY APPROACH: This shells out to the CLI and parses JSON output. + * Eventually we'll replace CLI calls with direct HTTP/gRPC to the NotebookLM + * API once a stable TypeScript SDK or REST endpoint is available. + * + * @module lib/services/research + */ + +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { mkdir } from "node:fs/promises"; +import { join } from "node:path"; + +const execFileAsync = promisify(execFile); + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** Research payload that feeds into Gemini script generation */ +export interface ResearchPayload { + topic: string; + notebookId: string; + createdAt: string; + completedAt: string; + sources: ResearchSource[]; + briefing: string; + talkingPoints: string[]; + codeExamples: CodeExample[]; + comparisonData?: ComparisonData[]; + mindMap?: string; + dataTable?: string; + prosCons?: Record<string, string[]>; + commonMistakes?: string[]; + sceneHints: SceneHint[]; + infographicPath?: string; // local path to downloaded PNG +} + +export interface ResearchSource { + title: string; + url: string; + type: "youtube" | "article" | "docs" | "unknown"; +} + +export interface CodeExample { + snippet: string; + language: string; + context: string; +} + +export interface ComparisonData { + leftLabel: string; + rightLabel: string; + rows: { left: string; right: string }[]; +} + +export interface SceneHint { + content: string; + suggestedSceneType: "narration" | "code" | "list" | "comparison" | "mockup"; + reason: string; +} + +export interface ResearchConfig { + /** Path to notebooklm CLI binary (default: "notebooklm") */ + cliBinary?: string; + /** Path to storage_state.json for auth */ + storagePath?: string; + /** Working directory for output files */ + outputDir?: string; + /** Timeout for individual CLI commands in ms (default: 120000) */ + commandTimeout?: number; + /** Timeout for research/artifact generation in ms (default: 300000) */ + generationTimeout?: number; +} + +// --------------------------------------------------------------------------- +// Configuration +// --------------------------------------------------------------------------- + +function resolveConfig(overrides?: ResearchConfig): Required<ResearchConfig> { + return { + cliBinary: + overrides?.cliBinary || + process.env.NOTEBOOKLM_CLI_PATH || + "notebooklm", + storagePath: + overrides?.storagePath || process.env.NOTEBOOKLM_STORAGE_PATH || "", + outputDir: + overrides?.outputDir || + process.env.NOTEBOOKLM_OUTPUT_DIR || + "/tmp/notebooklm-research", + commandTimeout: overrides?.commandTimeout ?? 120_000, + generationTimeout: overrides?.generationTimeout ?? 300_000, + }; +} + +// --------------------------------------------------------------------------- +// CLI Wrapper +// --------------------------------------------------------------------------- + +/** + * Execute a `notebooklm` CLI command and return stdout. + * + * Automatically appends `--json` when the command supports it, and routes + * notebook-scoped commands via the `-n <id>` flag. + */ +async function runNotebookLM( + cfg: Required<ResearchConfig>, + args: string[], + options?: { + timeout?: number; + notebook?: string; + /** Some commands (e.g. `download`) don't support --json */ + skipJson?: boolean; + } +): Promise<string> { + const cmd = cfg.cliBinary; + const fullArgs = [...args]; + + // Scope to a specific notebook + if (options?.notebook) { + fullArgs.unshift("-n", options.notebook); + } + + // Add --json flag if not already present and not explicitly skipped + if (!options?.skipJson && !fullArgs.includes("--json")) { + fullArgs.push("--json"); + } + + const timeout = options?.timeout ?? cfg.commandTimeout; + + try { + const { stdout, stderr } = await execFileAsync(cmd, fullArgs, { + timeout, + maxBuffer: 10 * 1024 * 1024, // 10 MB — artifacts can be large + env: { + ...process.env, + ...(cfg.storagePath + ? { NOTEBOOKLM_STORAGE: cfg.storagePath } + : undefined), + }, + }); + + if (stderr) { + console.warn(`[research] CLI stderr: ${stderr.slice(0, 500)}`); + } + + return stdout; + } catch (error: unknown) { + // Provide a clear message if the CLI is not installed + if (isExecError(error) && error.code === "ENOENT") { + throw new Error( + `[research] notebooklm CLI not found at "${cmd}". ` + + `Install it with: pip install notebooklm (or set NOTEBOOKLM_CLI_PATH ` + + `to the full path of the binary)` + ); + } + throw error; + } +} + +/** Parse JSON from CLI stdout, with a fallback for non-JSON output. Returns null for empty input. */ +function parseJsonOutput<T = unknown>(stdout: string): T | null { + const trimmed = stdout.trim(); + if (!trimmed) { + return null; + } + try { + return JSON.parse(trimmed) as T; + } catch { + // Some commands return text with JSON embedded — try to extract it + const jsonMatch = trimmed.match(/\{[\s\S]*\}/); + if (jsonMatch) { + return JSON.parse(jsonMatch[0]) as T; + } + // Return raw text wrapped in an object + return { raw: trimmed } as T; + } +} + +// --------------------------------------------------------------------------- +// Type guard helpers +// --------------------------------------------------------------------------- + +interface ExecError extends Error { + code?: string; + killed?: boolean; + signal?: string; +} + +function isExecError(err: unknown): err is ExecError { + return err instanceof Error && "code" in err; +} + +// --------------------------------------------------------------------------- +// Source type classification +// --------------------------------------------------------------------------- + +function classifySourceType( + url: string +): "youtube" | "article" | "docs" | "unknown" { + if (!url) return "unknown"; + const lower = url.toLowerCase(); + if ( + lower.includes("youtube.com") || + lower.includes("youtu.be") || + lower.includes("youtube") + ) { + return "youtube"; + } + if ( + lower.includes("/docs") || + lower.includes("documentation") || + lower.includes("developer.") || + lower.includes("devdocs") || + lower.includes("mdn") || + lower.includes("spec.") + ) { + return "docs"; + } + if ( + lower.includes("blog") || + lower.includes("medium.com") || + lower.includes("dev.to") || + lower.includes("hashnode") || + lower.includes("article") + ) { + return "article"; + } + return "unknown"; +} + +// --------------------------------------------------------------------------- +// Scene hint generation +// --------------------------------------------------------------------------- + +/** + * Analyze a piece of content and suggest what type of video scene it maps to. + */ +function classifyScene( + content: string +): "narration" | "code" | "list" | "comparison" | "mockup" { + // Code blocks + if (/```[\s\S]*?```/.test(content) || /^\s{2,}(const|let|var|function|import|export|class|def|return)\b/m.test(content)) { + return "code"; + } + // Numbered or bulleted lists (3+ items) + const listMatches = content.match(/^[\s]*[-•*\d]+[.)]\s/gm); + if (listMatches && listMatches.length >= 3) { + return "list"; + } + // Comparison language + if ( + /\bvs\.?\b/i.test(content) || + /\bcompare[ds]?\b/i.test(content) || + /\bdifference[s]?\b/i.test(content) || + /\bpros\s+(and|&)\s+cons\b/i.test(content) + ) { + return "comparison"; + } + // UI / mockup language + if ( + /\b(UI|interface|dashboard|screen|layout|component|widget|button|modal)\b/i.test( + content + ) + ) { + return "mockup"; + } + return "narration"; +} + +function generateSceneHints(sections: string[]): SceneHint[] { + const hints: SceneHint[] = []; + + for (const section of sections) { + if (!section.trim()) continue; + + const sceneType = classifyScene(section); + const reasonMap: Record<typeof sceneType, string> = { + code: "Contains code blocks or programming constructs", + list: "Contains a numbered or bulleted list with 3+ items", + comparison: "Contains comparison language (vs, compare, differences)", + mockup: "Describes UI elements or interface components", + narration: "General explanatory content best suited for narration", + }; + + hints.push({ + content: section.slice(0, 500), // Truncate for payload size + suggestedSceneType: sceneType, + reason: reasonMap[sceneType], + }); + } + + return hints; +} + +// --------------------------------------------------------------------------- +// Content extraction helpers +// --------------------------------------------------------------------------- + +/** + * Parse talking points from a free-text Q&A response. + * Expects numbered or bulleted items. + */ +function extractTalkingPoints(text: string): string[] { + const lines = text.split("\n"); + const points: string[] = []; + + for (const line of lines) { + const cleaned = line.replace(/^[\s]*[-•*\d]+[.)]\s*/, "").trim(); + if (cleaned.length > 20) { + points.push(cleaned); + } + } + + return points.slice(0, 8); // Cap at 8 points +} + +/** + * Parse code examples from a free-text Q&A response. + * Looks for fenced code blocks with optional language tags. + */ +function extractCodeExamples(text: string): CodeExample[] { + const examples: CodeExample[] = []; + const codeBlockRegex = /```(\w*)\n([\s\S]*?)```/g; + let match: RegExpExecArray | null; + + while ((match = codeBlockRegex.exec(text)) !== null) { + const language = match[1] || "typescript"; + const snippet = match[2].trim(); + + // Try to find context: the text immediately before the code block + const beforeBlock = text.slice(0, match.index); + const contextLines = beforeBlock.split("\n").filter((l) => l.trim()); + const context = contextLines.length > 0 + ? contextLines[contextLines.length - 1].trim() + : "Code example"; + + examples.push({ snippet, language, context }); + } + + return examples; +} + +/** + * Parse pros and cons from a free-text Q&A response. + * Returns a record like { "Pros": [...], "Cons": [...] }. + */ +function extractProsCons(text: string): Record<string, string[]> { + const result: Record<string, string[]> = { Pros: [], Cons: [] }; + let currentSection: "Pros" | "Cons" | null = null; + + for (const line of text.split("\n")) { + const lower = line.toLowerCase().trim(); + if (lower.startsWith("pro") || lower.includes("advantages") || lower.includes("benefits")) { + currentSection = "Pros"; + continue; + } + if (lower.startsWith("con") || lower.includes("disadvantages") || lower.includes("drawbacks")) { + currentSection = "Cons"; + continue; + } + if (currentSection) { + const cleaned = line.replace(/^[\s]*[-•*\d]+[.)]\s*/, "").trim(); + if (cleaned.length > 10) { + result[currentSection].push(cleaned); + } + } + } + + return result; +} + +/** + * Parse common mistakes from a free-text Q&A response. + */ +function extractMistakes(text: string): string[] { + const lines = text.split("\n"); + const mistakes: string[] = []; + + for (const line of lines) { + const cleaned = line.replace(/^[\s]*[-•*\d]+[.)]\s*/, "").trim(); + if (cleaned.length > 15) { + mistakes.push(cleaned); + } + } + + return mistakes.slice(0, 10); +} + +/** + * Parse comparison data from a free-text Q&A response. + * Attempts to find table-like or "X vs Y" structures. + */ +function extractComparisonData(text: string): ComparisonData[] { + const comparisons: ComparisonData[] = []; + + // Look for "X vs Y" pattern in the text + const vsMatch = text.match(/(\w[\w\s.]*?)\s+vs\.?\s+(\w[\w\s.]*?)[\n:]/i); + if (vsMatch) { + const leftLabel = vsMatch[1].trim(); + const rightLabel = vsMatch[2].trim(); + const rows: { left: string; right: string }[] = []; + + // Try to extract comparison rows from the remaining text + const lines = text.split("\n"); + for (const line of lines) { + // Look for lines with separators like "|", "→", or "vs" + const pipeMatch = line.match(/^\s*\|?\s*(.+?)\s*\|\s*(.+?)\s*\|?\s*$/); + if (pipeMatch && pipeMatch[1].trim() && pipeMatch[2].trim()) { + const left = pipeMatch[1].trim(); + const right = pipeMatch[2].trim(); + // Skip header separators + if (!left.match(/^[-=]+$/) && !right.match(/^[-=]+$/)) { + rows.push({ left, right }); + } + } + } + + if (rows.length > 0) { + comparisons.push({ leftLabel, rightLabel, rows }); + } + } + + return comparisons; +} + +// --------------------------------------------------------------------------- +// Targeted questions +// --------------------------------------------------------------------------- + +function getTargetedQuestions(topic: string): { + key: string; + question: string; +}[] { + return [ + { + key: "talkingPoints", + question: `What are the 5-6 most important talking points about ${topic} for a web developer audience?`, + }, + { + key: "codeExamples", + question: `Show me the most important code examples for ${topic} with explanations`, + }, + { + key: "prosCons", + question: `What are the pros and cons of ${topic}?`, + }, + { + key: "mistakes", + question: `What are common mistakes or misconceptions about ${topic}?`, + }, + { + key: "comparison", + question: `How does ${topic} compare to alternatives?`, + }, + ]; +} + +// --------------------------------------------------------------------------- +// Safe wrapper for optional pipeline steps +// --------------------------------------------------------------------------- + +/** + * Run an async operation, returning `undefined` on failure instead of + * throwing. Logs the error so the pipeline can continue. + */ +async function safeStep<T>( + label: string, + fn: () => Promise<T> +): Promise<T | undefined> { + try { + return await fn(); + } catch (error) { + console.error( + `[research] Step "${label}" failed, continuing pipeline:`, + error instanceof Error ? error.message : error + ); + return undefined; + } +} + +// --------------------------------------------------------------------------- +// Main pipeline +// --------------------------------------------------------------------------- + +/** + * Run the full NotebookLM research pipeline for a topic. + * + * Creates a notebook, runs deep web research, generates artifacts, asks + * targeted questions, and returns a structured {@link ResearchPayload}. + * + * @param topic - The topic to research (e.g. "React Server Components") + * @param configOverrides - Optional configuration overrides + * @returns Structured research payload for downstream script generation + * + * @example + * ```ts + * import { conductResearch } from "@/lib/services/research"; + * + * const research = await conductResearch("React Server Components"); + * console.log(research.talkingPoints); + * ``` + */ +export async function conductResearch( + topic: string, + configOverrides?: ResearchConfig +): Promise<ResearchPayload> { + const cfg = resolveConfig(configOverrides); + const createdAt = new Date().toISOString(); + + console.log(`[research] Starting research pipeline for: "${topic}"`); + + // Ensure output directory exists + await mkdir(cfg.outputDir, { recursive: true }); + + // ----------------------------------------------------------------------- + // Step 1: Create a notebook + // ----------------------------------------------------------------------- + console.log("[research] Step 1/8: Creating notebook..."); + const createOutput = await runNotebookLM(cfg, ["create", topic]); + const createResult = parseJsonOutput<{ id?: string; notebook_id?: string }>( + createOutput + ); + const notebookId = createResult?.id || createResult?.notebook_id || ""; + + if (!notebookId) { + throw new Error( + `[research] Failed to create notebook — no ID returned. Output: ${createOutput.slice(0, 200)}` + ); + } + + console.log(`[research] Notebook created: ${notebookId}`); + + // ----------------------------------------------------------------------- + // Step 2: Run deep web research + // ----------------------------------------------------------------------- + console.log("[research] Step 2/8: Running deep web research..."); + await runNotebookLM( + cfg, + [ + "source", + "add-research", + topic, + "--mode", + "deep", + "--import-all", + "--no-wait", + ], + { + notebook: notebookId, + timeout: cfg.commandTimeout, + skipJson: true, + } + ); + + // ----------------------------------------------------------------------- + // Step 3: Wait for research to complete + // ----------------------------------------------------------------------- + console.log("[research] Step 3/8: Waiting for research to complete..."); + await runNotebookLM(cfg, ["research", "wait"], { + notebook: notebookId, + timeout: cfg.generationTimeout, + skipJson: true, + }); + + console.log("[research] Research complete."); + + // ----------------------------------------------------------------------- + // Step 4: Generate artifacts in parallel + // ----------------------------------------------------------------------- + console.log("[research] Step 4/8: Generating artifacts..."); + + const [mindMapResult, briefingResult, dataTableResult, infographicResult] = + await Promise.all([ + safeStep("mind-map", () => + runNotebookLM(cfg, ["generate", "mind-map", "--wait"], { + notebook: notebookId, + timeout: cfg.generationTimeout, + }) + ), + safeStep("briefing-doc", () => + runNotebookLM( + cfg, + ["generate", "report", "--type", "briefing-doc", "--wait"], + { + notebook: notebookId, + timeout: cfg.generationTimeout, + } + ) + ), + safeStep("data-table", () => + runNotebookLM(cfg, ["generate", "data-table", "--wait"], { + notebook: notebookId, + timeout: cfg.generationTimeout, + }) + ), + safeStep("infographic", () => + runNotebookLM( + cfg, + [ + "generate", + "infographic", + "--orientation", + "landscape", + "--detail", + "detailed", + "--wait", + ], + { + notebook: notebookId, + timeout: cfg.generationTimeout, + } + ) + ), + ]); + + // Parse artifact results + const mindMap = mindMapResult + ? (parseJsonOutput<{ content?: string }>(mindMapResult)?.content ?? + mindMapResult.trim()) + : undefined; + + const briefingParsed = briefingResult + ? parseJsonOutput<{ content?: string }>(briefingResult) + : undefined; + const briefing = briefingParsed?.content ?? briefingResult?.trim() ?? ""; + + const dataTable = dataTableResult + ? (parseJsonOutput<{ content?: string }>(dataTableResult)?.content ?? + dataTableResult.trim()) + : undefined; + + console.log("[research] Artifacts generated."); + + // ----------------------------------------------------------------------- + // Step 5: Ask targeted questions + // ----------------------------------------------------------------------- + console.log("[research] Step 5/8: Asking targeted questions..."); + + const questions = getTargetedQuestions(topic); + const answers: Record<string, string> = {}; + + // Run questions sequentially to avoid rate limiting + for (const { key, question } of questions) { + const result = await safeStep(`ask:${key}`, () => + runNotebookLM(cfg, ["ask", question], { + notebook: notebookId, + timeout: cfg.commandTimeout, + }) + ); + + if (result) { + const parsed = parseJsonOutput<{ + answer?: string; + response?: string; + text?: string; + }>(result); + answers[key] = + parsed?.answer || parsed?.response || parsed?.text || result.trim(); + } + } + + // Extract structured data from answers + const talkingPoints = answers.talkingPoints + ? extractTalkingPoints(answers.talkingPoints) + : []; + const codeExamples = answers.codeExamples + ? extractCodeExamples(answers.codeExamples) + : []; + const prosCons = answers.prosCons + ? extractProsCons(answers.prosCons) + : undefined; + const commonMistakes = answers.mistakes + ? extractMistakes(answers.mistakes) + : undefined; + const comparisonData = answers.comparison + ? extractComparisonData(answers.comparison) + : undefined; + + console.log( + `[research] Q&A complete: ${talkingPoints.length} talking points, ${codeExamples.length} code examples` + ); + + // ----------------------------------------------------------------------- + // Step 6: Download infographic PNG + // ----------------------------------------------------------------------- + console.log("[research] Step 6/8: Downloading infographic..."); + + let infographicPath: string | undefined; + if (infographicResult) { + const sanitizedTopic = topic + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .slice(0, 50); + const pngPath = join( + cfg.outputDir, + `${sanitizedTopic}-infographic.png` + ); + + const downloaded = await safeStep("download-infographic", () => + runNotebookLM(cfg, ["download", "infographic", pngPath], { + notebook: notebookId, + timeout: cfg.commandTimeout, + skipJson: true, + }) + ); + + if (downloaded !== undefined) { + infographicPath = pngPath; + console.log(`[research] Infographic saved to: ${pngPath}`); + } + } + + // ----------------------------------------------------------------------- + // Step 7: Fetch sources + // ----------------------------------------------------------------------- + console.log("[research] Step 7/8: Fetching source list..."); + + let sources: ResearchSource[] = []; + const sourcesResult = await safeStep("source-list", () => + runNotebookLM(cfg, ["source", "list"], { + notebook: notebookId, + }) + ); + + if (sourcesResult) { + const parsed = parseJsonOutput< + Array<{ title?: string; url?: string; name?: string; source_url?: string }> + >(sourcesResult); + + if (Array.isArray(parsed)) { + sources = parsed.map((s) => ({ + title: s.title || s.name || "Untitled", + url: s.url || s.source_url || "", + type: classifySourceType(s.url || s.source_url || ""), + })); + } + } + + console.log(`[research] Found ${sources.length} sources.`); + + // ----------------------------------------------------------------------- + // Step 8: Generate scene hints + // ----------------------------------------------------------------------- + console.log("[research] Step 8/8: Generating scene hints..."); + + // Collect all content sections for scene analysis + const contentSections: string[] = []; + + for (const point of talkingPoints) { + contentSections.push(point); + } + for (const example of codeExamples) { + contentSections.push(`\`\`\`${example.language}\n${example.snippet}\n\`\`\``); + } + if (briefing) { + // Split briefing into paragraphs for granular scene hints + const paragraphs = briefing.split(/\n\n+/).filter((p) => p.trim()); + contentSections.push(...paragraphs); + } + if (answers.comparison) { + contentSections.push(answers.comparison); + } + + const sceneHints = generateSceneHints(contentSections); + + const completedAt = new Date().toISOString(); + + console.log( + `[research] Pipeline complete for "${topic}" in ${Math.round( + (new Date(completedAt).getTime() - new Date(createdAt).getTime()) / 1000 + )}s` + ); + + // ----------------------------------------------------------------------- + // Return structured payload + // ----------------------------------------------------------------------- + return { + topic, + notebookId, + createdAt, + completedAt, + sources, + briefing, + talkingPoints, + codeExamples, + comparisonData: + comparisonData && comparisonData.length > 0 + ? comparisonData + : undefined, + mindMap, + dataTable, + prosCons, + commonMistakes, + sceneHints, + infographicPath, + }; +} diff --git a/lib/services/trend-discovery.ts b/lib/services/trend-discovery.ts new file mode 100644 index 00000000..b0f9da4e --- /dev/null +++ b/lib/services/trend-discovery.ts @@ -0,0 +1,1117 @@ +/** + * Trend Discovery Service + * + * Discovers trending web-dev and AI topics from five sources: + * 1. Hacker News (Firebase API) + * 2. Dev.to (public API) + * 3. Blog RSS / Atom feeds + * 4. YouTube Data API v3 + * 5. GitHub Trending (HTML scrape) + * + * Replaces the basic `fetchTrendingTopics()` in `app/api/cron/ingest/route.ts` + * with richer, multi-source trend signals and deduplication. + * + * @module trend-discovery + */ + +const LOG_PREFIX = "[trend-discovery]"; + +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + +export interface TrendSignal { + source: "hackernews" | "devto" | "blog" | "youtube" | "github"; + title: string; + url: string; + score: number; + publishedAt?: string; + metadata?: Record<string, unknown>; +} + +export interface TrendResult { + topic: string; + slug: string; + score: number; + signals: TrendSignal[]; + whyTrending: string; + suggestedAngle: string; +} + +export interface TrendDiscoveryConfig { + /** How many days back to consider (default: 7) */ + lookbackDays?: number; + /** Maximum topics to return (default: 10) */ + maxTopics?: number; + /** YouTube Data API key – falls back to YOUTUBE_API_KEY or GOOGLE_API_KEY env */ + youtubeApiKey?: string; +} + +// --------------------------------------------------------------------------- +// Constants & taxonomy +// --------------------------------------------------------------------------- + +/** Keyword taxonomy – 60+ terms covering web-dev and AI */ +const KEYWORD_TAXONOMY: string[] = [ + "react", + "nextjs", + "next.js", + "svelte", + "sveltekit", + "vue", + "nuxt", + "angular", + "remix", + "astro", + "node", + "nodejs", + "deno", + "bun", + "typescript", + "javascript", + "wasm", + "webassembly", + "vercel", + "cloudflare", + "firebase", + "supabase", + "serverless", + "ai", + "copilot", + "cursor", + "gemini", + "claude", + "openai", + "llm", + "gpt", + "mcp", + "css", + "html", + "webgpu", + "vite", + "turbopack", + "tailwind", + "tailwindcss", + "prisma", + "drizzle", + "graphql", + "trpc", + "server components", + "ssr", + "ssg", + "edge computing", + "fullstack", + "full-stack", + "frontend", + "front-end", + "backend", + "back-end", + "webdev", + "web dev", + "sdk", + "api", + "rest", + "middleware", + "monorepo", + "turborepo", + "pnpm", + "npm", + "yarn", + "docker", + "kubernetes", + "ci/cd", + "testing", + "playwright", + "vitest", + "jest", + "storybook", + "figma", + "design system", + "accessibility", + "a11y", + "pwa", + "service worker", + "web worker", + "streaming", + "rsc", + "react server", + "shadcn", + "radix", + "zod", + "tRPC", +]; + +/** Domains whose HN stories are always relevant */ +const HN_DOMAIN_ALLOWLIST: string[] = [ + "github.com", + "vercel.com", + "nextjs.org", + "cloudflare.com", + "svelte.dev", + "firebase.google.com", + "developer.chrome.com", + "web.dev", + "deno.com", + "bun.sh", + "astro.build", + "remix.run", + "angular.dev", + "vuejs.org", + "react.dev", + "nodejs.org", + "typescriptlang.org", + "developer.mozilla.org", +]; + +/** HN stories matching these patterns are excluded */ +const HN_EXCLUSION_PATTERNS: RegExp[] = [ + /\bhiring\b/i, + /\bjobs?\b/i, + /\bpolitics\b/i, + /\belection\b/i, + /\bcrypto\b/i, + /\bblockchain\b/i, + /\bbitcoin\b/i, + /\bhardware\b/i, + /\bsemiconductor\b/i, + /who is hiring/i, +]; + +/** Blog RSS / Atom feeds */ +const BLOG_FEEDS: { name: string; url: string }[] = [ + { name: "Cloudflare", url: "https://blog.cloudflare.com/rss/" }, + { name: "Next.js", url: "https://nextjs.org/feed.xml" }, + { name: "Vercel", url: "https://vercel.com/atom" }, + { name: "Chrome", url: "https://developer.chrome.com/blog/feed.xml" }, + { name: "Web.dev", url: "https://web.dev/feed.xml" }, + { name: "Firebase", url: "https://firebase.blog/rss.xml" }, +]; + +/** Dev.to tags to query */ +const DEVTO_TAGS: string[] = [ + "webdev", + "javascript", + "typescript", + "react", + "nextjs", + "ai", + "node", +]; + +/** GitHub Trending languages */ +const GITHUB_TRENDING_LANGS: string[] = [ + "typescript", + "javascript", + "python", +]; + +/** Stop words for deduplication (100+) */ +const STOP_WORDS = new Set<string>([ + "a", "about", "above", "after", "again", "against", "all", "am", "an", + "and", "any", "are", "aren't", "as", "at", "be", "because", "been", + "before", "being", "below", "between", "both", "but", "by", "can", + "can't", "cannot", "could", "couldn't", "did", "didn't", "do", "does", + "doesn't", "doing", "don't", "down", "during", "each", "few", "for", + "from", "further", "get", "got", "had", "hadn't", "has", "hasn't", + "have", "haven't", "having", "he", "her", "here", "hers", "herself", + "him", "himself", "his", "how", "i", "if", "in", "into", "is", "isn't", + "it", "its", "itself", "just", "let", "like", "ll", "me", "might", + "more", "most", "mustn't", "my", "myself", "need", "no", "nor", "not", + "now", "of", "off", "on", "once", "only", "or", "other", "our", "ours", + "ourselves", "out", "over", "own", "re", "s", "same", "shan't", "she", + "should", "shouldn't", "so", "some", "such", "t", "than", "that", "the", + "their", "theirs", "them", "themselves", "then", "there", "these", "they", + "this", "those", "through", "to", "too", "under", "until", "up", "us", + "ve", "very", "was", "wasn't", "we", "were", "weren't", "what", "when", + "where", "which", "while", "who", "whom", "why", "will", "with", "won't", + "would", "wouldn't", "you", "your", "yours", "yourself", "yourselves", + "new", "use", "using", "used", "way", "make", "made", "also", "one", + "two", "first", "last", "many", "much", "well", "back", "even", "still", + "may", "say", "said", "take", "come", "go", "know", "see", "look", + "think", "give", "find", "tell", "work", "call", "try", "ask", "put", + "keep", "help", "show", "begin", "seem", "turn", "leave", "play", "run", + "move", "live", "believe", "bring", "happen", "write", "provide", "sit", + "stand", "lose", "pay", "meet", "include", "continue", "set", "learn", + "change", "lead", "understand", "watch", "follow", "stop", "create", + "speak", "read", "allow", "add", "spend", "grow", "open", "walk", "win", + "offer", "remember", "love", "consider", "appear", "buy", "wait", "serve", + "die", "send", "expect", "build", "stay", "fall", "cut", "reach", "kill", + "remain", "suggest", "raise", "pass", "sell", "require", "report", + "decide", "pull", "develop", "really", "already", "best", "better", + "big", "great", "good", "right", "long", "little", "old", "different", + "large", "small", "another", "important", "next", "early", "young", + "possible", "able", "every", "sure", "enough", "far", "away", "today", + "during", "might", "part", "year", "place", "around", "however", "home", + "never", "world", "day", "got", "going", "want", "thing", "things", + "something", "nothing", "everything", "anything", "someone", "everyone", + "anyone", "people", "time", "years", "number", "point", "hand", "end", + "head", "fact", "without", "within", "along", "since", "often", "always", + "usually", "sometimes", "yet", "though", "although", "whether", "either", + "neither", "rather", "quite", "almost", "perhaps", "probably", "simply", + "actually", "certainly", "especially", "recently", "finally", "suddenly", + "quickly", "slowly", "exactly", "directly", "likely", "simply", + "generally", "specifically", "currently", "previously", "apparently", + "eventually", "obviously", "basically", "essentially", "particularly", + "increasingly", "relatively", "significantly", "approximately", + "immediately", "constantly", "frequently", "occasionally", "ultimately", + "effectively", "necessarily", "absolutely", "completely", "entirely", + "perfectly", "seriously", "definitely", "clearly", "merely", "hardly", + "roughly", "virtually", "literally", "truly", "deeply", "highly", + "largely", "mostly", "partly", "slightly", "somewhat", "widely", + "fully", "totally", "entirely", "extremely", "incredibly", "remarkably", + "surprisingly", "unfortunately", "fortunately", "hopefully", "honestly", + "frankly", "personally", "technically", "officially", "publicly", + "privately", "internally", "externally", "initially", "originally", + "traditionally", "typically", "normally", "naturally", "automatically", + "manually", "physically", "mentally", "emotionally", "financially", + "politically", "socially", "culturally", "legally", "officially", + "formally", "informally", "independently", "collectively", "individually", + "simultaneously", "respectively", "accordingly", "consequently", + "subsequently", "alternatively", "additionally", "furthermore", + "moreover", "meanwhile", "nevertheless", "nonetheless", "otherwise", + "regardless", "instead", "therefore", "thus", "hence", "thereby", + "whereby", "wherein", "whereas", "wherever", "whenever", "whatever", + "whichever", "whoever", "however", "here", "there", "everywhere", + "somewhere", "nowhere", "anywhere", +]); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Fetch with a 10-second timeout via AbortController. + */ +async function fetchWithTimeout( + url: string, + init?: RequestInit, +): Promise<Response> { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 10_000); + try { + return await fetch(url, { ...init, signal: controller.signal }); + } finally { + clearTimeout(timeout); + } +} + +/** + * Compute relevance score (0–1) based on keyword taxonomy matches. + * + * - 0 matches → 0.1 + * - 1 match → 0.35 + * - 2 matches → 0.5 + * - 3+ matches → 0.65 + */ +function computeRelevance(text: string): number { + const lower = text.toLowerCase(); + let matches = 0; + for (const kw of KEYWORD_TAXONOMY) { + if (lower.includes(kw)) { + matches++; + } + } + if (matches === 0) return 0.1; + if (matches === 1) return 0.35; + if (matches === 2) return 0.5; + return 0.65; +} + +/** + * Turn a title into a URL-safe slug. + */ +function slugify(text: string): string { + return text + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/(^-|-$)/g, "") + .slice(0, 80); +} + +/** + * Extract significant words from a title (skip stop words, short words). + */ +function significantWords(title: string): string[] { + return title + .toLowerCase() + .replace(/[^a-z0-9\s]/g, " ") + .split(/\s+/) + .filter((w) => w.length > 2 && !STOP_WORDS.has(w)); +} + +/** + * Check whether a URL belongs to one of the HN allowlisted domains. + */ +function isAllowlistedDomain(url: string): boolean { + try { + const hostname = new URL(url).hostname.replace(/^www\./, ""); + return HN_DOMAIN_ALLOWLIST.some( + (d) => hostname === d || hostname.endsWith(`.${d}`), + ); + } catch { + return false; + } +} + +/** + * Check whether text matches any HN exclusion pattern. + */ +function isExcluded(text: string): boolean { + return HN_EXCLUSION_PATTERNS.some((p) => p.test(text)); +} + +// --------------------------------------------------------------------------- +// RSS / Atom parsing (regex-based, no external deps) +// --------------------------------------------------------------------------- + +interface FeedItem { + title: string; + url: string; + publishedAt?: string; +} + +/** + * Parse RSS `<item>` and Atom `<entry>` elements from XML text. + */ +function parseFeedItems(xml: string): FeedItem[] { + const items: FeedItem[] = []; + + // RSS <item> blocks + const rssItemRegex = /<item>([\s\S]*?)<\/item>/gi; + let match: RegExpExecArray | null; + while ((match = rssItemRegex.exec(xml)) !== null) { + const block = match[1]; + const titleCdata = block.match(/<title><!\[CDATA\[(.*?)\]\]><\/title>/); + const titlePlain = block.match(/<title>(.*?)<\/title>/); + const title = (titleCdata?.[1] ?? titlePlain?.[1] ?? "").trim(); + + const linkMatch = block.match(/<link>(.*?)<\/link>/); + const url = (linkMatch?.[1] ?? "").trim(); + + const pubDateMatch = block.match(/<pubDate>(.*?)<\/pubDate>/); + const publishedAt = pubDateMatch?.[1]?.trim(); + + if (title && url) { + items.push({ title, url, publishedAt }); + } + } + + // Atom <entry> blocks + const atomEntryRegex = /<entry>([\s\S]*?)<\/entry>/gi; + while ((match = atomEntryRegex.exec(xml)) !== null) { + const block = match[1]; + const titleMatch = block.match(/<title[^>]*>(.*?)<\/title>/); + const title = (titleMatch?.[1] ?? "").trim(); + + // Atom links: <link href="..." /> or <link href="...">...</link> + const linkMatch = block.match(/<link[^>]*href=["']([^"']+)["'][^>]*\/?>/); + const url = (linkMatch?.[1] ?? "").trim(); + + const updatedMatch = block.match(/<updated>(.*?)<\/updated>/); + const publishedMatch = block.match(/<published>(.*?)<\/published>/); + const publishedAt = ( + publishedMatch?.[1] ?? + updatedMatch?.[1] ?? + "" + ).trim(); + + if (title && url) { + items.push({ title, url, publishedAt: publishedAt || undefined }); + } + } + + return items; +} + +// --------------------------------------------------------------------------- +// Source 1: Hacker News +// --------------------------------------------------------------------------- + +/** YouTube Data API v3 search response shape. */ +interface YouTubeSearchItem { + id: { videoId: string }; + snippet: { + title: string; + publishedAt: string; + channelTitle: string; + }; +} + +interface YouTubeSearchResponse { + items?: YouTubeSearchItem[]; +} + +/** YouTube Data API v3 video statistics response shape. */ +interface YouTubeVideoStats { + items?: Array<{ + id: string; + statistics: { viewCount?: string }; + }>; +} + +interface HNItem { + id: number; + title?: string; + url?: string; + score?: number; + time?: number; + type?: string; +} + +/** + * Fetch trending stories from Hacker News (Firebase API). + * Fetches top 100 story IDs, then batches item details 10 at a time. + */ +async function fetchHackerNews(lookbackDays: number): Promise<TrendSignal[]> { + const signals: TrendSignal[] = []; + try { + const res = await fetchWithTimeout( + "https://hacker-news.firebaseio.com/v0/topstories.json", + ); + if (!res.ok) { + console.warn(`${LOG_PREFIX} HN topstories failed: ${res.status}`); + return signals; + } + const ids: number[] = await res.json(); + const top100 = ids.slice(0, 100); + + const cutoff = Date.now() / 1000 - lookbackDays * 86400; + + // Batch fetch 10 at a time + for (let i = 0; i < top100.length; i += 10) { + const batch = top100.slice(i, i + 10); + const results = await Promise.allSettled( + batch.map(async (id) => { + const r = await fetchWithTimeout( + `https://hacker-news.firebaseio.com/v0/item/${id}.json`, + ); + if (!r.ok) return null; + return (await r.json()) as HNItem; + }), + ); + + for (const result of results) { + if (result.status !== "fulfilled" || !result.value) continue; + const item = result.value; + if (!item.title || !item.url) continue; + if (item.time && item.time < cutoff) continue; + if (isExcluded(item.title)) continue; + + const relevance = computeRelevance(`${item.title} ${item.url}`); + const domainMatch = isAllowlistedDomain(item.url); + + if (relevance <= 0.1 && !domainMatch) continue; + + signals.push({ + source: "hackernews", + title: item.title, + url: item.url, + score: (item.score ?? 0) * relevance, + publishedAt: item.time + ? new Date(item.time * 1000).toISOString() + : undefined, + metadata: { + hnScore: item.score, + relevance, + domainMatch, + }, + }); + } + } + + console.log(`${LOG_PREFIX} HN: ${signals.length} relevant stories`); + } catch (err) { + console.warn(`${LOG_PREFIX} HN fetch error:`, err); + } + return signals; +} + +// --------------------------------------------------------------------------- +// Source 2: Dev.to +// --------------------------------------------------------------------------- + +interface DevtoArticle { + title: string; + url: string; + published_at: string; + positive_reactions_count: number; + comments_count: number; + tag_list: string[]; +} + +/** + * Fetch trending articles from Dev.to across multiple tags. + */ +async function fetchDevto(lookbackDays: number): Promise<TrendSignal[]> { + const signals: TrendSignal[] = []; + try { + const results = await Promise.allSettled( + DEVTO_TAGS.map(async (tag) => { + const res = await fetchWithTimeout( + `https://dev.to/api/articles?tag=${tag}&top=${lookbackDays}`, + ); + if (!res.ok) return []; + return (await res.json()) as DevtoArticle[]; + }), + ); + + const seen = new Set<string>(); + for (const result of results) { + if (result.status !== "fulfilled") continue; + for (const article of result.value) { + if (seen.has(article.url)) continue; + seen.add(article.url); + + const relevance = computeRelevance( + `${article.title} ${article.tag_list?.join(" ") ?? ""}`, + ); + signals.push({ + source: "devto", + title: article.title, + url: article.url, + score: + (article.positive_reactions_count + article.comments_count * 2) * + relevance, + publishedAt: article.published_at, + metadata: { + reactions: article.positive_reactions_count, + comments: article.comments_count, + tags: article.tag_list, + relevance, + }, + }); + } + } + + console.log(`${LOG_PREFIX} Dev.to: ${signals.length} articles`); + } catch (err) { + console.warn(`${LOG_PREFIX} Dev.to fetch error:`, err); + } + return signals; +} + +// --------------------------------------------------------------------------- +// Source 3: Blog RSS / Atom feeds +// --------------------------------------------------------------------------- + +/** + * Fetch and parse blog RSS/Atom feeds. + */ +async function fetchBlogFeeds(lookbackDays: number): Promise<TrendSignal[]> { + const signals: TrendSignal[] = []; + const cutoff = Date.now() - lookbackDays * 86400 * 1000; + + try { + const results = await Promise.allSettled( + BLOG_FEEDS.map(async (feed) => { + const res = await fetchWithTimeout(feed.url); + if (!res.ok) { + console.warn( + `${LOG_PREFIX} Blog feed ${feed.name} failed: ${res.status}`, + ); + return []; + } + const xml = await res.text(); + const items = parseFeedItems(xml); + return items.map((item) => ({ ...item, feedName: feed.name })); + }), + ); + + for (const result of results) { + if (result.status !== "fulfilled") continue; + for (const item of result.value) { + // Filter by lookback window if date is available + if (item.publishedAt) { + const pubDate = new Date(item.publishedAt).getTime(); + if (!isNaN(pubDate) && pubDate < cutoff) continue; + } + + const relevance = computeRelevance(item.title); + signals.push({ + source: "blog", + title: item.title, + url: item.url, + score: relevance * 100 * 1.5, // Blog posts get 1.5x multiplier + publishedAt: item.publishedAt, + metadata: { + feedName: item.feedName, + relevance, + }, + }); + } + } + + console.log(`${LOG_PREFIX} Blogs: ${signals.length} posts`); + } catch (err) { + console.warn(`${LOG_PREFIX} Blog feeds error:`, err); + } + return signals; +} + +// --------------------------------------------------------------------------- +// Source 4: YouTube +// --------------------------------------------------------------------------- + +/** + * Fetch trending web-dev / AI videos from YouTube Data API v3. + * Skips gracefully if no API key is available. + */ +async function fetchYouTube( + lookbackDays: number, + apiKey?: string, +): Promise<TrendSignal[]> { + const key = + apiKey ?? + process.env.YOUTUBE_API_KEY ?? + process.env.GOOGLE_API_KEY ?? + ""; + if (!key) { + console.log(`${LOG_PREFIX} YouTube: skipped (no API key)`); + return []; + } + + const signals: TrendSignal[] = []; + try { + const publishedAfter = new Date( + Date.now() - lookbackDays * 86400 * 1000, + ).toISOString(); + + const currentYear = new Date().getFullYear(); + const queries = [`web development ${currentYear}`, "nextjs react tutorial", "AI coding tools"]; + const results = await Promise.allSettled( + queries.map(async (q) => { + const params = new URLSearchParams({ + part: "snippet", + q, + type: "video", + order: "viewCount", + publishedAfter, + maxResults: "10", + key, + }); + const res = await fetchWithTimeout( + `https://www.googleapis.com/youtube/v3/search?${params}`, + ); + if (!res.ok) { + console.warn(`${LOG_PREFIX} YouTube search failed: ${res.status}`); + return []; + } + const data: YouTubeSearchResponse = await res.json(); + return (data.items ?? []); + }), + ); + + const seen = new Set<string>(); + for (const result of results) { + if (result.status !== "fulfilled") continue; + for (const item of result.value) { + const videoId = item.id?.videoId; + if (!videoId || seen.has(videoId)) continue; + seen.add(videoId); + + const title = item.snippet?.title ?? ""; + const relevance = computeRelevance(title); + signals.push({ + source: "youtube", + title, + url: `https://www.youtube.com/watch?v=${videoId}`, + score: relevance * 50, + publishedAt: item.snippet?.publishedAt, + metadata: { + channelTitle: item.snippet?.channelTitle, + videoId, + relevance, + }, + }); + } + } + + console.log(`${LOG_PREFIX} YouTube: ${signals.length} videos`); + } catch (err) { + console.warn(`${LOG_PREFIX} YouTube fetch error:`, err); + } + return signals; +} + +// --------------------------------------------------------------------------- +// Source 5: GitHub Trending +// --------------------------------------------------------------------------- + +interface GitHubTrendingRepo { + name: string; + description: string; + url: string; + starsToday: number; + language: string; +} + +/** + * Parse GitHub Trending HTML page for repo information. + */ +function parseGitHubTrendingHTML(html: string, language: string): GitHubTrendingRepo[] { + const repos: GitHubTrendingRepo[] = []; + + // Match article/row blocks – GitHub uses <article class="Box-row"> elements + const articleRegex = /<article[^>]*class="[^"]*Box-row[^"]*"[^>]*>([\s\S]*?)<\/article>/gi; + let articleMatch: RegExpExecArray | null; + + while ((articleMatch = articleRegex.exec(html)) !== null) { + const block = articleMatch[1]; + + // Repo path from <h2> with lh-condensed class + const h2Match = block.match( + /<h2[^>]*class="[^"]*lh-condensed[^"]*"[^>]*>[\s\S]*?<a[^>]*href="([^"]+)"[^>]*>/, + ); + const repoPath = h2Match?.[1]?.trim(); + if (!repoPath) continue; + + const name = repoPath.replace(/^\//, ""); + const url = `https://github.com${repoPath}`; + + // Description from <p class="col-9..."> + const descMatch = block.match( + /<p[^>]*class="[^"]*col-9[^"]*"[^>]*>([\s\S]*?)<\/p>/, + ); + const description = (descMatch?.[1] ?? "") + .replace(/<[^>]+>/g, "") + .trim(); + + // Stars today from "N stars today" text + const starsMatch = block.match( + /(\d[\d,]*)\s+stars?\s+today/i, + ); + const starsToday = starsMatch + ? parseInt(starsMatch[1].replace(/,/g, ""), 10) + : 0; + + repos.push({ + name, + description, + url, + starsToday, + language, + }); + } + + return repos; +} + +/** + * Fetch trending repos from GitHub for multiple languages. + */ +async function fetchGitHubTrending(): Promise<TrendSignal[]> { + const signals: TrendSignal[] = []; + try { + const results = await Promise.allSettled( + GITHUB_TRENDING_LANGS.map(async (lang) => { + const res = await fetchWithTimeout( + `https://github.com/trending/${lang}?since=weekly`, + { + headers: { + "User-Agent": "Mozilla/5.0 (compatible; TrendDiscovery/1.0)", + Accept: "text/html", + }, + }, + ); + if (!res.ok) { + console.warn( + `${LOG_PREFIX} GitHub trending ${lang} failed: ${res.status}`, + ); + return []; + } + const html = await res.text(); + return parseGitHubTrendingHTML(html, lang); + }), + ); + + const seen = new Set<string>(); + for (const result of results) { + if (result.status !== "fulfilled") continue; + for (const repo of result.value) { + if (seen.has(repo.url)) continue; + seen.add(repo.url); + + const text = `${repo.name} ${repo.description}`; + const relevance = computeRelevance(text); + + // Filter by web dev / AI relevance + if (relevance <= 0.1) continue; + + signals.push({ + source: "github", + title: `${repo.name}: ${repo.description || "Trending repository"}`, + url: repo.url, + score: repo.starsToday * relevance * 1.5, + metadata: { + starsToday: repo.starsToday, + language: repo.language, + repoName: repo.name, + relevance, + }, + }); + } + } + + console.log(`${LOG_PREFIX} GitHub: ${signals.length} trending repos`); + } catch (err) { + console.warn(`${LOG_PREFIX} GitHub trending error:`, err); + } + return signals; +} + +// --------------------------------------------------------------------------- +// Deduplication & merging +// --------------------------------------------------------------------------- + +interface SignalGroup { + topic: string; + signals: TrendSignal[]; + rawScore: number; +} + +/** + * Normalize a title for comparison: lowercase, strip punctuation. + */ +function normalizeTitle(title: string): string { + return title + .toLowerCase() + .replace(/[^a-z0-9\s]/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +/** + * Check if two signals should be merged based on shared significant words. + * Merge if 3+ shared significant words AND 50%+ overlap. + */ +function shouldMerge(a: string, b: string): boolean { + const wordsA = significantWords(a); + const wordsB = significantWords(b); + + if (wordsA.length === 0 || wordsB.length === 0) return false; + + const setA = new Set(wordsA); + const setB = new Set(wordsB); + let shared = 0; + for (const w of setA) { + if (setB.has(w)) shared++; + } + + if (shared < 3) return false; + + const minSize = Math.min(setA.size, setB.size); + return shared / minSize >= 0.5; +} + +/** + * Group signals into deduplicated topic clusters. + */ +function deduplicateSignals(allSignals: TrendSignal[]): SignalGroup[] { + const groups: SignalGroup[] = []; + + for (const signal of allSignals) { + const normTitle = normalizeTitle(signal.title); + let merged = false; + + for (const group of groups) { + if (shouldMerge(normTitle, normalizeTitle(group.topic))) { + group.signals.push(signal); + group.rawScore += signal.score; + merged = true; + break; + } + } + + if (!merged) { + groups.push({ + topic: signal.title, + signals: [signal], + rawScore: signal.score, + }); + } + } + + return groups; +} + +// --------------------------------------------------------------------------- +// Scoring & result generation +// --------------------------------------------------------------------------- + +/** + * Generate a human-readable "why trending" explanation. + */ +function generateWhyTrending(group: SignalGroup): string { + const sources = [...new Set(group.signals.map((s) => s.source))]; + const sourceNames: Record<TrendSignal["source"], string> = { + hackernews: "Hacker News", + devto: "Dev.to", + blog: "tech blogs", + youtube: "YouTube", + github: "GitHub Trending", + }; + + const parts: string[] = []; + parts.push( + `Appeared in ${group.signals.length} signal(s) across ${sources.map((s) => sourceNames[s]).join(", ")}`, + ); + + const hnSignals = group.signals.filter((s) => s.source === "hackernews"); + if (hnSignals.length > 0) { + const maxScore = Math.max( + ...hnSignals.map((s) => (s.metadata?.hnScore as number) ?? 0), + ); + if (maxScore > 100) { + parts.push(`HN score up to ${maxScore}`); + } + } + + const ghSignals = group.signals.filter((s) => s.source === "github"); + if (ghSignals.length > 0) { + const totalStars = ghSignals.reduce( + (sum, s) => sum + ((s.metadata?.starsToday as number) ?? 0), + 0, + ); + if (totalStars > 0) { + parts.push(`${totalStars} GitHub stars today`); + } + } + + return parts.join(". ") + "."; +} + +/** + * Generate a suggested content angle for the topic. + */ +function generateSuggestedAngle(group: SignalGroup): string { + const topic = group.topic; + const sources = [...new Set(group.signals.map((s) => s.source))]; + + if (sources.includes("github") && sources.length > 1) { + return `Deep-dive into ${topic} — explore the trending repo and explain why developers are excited.`; + } + if (sources.includes("hackernews") && sources.includes("devto")) { + return `Community spotlight: ${topic} is generating discussion on both HN and Dev.to — compare perspectives.`; + } + if (sources.includes("blog")) { + return `Official announcement breakdown: explain what ${topic} means for web developers.`; + } + return `Explainer video: what is ${topic} and why should web developers care?`; +} + +/** + * Apply cross-source boost and normalize scores to 0–100. + */ +function scoreAndRank( + groups: SignalGroup[], + maxTopics: number, +): TrendResult[] { + // Apply cross-source boost + for (const group of groups) { + const uniqueSources = new Set(group.signals.map((s) => s.source)).size; + if (uniqueSources >= 3) { + group.rawScore *= 1.5; + } else if (uniqueSources >= 2) { + group.rawScore *= 1.3; + } + } + + // Find max score for normalization + const maxRaw = Math.max(...groups.map((g) => g.rawScore), 1); + + // Build results, normalize to 0–100 + const results: TrendResult[] = groups.map((group) => ({ + topic: group.topic, + slug: slugify(group.topic), + score: Math.round((group.rawScore / maxRaw) * 100), + signals: group.signals, + whyTrending: generateWhyTrending(group), + suggestedAngle: generateSuggestedAngle(group), + })); + + // Sort by score descending + results.sort((a, b) => b.score - a.score); + + return results.slice(0, maxTopics); +} + +// --------------------------------------------------------------------------- +// Main entry point +// --------------------------------------------------------------------------- + +/** + * Discover trending web-dev and AI topics from multiple sources. + * + * Fetches signals from Hacker News, Dev.to, blog RSS feeds, YouTube, and + * GitHub Trending in parallel, deduplicates them, and returns scored results. + * + * @param config - Optional configuration overrides + * @returns Sorted array of trending topics with scores and signals + * + * @example + * ```ts + * import { discoverTrends } from "@/lib/services/trend-discovery"; + * + * const trends = await discoverTrends({ maxTopics: 5 }); + * for (const t of trends) { + * console.log(`${t.topic} (score: ${t.score}) — ${t.whyTrending}`); + * } + * ``` + */ +export async function discoverTrends( + config?: TrendDiscoveryConfig, +): Promise<TrendResult[]> { + const lookbackDays = config?.lookbackDays ?? 7; + const maxTopics = config?.maxTopics ?? 10; + const youtubeApiKey = config?.youtubeApiKey; + + console.log( + `${LOG_PREFIX} Starting trend discovery (lookback=${lookbackDays}d, max=${maxTopics})`, + ); + + // Fetch all sources in parallel + const [hnResult, devtoResult, blogResult, ytResult, ghResult] = + await Promise.allSettled([ + fetchHackerNews(lookbackDays), + fetchDevto(lookbackDays), + fetchBlogFeeds(lookbackDays), + fetchYouTube(lookbackDays, youtubeApiKey), + fetchGitHubTrending(), + ]); + + // Collect all signals + const allSignals: TrendSignal[] = []; + const sourceResults = [hnResult, devtoResult, blogResult, ytResult, ghResult]; + const sourceNames = ["HN", "Dev.to", "Blogs", "YouTube", "GitHub"]; + + for (let i = 0; i < sourceResults.length; i++) { + const result = sourceResults[i]; + if (result.status === "fulfilled") { + allSignals.push(...result.value); + } else { + console.warn( + `${LOG_PREFIX} ${sourceNames[i]} source failed:`, + result.reason, + ); + } + } + + console.log(`${LOG_PREFIX} Total signals collected: ${allSignals.length}`); + + if (allSignals.length === 0) { + console.warn(`${LOG_PREFIX} No signals found from any source`); + return []; + } + + // Deduplicate and group + const groups = deduplicateSignals(allSignals); + console.log(`${LOG_PREFIX} Deduplicated into ${groups.length} topic groups`); + + // Score, rank, and return + const results = scoreAndRank(groups, maxTopics); + console.log( + `${LOG_PREFIX} Returning ${results.length} trending topics (top score: ${results[0]?.score ?? 0})`, + ); + + return results; +} diff --git a/lib/services/video-pipeline.ts b/lib/services/video-pipeline.ts index d1108c3b..90d6fcee 100644 --- a/lib/services/video-pipeline.ts +++ b/lib/services/video-pipeline.ts @@ -12,6 +12,8 @@ import { createClient, type SanityClient } from 'next-sanity'; import { apiVersion, dataset, projectId } from '@/sanity/lib/api'; import { generateSpeechFromScript } from '@/lib/services/elevenlabs'; +import { generatePerSceneAudio } from '@/lib/services/elevenlabs'; +import type { WordTimestamp } from '@/lib/utils/audio-timestamps'; import { uploadAudioToSanity } from '@/lib/services/sanity-upload'; import { getBRollForScenes } from '@/lib/services/pexels'; import { startBothRenders } from '@/lib/services/remotion'; @@ -24,6 +26,11 @@ interface VideoScene { visualDescription?: string; bRollKeywords?: string[]; durationEstimate?: number; + sceneType?: string; + code?: { snippet: string; language: string; highlightLines?: number[] }; + list?: { items: string[]; icon?: string }; + comparison?: { leftLabel: string; rightLabel: string; rows: { left: string; right: string }[] }; + mockup?: { deviceType: string; screenContent: string }; } interface VideoScript { @@ -129,14 +136,60 @@ export async function processVideoProduction(documentId: string): Promise<void> console.log(`[VIDEO-PIPELINE] Updating status to "audio_gen"`); await updateStatus(client, documentId, { status: 'audio_gen' }); - // Step 4: Generate speech with ElevenLabs + // Step 4: Generate per-scene audio with timestamps (or fallback to single blob) console.log(`[VIDEO-PIPELINE] Generating TTS audio...`); - const audioBuffer = await generateSpeechFromScript({ - hook: script.hook, - scenes: script.scenes, - cta: script.cta, - }); - console.log(`[VIDEO-PIPELINE] TTS audio generated: ${audioBuffer.length} bytes`); + let audioBuffer: Buffer; + let audioDurationSeconds: number; + let sceneWordTimestamps: (WordTimestamp[] | undefined)[] = []; + + try { + console.log(`[VIDEO-PIPELINE] Attempting per-scene audio generation with timestamps...`); + const perSceneResult = await generatePerSceneAudio({ + hook: script.hook, + scenes: script.scenes, + cta: script.cta, + }); + + // Concatenate all audio buffers into one combined buffer + const allBuffers = [ + perSceneResult.hook.audioBuffer, + ...perSceneResult.scenes.map(s => s.audioBuffer), + perSceneResult.cta.audioBuffer, + ]; + audioBuffer = Buffer.concat(allBuffers); + + // Use actual duration from ElevenLabs (much more accurate than estimates) + audioDurationSeconds = Math.ceil(perSceneResult.totalDurationMs / 1000); + + // Collect per-scene word timestamps for Remotion + sceneWordTimestamps = perSceneResult.scenes.map(s => s.wordTimestamps); + + console.log( + `[VIDEO-PIPELINE] Per-scene audio generated: ${allBuffers.length} segments, ` + + `${audioBuffer.length} bytes, ${audioDurationSeconds}s total` + ); + } catch (perSceneError) { + console.warn( + `[VIDEO-PIPELINE] Per-scene audio failed, falling back to single blob: ` + + `${perSceneError instanceof Error ? perSceneError.message : String(perSceneError)}` + ); + + // Fallback: single blob without timestamps + audioBuffer = await generateSpeechFromScript({ + hook: script.hook, + scenes: script.scenes, + cta: script.cta, + }); + + // Estimate duration from scene estimates (existing behavior) + const estimatedDurationFromScenes = script.scenes.reduce( + (sum, s) => sum + (s.durationEstimate || 15), + 0 + ); + audioDurationSeconds = estimatedDurationFromScenes + 10; + } + + console.log(`[VIDEO-PIPELINE] TTS audio: ${audioBuffer.length} bytes, ${audioDurationSeconds}s`); // Step 5: Upload audio to Sanity console.log(`[VIDEO-PIPELINE] Uploading audio to Sanity...`); @@ -167,16 +220,7 @@ export async function processVideoProduction(documentId: string): Promise<void> bRollUrls[sceneIndex] = clip.videoUrl; }); - // Step 8: Calculate audio duration from scene estimates (or estimate from buffer) - const estimatedDurationFromScenes = script.scenes.reduce( - (sum, s) => sum + (s.durationEstimate || 15), - 0 - ); - // Add ~5s for hook and ~5s for CTA - const audioDurationSeconds = estimatedDurationFromScenes + 10; - console.log(`[VIDEO-PIPELINE] Estimated audio duration: ${audioDurationSeconds}s`); - - // Step 9: Fetch sponsor data if sponsorSlot is set + // Step 8: Fetch sponsor data if sponsorSlot is set let sponsor: { name: string; logoUrl?: string; message?: string } | undefined; if (doc.sponsorSlot?._ref) { console.log(`[VIDEO-PIPELINE] Fetching sponsor data: ${doc.sponsorSlot._ref}`); @@ -190,13 +234,16 @@ export async function processVideoProduction(documentId: string): Promise<void> } } - // Step 10: Start Remotion renders for both formats (no polling — returns immediately) + // Step 9: Start Remotion renders for both formats (no polling — returns immediately) console.log(`[VIDEO-PIPELINE] Starting Remotion renders (main + short)...`); const renderResults = await startBothRenders({ audioUrl, script: { hook: script.hook, - scenes: script.scenes, + scenes: script.scenes.map((s, i) => ({ + ...s, + wordTimestamps: sceneWordTimestamps[i], + })), cta: script.cta, }, bRollUrls, @@ -207,7 +254,7 @@ export async function processVideoProduction(documentId: string): Promise<void> `[VIDEO-PIPELINE] Renders started — mainRenderId: ${renderResults.mainRenderId}, shortRenderId: ${renderResults.shortRenderId}` ); - // Step 11: Store render IDs and set status to "rendering" + // Step 10: Store render IDs and set status to "rendering" // The check-renders cron will poll for completion, download, upload, and set video_gen. console.log(`[VIDEO-PIPELINE] Storing render IDs and setting status to "rendering"`); await updateStatus(client, documentId, { diff --git a/lib/utils/audio-timestamps.ts b/lib/utils/audio-timestamps.ts new file mode 100644 index 00000000..946cb013 --- /dev/null +++ b/lib/utils/audio-timestamps.ts @@ -0,0 +1,153 @@ +/** + * Audio Timestamp Utilities + * + * Converts ElevenLabs character-level alignment data to word-level timestamps + * for use in Remotion scene components. + */ + +/** Character-level alignment from ElevenLabs API */ +export interface CharacterAlignment { + characters: string[]; + character_start_times_seconds: number[]; + character_end_times_seconds: number[]; +} + +/** Word-level timestamp */ +export interface WordTimestamp { + text: string; + startMs: number; + endMs: number; +} + +/** Per-scene audio result */ +export interface SceneAudioResult { + /** Base64-encoded audio data */ + audioBase64: string; + /** Audio as Buffer */ + audioBuffer: Buffer; + /** Word-level timestamps */ + wordTimestamps: WordTimestamp[]; + /** Total duration in milliseconds */ + durationMs: number; +} + +/** + * Aggregate character-level alignment to word-level timestamps. + * + * ElevenLabs returns per-character timing. We group characters into words + * (splitting on whitespace) and take the min start / max end for each word. + */ +export function aggregateToWordTimestamps( + alignment: CharacterAlignment +): WordTimestamp[] { + const { characters, character_start_times_seconds, character_end_times_seconds } = alignment; + + if (!characters?.length) return []; + + const words: WordTimestamp[] = []; + let currentWord = ""; + let wordStartMs = 0; + let wordEndMs = 0; + let inWord = false; + + for (let i = 0; i < characters.length; i++) { + const char = characters[i]; + const startMs = Math.round(character_start_times_seconds[i] * 1000); + const endMs = Math.round(character_end_times_seconds[i] * 1000); + + if (char === " " || char === "\n" || char === "\t") { + // Whitespace — flush current word if any + if (inWord && currentWord.trim()) { + words.push({ + text: currentWord.trim(), + startMs: wordStartMs, + endMs: wordEndMs, + }); + currentWord = ""; + inWord = false; + } + } else { + // Non-whitespace character + if (!inWord) { + // Start of a new word + wordStartMs = startMs; + inWord = true; + } + currentWord += char; + wordEndMs = endMs; + } + } + + // Flush last word + if (inWord && currentWord.trim()) { + words.push({ + text: currentWord.trim(), + startMs: wordStartMs, + endMs: wordEndMs, + }); + } + + return words; +} + +/** + * Convert milliseconds to Remotion frame number. + */ +export function msToFrame(ms: number, fps: number): number { + return Math.round((ms / 1000) * fps); +} + +/** + * Convert Remotion frame number to milliseconds. + */ +export function frameToMs(frame: number, fps: number): number { + return Math.round((frame / fps) * 1000); +} + +/** + * Find the active word at a given frame. + * Returns the index of the word being spoken, or -1 if between words. + */ +export function getActiveWordAtFrame( + wordTimestamps: WordTimestamp[], + frame: number, + fps: number +): number { + const ms = frameToMs(frame, fps); + for (let i = 0; i < wordTimestamps.length; i++) { + if (ms >= wordTimestamps[i].startMs && ms <= wordTimestamps[i].endMs) { + return i; + } + } + return -1; +} + +/** + * Find which "segment" (e.g., list item, code line) is active at a given frame. + * Segments are defined by keyword markers in the word timestamps. + * + * @param wordTimestamps - All word timestamps for the scene + * @param segmentCount - Number of segments (e.g., number of list items) + * @param frame - Current Remotion frame + * @param fps - Frames per second + * @returns Index of the active segment (0-based), or 0 if can't determine + */ +export function getActiveSegmentAtFrame( + wordTimestamps: WordTimestamp[], + segmentCount: number, + frame: number, + fps: number +): number { + if (!wordTimestamps.length || segmentCount <= 1) return 0; + + const ms = frameToMs(frame, fps); + const totalDuration = wordTimestamps[wordTimestamps.length - 1].endMs; + + if (totalDuration <= 0) return 0; + + // Simple proportional mapping: divide narration time evenly across segments + const segmentDuration = totalDuration / segmentCount; + const activeSegment = Math.floor(ms / segmentDuration); + + return Math.min(activeSegment, segmentCount - 1); +} diff --git a/remotion/components/CodeMorphScene.tsx b/remotion/components/CodeMorphScene.tsx new file mode 100644 index 00000000..d06df675 --- /dev/null +++ b/remotion/components/CodeMorphScene.tsx @@ -0,0 +1,436 @@ +import React from "react"; +import { + AbsoluteFill, + interpolate, + spring, + useCurrentFrame, + useVideoConfig, +} from "remotion"; +import type { CodeMorphSceneProps } from "../types"; +import { ANIMATION, COLORS, CODE_COLORS, FONT_SIZES } from "../constants"; +import { getActiveSegmentAtFrame } from "../../lib/utils/audio-timestamps"; + +/** + * CodeMorphScene — Animated code display with syntax highlighting, + * typing animation, and glassmorphism window frame. + * + * NOTE: Code rendering uses a manual monospace approach. When `react-shiki` + * or `shiki` is installed, replace the plain-text rendering in `renderCodeLine()` + * with Shiki-based syntax highlighting for proper token coloring. + */ + +const CODE_FONT_FAMILY = + '"Fira Code", "JetBrains Mono", "Cascadia Code", monospace'; + +/** Map language names to file extensions for the title bar */ +function getFilenameForLanguage(language: string): string { + const extensionMap: Record<string, string> = { + typescript: "example.ts", + javascript: "example.js", + tsx: "example.tsx", + jsx: "example.jsx", + python: "example.py", + rust: "example.rs", + go: "example.go", + html: "index.html", + css: "styles.css", + json: "data.json", + yaml: "config.yaml", + yml: "config.yml", + bash: "script.sh", + shell: "script.sh", + sh: "script.sh", + sql: "query.sql", + graphql: "schema.graphql", + markdown: "README.md", + md: "README.md", + swift: "example.swift", + kotlin: "example.kt", + java: "Example.java", + csharp: "Example.cs", + cs: "Example.cs", + cpp: "example.cpp", + c: "example.c", + ruby: "example.rb", + php: "example.php", + dart: "example.dart", + svelte: "Component.svelte", + vue: "Component.vue", + }; + return extensionMap[language.toLowerCase()] ?? `example.${language}`; +} + +export const CodeMorphScene: React.FC<CodeMorphSceneProps> = ({ + narration, + sceneIndex, + durationInFrames, + isVertical = false, + wordTimestamps, + code, +}) => { + const frame = useCurrentFrame(); + const { fps } = useVideoConfig(); + const fonts = isVertical ? FONT_SIZES.portrait : FONT_SIZES.landscape; + + const { snippet, language, highlightLines } = code; + const lines = snippet ? snippet.split("\n") : [""]; + const lineCount = lines.length; + const filename = getFilenameForLanguage(language); + + // --- Scene-level fade in/out --- + const sceneOpacity = interpolate( + frame, + [0, 15, durationInFrames - ANIMATION.fadeOut, durationInFrames], + [0, 1, 1, 0], + { extrapolateLeft: "clamp", extrapolateRight: "clamp" }, + ); + + // --- Window entrance: spring scale from 0.95 → 1.0 --- + const windowScale = spring({ + frame, + fps, + config: { + damping: ANIMATION.springDamping, + mass: ANIMATION.springMass, + stiffness: ANIMATION.springStiffness, + }, + from: 0.95, + to: 1, + }); + + // --- Typing animation --- + // Reserve first 10% of frames for window entrance, last 10% for hold + const typingStartFrame = Math.round(durationInFrames * 0.08); + const typingEndFrame = Math.round(durationInFrames * 0.85); + + // How many lines are "typed" at the current frame (continuous float for smooth reveal) + const typedLineProgress = interpolate( + frame, + [typingStartFrame, typingEndFrame], + [0, lineCount], + { extrapolateLeft: "clamp", extrapolateRight: "clamp" }, + ); + + // Number of fully visible lines + const fullyTypedLines = Math.floor(typedLineProgress); + // The line currently being typed (partial) + const currentTypingLine = fullyTypedLines; + // Progress within the current line (0 → 1) + const currentLineProgress = typedLineProgress - fullyTypedLines; + + // --- Cursor blink --- + const cursorVisible = Math.floor(frame / 15) % 2 === 0; + + // --- Line highlighting --- + const hasHighlightLines = + highlightLines !== undefined && highlightLines.length > 0; + + // Determine which highlight group is active (timestamp-driven or static) + let activeHighlightGroup = -1; + if ( + hasHighlightLines && + wordTimestamps !== undefined && + wordTimestamps.length > 0 + ) { + activeHighlightGroup = getActiveSegmentAtFrame( + wordTimestamps, + highlightLines!.length, + frame, + fps, + ); + } + + /** + * Check if a line (1-based) should be highlighted at the current frame. + */ + function isLineHighlighted(lineNumber: number): boolean { + if (!hasHighlightLines) return false; + + // If we have timestamps, only highlight the line corresponding to the active segment + if (wordTimestamps !== undefined && wordTimestamps.length > 0) { + return ( + activeHighlightGroup >= 0 && + activeHighlightGroup < highlightLines!.length && + highlightLines![activeHighlightGroup] === lineNumber + ); + } + + // Static: highlight all specified lines once they've been typed + return ( + highlightLines!.includes(lineNumber) && lineNumber <= fullyTypedLines + ); + } + + // --- Narration text animation --- + const narrationOpacity = interpolate( + frame, + [ANIMATION.fadeIn, ANIMATION.fadeIn + 10], + [0, 1], + { extrapolateLeft: "clamp", extrapolateRight: "clamp" }, + ); + + // --- Gradient background angle (alternating per scene) --- + const gradientAngle = (sceneIndex % 4) * 90; + + // --- Layout dimensions --- + const codeFontSize = fonts.code; + const lineHeight = codeFontSize * 1.7; + const lineNumberWidth = 48; + + return ( + <AbsoluteFill style={{ opacity: sceneOpacity }}> + {/* Layer 1: Dark gradient background */} + <AbsoluteFill + style={{ + background: `linear-gradient(${gradientAngle}deg, ${COLORS.gradientStart}, ${COLORS.backgroundDark}, ${COLORS.backgroundMedium})`, + }} + /> + + {/* Layer 2: Glassmorphism code window */} + <AbsoluteFill + style={{ + justifyContent: "center", + alignItems: "center", + padding: isVertical ? "60px 24px" : "60px 80px", + }} + > + <div + style={{ + transform: `scale(${windowScale})`, + width: isVertical ? "95%" : "75%", + maxHeight: isVertical ? "60%" : "70%", + display: "flex", + flexDirection: "column", + borderRadius: 12, + border: `1px solid ${CODE_COLORS.windowBorder}`, + overflow: "hidden", + backdropFilter: "blur(20px)", + WebkitBackdropFilter: "blur(20px)", + boxShadow: `0 24px 80px rgba(0, 0, 0, 0.5), 0 0 40px rgba(109, 40, 217, 0.15)`, + }} + > + {/* Title bar */} + <div + style={{ + display: "flex", + alignItems: "center", + padding: "12px 16px", + backgroundColor: CODE_COLORS.titleBar, + borderBottom: `1px solid ${CODE_COLORS.windowBorder}`, + flexShrink: 0, + }} + > + {/* Traffic light dots */} + <div style={{ display: "flex", gap: 8 }}> + <div + style={{ + width: 12, + height: 12, + borderRadius: "50%", + backgroundColor: CODE_COLORS.dotRed, + }} + /> + <div + style={{ + width: 12, + height: 12, + borderRadius: "50%", + backgroundColor: CODE_COLORS.dotYellow, + }} + /> + <div + style={{ + width: 12, + height: 12, + borderRadius: "50%", + backgroundColor: CODE_COLORS.dotGreen, + }} + /> + </div> + {/* Filename */} + <div + style={{ + flex: 1, + textAlign: "center", + fontSize: codeFontSize * 0.75, + color: COLORS.textMuted, + fontFamily: CODE_FONT_FAMILY, + fontWeight: 500, + }} + > + {filename} + </div> + {/* Spacer to balance the dots */} + <div style={{ width: 52 }} /> + </div> + + {/* Code content area */} + <div + style={{ + flex: 1, + backgroundColor: CODE_COLORS.windowBg, + padding: "16px 0", + overflow: "hidden", + }} + > + {lines.map((line, index) => { + const lineNumber = index + 1; + const isFullyTyped = index < fullyTypedLines; + const isCurrentlyTyping = index === currentTypingLine; + const isVisible = isFullyTyped || isCurrentlyTyping; + + if (!isVisible) { + // Render empty line to preserve layout + return ( + <div + key={index} + style={{ + display: "flex", + height: lineHeight, + alignItems: "center", + }} + /> + ); + } + + // Calculate opacity for the line being typed + const lineOpacity = isFullyTyped + ? 1 + : interpolate(currentLineProgress, [0, 0.3], [0, 1], { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + }); + + // Characters to show for the currently typing line + const displayText = isFullyTyped + ? line + : line.slice( + 0, + Math.floor(currentLineProgress * line.length), + ); + + const highlighted = isLineHighlighted(lineNumber); + + return ( + <div + key={index} + style={{ + display: "flex", + height: lineHeight, + alignItems: "center", + opacity: lineOpacity, + backgroundColor: highlighted + ? CODE_COLORS.lineHighlight + : "transparent", + paddingRight: 16, + }} + > + {/* Line number */} + <div + style={{ + width: lineNumberWidth, + textAlign: "right", + paddingRight: 16, + fontSize: codeFontSize * 0.85, + color: CODE_COLORS.lineNumber, + fontFamily: CODE_FONT_FAMILY, + userSelect: "none", + flexShrink: 0, + }} + > + {lineNumber} + </div> + + {/* Code text */} + {/* + * TODO: Replace this plain-text rendering with Shiki-based + * syntax highlighting when react-shiki or shiki is installed. + * Use `codeToHtml()` or `<ShikiHighlighter>` to tokenize + * and color the code based on the `language` prop. + */} + <div + style={{ + flex: 1, + fontSize: codeFontSize, + color: COLORS.textWhite, + fontFamily: CODE_FONT_FAMILY, + whiteSpace: "pre", + lineHeight: `${lineHeight}px`, + }} + > + {displayText} + {/* Blinking cursor on the currently typing line */} + {isCurrentlyTyping && cursorVisible && ( + <span + style={{ + color: COLORS.secondary, + fontWeight: 700, + }} + > + | + </span> + )} + </div> + </div> + ); + })} + </div> + </div> + </AbsoluteFill> + + {/* Layer 3: Narration text overlay */} + <div + style={{ + position: "absolute", + bottom: isVertical ? 100 : 60, + left: 0, + right: 0, + display: "flex", + justifyContent: "center", + padding: isVertical ? "0 32px" : "0 120px", + opacity: narrationOpacity, + }} + > + <div + style={{ + backgroundColor: "rgba(0, 0, 0, 0.6)", + borderRadius: 12, + padding: isVertical ? "16px 20px" : "16px 32px", + maxWidth: isVertical ? "95%" : "75%", + backdropFilter: "blur(8px)", + borderLeft: `3px solid ${COLORS.primary}`, + }} + > + <div + style={{ + fontSize: fonts.narration * 0.7, + color: COLORS.textWhite, + fontFamily: "sans-serif", + fontWeight: 400, + lineHeight: 1.4, + textAlign: isVertical ? "center" : "left", + }} + > + {narration} + </div> + </div> + </div> + + {/* Layer 4: CodingCat.dev watermark */} + <div + style={{ + position: "absolute", + bottom: isVertical ? 30 : 20, + right: isVertical ? 30 : 30, + fontSize: fonts.watermark, + color: "rgba(255, 255, 255, 0.35)", + fontFamily: "monospace", + fontWeight: 600, + letterSpacing: 1, + }} + > + codingcat.dev + </div> + </AbsoluteFill> + ); +}; diff --git a/remotion/components/ComparisonGridScene.tsx b/remotion/components/ComparisonGridScene.tsx new file mode 100644 index 00000000..bab0dd08 --- /dev/null +++ b/remotion/components/ComparisonGridScene.tsx @@ -0,0 +1,397 @@ +import React from "react"; +import { + AbsoluteFill, + interpolate, + spring, + useCurrentFrame, + useVideoConfig, +} from "remotion"; +import type { ComparisonGridSceneProps } from "../types"; +import { ANIMATION, COLORS, COMPARISON_COLORS, FONT_SIZES } from "../constants"; +import { getActiveSegmentAtFrame } from "../../lib/utils/audio-timestamps"; + +export const ComparisonGridScene: React.FC<ComparisonGridSceneProps> = ({ + narration, + sceneIndex, + durationInFrames, + isVertical = false, + wordTimestamps, + leftLabel, + rightLabel, + rows, +}) => { + const frame = useCurrentFrame(); + const { fps } = useVideoConfig(); + const fonts = isVertical ? FONT_SIZES.portrait : FONT_SIZES.landscape; + + // Guard against empty data + if (!rows || rows.length === 0) { + return ( + <AbsoluteFill + style={{ + background: `linear-gradient(135deg, ${COLORS.gradientStart}, ${COLORS.backgroundDark})`, + }} + /> + ); + } + + // --- Scene-level fade in/out --- + const sceneOpacity = interpolate( + frame, + [0, 15, durationInFrames - ANIMATION.fadeOut, durationInFrames], + [0, 1, 1, 0], + { extrapolateLeft: "clamp", extrapolateRight: "clamp" }, + ); + + // --- Narration text fade --- + const textOpacity = interpolate( + frame, + [ + 0, + ANIMATION.fadeIn, + durationInFrames - ANIMATION.fadeOut, + durationInFrames, + ], + [0, 1, 1, 0], + { extrapolateLeft: "clamp", extrapolateRight: "clamp" }, + ); + + // --- Active segment (focus/dimming) --- + const activeSegment = + wordTimestamps && wordTimestamps.length > 0 + ? getActiveSegmentAtFrame(wordTimestamps, rows.length, frame, fps) + : Math.min( + Math.floor((frame / durationInFrames) * rows.length), + rows.length - 1, + ); + + // Alternating gradient direction + const gradientAngle = (sceneIndex % 4) * 90; + + // --- SVG Grid Line Drawing --- + // Grid dimensions + const gridWidth = isVertical ? 900 : 1400; + const headerHeight = 70; + const rowHeight = isVertical ? 120 : 70; + const gridHeight = headerHeight + rows.length * rowHeight; + + const drawEnd = Math.round(durationInFrames * 0.2); + + // Vertical divider line (center) — only in landscape + const verticalLineLength = gridHeight; + const verticalDrawProgress = interpolate( + frame, + [0, drawEnd], + [verticalLineLength, 0], + { extrapolateLeft: "clamp", extrapolateRight: "clamp" }, + ); + + // Horizontal line lengths + const horizontalLineLength = gridWidth; + const horizontalDrawProgress = interpolate( + frame, + [0, drawEnd], + [horizontalLineLength, 0], + { extrapolateLeft: "clamp", extrapolateRight: "clamp" }, + ); + + // --- Header entrance spring --- + const headerSpring = spring({ + frame, + fps, + config: { + damping: ANIMATION.springDamping, + mass: ANIMATION.springMass, + stiffness: ANIMATION.springStiffness, + }, + }); + + const headerTranslateY = interpolate(headerSpring, [0, 1], [30, 0], { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + }); + + return ( + <AbsoluteFill style={{ opacity: sceneOpacity }}> + {/* Layer 1: Dark gradient background */} + <AbsoluteFill + style={{ + background: `linear-gradient(${gradientAngle}deg, ${COLORS.gradientStart}, ${COLORS.backgroundDark}, ${COLORS.backgroundMedium})`, + }} + /> + + {/* Layer 2: Comparison grid */} + <AbsoluteFill + style={{ + justifyContent: "center", + alignItems: "center", + padding: isVertical ? "80px 20px" : "60px 80px", + }} + > + <div + style={{ + position: "relative", + maxWidth: isVertical ? "95%" : "80%", + width: "100%", + display: "flex", + flexDirection: "column", + gap: 0, + }} + > + {/* SVG Grid Lines Overlay */} + <svg + style={{ + position: "absolute", + top: 0, + left: 0, + width: "100%", + height: "100%", + pointerEvents: "none", + zIndex: 1, + }} + viewBox={`0 0 ${gridWidth} ${gridHeight}`} + preserveAspectRatio="none" + > + {/* Vertical divider (center) — landscape only */} + {!isVertical && ( + <line + x1={gridWidth / 2} + y1={0} + x2={gridWidth / 2} + y2={gridHeight} + stroke={COMPARISON_COLORS.gridLine} + strokeWidth={2} + strokeDasharray={verticalLineLength} + strokeDashoffset={verticalDrawProgress} + /> + )} + + {/* Horizontal separators between header and rows, and between rows */} + {Array.from({ length: rows.length + 1 }).map((_, i) => { + const y = headerHeight + i * rowHeight; + return ( + <line + key={`h-line-${i}`} + x1={0} + y1={y} + x2={gridWidth} + y2={y} + stroke={COMPARISON_COLORS.gridLine} + strokeWidth={2} + strokeDasharray={horizontalLineLength} + strokeDashoffset={horizontalDrawProgress} + /> + ); + })} + </svg> + + {/* Header Row */} + <div + style={{ + display: "flex", + flexDirection: isVertical ? "column" : "row", + opacity: headerSpring, + transform: `translateY(${headerTranslateY}px)`, + background: COMPARISON_COLORS.headerBg, + backdropFilter: "blur(8px)", + borderRadius: "12px 12px 0 0", + overflow: "hidden", + position: "relative", + zIndex: 2, + }} + > + {/* Left label */} + <div + style={{ + flex: 1, + padding: isVertical ? "16px 20px" : "20px 24px", + fontSize: fonts.comparisonHeader, + fontFamily: "sans-serif", + fontWeight: 700, + color: COMPARISON_COLORS.leftAccent, + textAlign: "center", + borderBottom: isVertical + ? `2px solid ${COMPARISON_COLORS.gridLine}` + : "none", + }} + > + {leftLabel} + </div> + + {/* Right label */} + <div + style={{ + flex: 1, + padding: isVertical ? "16px 20px" : "20px 24px", + fontSize: fonts.comparisonHeader, + fontFamily: "sans-serif", + fontWeight: 700, + color: COMPARISON_COLORS.rightAccent, + textAlign: "center", + }} + > + {rightLabel} + </div> + </div> + + {/* Data Rows */} + {rows.map((row, index) => { + const staggerDelay = Math.round(durationInFrames * 0.05) + index * 4; + + // Row entrance spring + const rowSpring = + frame >= staggerDelay + ? spring({ + frame: frame - staggerDelay, + fps, + config: { + damping: ANIMATION.springDamping, + mass: ANIMATION.springMass, + stiffness: ANIMATION.springStiffness, + }, + }) + : 0; + + const hasEntered = frame >= staggerDelay; + const isActive = hasEntered && index === activeSegment; + + // Opacity: invisible before entrance, then active/inactive + const rowOpacity = !hasEntered + ? 0 + : isActive + ? rowSpring + : rowSpring * 0.5; + + // Transform values from spring + const translateY = interpolate(rowSpring, [0, 1], [30, 0], { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + }); + + const scale = isActive + ? interpolate(rowSpring, [0, 1], [0.95, 1.02], { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + }) + : interpolate(rowSpring, [0, 1], [0.95, 1.0], { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + }); + + const rowBg = isActive + ? COMPARISON_COLORS.activeRow + : "rgba(15, 15, 35, 0.6)"; + + return ( + <div + key={index} + style={{ + display: "flex", + flexDirection: isVertical ? "column" : "row", + opacity: rowOpacity, + transform: `translateY(${translateY}px) scale(${scale})`, + background: rowBg, + backdropFilter: "blur(8px)", + position: "relative", + zIndex: 2, + borderRadius: + index === rows.length - 1 ? "0 0 12px 12px" : 0, + boxShadow: isActive + ? "0 0 20px rgba(167, 139, 250, 0.2)" + : "none", + }} + > + {/* Left cell */} + <div + style={{ + flex: 1, + padding: isVertical ? "14px 20px" : "18px 24px", + fontSize: fonts.comparisonCell, + fontFamily: "sans-serif", + fontWeight: 500, + color: COLORS.textWhite, + textAlign: "center", + lineHeight: 1.4, + borderBottom: isVertical + ? `1px solid ${COMPARISON_COLORS.gridLine}` + : "none", + borderLeft: `3px solid ${COMPARISON_COLORS.leftAccent}`, + }} + > + {row.left} + </div> + + {/* Right cell */} + <div + style={{ + flex: 1, + padding: isVertical ? "14px 20px" : "18px 24px", + fontSize: fonts.comparisonCell, + fontFamily: "sans-serif", + fontWeight: 500, + color: COLORS.textWhite, + textAlign: "center", + lineHeight: 1.4, + borderRight: `3px solid ${COMPARISON_COLORS.rightAccent}`, + }} + > + {row.right} + </div> + </div> + ); + })} + </div> + </AbsoluteFill> + + {/* Layer 3: Narration text overlay (bottom) */} + <AbsoluteFill + style={{ + justifyContent: "flex-end", + alignItems: "center", + padding: isVertical ? "60px 40px" : "80px 120px", + }} + > + <div + style={{ + opacity: textOpacity, + backgroundColor: "rgba(0, 0, 0, 0.6)", + borderRadius: 16, + padding: isVertical ? "28px 24px" : "32px 48px", + maxWidth: isVertical ? "95%" : "80%", + backdropFilter: "blur(8px)", + borderLeft: `4px solid ${COLORS.primary}`, + }} + > + <div + style={{ + fontSize: fonts.narration, + color: COLORS.textWhite, + fontFamily: "sans-serif", + fontWeight: 500, + lineHeight: 1.5, + textAlign: isVertical ? "center" : "left", + }} + > + {narration} + </div> + </div> + </AbsoluteFill> + + {/* Layer 4: CodingCat.dev watermark */} + <div + style={{ + position: "absolute", + bottom: isVertical ? 30 : 20, + right: isVertical ? 30 : 30, + fontSize: fonts.watermark, + color: "rgba(255, 255, 255, 0.35)", + fontFamily: "monospace", + fontWeight: 600, + letterSpacing: 1, + }} + > + codingcat.dev + </div> + </AbsoluteFill> + ); +}; diff --git a/remotion/components/DynamicListScene.tsx b/remotion/components/DynamicListScene.tsx new file mode 100644 index 00000000..fe55ac35 --- /dev/null +++ b/remotion/components/DynamicListScene.tsx @@ -0,0 +1,260 @@ +import React from "react"; +import { + AbsoluteFill, + interpolate, + spring, + useCurrentFrame, + useVideoConfig, +} from "remotion"; +import type { DynamicListSceneProps } from "../types"; +import { ANIMATION, COLORS, LIST_COLORS, FONT_SIZES } from "../constants"; +import { getActiveSegmentAtFrame } from "../../lib/utils/audio-timestamps"; + +export const DynamicListScene: React.FC<DynamicListSceneProps> = ({ + narration, + sceneIndex, + durationInFrames, + isVertical = false, + wordTimestamps, + items, + icon, +}) => { + const frame = useCurrentFrame(); + const { fps } = useVideoConfig(); + const fonts = isVertical ? FONT_SIZES.portrait : FONT_SIZES.landscape; + + // Guard against empty items + if (!items || items.length === 0) { + return ( + <AbsoluteFill + style={{ + background: `linear-gradient(135deg, ${COLORS.gradientStart}, ${COLORS.backgroundDark})`, + }} + /> + ); + } + + // --- Scene-level fade in/out --- + const sceneOpacity = interpolate( + frame, + [0, 15, durationInFrames - ANIMATION.fadeOut, durationInFrames], + [0, 1, 1, 0], + { extrapolateLeft: "clamp", extrapolateRight: "clamp" }, + ); + + // --- Narration text fade --- + const textOpacity = interpolate( + frame, + [ + 0, + ANIMATION.fadeIn, + durationInFrames - ANIMATION.fadeOut, + durationInFrames, + ], + [0, 1, 1, 0], + { extrapolateLeft: "clamp", extrapolateRight: "clamp" }, + ); + + // --- Entrance timing --- + const entranceWindow = durationInFrames * 0.6; + const staggerDelay = Math.max(1, Math.floor(entranceWindow / items.length)); + + // --- Active segment (focus/dimming) --- + const activeSegment = + wordTimestamps && wordTimestamps.length > 0 + ? getActiveSegmentAtFrame(wordTimestamps, items.length, frame, fps) + : Math.min( + Math.floor((frame / durationInFrames) * items.length), + items.length - 1, + ); + + // Alternating gradient direction + const gradientAngle = (sceneIndex % 4) * 90; + + // Bullet character + const bulletChar = icon || "✓"; + + return ( + <AbsoluteFill style={{ opacity: sceneOpacity }}> + {/* Layer 1: Dark gradient background */} + <AbsoluteFill + style={{ + background: `linear-gradient(${gradientAngle}deg, ${COLORS.gradientStart}, ${COLORS.backgroundDark}, ${COLORS.backgroundMedium})`, + }} + /> + + {/* Layer 2: List container */} + <AbsoluteFill + style={{ + justifyContent: "center", + alignItems: "center", + padding: isVertical ? "80px 20px" : "60px 80px", + }} + > + <div + style={{ + display: "flex", + flexDirection: "column", + gap: isVertical ? 12 : 16, + maxWidth: isVertical ? "90%" : "80%", + width: "100%", + }} + > + {items.map((item, index) => { + const itemEntryFrame = index * staggerDelay; + + // Item entrance spring + const itemSpring = + frame >= itemEntryFrame + ? spring({ + frame: frame - itemEntryFrame, + fps, + config: { damping: 12, mass: 0.6, stiffness: 100 }, + }) + : 0; + + // Icon spring (triggers slightly after item entrance) + const iconSpring = + frame >= itemEntryFrame + 3 + ? spring({ + frame: frame - itemEntryFrame - 3, + fps, + config: { damping: 10, mass: 0.4, stiffness: 120 }, + }) + : 0; + + // Has this item entered yet? + const hasEntered = frame >= itemEntryFrame; + + // Is this the active item? + const isActive = hasEntered && index === activeSegment; + + // Opacity: invisible before entrance, then active/inactive + const itemOpacity = !hasEntered + ? 0 + : isActive + ? itemSpring + : itemSpring * LIST_COLORS.inactiveOpacity; + + // Transform values from spring + const translateY = interpolate(itemSpring, [0, 1], [30, 0], { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + }); + const scale = interpolate(itemSpring, [0, 1], [0.8, 1.0], { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + }); + + // Border color + const borderColor = isActive + ? LIST_COLORS.activeBorder + : "transparent"; + + // Background + const cardBg = isActive + ? LIST_COLORS.activeBg + : "rgba(15, 15, 35, 0.6)"; + + return ( + <div + key={index} + style={{ + opacity: itemOpacity, + transform: `translateY(${translateY}px) scale(${scale})`, + background: cardBg, + backdropFilter: "blur(8px)", + borderRadius: 12, + padding: isVertical ? "16px 20px" : "20px 24px", + borderLeft: `4px solid ${borderColor}`, + display: "flex", + alignItems: "center", + gap: 16, + boxShadow: isActive + ? `0 0 20px rgba(167, 139, 250, 0.2)` + : "none", + }} + > + {/* Icon / bullet */} + <div + style={{ + fontSize: isVertical ? 32 : 28, + transform: `scale(${iconSpring})`, + flexShrink: 0, + color: icon ? undefined : LIST_COLORS.bulletColor, + lineHeight: 1, + }} + > + {bulletChar} + </div> + + {/* Item text */} + <div + style={{ + fontSize: fonts.listItem, + color: COLORS.textWhite, + fontFamily: "sans-serif", + fontWeight: 500, + lineHeight: 1.4, + }} + > + {item} + </div> + </div> + ); + })} + </div> + </AbsoluteFill> + + {/* Layer 3: Narration text overlay (bottom) */} + <AbsoluteFill + style={{ + justifyContent: "flex-end", + alignItems: "center", + padding: isVertical ? "60px 40px" : "80px 120px", + }} + > + <div + style={{ + opacity: textOpacity, + backgroundColor: "rgba(0, 0, 0, 0.6)", + borderRadius: 16, + padding: isVertical ? "28px 24px" : "32px 48px", + maxWidth: isVertical ? "95%" : "80%", + backdropFilter: "blur(8px)", + borderLeft: `4px solid ${COLORS.primary}`, + }} + > + <div + style={{ + fontSize: fonts.narration, + color: COLORS.textWhite, + fontFamily: "sans-serif", + fontWeight: 500, + lineHeight: 1.5, + textAlign: isVertical ? "center" : "left", + }} + > + {narration} + </div> + </div> + </AbsoluteFill> + + {/* Layer 4: CodingCat.dev watermark */} + <div + style={{ + position: "absolute", + bottom: isVertical ? 30 : 20, + right: isVertical ? 30 : 30, + fontSize: fonts.watermark, + color: "rgba(255, 255, 255, 0.35)", + fontFamily: "monospace", + fontWeight: 600, + letterSpacing: 1, + }} + > + codingcat.dev + </div> + </AbsoluteFill> + ); +}; diff --git a/remotion/components/IsometricMockupScene.tsx b/remotion/components/IsometricMockupScene.tsx new file mode 100644 index 00000000..7093ed5e --- /dev/null +++ b/remotion/components/IsometricMockupScene.tsx @@ -0,0 +1,666 @@ +import React from "react"; +import { + AbsoluteFill, + interpolate, + spring, + useCurrentFrame, + useVideoConfig, +} from "remotion"; +import type { IsometricMockupSceneProps } from "../types"; +import { ANIMATION, COLORS, CODE_COLORS, FONT_SIZES } from "../constants"; + +/** + * IsometricMockupScene — CSS 3D device mockup that displays content + * in a realistic device frame (browser, phone, or terminal). + * + * Features: + * - Three device types: browser (Chrome-style), phone (notch), terminal (green-on-black) + * - CSS 3D perspective tilt with spring-animated entrance + * - Typing animation for terminal, fade-in for browser/phone + * - Narration overlay at bottom with blur backdrop + * - Watermark at bottom-right + */ + +const CODE_FONT_FAMILY = + '"Fira Code", "JetBrains Mono", "Cascadia Code", monospace'; + +/** Font sizes type — union of landscape and portrait */ +type FontSizes = + | (typeof FONT_SIZES)["landscape"] + | (typeof FONT_SIZES)["portrait"]; + +// --- Browser Frame --- +const BrowserFrame: React.FC<{ + screenContent: string; + contentOpacity: number; + isVertical: boolean; + fonts: FontSizes; +}> = ({ screenContent, contentOpacity, isVertical, fonts }) => { + // Determine if screenContent looks like a URL + const isUrl = + screenContent.startsWith("http://") || + screenContent.startsWith("https://") || + screenContent.startsWith("www."); + const urlBarText = isUrl ? screenContent : "https://example.com"; + const viewportText = isUrl ? "" : screenContent; + + return ( + <div + style={{ + width: isVertical ? "90%" : "75%", + maxHeight: isVertical ? "55%" : "65%", + display: "flex", + flexDirection: "column", + borderRadius: 12, + border: `1px solid ${CODE_COLORS.windowBorder}`, + overflow: "hidden", + backdropFilter: "blur(20px)", + WebkitBackdropFilter: "blur(20px)", + boxShadow: + "0 24px 80px rgba(0, 0, 0, 0.5), 0 0 40px rgba(109, 40, 217, 0.15)", + }} + > + {/* Title bar */} + <div + style={{ + display: "flex", + alignItems: "center", + padding: "12px 16px", + backgroundColor: CODE_COLORS.titleBar, + borderBottom: `1px solid ${CODE_COLORS.windowBorder}`, + flexShrink: 0, + }} + > + {/* Traffic light dots */} + <div style={{ display: "flex", gap: 8 }}> + <div + style={{ + width: 12, + height: 12, + borderRadius: "50%", + backgroundColor: CODE_COLORS.dotRed, + }} + /> + <div + style={{ + width: 12, + height: 12, + borderRadius: "50%", + backgroundColor: CODE_COLORS.dotYellow, + }} + /> + <div + style={{ + width: 12, + height: 12, + borderRadius: "50%", + backgroundColor: CODE_COLORS.dotGreen, + }} + /> + </div> + + {/* URL bar */} + <div + style={{ + flex: 1, + marginLeft: 16, + marginRight: 16, + backgroundColor: "rgba(0, 0, 0, 0.3)", + borderRadius: 6, + padding: "6px 12px", + display: "flex", + alignItems: "center", + }} + > + {/* Lock icon */} + <span + style={{ + fontSize: fonts.code * 0.65, + color: CODE_COLORS.dotGreen, + marginRight: 8, + }} + > + 🔒 + </span> + <span + style={{ + fontSize: fonts.code * 0.7, + color: COLORS.textMuted, + fontFamily: CODE_FONT_FAMILY, + whiteSpace: "nowrap", + overflow: "hidden", + textOverflow: "ellipsis", + }} + > + {urlBarText} + </span> + </div> + + {/* Spacer */} + <div style={{ width: 36 }} /> + </div> + + {/* Content area */} + <div + style={{ + flex: 1, + backgroundColor: CODE_COLORS.windowBg, + padding: isVertical ? "24px 20px" : "32px 28px", + minHeight: 200, + display: "flex", + alignItems: "center", + justifyContent: "center", + }} + > + {viewportText ? ( + <div + style={{ + opacity: contentOpacity, + fontSize: fonts.listItem * 0.85, + color: COLORS.textWhite, + fontFamily: "sans-serif", + fontWeight: 400, + lineHeight: 1.6, + textAlign: "center", + padding: "0 16px", + }} + > + {viewportText} + </div> + ) : ( + <div + style={{ + opacity: contentOpacity, + display: "flex", + flexDirection: "column", + gap: 12, + width: "100%", + }} + > + {/* Placeholder content blocks for URL display */} + <div + style={{ + height: 20, + width: "60%", + backgroundColor: "rgba(167, 139, 250, 0.2)", + borderRadius: 4, + }} + /> + <div + style={{ + height: 14, + width: "90%", + backgroundColor: "rgba(255, 255, 255, 0.08)", + borderRadius: 4, + }} + /> + <div + style={{ + height: 14, + width: "75%", + backgroundColor: "rgba(255, 255, 255, 0.08)", + borderRadius: 4, + }} + /> + <div + style={{ + height: 14, + width: "85%", + backgroundColor: "rgba(255, 255, 255, 0.08)", + borderRadius: 4, + }} + /> + </div> + )} + </div> + </div> + ); +}; + +// --- Phone Frame --- +const PhoneFrame: React.FC<{ + screenContent: string; + contentOpacity: number; + isVertical: boolean; + fonts: FontSizes; +}> = ({ screenContent, contentOpacity, isVertical, fonts }) => { + const phoneWidth = isVertical ? 320 : 300; + const phoneHeight = isVertical ? 580 : 540; + + return ( + <div + style={{ + width: phoneWidth, + height: phoneHeight, + backgroundColor: "rgba(20, 20, 40, 0.95)", + borderRadius: 40, + border: "3px solid rgba(167, 139, 250, 0.4)", + display: "flex", + flexDirection: "column", + overflow: "hidden", + boxShadow: + "0 24px 80px rgba(0, 0, 0, 0.6), 0 0 40px rgba(109, 40, 217, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.1)", + position: "relative", + }} + > + {/* Notch */} + <div + style={{ + display: "flex", + justifyContent: "center", + paddingTop: 12, + flexShrink: 0, + }} + > + <div + style={{ + width: 120, + height: 28, + backgroundColor: "rgba(0, 0, 0, 0.8)", + borderRadius: 14, + }} + /> + </div> + + {/* Screen area */} + <div + style={{ + flex: 1, + margin: "12px 12px 0 12px", + backgroundColor: "rgba(15, 15, 35, 0.9)", + borderRadius: 8, + display: "flex", + alignItems: "center", + justifyContent: "center", + padding: "20px 16px", + overflow: "hidden", + }} + > + <div + style={{ + opacity: contentOpacity, + fontSize: fonts.code, + color: COLORS.textWhite, + fontFamily: "sans-serif", + fontWeight: 400, + lineHeight: 1.5, + textAlign: "center", + }} + > + {screenContent} + </div> + </div> + + {/* Home indicator bar */} + <div + style={{ + display: "flex", + justifyContent: "center", + padding: "16px 0 12px 0", + flexShrink: 0, + }} + > + <div + style={{ + width: 100, + height: 5, + backgroundColor: "rgba(255, 255, 255, 0.3)", + borderRadius: 3, + }} + /> + </div> + </div> + ); +}; + +// --- Terminal Frame --- +const TerminalFrame: React.FC<{ + screenContent: string; + typedCharCount: number; + cursorVisible: boolean; + isVertical: boolean; + fonts: FontSizes; +}> = ({ screenContent, typedCharCount, cursorVisible, isVertical, fonts }) => { + const displayText = screenContent.slice(0, typedCharCount); + + return ( + <div + style={{ + width: isVertical ? "90%" : "75%", + maxHeight: isVertical ? "55%" : "65%", + display: "flex", + flexDirection: "column", + borderRadius: 12, + border: `1px solid ${CODE_COLORS.windowBorder}`, + overflow: "hidden", + backdropFilter: "blur(20px)", + WebkitBackdropFilter: "blur(20px)", + boxShadow: + "0 24px 80px rgba(0, 0, 0, 0.5), 0 0 40px rgba(109, 40, 217, 0.15)", + }} + > + {/* Title bar */} + <div + style={{ + display: "flex", + alignItems: "center", + padding: "12px 16px", + backgroundColor: CODE_COLORS.titleBar, + borderBottom: `1px solid ${CODE_COLORS.windowBorder}`, + flexShrink: 0, + }} + > + {/* Traffic light dots */} + <div style={{ display: "flex", gap: 8 }}> + <div + style={{ + width: 12, + height: 12, + borderRadius: "50%", + backgroundColor: CODE_COLORS.dotRed, + }} + /> + <div + style={{ + width: 12, + height: 12, + borderRadius: "50%", + backgroundColor: CODE_COLORS.dotYellow, + }} + /> + <div + style={{ + width: 12, + height: 12, + borderRadius: "50%", + backgroundColor: CODE_COLORS.dotGreen, + }} + /> + </div> + {/* Terminal title */} + <div + style={{ + flex: 1, + textAlign: "center", + fontSize: fonts.code * 0.75, + color: COLORS.textMuted, + fontFamily: CODE_FONT_FAMILY, + fontWeight: 500, + }} + > + Terminal + </div> + {/* Spacer */} + <div style={{ width: 52 }} /> + </div> + + {/* Terminal content area */} + <div + style={{ + flex: 1, + backgroundColor: "rgba(5, 5, 15, 0.95)", + padding: isVertical ? "20px 16px" : "24px 20px", + minHeight: 200, + }} + > + <div + style={{ + fontFamily: CODE_FONT_FAMILY, + fontSize: fonts.code, + lineHeight: 1.7, + whiteSpace: "pre-wrap", + wordBreak: "break-word", + }} + > + {/* Prompt */} + <span style={{ color: "#10B981", fontWeight: 700 }}>$ </span> + {/* Typed text */} + <span style={{ color: "#10B981" }}>{displayText}</span> + {/* Blinking cursor */} + {cursorVisible && ( + <span + style={{ + color: "#10B981", + fontWeight: 700, + }} + > + ▌ + </span> + )} + </div> + </div> + </div> + ); +}; + +// --- Main Component --- +export const IsometricMockupScene: React.FC<IsometricMockupSceneProps> = ({ + narration, + sceneIndex, + durationInFrames, + isVertical = false, + // wordTimestamps is accepted but not used for content sync in this component + // (reserved for future narration highlighting) + deviceType, + screenContent, +}) => { + const frame = useCurrentFrame(); + const { fps } = useVideoConfig(); + const fonts = isVertical ? FONT_SIZES.portrait : FONT_SIZES.landscape; + + // --- Scene-level fade in/out --- + const sceneOpacity = interpolate( + frame, + [0, 15, durationInFrames - ANIMATION.fadeOut, durationInFrames], + [0, 1, 1, 0], + { extrapolateLeft: "clamp", extrapolateRight: "clamp" }, + ); + + // --- Device entrance: spring fly-in from below --- + const entranceSpring = spring({ + frame, + fps, + config: { + damping: ANIMATION.springDamping, + mass: ANIMATION.springMass, + stiffness: ANIMATION.springStiffness, + }, + }); + + const translateY = interpolate(entranceSpring, [0, 1], [200, 0], { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + }); + + // --- 3D tilt settling with spring --- + const tiltProgress = spring({ + frame, + fps, + config: { damping: 15, mass: 0.8, stiffness: 80 }, + }); + + // Different tilt targets per device type + let tiltXRange: [number, number]; + let tiltYRange: [number, number]; + + if (deviceType === "browser") { + tiltXRange = [-15, -5]; + tiltYRange = [20, 10]; + } else if (deviceType === "phone") { + tiltXRange = [-10, -3]; + tiltYRange = [-16, -8]; + } else { + // terminal + tiltXRange = [-12, -4]; + tiltYRange = [15, 8]; + } + + const tiltX = interpolate(tiltProgress, [0, 1], tiltXRange, { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + }); + const tiltY = interpolate(tiltProgress, [0, 1], tiltYRange, { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + }); + + // --- Content appearance --- + const contentStartFrame = Math.round(durationInFrames * 0.15); + const contentEndFrame = Math.round(durationInFrames * 0.85); + + // For browser/phone: fade-in opacity + const contentOpacity = interpolate( + frame, + [contentStartFrame, contentStartFrame + 20], + [0, 1], + { extrapolateLeft: "clamp", extrapolateRight: "clamp" }, + ); + + // For terminal: typing animation (character count) + const typedCharCount = Math.floor( + interpolate( + frame, + [contentStartFrame, contentEndFrame], + [0, screenContent.length], + { extrapolateLeft: "clamp", extrapolateRight: "clamp" }, + ), + ); + + // Cursor blink (toggles every 15 frames) + const cursorVisible = Math.floor(frame / 15) % 2 === 0; + + // --- Narration text animation --- + const narrationOpacity = interpolate( + frame, + [ANIMATION.fadeIn, ANIMATION.fadeIn + 10], + [0, 1], + { extrapolateLeft: "clamp", extrapolateRight: "clamp" }, + ); + + // --- Gradient background angle (alternating per scene) --- + const gradientAngle = (sceneIndex % 4) * 90; + + return ( + <AbsoluteFill style={{ opacity: sceneOpacity }}> + {/* Layer 1: Dark gradient background */} + <AbsoluteFill + style={{ + background: `linear-gradient(${gradientAngle}deg, ${COLORS.gradientStart}, ${COLORS.backgroundDark}, ${COLORS.backgroundMedium})`, + }} + /> + + {/* Layer 2: 3D Device mockup */} + <AbsoluteFill + style={{ + justifyContent: "center", + alignItems: "center", + padding: isVertical ? "60px 24px" : "60px 80px", + }} + > + {/* Perspective container */} + <div + style={{ + perspective: 1200, + display: "flex", + justifyContent: "center", + alignItems: "center", + width: "100%", + height: "100%", + }} + > + {/* 3D transform wrapper */} + <div + style={{ + transform: `translateY(${translateY}px) rotateX(${tiltX}deg) rotateY(${tiltY}deg)`, + transformStyle: "preserve-3d", + display: "flex", + justifyContent: "center", + alignItems: "center", + width: "100%", + }} + > + {deviceType === "browser" && ( + <BrowserFrame + screenContent={screenContent} + contentOpacity={contentOpacity} + isVertical={isVertical} + fonts={fonts} + /> + )} + {deviceType === "phone" && ( + <PhoneFrame + screenContent={screenContent} + contentOpacity={contentOpacity} + isVertical={isVertical} + fonts={fonts} + /> + )} + {deviceType === "terminal" && ( + <TerminalFrame + screenContent={screenContent} + typedCharCount={typedCharCount} + cursorVisible={cursorVisible} + isVertical={isVertical} + fonts={fonts} + /> + )} + </div> + </div> + </AbsoluteFill> + + {/* Layer 3: Narration text overlay */} + <div + style={{ + position: "absolute", + bottom: isVertical ? 100 : 60, + left: 0, + right: 0, + display: "flex", + justifyContent: "center", + padding: isVertical ? "0 32px" : "0 120px", + opacity: narrationOpacity, + }} + > + <div + style={{ + backgroundColor: "rgba(0, 0, 0, 0.6)", + borderRadius: 12, + padding: isVertical ? "16px 20px" : "16px 32px", + maxWidth: isVertical ? "95%" : "75%", + backdropFilter: "blur(8px)", + borderLeft: `3px solid ${COLORS.primary}`, + }} + > + <div + style={{ + fontSize: fonts.narration * 0.7, + color: COLORS.textWhite, + fontFamily: "sans-serif", + fontWeight: 400, + lineHeight: 1.4, + textAlign: isVertical ? "center" : "left", + }} + > + {narration} + </div> + </div> + </div> + + {/* Layer 4: CodingCat.dev watermark */} + <div + style={{ + position: "absolute", + bottom: isVertical ? 30 : 20, + right: isVertical ? 30 : 30, + fontSize: fonts.watermark, + color: "rgba(255, 255, 255, 0.35)", + fontFamily: "monospace", + fontWeight: 600, + letterSpacing: 1, + }} + > + codingcat.dev + </div> + </AbsoluteFill> + ); +}; diff --git a/remotion/components/SceneRouter.tsx b/remotion/components/SceneRouter.tsx new file mode 100644 index 00000000..22b92a48 --- /dev/null +++ b/remotion/components/SceneRouter.tsx @@ -0,0 +1,76 @@ +import React from "react"; +import type { SceneData } from "../types"; +import { Scene } from "./Scene"; +// Scene component imports (uncomment as components are built): +import { CodeMorphScene } from "./CodeMorphScene"; +import { DynamicListScene } from "./DynamicListScene"; +import { ComparisonGridScene } from "./ComparisonGridScene"; +import { IsometricMockupScene } from "./IsometricMockupScene"; + +interface SceneRouterProps { + scene: SceneData; + sceneIndex: number; + durationInFrames: number; + isVertical?: boolean; +} + +/** + * Routes a scene to the appropriate component based on its sceneType. + * Falls back to the generic Scene component for unimplemented types. + */ +export const SceneRouter: React.FC<SceneRouterProps> = ({ + scene, + sceneIndex, + durationInFrames, + isVertical = false, +}) => { + const baseProps = { + narration: scene.narration, + sceneIndex, + durationInFrames, + isVertical, + wordTimestamps: scene.wordTimestamps, + }; + + switch (scene.sceneType) { + case "code": + if (scene.code) { + return <CodeMorphScene {...baseProps} code={scene.code} />; + } + break; + + case "list": + if (scene.list) { + return <DynamicListScene {...baseProps} items={scene.list.items} icon={scene.list.icon} />; + } + break; + + case "comparison": + if (scene.comparison) { + return <ComparisonGridScene {...baseProps} {...scene.comparison} />; + } + break; + + case "mockup": + if (scene.mockup) { + return <IsometricMockupScene {...baseProps} {...scene.mockup} />; + } + break; + + case "narration": + default: + break; + } + + // Fallback: use the existing Scene component + return ( + <Scene + narration={scene.narration} + bRollUrl={scene.bRollUrl} + visualDescription={scene.visualDescription} + sceneIndex={sceneIndex} + durationInFrames={durationInFrames} + isVertical={isVertical} + /> + ); +}; diff --git a/remotion/compositions/MainVideo.tsx b/remotion/compositions/MainVideo.tsx index 4c350390..4a625f98 100644 --- a/remotion/compositions/MainVideo.tsx +++ b/remotion/compositions/MainVideo.tsx @@ -9,7 +9,7 @@ import { SPONSOR_INSERT_SECONDS, } from "../constants"; import { HookScene } from "../components/HookScene"; -import { Scene } from "../components/Scene"; +import { SceneRouter } from "../components/SceneRouter"; import { CTAScene } from "../components/CTAScene"; import { SponsorSlot } from "../components/SponsorSlot"; @@ -71,10 +71,8 @@ export const MainVideo: React.FC<VideoInputProps> = ({ durationInFrames={perSceneFrames} name={`Scene ${index + 1}`} > - <Scene - narration={scene.narration} - bRollUrl={scene.bRollUrl} - visualDescription={scene.visualDescription} + <SceneRouter + scene={scene} sceneIndex={index} durationInFrames={perSceneFrames} isVertical={false} diff --git a/remotion/compositions/ShortVideo.tsx b/remotion/compositions/ShortVideo.tsx index 9b4fad7d..5a28e1c0 100644 --- a/remotion/compositions/ShortVideo.tsx +++ b/remotion/compositions/ShortVideo.tsx @@ -9,7 +9,7 @@ import { SPONSOR_INSERT_SECONDS, } from "../constants"; import { HookScene } from "../components/HookScene"; -import { Scene } from "../components/Scene"; +import { SceneRouter } from "../components/SceneRouter"; import { CTAScene } from "../components/CTAScene"; import { SponsorSlot } from "../components/SponsorSlot"; @@ -64,10 +64,8 @@ export const ShortVideo: React.FC<VideoInputProps> = ({ durationInFrames={perSceneFrames} name={`Scene ${index + 1}`} > - <Scene - narration={scene.narration} - bRollUrl={scene.bRollUrl} - visualDescription={scene.visualDescription} + <SceneRouter + scene={scene} sceneIndex={index} durationInFrames={perSceneFrames} isVertical diff --git a/remotion/constants.ts b/remotion/constants.ts index af348f97..a75b0e4a 100644 --- a/remotion/constants.ts +++ b/remotion/constants.ts @@ -69,6 +69,10 @@ export const FONT_SIZES = { sponsorTitle: 28, sponsorMessage: 22, sponsorLabel: 16, + code: 24, + listItem: 36, + comparisonCell: 28, + comparisonHeader: 32, }, /** Short video (portrait) — larger for mobile readability */ portrait: { @@ -79,6 +83,10 @@ export const FONT_SIZES = { sponsorTitle: 32, sponsorMessage: 26, sponsorLabel: 18, + code: 20, + listItem: 40, + comparisonCell: 32, + comparisonHeader: 36, }, } as const; @@ -107,3 +115,47 @@ export const BRAND = { twitter: "@CodingCatDev", discord: "discord.gg/codingcatdev", } as const; + +// --- Code Scene Colors --- +export const CODE_COLORS = { + /** Glassmorphism window background */ + windowBg: "rgba(15, 15, 35, 0.85)", + /** Window border */ + windowBorder: "rgba(167, 139, 250, 0.3)", + /** Window title bar */ + titleBar: "rgba(30, 30, 60, 0.9)", + /** Traffic light dots */ + dotRed: "#FF5F57", + dotYellow: "#FEBC2E", + dotGreen: "#28C840", + /** Line highlight */ + lineHighlight: "rgba(109, 40, 217, 0.25)", + /** Line number color */ + lineNumber: "rgba(255, 255, 255, 0.3)", +} as const; + +// --- List Scene Constants --- +export const LIST_COLORS = { + /** Active item background */ + activeBg: "rgba(109, 40, 217, 0.3)", + /** Active item border */ + activeBorder: "#A78BFA", + /** Inactive item opacity */ + inactiveOpacity: 0.35, + /** Bullet/check color */ + bulletColor: "#10B981", +} as const; + +// --- Comparison Scene Constants --- +export const COMPARISON_COLORS = { + /** Grid line color */ + gridLine: "rgba(167, 139, 250, 0.4)", + /** Header background */ + headerBg: "rgba(109, 40, 217, 0.5)", + /** Active row highlight */ + activeRow: "rgba(109, 40, 217, 0.2)", + /** Left column accent */ + leftAccent: "#A78BFA", + /** Right column accent */ + rightAccent: "#F59E0B", +} as const; diff --git a/remotion/types.ts b/remotion/types.ts index e66dcfc4..f3d064c2 100644 --- a/remotion/types.ts +++ b/remotion/types.ts @@ -2,13 +2,67 @@ import { z } from "zod"; // --- Zod Schemas (used for Remotion input props validation) --- +// Scene type discriminator — Gemini picks this per scene +export const SCENE_TYPES = ["narration", "code", "list", "comparison", "mockup"] as const; +export type SceneType = typeof SCENE_TYPES[number]; + +// Word-level timestamp from ElevenLabs +export const wordTimestampSchema = z.object({ + text: z.string(), + startMs: z.number(), + endMs: z.number(), +}); +export type WordTimestamp = z.infer<typeof wordTimestampSchema>; + +// Code scene data +export const codeDataSchema = z.object({ + snippet: z.string(), + language: z.string(), + highlightLines: z.array(z.number()).optional(), +}); + +// List scene data +export const listDataSchema = z.object({ + items: z.array(z.string()).min(1), + icon: z.string().optional(), // emoji or SVG reference +}); + +// Comparison scene data +export const comparisonRowSchema = z.object({ + left: z.string(), + right: z.string(), +}); +export const comparisonDataSchema = z.object({ + leftLabel: z.string(), + rightLabel: z.string(), + rows: z.array(comparisonRowSchema).min(1), +}); + +// Mockup scene data +export const mockupDataSchema = z.object({ + deviceType: z.enum(["browser", "phone", "terminal"]), + screenContent: z.string(), // URL or description +}); + export const sceneDataSchema = z.object({ narration: z.string(), + sceneType: z.enum(SCENE_TYPES).optional(), bRollKeywords: z.array(z.string()).optional(), visualDescription: z.string().optional(), sceneNumber: z.number().optional(), durationEstimate: z.number().optional(), bRollUrl: z.string().url().optional(), + // Scene-type-specific data + code: codeDataSchema.optional(), + list: listDataSchema.optional(), + comparison: comparisonDataSchema.optional(), + mockup: mockupDataSchema.optional(), + // Word-level timestamps from ElevenLabs + wordTimestamps: z.array(wordTimestampSchema).optional(), + // Per-scene audio URL (for per-scene audio generation) + audioUrl: z.string().url().optional(), + // Per-scene audio duration in ms + audioDurationMs: z.number().optional(), }); export const sponsorDataSchema = z.object({ @@ -74,3 +128,42 @@ export interface SponsorSlotProps { durationInFrames: number; isVertical?: boolean; } + +// --- New Scene Component Prop Types --- + +// Base props shared by all scene components +export interface BaseSceneProps { + narration: string; + sceneIndex: number; + durationInFrames: number; + isVertical?: boolean; + wordTimestamps?: WordTimestamp[]; +} + +// CodeMorphScene props +export interface CodeMorphSceneProps extends BaseSceneProps { + code: { + snippet: string; + language: string; + highlightLines?: number[]; + }; +} + +// DynamicListScene props +export interface DynamicListSceneProps extends BaseSceneProps { + items: string[]; + icon?: string; +} + +// ComparisonGridScene props +export interface ComparisonGridSceneProps extends BaseSceneProps { + leftLabel: string; + rightLabel: string; + rows: { left: string; right: string }[]; +} + +// IsometricMockupScene props +export interface IsometricMockupSceneProps extends BaseSceneProps { + deviceType: "browser" | "phone" | "terminal"; + screenContent: string; +} diff --git a/sanity.config.ts b/sanity.config.ts index fb06da29..0915bb2d 100644 --- a/sanity.config.ts +++ b/sanity.config.ts @@ -44,6 +44,7 @@ import podcast from "@/sanity/schemas/documents/podcast"; import podcastType from "@/sanity/schemas/documents/podcastType"; import post from "@/sanity/schemas/documents/post"; import settings from "@/sanity/schemas/singletons/settings"; +import dashboardSettings from "@/sanity/schemas/singletons/dashboardSettings"; import sponsor from "@/sanity/schemas/documents/sponsor"; import sponsorshipRequest from "@/sanity/schemas/documents/sponsorshipRequest"; @@ -139,6 +140,7 @@ export default defineConfig({ rowType, // Singletons settings, + dashboardSettings, // Documents author, course, @@ -207,7 +209,7 @@ export default defineConfig({ }), structureTool({ structure: podcastStructure() }), // Configures the global "new document" button, and document actions, to suit the Settings document singleton - singletonPlugin([settings.name]), + singletonPlugin([settings.name, dashboardSettings.name]), // Sets up AI Assist with preset prompts // https://www.sanity.io/docs/ai-assistPcli assistWithPresets(), diff --git a/sanity/schemas/documents/automatedVideo.ts b/sanity/schemas/documents/automatedVideo.ts index 9f7b90c0..a1371bcf 100644 --- a/sanity/schemas/documents/automatedVideo.ts +++ b/sanity/schemas/documents/automatedVideo.ts @@ -195,6 +195,25 @@ export default defineType({ title: 'Flagged Reason', type: 'text', }), + defineField({ + name: 'distributionLog', + title: 'Distribution Log', + type: 'array', + description: 'Tracks distribution step results for retry and debugging', + of: [ + { + type: 'object', + fields: [ + defineField({ name: 'step', title: 'Step', type: 'string' }), + defineField({ name: 'status', title: 'Status', type: 'string', options: { list: ['success', 'failed', 'skipped'] } }), + defineField({ name: 'error', title: 'Error', type: 'text' }), + defineField({ name: 'timestamp', title: 'Timestamp', type: 'datetime' }), + defineField({ name: 'result', title: 'Result', type: 'string', description: 'e.g. YouTube video ID, tweet ID' }), + ], + }, + ], + hidden: true, + }), ], orderings: [ { diff --git a/sanity/schemas/singletons/dashboardSettings.ts b/sanity/schemas/singletons/dashboardSettings.ts new file mode 100644 index 00000000..bec2216f --- /dev/null +++ b/sanity/schemas/singletons/dashboardSettings.ts @@ -0,0 +1,77 @@ +import { defineField, defineType } from "sanity"; + +export default defineType({ + name: "dashboardSettings", + title: "Dashboard Settings", + type: "document", + icon: () => "⚙️", + fields: [ + defineField({ + name: "videosPerWeek", + title: "Videos Per Week", + type: "number", + initialValue: 3, + validation: (rule) => rule.min(1).max(14), + }), + defineField({ + name: "publishDays", + title: "Preferred Publish Days", + type: "array", + of: [{ type: "string" }], + options: { + list: [ + { title: "Monday", value: "Mon" }, + { title: "Tuesday", value: "Tue" }, + { title: "Wednesday", value: "Wed" }, + { title: "Thursday", value: "Thu" }, + { title: "Friday", value: "Fri" }, + { title: "Saturday", value: "Sat" }, + { title: "Sunday", value: "Sun" }, + ], + }, + initialValue: ["Mon", "Wed", "Fri"], + }), + defineField({ + name: "contentCategories", + title: "Content Categories", + type: "array", + of: [{ type: "string" }], + initialValue: [ + "JavaScript", "TypeScript", "React", "Next.js", "Angular", + "Svelte", "Node.js", "CSS", "DevOps", "AI / ML", + "Web Performance", "Tooling", + ], + }), + defineField({ + name: "rateCardTiers", + title: "Sponsor Rate Card Tiers", + type: "array", + of: [ + { + type: "object", + fields: [ + defineField({ name: "name", title: "Tier Name", type: "string" }), + defineField({ name: "description", title: "Description", type: "string" }), + defineField({ name: "price", title: "Price", type: "number" }), + ], + preview: { + select: { title: "name", subtitle: "price" }, + prepare({ title, subtitle }) { + return { title, subtitle: subtitle ? `$${subtitle}` : "" }; + }, + }, + }, + ], + initialValue: [ + { _type: "object", name: "Pre-roll Mention", description: "15-second sponsor mention at the start of the video", price: 200 }, + { _type: "object", name: "Mid-roll Segment", description: "60-second dedicated sponsor segment mid-video", price: 500 }, + { _type: "object", name: "Dedicated Video", description: "Full sponsored video with product deep-dive", price: 1500 }, + ], + }), + ], + preview: { + prepare() { + return { title: "Dashboard Settings" }; + }, + }, +});