with lh-condensed class
+ const h2Match = block.match(
+ /]*class="[^"]*lh-condensed[^"]*"[^>]*>[\s\S]*?]*href="([^"]+)"[^>]*>/,
+ );
+ const repoPath = h2Match?.[1]?.trim();
+ if (!repoPath) continue;
+
+ const name = repoPath.replace(/^\//, "");
+ const url = `https://github.com${repoPath}`;
+
+ // Description from
+ const descMatch = block.match(
+ /
]*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 {
+ 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();
+ 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 = {
+ 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 {
+ 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
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
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
}
}
- // 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
`[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 = {
+ 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 = ({
+ 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 (
+
+ {/* Layer 1: Dark gradient background */}
+
+
+ {/* Layer 2: Glassmorphism code window */}
+
+
+ {/* Title bar */}
+
+ {/* Traffic light dots */}
+
+
+
+
+
+ {/* Filename */}
+
+ {filename}
+
+ {/* Spacer to balance the dots */}
+
+
+
+ {/* Code content area */}
+
+ {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 (
+
+ );
+ }
+
+ // 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 (
+
+ {/* Line number */}
+
+ {lineNumber}
+
+
+ {/* Code text */}
+ {/*
+ * TODO: Replace this plain-text rendering with Shiki-based
+ * syntax highlighting when react-shiki or shiki is installed.
+ * Use `codeToHtml()` or `` to tokenize
+ * and color the code based on the `language` prop.
+ */}
+
+ {displayText}
+ {/* Blinking cursor on the currently typing line */}
+ {isCurrentlyTyping && cursorVisible && (
+
+ |
+
+ )}
+
+
+ );
+ })}
+
+
+
+
+ {/* Layer 3: Narration text overlay */}
+
+
+
+ {narration}
+
+
+
+
+ {/* Layer 4: CodingCat.dev watermark */}
+
+ codingcat.dev
+
+
+ );
+};
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 = ({
+ 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 (
+
+ );
+ }
+
+ // --- 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 (
+
+ {/* Layer 1: Dark gradient background */}
+
+
+ {/* Layer 2: Comparison grid */}
+
+
+ {/* SVG Grid Lines Overlay */}
+
+
+ {/* Header Row */}
+
+ {/* Left label */}
+
+ {leftLabel}
+
+
+ {/* Right label */}
+
+ {rightLabel}
+
+
+
+ {/* 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 (
+
+ {/* Left cell */}
+
+ {row.left}
+
+
+ {/* Right cell */}
+
+ {row.right}
+
+
+ );
+ })}
+
+
+
+ {/* Layer 3: Narration text overlay (bottom) */}
+
+
+
+ {narration}
+
+
+
+
+ {/* Layer 4: CodingCat.dev watermark */}
+
+ codingcat.dev
+
+
+ );
+};
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 = ({
+ 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 (
+
+ );
+ }
+
+ // --- 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 (
+
+ {/* Layer 1: Dark gradient background */}
+
+
+ {/* Layer 2: List container */}
+
+
+ {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 (
+
+ {/* Icon / bullet */}
+
+ {bulletChar}
+
+
+ {/* Item text */}
+
+ {item}
+
+
+ );
+ })}
+
+
+
+ {/* Layer 3: Narration text overlay (bottom) */}
+
+
+
+ {narration}
+
+
+
+
+ {/* Layer 4: CodingCat.dev watermark */}
+
+ codingcat.dev
+
+
+ );
+};
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 (
+
+ {/* Title bar */}
+
+ {/* Traffic light dots */}
+
+
+
+
+
+
+ {/* URL bar */}
+
+ {/* Lock icon */}
+
+ 🔒
+
+
+ {urlBarText}
+
+
+
+ {/* Spacer */}
+
+
+
+ {/* Content area */}
+
+ {viewportText ? (
+
+ {viewportText}
+
+ ) : (
+
+ {/* Placeholder content blocks for URL display */}
+
+
+
+
+
+ )}
+
+
+ );
+};
+
+// --- 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 (
+
+ {/* Notch */}
+
+
+
+
+ {/* Screen area */}
+
+
+ {screenContent}
+
+
+
+ {/* Home indicator bar */}
+
+
+
+
+ );
+};
+
+// --- 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 (
+
+ {/* Title bar */}
+
+ {/* Traffic light dots */}
+
+
+
+
+
+ {/* Terminal title */}
+
+ Terminal
+
+ {/* Spacer */}
+
+
+
+ {/* Terminal content area */}
+
+
+ {/* Prompt */}
+ $
+ {/* Typed text */}
+ {displayText}
+ {/* Blinking cursor */}
+ {cursorVisible && (
+
+ ▌
+
+ )}
+
+
+
+ );
+};
+
+// --- Main Component ---
+export const IsometricMockupScene: React.FC = ({
+ 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 (
+
+ {/* Layer 1: Dark gradient background */}
+
+
+ {/* Layer 2: 3D Device mockup */}
+
+ {/* Perspective container */}
+
+ {/* 3D transform wrapper */}
+
+ {deviceType === "browser" && (
+
+ )}
+ {deviceType === "phone" && (
+
+ )}
+ {deviceType === "terminal" && (
+
+ )}
+
+
+
+
+ {/* Layer 3: Narration text overlay */}
+
+
+
+ {narration}
+
+
+
+
+ {/* Layer 4: CodingCat.dev watermark */}
+
+ codingcat.dev
+
+
+ );
+};
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 = ({
+ 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 ;
+ }
+ break;
+
+ case "list":
+ if (scene.list) {
+ return ;
+ }
+ break;
+
+ case "comparison":
+ if (scene.comparison) {
+ return ;
+ }
+ break;
+
+ case "mockup":
+ if (scene.mockup) {
+ return ;
+ }
+ break;
+
+ case "narration":
+ default:
+ break;
+ }
+
+ // Fallback: use the existing Scene component
+ return (
+
+ );
+};
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 = ({
durationInFrames={perSceneFrames}
name={`Scene ${index + 1}`}
>
- = ({
durationInFrames={perSceneFrames}
name={`Scene ${index + 1}`}
>
- ;
+
+// 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" };
+ },
+ },
+});
+ const descMatch = block.match( + /
]*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