From 7afb22e35a30775875aafddd38f41f599c0d1e4f Mon Sep 17 00:00:00 2001 From: Alex Patterson Date: Sat, 10 Oct 2026 13:01:58 -0400 Subject: [PATCH] feat(seo): add VideoObject structured data and Gemini video storyboard pipeline --- apps/site/src/lib/structured-data.ts | 61 ++++++++++++ apps/site/src/pages/podcast/[slug].astro | 16 ++++ apps/site/src/pages/post/[slug].astro | 17 +++- scripts/generate-video-storyboard.mjs | 114 +++++++++++++++++++++++ 4 files changed, 207 insertions(+), 1 deletion(-) create mode 100755 scripts/generate-video-storyboard.mjs diff --git a/apps/site/src/lib/structured-data.ts b/apps/site/src/lib/structured-data.ts index c098266e1..23c1bd41f 100644 --- a/apps/site/src/lib/structured-data.ts +++ b/apps/site/src/lib/structured-data.ts @@ -36,6 +36,8 @@ export function organizationSchema(origin: string, logoUrl?: string): Node { "https://www.youtube.com/@CodingCatDev", "https://twitter.com/CodingCatDev", "https://github.com/codingcatdev", + "https://www.linkedin.com/company/codingcatdev", + "https://bsky.app/profile/codingcat.dev", ], }; } @@ -160,6 +162,65 @@ export function personSchema( }; } +export function videoObjectSchema( + origin: string, + video: { + title?: string | null; + description?: string | null; + uploadDate?: string | null; + thumbnailUrl?: string; + youtubeUrl?: string | null; + durationSeconds?: number | null; + }, + path: string, +): Node | null { + if (!video.youtubeUrl) return null; + + const url = absoluteUrl(path, origin); + // Format ISO 8601 duration: PT#M#S or PT#S + let durationIso: string | undefined; + if (video.durationSeconds && video.durationSeconds > 0) { + const hours = Math.floor(video.durationSeconds / 3600); + const minutes = Math.floor((video.durationSeconds % 3600) / 60); + const seconds = Math.floor(video.durationSeconds % 60); + let d = "PT"; + if (hours > 0) d += `${hours}H`; + if (minutes > 0) d += `${minutes}M`; + if (seconds > 0 || d === "PT") d += `${seconds}S`; + durationIso = d; + } + + return { + "@type": "VideoObject", + "@id": `${url}#video`, + name: video.title ?? undefined, + description: video.description ?? undefined, + ...(video.thumbnailUrl ? { thumbnailUrl: [video.thumbnailUrl] } : {}), + ...(video.uploadDate ? { uploadDate: video.uploadDate } : {}), + ...(durationIso ? { duration: durationIso } : {}), + contentUrl: video.youtubeUrl, + embedUrl: video.youtubeUrl.replace("watch?v=", "embed/").replace("youtu.be/", "www.youtube.com/embed/"), + }; +} + +export function faqSchema( + faqs: Array<{ question: string; answer: string }>, +): Node | null { + if (!faqs || faqs.length === 0) return null; + + return { + "@type": "FAQPage", + mainEntity: faqs.map((faq) => ({ + "@type": "Question", + name: faq.question, + acceptedAnswer: { + "@type": "Answer", + text: faq.answer, + }, + })), + }; +} + interface PodcastEpisodeInput { title?: string | null; excerpt?: string | null; diff --git a/apps/site/src/pages/podcast/[slug].astro b/apps/site/src/pages/podcast/[slug].astro index f1ce7f824..f29158aec 100644 --- a/apps/site/src/pages/podcast/[slug].astro +++ b/apps/site/src/pages/podcast/[slug].astro @@ -18,6 +18,7 @@ import { articleSchema, breadcrumbSchema, podcastEpisodeSchema, + videoObjectSchema, } from "@/lib/structured-data"; const { sanity } = Astro.locals; @@ -40,6 +41,20 @@ const ogImage = sanity.resolveOpenGraphImage(podcast.coverImage ?? undefined); const authors = podcast.author ?? []; const guests = podcast.guest ?? []; const audioUrl = podcast.spotify?.enclosures?.at(0)?.url; +const videoSchema = podcast.youtube + ? videoObjectSchema( + origin, + { + title: podcast.title, + description: podcast.excerpt, + uploadDate: podcast.date, + thumbnailUrl: ogImage?.url, + youtubeUrl: podcast.youtube, + durationSeconds: podcast.transcript?.durationSeconds, + }, + path, + ) + : null; --- diff --git a/scripts/generate-video-storyboard.mjs b/scripts/generate-video-storyboard.mjs new file mode 100755 index 000000000..98efa2e7f --- /dev/null +++ b/scripts/generate-video-storyboard.mjs @@ -0,0 +1,114 @@ +#!/usr/bin/env node +/** + * CodingCat.dev AI Video Storyboard & Script Generator + * + * Inspired by: + * - Better Stack: High-touch, high-pain developer problem solving (costs, downtime, benchmarks). + * - Cleo Abram: Kinetic visual metaphors, 3-beat narrative arc, fast-paced animations, 10s curiosity hook. + * + * Powered by Google Gemini 1.5 Pro (via Google AI / OpenRouter). + */ + +const topic = process.argv.slice(2).join(" "); + +if (!topic) { + console.log(` +Usage: node scripts/generate-video-storyboard.mjs "" + +Example: + node scripts/generate-video-storyboard.mjs "Dockerless Serverless GPUs: Cutting AI inference cost by 90% with RunPod Flash" +`); + process.exit(1); +} + +const openRouterKey = process.env.OPENROUTER_API_KEY; + +if (!openRouterKey) { + console.error("Error: OPENROUTER_API_KEY environment variable is required."); + process.exit(1); +} + +const SYSTEM_PROMPT = `You are the Lead Creative Director & Tech Scriptwriter for CodingCat.dev, producing top-tier developer educational videos. + +Your mission is to blend two elite creator playbooks: +1. THE BETTER STACK CONTENT STRATEGY: + - Target genuine developer pain, extreme cost optimization, performance benchmarks, and production post-mortems. + - Ground everything in real metrics, architecture trade-offs, and actionable code. +2. THE CLEO ABRAM (Huge If True) VISUAL STYLE: + - THE 10-SECOND HOOK: Start with a surprising contradiction, visual mystery, or bold curiosity question. Never start with "Hey guys, welcome back". + - 3-BEAT NARRATIVE ARC: + * Beat 1: The Frustrating Status Quo (Why the standard way is broken/expensive/slow). + * Beat 2: The Secret Mechanism (Under the hood breakdown using visual metaphors). + * Beat 3: The Proof & Benchmark ("Huge If True" - The live test, savings, or live demo). + - DENSE VISUAL CHOREOGRAPHY: Never stay on talking head or a static screen for more than 4-5 seconds. Alternate with: + * Kinetic isometric server/network diagrams + * Code punch-ins (highlighting 2-3 key lines, not full files) + * Side-by-side racing clocks or dollar meters + * Tactile UI sound effects (foley pops, whooshes, mechanical keyboard clicks) + +OUTPUT FORMAT: +Generate a complete, ready-to-produce production document in Markdown with the following sections: + +# [Video Title: 3 High-CTR Options] + +## Video Metadata +- **Target Audience:** +- **Core Frustration Solved:** +- **The "North Star" Metric:** (e.g. "$0.03 -> $0.0003 per image", "150ms -> 12ms TTFB") +- **YouTube Description & Key Moments:** + +## Act 1: The 10-Second Hook (0:00 - 0:30) +Format as a 2-column table: +| Timestamp | Visual Director & Animation Notes (Cleo Style) | Spoken Audio / Voiceover | Sound Design (SFX) | + +## Act 2: The Hidden Mechanism & Architecture (0:30 - 3:30) +Format as a 2-column table with precise animation prompts (e.g., Motion Canvas / Remotion code prompts, isometric SVG descriptions). + +## Act 3: Live Benchmark & The Code (3:30 - 6:00) +Format as a 2-column table showing code highlights and race/cost comparison. + +## Act 4: The Takeaway & Call to Action (6:00 - 7:00) +Direct punchy conclusion pointing to the canonical CodingCat.dev article and GitHub repo. + +## Reusable Animation Prompts (For Motion Canvas / Remotion / After Effects) +Provide 2-3 exact TypeScript or motion graphic prompt specifications that can be fed into Motion Canvas or Remotion to generate the key graphics. +`; + +async function run() { + console.log(`\nšŸš€ Generating Cleo-style / Better Stack Storyboard for: "${topic}"\nUsing Gemini 1.5 Pro...\n`); + + try { + const response = await fetch("https://openrouter.ai/api/v1/chat/completions", { + method: "POST", + headers: { + "Authorization": `Bearer ${openRouterKey}`, + "Content-Type": "application/json", + "HTTP-Referer": "https://codingcat.dev", + "X-Title": "CodingCat Video Storyboard Generator", + }, + body: JSON.stringify({ + model: "google/gemini-pro-1.5", + messages: [ + { role: "system", content: SYSTEM_PROMPT }, + { role: "user", content: `Create a complete video storyboard and script for: ${topic}` }, + ], + temperature: 0.7, + }), + }); + + if (!response.ok) { + const err = await response.text(); + throw new Error(`OpenRouter API error (${response.status}): ${err}`); + } + + const data = await response.json(); + const result = data.choices?.[0]?.message?.content; + + console.log(result); + } catch (err) { + console.error("Generation failed:", err.message); + process.exit(1); + } +} + +run();