Skip to content

Commit 76d113d

Browse files
authored
feat(seo): add VideoObject structured data and Gemini video storyboard pipeline (#716)
* feat(syndication): add syndicate-post workflow and dispatch runner (#708) * fix(syndication): update Hashnode tag shape to PublishPostTagInput (#710) * fix(syndication): update Hashnode coverImage input to string URL (#713) * feat(seo): add VideoObject structured data and Gemini video storyboard pipeline (#715)
1 parent 2f12b61 commit 76d113d

4 files changed

Lines changed: 207 additions & 1 deletion

File tree

‎apps/site/src/lib/structured-data.ts‎

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ export function organizationSchema(origin: string, logoUrl?: string): Node {
3636
"https://www.youtube.com/@CodingCatDev",
3737
"https://twitter.com/CodingCatDev",
3838
"https://github.com/codingcatdev",
39+
"https://www.linkedin.com/company/codingcatdev",
40+
"https://bsky.app/profile/codingcat.dev",
3941
],
4042
};
4143
}
@@ -160,6 +162,65 @@ export function personSchema(
160162
};
161163
}
162164

165+
export function videoObjectSchema(
166+
origin: string,
167+
video: {
168+
title?: string | null;
169+
description?: string | null;
170+
uploadDate?: string | null;
171+
thumbnailUrl?: string;
172+
youtubeUrl?: string | null;
173+
durationSeconds?: number | null;
174+
},
175+
path: string,
176+
): Node | null {
177+
if (!video.youtubeUrl) return null;
178+
179+
const url = absoluteUrl(path, origin);
180+
// Format ISO 8601 duration: PT#M#S or PT#S
181+
let durationIso: string | undefined;
182+
if (video.durationSeconds && video.durationSeconds > 0) {
183+
const hours = Math.floor(video.durationSeconds / 3600);
184+
const minutes = Math.floor((video.durationSeconds % 3600) / 60);
185+
const seconds = Math.floor(video.durationSeconds % 60);
186+
let d = "PT";
187+
if (hours > 0) d += `${hours}H`;
188+
if (minutes > 0) d += `${minutes}M`;
189+
if (seconds > 0 || d === "PT") d += `${seconds}S`;
190+
durationIso = d;
191+
}
192+
193+
return {
194+
"@type": "VideoObject",
195+
"@id": `${url}#video`,
196+
name: video.title ?? undefined,
197+
description: video.description ?? undefined,
198+
...(video.thumbnailUrl ? { thumbnailUrl: [video.thumbnailUrl] } : {}),
199+
...(video.uploadDate ? { uploadDate: video.uploadDate } : {}),
200+
...(durationIso ? { duration: durationIso } : {}),
201+
contentUrl: video.youtubeUrl,
202+
embedUrl: video.youtubeUrl.replace("watch?v=", "embed/").replace("youtu.be/", "www.youtube.com/embed/"),
203+
};
204+
}
205+
206+
export function faqSchema(
207+
faqs: Array<{ question: string; answer: string }>,
208+
): Node | null {
209+
if (!faqs || faqs.length === 0) return null;
210+
211+
return {
212+
"@type": "FAQPage",
213+
mainEntity: faqs.map((faq) => ({
214+
"@type": "Question",
215+
name: faq.question,
216+
acceptedAnswer: {
217+
"@type": "Answer",
218+
text: faq.answer,
219+
},
220+
})),
221+
};
222+
}
223+
163224
interface PodcastEpisodeInput {
164225
title?: string | null;
165226
excerpt?: string | null;

‎apps/site/src/pages/podcast/[slug].astro‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
articleSchema,
1919
breadcrumbSchema,
2020
podcastEpisodeSchema,
21+
videoObjectSchema,
2122
} from "@/lib/structured-data";
2223
2324
const { sanity } = Astro.locals;
@@ -40,6 +41,20 @@ const ogImage = sanity.resolveOpenGraphImage(podcast.coverImage ?? undefined);
4041
const authors = podcast.author ?? [];
4142
const guests = podcast.guest ?? [];
4243
const audioUrl = podcast.spotify?.enclosures?.at(0)?.url;
44+
const videoSchema = podcast.youtube
45+
? videoObjectSchema(
46+
origin,
47+
{
48+
title: podcast.title,
49+
description: podcast.excerpt,
50+
uploadDate: podcast.date,
51+
thumbnailUrl: ogImage?.url,
52+
youtubeUrl: podcast.youtube,
53+
durationSeconds: podcast.transcript?.durationSeconds,
54+
},
55+
path,
56+
)
57+
: null;
4358
---
4459

4560
<BaseLayout
@@ -87,6 +102,7 @@ const audioUrl = podcast.spotify?.enclosures?.at(0)?.url;
87102
{ name: "Podcasts", path: "/podcasts/page/1" },
88103
{ name: podcast.title, path },
89104
]),
105+
...(videoSchema ? [videoSchema] : []),
90106
]}
91107
>
92108
<Breadcrumbs

‎apps/site/src/pages/post/[slug].astro‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import TranscriptModal from "@/components/TranscriptModal.astro";
1111
import BaseLayout from "@/layouts/BaseLayout.astro";
1212
import type { ContentListItem } from "@/lib/content";
1313
import { morePostQuery, postQuery } from "@/lib/sanity/queries";
14-
import { articleSchema, breadcrumbSchema } from "@/lib/structured-data";
14+
import { articleSchema, breadcrumbSchema, videoObjectSchema } from "@/lib/structured-data";
1515
1616
const { sanity } = Astro.locals;
1717
const { slug } = Astro.params;
@@ -31,6 +31,20 @@ const path = `/post/${slug}`;
3131
const origin = Astro.locals.siteUrl.origin;
3232
const ogImage = sanity.resolveOpenGraphImage(post.coverImage ?? undefined);
3333
const authors = post.author ?? [];
34+
const videoSchema = post.youtube
35+
? videoObjectSchema(
36+
origin,
37+
{
38+
title: post.title,
39+
description: post.excerpt,
40+
uploadDate: post.date,
41+
thumbnailUrl: ogImage?.url,
42+
youtubeUrl: post.youtube,
43+
durationSeconds: post.transcript?.durationSeconds,
44+
},
45+
path,
46+
)
47+
: null;
3448
---
3549

3650
<BaseLayout
@@ -56,6 +70,7 @@ const authors = post.author ?? [];
5670
{ name: "Blog", path: "/blog/page/1" },
5771
{ name: post.title, path },
5872
]),
73+
...(videoSchema ? [videoSchema] : []),
5974
]}
6075
>
6176
<Breadcrumbs links={[{ title: "Blog", href: "/blog/page/1" }, { title: post.title }]} />
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
#!/usr/bin/env node
2+
/**
3+
* CodingCat.dev AI Video Storyboard & Script Generator
4+
*
5+
* Inspired by:
6+
* - Better Stack: High-touch, high-pain developer problem solving (costs, downtime, benchmarks).
7+
* - Cleo Abram: Kinetic visual metaphors, 3-beat narrative arc, fast-paced animations, 10s curiosity hook.
8+
*
9+
* Powered by Google Gemini 1.5 Pro (via Google AI / OpenRouter).
10+
*/
11+
12+
const topic = process.argv.slice(2).join(" ");
13+
14+
if (!topic) {
15+
console.log(`
16+
Usage: node scripts/generate-video-storyboard.mjs "<Topic, Repository, or Architecture Problem>"
17+
18+
Example:
19+
node scripts/generate-video-storyboard.mjs "Dockerless Serverless GPUs: Cutting AI inference cost by 90% with RunPod Flash"
20+
`);
21+
process.exit(1);
22+
}
23+
24+
const openRouterKey = process.env.OPENROUTER_API_KEY;
25+
26+
if (!openRouterKey) {
27+
console.error("Error: OPENROUTER_API_KEY environment variable is required.");
28+
process.exit(1);
29+
}
30+
31+
const SYSTEM_PROMPT = `You are the Lead Creative Director & Tech Scriptwriter for CodingCat.dev, producing top-tier developer educational videos.
32+
33+
Your mission is to blend two elite creator playbooks:
34+
1. THE BETTER STACK CONTENT STRATEGY:
35+
- Target genuine developer pain, extreme cost optimization, performance benchmarks, and production post-mortems.
36+
- Ground everything in real metrics, architecture trade-offs, and actionable code.
37+
2. THE CLEO ABRAM (Huge If True) VISUAL STYLE:
38+
- THE 10-SECOND HOOK: Start with a surprising contradiction, visual mystery, or bold curiosity question. Never start with "Hey guys, welcome back".
39+
- 3-BEAT NARRATIVE ARC:
40+
* Beat 1: The Frustrating Status Quo (Why the standard way is broken/expensive/slow).
41+
* Beat 2: The Secret Mechanism (Under the hood breakdown using visual metaphors).
42+
* Beat 3: The Proof & Benchmark ("Huge If True" - The live test, savings, or live demo).
43+
- DENSE VISUAL CHOREOGRAPHY: Never stay on talking head or a static screen for more than 4-5 seconds. Alternate with:
44+
* Kinetic isometric server/network diagrams
45+
* Code punch-ins (highlighting 2-3 key lines, not full files)
46+
* Side-by-side racing clocks or dollar meters
47+
* Tactile UI sound effects (foley pops, whooshes, mechanical keyboard clicks)
48+
49+
OUTPUT FORMAT:
50+
Generate a complete, ready-to-produce production document in Markdown with the following sections:
51+
52+
# [Video Title: 3 High-CTR Options]
53+
54+
## Video Metadata
55+
- **Target Audience:**
56+
- **Core Frustration Solved:**
57+
- **The "North Star" Metric:** (e.g. "$0.03 -> $0.0003 per image", "150ms -> 12ms TTFB")
58+
- **YouTube Description & Key Moments:**
59+
60+
## Act 1: The 10-Second Hook (0:00 - 0:30)
61+
Format as a 2-column table:
62+
| Timestamp | Visual Director & Animation Notes (Cleo Style) | Spoken Audio / Voiceover | Sound Design (SFX) |
63+
64+
## Act 2: The Hidden Mechanism & Architecture (0:30 - 3:30)
65+
Format as a 2-column table with precise animation prompts (e.g., Motion Canvas / Remotion code prompts, isometric SVG descriptions).
66+
67+
## Act 3: Live Benchmark & The Code (3:30 - 6:00)
68+
Format as a 2-column table showing code highlights and race/cost comparison.
69+
70+
## Act 4: The Takeaway & Call to Action (6:00 - 7:00)
71+
Direct punchy conclusion pointing to the canonical CodingCat.dev article and GitHub repo.
72+
73+
## Reusable Animation Prompts (For Motion Canvas / Remotion / After Effects)
74+
Provide 2-3 exact TypeScript or motion graphic prompt specifications that can be fed into Motion Canvas or Remotion to generate the key graphics.
75+
`;
76+
77+
async function run() {
78+
console.log(`\n🚀 Generating Cleo-style / Better Stack Storyboard for: "${topic}"\nUsing Gemini 1.5 Pro...\n`);
79+
80+
try {
81+
const response = await fetch("https://openrouter.ai/api/v1/chat/completions", {
82+
method: "POST",
83+
headers: {
84+
"Authorization": `Bearer ${openRouterKey}`,
85+
"Content-Type": "application/json",
86+
"HTTP-Referer": "https://codingcat.dev",
87+
"X-Title": "CodingCat Video Storyboard Generator",
88+
},
89+
body: JSON.stringify({
90+
model: "google/gemini-pro-1.5",
91+
messages: [
92+
{ role: "system", content: SYSTEM_PROMPT },
93+
{ role: "user", content: `Create a complete video storyboard and script for: ${topic}` },
94+
],
95+
temperature: 0.7,
96+
}),
97+
});
98+
99+
if (!response.ok) {
100+
const err = await response.text();
101+
throw new Error(`OpenRouter API error (${response.status}): ${err}`);
102+
}
103+
104+
const data = await response.json();
105+
const result = data.choices?.[0]?.message?.content;
106+
107+
console.log(result);
108+
} catch (err) {
109+
console.error("Generation failed:", err.message);
110+
process.exit(1);
111+
}
112+
}
113+
114+
run();

0 commit comments

Comments
 (0)