From 2a8608ffc10bdeb479f9dd9f2bce654ba87f8ef1 Mon Sep 17 00:00:00 2001 From: Alex Patterson Date: Sat, 10 Oct 2026 13:22:01 -0400 Subject: [PATCH] feat: complete Shorts story (/shorts shelf + kinetic captions + sync), Gemini 2.5/TTS upgrades, and TechArticle/Auto-FAQ AEO pack --- .github/workflows/sync-shorts.yml | 42 ++++ apps/sanity/extract.json | 128 ++++++++--- .../functions/sync-youtube-analytics/index.ts | 60 ++++- apps/sanity/schemas/documents/short.ts | 38 ++++ .../sanity/schemas/singletons/engineConfig.ts | 4 +- .../schemas/singletons/pipelineConfig.ts | 4 +- apps/site/src/components/Header.astro | 8 +- apps/site/src/components/ShortsShelf.astro | 214 ++++++++++++++++++ apps/site/src/lib/sanity/queries/index.ts | 1 + apps/site/src/lib/sanity/queries/pages.ts | 28 +++ apps/site/src/lib/sanity/queries/shorts.ts | 86 +++++++ apps/site/src/lib/structured-data.ts | 63 +++++- apps/site/src/pages/index.astro | 11 +- apps/site/src/pages/podcast/[slug].astro | 4 + apps/site/src/pages/post/[slug].astro | 10 +- apps/site/src/pages/shorts/index.astro | 56 +++++ apps/site/src/pages/sitemap.xml.ts | 6 + .../dashboard/config/config-form.tsx | 4 +- apps/web/lib/gemini.ts | 2 +- apps/web/lib/services/elevenlabs.ts | 167 ++++++++++++-- apps/web/lib/sponsor/gemini-intent.ts | 2 +- apps/web/lib/sponsor/gemini-outreach.ts | 2 +- .../remotion/components/InfographicScene.tsx | 11 + .../remotion/components/KineticCaptions.tsx | 157 +++++++++++++ apps/web/remotion/components/Scene.tsx | 15 +- apps/web/remotion/components/SceneRouter.tsx | 3 +- apps/web/remotion/types.ts | 1 + package.json | 5 +- scripts/generate-aeo-distribution-pack.mjs | 181 +++++++++++++++ scripts/generate-video-storyboard.mjs | 1 + scripts/sync-shorts-from-automated-videos.mjs | 130 +++++++++++ 31 files changed, 1378 insertions(+), 66 deletions(-) create mode 100644 .github/workflows/sync-shorts.yml create mode 100644 apps/site/src/components/ShortsShelf.astro create mode 100644 apps/site/src/lib/sanity/queries/shorts.ts create mode 100644 apps/site/src/pages/shorts/index.astro create mode 100644 apps/web/remotion/components/KineticCaptions.tsx create mode 100644 scripts/generate-aeo-distribution-pack.mjs create mode 100644 scripts/sync-shorts-from-automated-videos.mjs diff --git a/.github/workflows/sync-shorts.yml b/.github/workflows/sync-shorts.yml new file mode 100644 index 000000000..4b9581763 --- /dev/null +++ b/.github/workflows/sync-shorts.yml @@ -0,0 +1,42 @@ +name: Sync YouTube Shorts to Sanity + +on: + workflow_dispatch: + inputs: + dataset: + description: "Target Sanity Dataset" + required: true + default: "production" + type: choice + options: + - production + - dev + schedule: + - cron: "30 6 * * *" + +jobs: + sync-shorts: + runs-on: ubuntu-latest + environment: production + + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version-file: ".nvmrc" + cache: "pnpm" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Sync Shorts from Automated Videos + run: node scripts/sync-shorts-from-automated-videos.mjs + env: + SANITY_AUTH_TOKEN: ${{ secrets.SANITY_AUTH_TOKEN }} + SANITY_API_WRITE_TOKEN: ${{ secrets.SANITY_API_WRITE_TOKEN }} + SANITY_STUDIO_PROJECT_ID: ${{ vars.SANITY_STUDIO_PROJECT_ID || 'hfh83o0w' }} + SANITY_STUDIO_DATASET: ${{ inputs.dataset || 'production' }} + YOUTUBE_API_KEY: ${{ secrets.YOUTUBE_API_KEY }} diff --git a/apps/sanity/extract.json b/apps/sanity/extract.json index d3376aa8b..b18ae4394 100644 --- a/apps/sanity/extract.json +++ b/apps/sanity/extract.json @@ -702,6 +702,36 @@ } } }, + { + "type": "type", + "name": "automatedVideo.reference", + "value": { + "type": "object", + "attributes": { + "_ref": { + "type": "objectAttribute", + "value": { + "type": "string" + } + }, + "_type": { + "type": "objectAttribute", + "value": { + "type": "string", + "value": "reference" + } + }, + "_weak": { + "type": "objectAttribute", + "value": { + "type": "boolean" + }, + "optional": true + } + }, + "dereferencesTo": "automatedVideo" + } + }, { "type": "type", "name": "category.reference", @@ -782,6 +812,13 @@ }, "optional": true }, + "description": { + "type": "objectAttribute", + "value": { + "type": "string" + }, + "optional": true + }, "youtube": { "type": "objectAttribute", "value": { @@ -789,6 +826,13 @@ }, "optional": true }, + "shortVideoUrl": { + "type": "objectAttribute", + "value": { + "type": "string" + }, + "optional": true + }, "thumbnail": { "type": "objectAttribute", "value": { @@ -844,6 +888,14 @@ }, "optional": true }, + "sourceAutomatedVideo": { + "type": "objectAttribute", + "value": { + "type": "inline", + "name": "automatedVideo.reference" + }, + "optional": true + }, "publishedAt": { "type": "objectAttribute", "value": { @@ -858,6 +910,52 @@ }, "optional": true }, + "statistics": { + "type": "objectAttribute", + "value": { + "type": "object", + "attributes": { + "youtube": { + "type": "objectAttribute", + "value": { + "type": "object", + "attributes": { + "viewCount": { + "type": "objectAttribute", + "value": { + "type": "number" + }, + "optional": true + }, + "likeCount": { + "type": "objectAttribute", + "value": { + "type": "number" + }, + "optional": true + }, + "commentCount": { + "type": "objectAttribute", + "value": { + "type": "number" + }, + "optional": true + }, + "favoriteCount": { + "type": "objectAttribute", + "value": { + "type": "number" + }, + "optional": true + } + } + }, + "optional": true + } + } + }, + "optional": true + }, "categories": { "type": "objectAttribute", "value": { @@ -1183,36 +1281,6 @@ } } }, - { - "type": "type", - "name": "automatedVideo.reference", - "value": { - "type": "object", - "attributes": { - "_ref": { - "type": "objectAttribute", - "value": { - "type": "string" - } - }, - "_type": { - "type": "objectAttribute", - "value": { - "type": "string", - "value": "reference" - } - }, - "_weak": { - "type": "objectAttribute", - "value": { - "type": "boolean" - }, - "optional": true - } - }, - "dereferencesTo": "automatedVideo" - } - }, { "name": "videoAnalytics", "type": "document", diff --git a/apps/sanity/functions/sync-youtube-analytics/index.ts b/apps/sanity/functions/sync-youtube-analytics/index.ts index 04d73730b..897580c7e 100644 --- a/apps/sanity/functions/sync-youtube-analytics/index.ts +++ b/apps/sanity/functions/sync-youtube-analytics/index.ts @@ -70,13 +70,69 @@ export const handler = scheduledEventHandler(async ({ context }) => { useCdn: false, }); - // Query all posts and podcasts with YouTube URLs + // Step 0: Sync any published automatedVideo Shorts into standalone `_type == "short"` documents + const autoShortDocs = await client.fetch< + Array<{ + _id: string; + _createdAt: string; + title: string; + slug?: { current?: string }; + youtubeShortId: string; + shortUrl?: string; + hook?: string; + }> + >( + `*[_type == "automatedVideo" && defined(youtubeShortId) && !(_id in path("drafts.**"))]{ + _id, + _createdAt, + title, + slug, + youtubeShortId, + shortUrl, + "hook": script.hook + }`, + ); + + for (const autoShort of autoShortDocs) { + const shortId = `short-${autoShort.youtubeShortId}`; + const slugCurrent = + autoShort.slug?.current || + autoShort.title + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/(^-|-$)/g, "") + .slice(0, 90); + await client + .createIfNotExists({ + _id: shortId, + _type: "short", + title: autoShort.title, + slug: { _type: "slug", current: slugCurrent }, + description: autoShort.hook || undefined, + youtube: `https://www.youtube.com/shorts/${autoShort.youtubeShortId}`, + shortVideoUrl: autoShort.shortUrl || undefined, + sourceAutomatedVideo: { + _type: "reference", + _ref: autoShort._id, + }, + publishedAt: autoShort._createdAt, + duration: 60, + }) + .catch((err) => { + console.warn( + `[Sync YouTube Analytics] Could not createIfNotExists short ${shortId}:`, + err, + ); + }); + } + + // Query all posts, podcasts, and shorts with YouTube URLs console.log( `[Sync YouTube Analytics] Fetching documents with YouTube URLs in project ${projectId}, dataset ${dataset}...`, ); const docs = await client.fetch( - `*[_type in ["post", "podcast"] && (defined(youtube) || defined(listenLinks.youtube)) && !(_id in path("drafts.**"))]{ + `*[_type in ["post", "podcast", "short"] && (defined(youtube) || defined(listenLinks.youtube)) && !(_id in path("drafts.**"))]{ _id, _type, title, diff --git a/apps/sanity/schemas/documents/short.ts b/apps/sanity/schemas/documents/short.ts index e7a294842..ee25af8b2 100644 --- a/apps/sanity/schemas/documents/short.ts +++ b/apps/sanity/schemas/documents/short.ts @@ -27,12 +27,25 @@ export default defineType({ }, validation: (rule) => rule.required(), }), + defineField({ + name: "description", + title: "Description / Hook", + type: "text", + rows: 3, + description: "Short hook or summary for SEO/AEO and card previews", + }), defineField({ name: "youtube", title: "YouTube", type: "string", description: "YouTube Shorts video ID or URL", }), + defineField({ + name: "shortVideoUrl", + title: "Direct MP4 Video URL", + type: "url", + description: "Direct Sanity CDN or R2 MP4 URL for native vertical video playback", + }), defineField({ name: "thumbnail", title: "Thumbnail", @@ -49,6 +62,13 @@ export default defineType({ to: [{ type: "podcast" }], description: "Long-form episode this Short was clipped from", }), + defineField({ + name: "sourceAutomatedVideo", + title: "Source Automated Video", + type: "reference", + to: [{ type: "automatedVideo" }], + description: "Automated video pipeline run that produced this Short", + }), defineField({ name: "publishedAt", title: "Published At", @@ -60,6 +80,24 @@ export default defineType({ type: "number", description: "Duration in seconds", }), + defineField({ + name: "statistics", + title: "Statistics", + type: "object", + fields: [ + defineField({ + name: "youtube", + title: "YouTube Statistics", + type: "object", + fields: [ + defineField({ name: "viewCount", title: "Views", type: "number" }), + defineField({ name: "likeCount", title: "Likes", type: "number" }), + defineField({ name: "commentCount", title: "Comments", type: "number" }), + defineField({ name: "favoriteCount", title: "Favorites", type: "number" }), + ], + }), + ], + }), defineField({ name: "categories", title: "Categories", diff --git a/apps/sanity/schemas/singletons/engineConfig.ts b/apps/sanity/schemas/singletons/engineConfig.ts index 9c570a9f3..ad1f2c9aa 100644 --- a/apps/sanity/schemas/singletons/engineConfig.ts +++ b/apps/sanity/schemas/singletons/engineConfig.ts @@ -327,8 +327,8 @@ export default defineType({ type: 'string', fieldset: 'aiGeneration', description: - 'The Google Gemini model used for script generation and content analysis', - initialValue: 'gemini-2.0-flash', + 'The Google Gemini model used for script generation and content analysis (e.g., gemini-2.5-flash, gemini-2.5-pro)', + initialValue: 'gemini-2.5-flash', }), defineField({ name: 'infographicModel', diff --git a/apps/sanity/schemas/singletons/pipelineConfig.ts b/apps/sanity/schemas/singletons/pipelineConfig.ts index 2d0ab77f8..cea163aec 100644 --- a/apps/sanity/schemas/singletons/pipelineConfig.ts +++ b/apps/sanity/schemas/singletons/pipelineConfig.ts @@ -10,8 +10,8 @@ export default defineType({ name: "geminiModel", title: "Gemini Model", type: "string", - description: "The Google Gemini model used for script generation and content analysis (e.g., gemini-2.0-flash, gemini-2.5-pro)", - initialValue: "gemini-2.0-flash", + description: "The Google Gemini model used for script generation and content analysis (e.g., gemini-2.5-flash, gemini-2.5-pro)", + initialValue: "gemini-2.5-flash", }), defineField({ name: "elevenLabsVoiceId", diff --git a/apps/site/src/components/Header.astro b/apps/site/src/components/Header.astro index 008031a3c..7adc07387 100644 --- a/apps/site/src/components/Header.astro +++ b/apps/site/src/components/Header.astro @@ -5,7 +5,13 @@ import ThemeToggle from "./ThemeToggle.astro"; import SearchModal from "./SearchModal.astro"; const settings = await Astro.locals.sanity.loadQuery(settingsQuery); -const navLinks = settings?.navLinks ?? []; +const rawNavLinks = settings?.navLinks ?? []; +const navLinks = rawNavLinks.some((link) => link?.path === "/shorts") + ? rawNavLinks + : [ + ...rawNavLinks, + { _key: "shorts-nav", path: "/shorts", title: "Shorts", sideOnly: false }, + ]; // The Next header split these across a desktop