Skip to content

Commit 3611f4d

Browse files
Miriadvideopipe
andcommitted
feat: migrate video services to Sanity config system (Phase B)
- remotion.ts: awsRegion, serveUrl, functionName from getConfigValue('remotion_config') - elevenlabs.ts: voiceId from getConfigValue('pipeline_config', 'elevenLabsVoiceId') - gcs.ts: bucketName, projectId from getConfigValue('gcs_config') - All use env var fallbacks for migration safety - Secrets (API keys, AWS credentials) remain as process.env Co-authored-by: videopipe <videopipe@miriad.systems>
1 parent 4df823b commit 3611f4d

10 files changed

Lines changed: 104 additions & 61 deletions

File tree

‎app/api/cron/check-renders/route.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
// Config migration: audited — no tweakable config in this route.
2+
// Remotion/ElevenLabs config is in the service layer (owned by @videopipe).
3+
// YouTube SEO prompt is specific to this route, not the shared system instruction.
14
export const fetchCache = 'force-no-store';
25
export const maxDuration = 60;
36

‎app/api/cron/check-research/route.ts‎

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { NotebookLMClient } from '@/lib/services/notebooklm/client';
88
import { initAuth } from '@/lib/services/notebooklm/auth';
99
import { ArtifactTypeCode, ArtifactStatus } from '@/lib/services/notebooklm/types';
1010
import { generateWithGemini, stripCodeFences } from '@/lib/gemini';
11+
import { getConfigValue } from '@/lib/config';
1112
import type { ResearchPayload } from '@/lib/services/research';
1213

1314
// ---------------------------------------------------------------------------
@@ -94,12 +95,15 @@ interface StepResult {
9495
// Constants
9596
// ---------------------------------------------------------------------------
9697

97-
/** Stuck thresholds per status (ms) */
98-
const STUCK_THRESHOLDS: Record<string, number> = {
99-
researching: 30 * 60 * 1000, // 30 minutes
100-
infographics_generating: 15 * 60 * 1000, // 15 minutes
101-
enriching: 10 * 60 * 1000, // 10 minutes
102-
};
98+
/** Build stuck thresholds from config (with fallbacks) */
99+
async function buildStuckThresholds(): Promise<Record<string, number>> {
100+
const stuckMinutes = await getConfigValue('pipeline_config', 'stuckTimeoutMinutes', 30);
101+
return {
102+
researching: stuckMinutes * 60 * 1000,
103+
infographics_generating: Math.round(stuckMinutes * 0.5) * 60 * 1000, // half the main timeout
104+
enriching: Math.round(stuckMinutes * 0.33) * 60 * 1000, // third of main timeout
105+
};
106+
}
103107

104108
/** Max docs to process per status per run — keeps total time well under 60s */
105109
const MAX_DOCS_PER_STATUS = 2;
@@ -132,12 +136,13 @@ function getSanityWriteClient(): SanityClient {
132136
async function flagStuckDocs(
133137
docs: PipelineDoc[],
134138
sanity: SanityClient,
139+
stuckThresholds: Record<string, number>,
135140
): Promise<StepResult[]> {
136141
const results: StepResult[] = [];
137142
const now = Date.now();
138143

139144
for (const doc of docs) {
140-
const threshold = STUCK_THRESHOLDS[doc.status];
145+
const threshold = stuckThresholds[doc.status];
141146
if (!threshold) continue;
142147

143148
const docAge = now - new Date(doc._updatedAt).getTime();
@@ -419,6 +424,11 @@ async function stepEnriching(
419424
// Generate enriched script with Gemini
420425
let enrichedScript: EnrichedScript | null = null;
421426
try {
427+
const SYSTEM_INSTRUCTION = await getConfigValue(
428+
'content_config',
429+
'systemInstruction',
430+
SYSTEM_INSTRUCTION_FALLBACK,
431+
);
422432
const prompt = buildEnrichmentPrompt(doc, researchPayload);
423433
const rawResponse = await generateWithGemini(prompt, SYSTEM_INSTRUCTION);
424434
const cleaned = stripCodeFences(rawResponse);
@@ -434,7 +444,8 @@ async function stepEnriching(
434444
const criticScore = criticResult.score;
435445
console.log(`[check-research] Critic score: ${criticScore}/100 — ${criticResult.summary}`);
436446

437-
const isFlagged = criticScore < 50;
447+
const qualityThreshold = await getConfigValue('pipeline_config', 'qualityThreshold', 50);
448+
const isFlagged = criticScore < qualityThreshold;
438449

439450
await sanity
440451
.patch(doc._id)
@@ -481,7 +492,9 @@ async function stepEnriching(
481492
// Gemini Script Enrichment
482493
// ---------------------------------------------------------------------------
483494

484-
const SYSTEM_INSTRUCTION = `You are a content strategist and scriptwriter for CodingCat.dev, a web development education channel run by Alex Patterson.
495+
// SYSTEM_INSTRUCTION is now fetched from content_config singleton via getConfigValue().
496+
// Fallback value preserved below for graceful degradation if the Sanity document doesn't exist yet.
497+
const SYSTEM_INSTRUCTION_FALLBACK = `You are a content strategist and scriptwriter for CodingCat.dev, a web development education channel run by Alex Patterson.
485498
486499
Your style is inspired by Cleo Abram's "Huge If True" — you make complex technical topics feel exciting, accessible, and important. Key principles:
487500
- Start with a BOLD claim or surprising fact that makes people stop scrolling
@@ -813,7 +826,8 @@ export async function GET(request: NextRequest) {
813826
const results: StepResult[] = [];
814827

815828
// Phase 1: Stuck detection — runs FIRST, no external API calls
816-
const stuckResults = await flagStuckDocs(docs, sanity);
829+
const stuckThresholds = await buildStuckThresholds();
830+
const stuckResults = await flagStuckDocs(docs, sanity, stuckThresholds);
817831
results.push(...stuckResults);
818832

819833
// Remove flagged docs from further processing

‎app/api/cron/ingest/route.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -330,10 +330,11 @@ async function createSanityDocuments(
330330
script: GeneratedScript,
331331
criticResult: CriticResult,
332332
trends: TrendResult[],
333+
qualityThreshold: number,
333334
research?: ResearchPayload,
334335
researchMeta?: { notebookId: string; taskId: string },
335336
) {
336-
const isFlagged = criticResult.score < 50;
337+
const isFlagged = criticResult.score < qualityThreshold;
337338
// When research is in-flight, status is "researching" (check-research cron will transition to script_ready)
338339
const isResearching = !!researchMeta?.notebookId;
339340
const status = isFlagged ? "flagged" : isResearching ? "researching" : "script_ready";
@@ -494,7 +495,7 @@ export async function GET(request: NextRequest) {
494495
);
495496

496497
console.log("[CRON/ingest] Creating Sanity documents...");
497-
const result = await createSanityDocuments(script, criticResult, trends, undefined, researchMeta);
498+
const result = await createSanityDocuments(script, criticResult, trends, qualityThreshold, undefined, researchMeta);
498499

499500
console.log("[CRON/ingest] Done!", result);
500501

‎app/api/cron/sponsor-outreach/route.ts‎

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,9 @@ import { NextResponse } from 'next/server'
44
import { sanityWriteClient } from '@/lib/sanity-write-client'
55
import { generateOutreachEmail } from '@/lib/sponsor/gemini-outreach'
66
import { sendSponsorEmail } from '@/lib/sponsor/email-service'
7+
import { getConfig } from '@/lib/config'
78
import type { SponsorPoolEntry } from '@/lib/sponsor/gemini-outreach'
89

9-
const MAX_PER_RUN = 5
10-
const COOLDOWN_DAYS = 14
11-
1210
export async function POST(request: Request) {
1311
// Auth: Bearer token check against CRON_SECRET
1412
const cronSecret = process.env.CRON_SECRET;
@@ -25,9 +23,24 @@ export async function POST(request: Request) {
2523
try {
2624
console.log('[SPONSOR] Starting outbound sponsor outreach cron...')
2725

26+
// Fetch sponsor config from Sanity singleton
27+
const sponsorCfg = await getConfig("sponsor_config");
28+
const maxPerRun = sponsorCfg.maxOutreachPerRun;
29+
const cooldownDays = sponsorCfg.cooldownDays;
30+
31+
// Build rate card string from config tiers
32+
const rateCard = [
33+
'CodingCat.dev Sponsorship Tiers:',
34+
...sponsorCfg.rateCardTiers.map(
35+
(t) => `- ${t.name} ($${t.price.toLocaleString()}) — ${t.description}`
36+
),
37+
'',
38+
'Our audience: 50K+ developers interested in web development, JavaScript/TypeScript, React, Next.js, and modern dev tools.',
39+
].join('\n');
40+
2841
// Calculate the cutoff date for cooldown
2942
const cutoffDate = new Date()
30-
cutoffDate.setDate(cutoffDate.getDate() - COOLDOWN_DAYS)
43+
cutoffDate.setDate(cutoffDate.getDate() - cooldownDays)
3144
const cutoffISO = cutoffDate.toISOString()
3245

3346
// Query Sanity for eligible sponsor pool entries
@@ -38,7 +51,7 @@ export async function POST(request: Request) {
3851
!defined(lastContactedAt)
3952
|| lastContactedAt < $cutoffDate
4053
)
41-
] | order(relevanceScore desc) [0...${MAX_PER_RUN - 1}] {
54+
] | order(relevanceScore desc) [0...${maxPerRun - 1}] {
4255
_id,
4356
companyName,
4457
contactName,
@@ -68,7 +81,7 @@ export async function POST(request: Request) {
6881
for (const sponsor of sponsors) {
6982
try {
7083
// Generate personalized outreach email
71-
const email = await generateOutreachEmail(sponsor)
84+
const email = await generateOutreachEmail(sponsor, rateCard)
7285

7386
// Send the email (stubbed)
7487
const sendResult = await sendSponsorEmail(

‎app/api/webhooks/sanity-distribute/route.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { generateWithGemini } from "@/lib/gemini";
55
import { uploadVideo, uploadShort, generateShortsMetadata } from "@/lib/youtube-upload";
66
import { notifySubscribers } from "@/lib/resend-notify";
77
import { postVideoAnnouncement } from "@/lib/x-social";
8+
import { getConfig } from "@/lib/config";
89

910
const WEBHOOK_SECRET = process.env.SANITY_WEBHOOK_SECRET;
1011

@@ -140,6 +141,9 @@ async function appendDistributionLog(docId: string, entries: DistributionLogEntr
140141
async function runDistribution(docId: string, doc: AutomatedVideoDoc): Promise<void> {
141142
const log: DistributionLogEntry[] = [];
142143

144+
// Fetch distribution config from Sanity singleton
145+
const distConfig = await getConfig("distribution_config");
146+
143147
try {
144148
await updateStatus(docId, "uploading");
145149

@@ -206,6 +210,8 @@ async function runDistribution(docId: string, doc: AutomatedVideoDoc): Promise<v
206210
videoTitle: metadata.title,
207211
videoUrl: ytUrl,
208212
description: metadata.description.slice(0, 280),
213+
fromEmail: distConfig.resendFromEmail,
214+
notificationEmails: distConfig.notificationEmails,
209215
});
210216
log.push(logEntry("email", "success"));
211217
} catch (e) {

‎lib/resend-notify.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ export async function notifySubscribers(opts: {
77
videoTitle: string;
88
videoUrl: string;
99
description: string;
10+
fromEmail?: string;
11+
notificationEmails?: string[];
1012
}): Promise<{ sent: boolean; error?: string }> {
1113
const apiKey = process.env.RESEND_API_KEY;
1214
if (!apiKey) {
@@ -19,8 +21,8 @@ export async function notifySubscribers(opts: {
1921
const resend = new Resend(apiKey);
2022

2123
await resend.emails.send({
22-
from: "CodingCat.dev <noreply@codingcat.dev>",
23-
to: ["subscribers@codingcat.dev"], // TODO: fetch subscriber list
24+
from: `CodingCat.dev <${opts.fromEmail || "noreply@codingcat.dev"}>`,
25+
to: opts.notificationEmails || ["subscribers@codingcat.dev"],
2426
subject: opts.subject,
2527
html: `
2628
<h1>${opts.videoTitle}</h1>

‎lib/services/elevenlabs.ts‎

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
type WordTimestamp,
1313
type SceneAudioResult,
1414
} from "@/lib/utils/audio-timestamps";
15+
import { getConfigValue } from "@/lib/config";
1516

1617
const ELEVENLABS_API_BASE = "https://api.elevenlabs.io/v1";
1718

@@ -57,14 +58,14 @@ interface TTSWithTimestampsResponse {
5758
}
5859

5960
/**
60-
* Reads the ElevenLabs configuration from environment variables.
61+
* Reads the ElevenLabs configuration from Sanity config + environment variables.
62+
* API key (secret) stays as process.env. Voice ID comes from Sanity config with env fallback.
6163
*
6264
* @returns The resolved {@link ElevenLabsConfig}.
63-
* @throws {Error} If required environment variables are missing.
65+
* @throws {Error} If the API key is missing.
6466
*/
65-
function getConfig(): ElevenLabsConfig {
67+
async function getElevenLabsConfig(): Promise<ElevenLabsConfig> {
6668
const apiKey = process.env.ELEVENLABS_API_KEY;
67-
const voiceId = process.env.ELEVENLABS_VOICE_ID;
6869

6970
if (!apiKey) {
7071
throw new Error(
@@ -73,12 +74,10 @@ function getConfig(): ElevenLabsConfig {
7374
);
7475
}
7576

76-
if (!voiceId) {
77-
throw new Error(
78-
"Missing ELEVENLABS_VOICE_ID environment variable. " +
79-
"Set it in your .env.local or deployment environment."
80-
);
81-
}
77+
const voiceId = await getConfigValue(
78+
"pipeline_config", "elevenLabsVoiceId",
79+
process.env.ELEVENLABS_VOICE_ID || "pNInz6obpgDQGcFmaJgB"
80+
);
8281

8382
return { apiKey, voiceId };
8483
}
@@ -106,7 +105,7 @@ export async function generateSpeech(text: string): Promise<Buffer> {
106105
throw new Error("Cannot generate speech from empty text.");
107106
}
108107

109-
const { apiKey, voiceId } = getConfig();
108+
const { apiKey, voiceId } = await getElevenLabsConfig();
110109

111110
const url = `${ELEVENLABS_API_BASE}/text-to-speech/${voiceId}`;
112111

@@ -244,7 +243,7 @@ export async function generateSpeechWithTimestamps(
244243
throw new Error("Cannot generate speech from empty text.");
245244
}
246245

247-
const { apiKey, voiceId } = getConfig();
246+
const { apiKey, voiceId } = await getElevenLabsConfig();
248247

249248
const url = `${ELEVENLABS_API_BASE}/text-to-speech/${voiceId}/with-timestamps`;
250249

‎lib/services/gcs.ts‎

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
*/
1515

1616
import * as crypto from "crypto";
17+
import { getConfigValue } from "@/lib/config";
1718

1819
// ---------------------------------------------------------------------------
1920
// Types
@@ -63,21 +64,21 @@ const TOKEN_REFRESH_MARGIN_MS = 5 * 60 * 1000; // 5 minutes
6364
* The private key may contain literal `\\n` sequences from the env var;
6465
* these are converted to real newline characters.
6566
*/
66-
export function getGCSConfig(): GCSConfig {
67-
const bucket = process.env.GCS_BUCKET;
68-
const projectId = process.env.GCS_PROJECT_ID;
67+
export async function getGCSConfig(): Promise<GCSConfig> {
68+
const bucket = await getConfigValue("gcs_config", "bucketName", process.env.GCS_BUCKET);
69+
const projectId = await getConfigValue("gcs_config", "projectId", process.env.GCS_PROJECT_ID);
6970
const clientEmail = process.env.GCS_CLIENT_EMAIL;
7071
let privateKey = process.env.GCS_PRIVATE_KEY;
7172

7273
if (!bucket || !projectId || !clientEmail || !privateKey) {
7374
const missing = [
74-
!bucket && "GCS_BUCKET",
75-
!projectId && "GCS_PROJECT_ID",
75+
!bucket && "GCS_BUCKET / gcs_config.bucketName",
76+
!projectId && "GCS_PROJECT_ID / gcs_config.projectId",
7677
!clientEmail && "GCS_CLIENT_EMAIL",
7778
!privateKey && "GCS_PRIVATE_KEY",
7879
].filter(Boolean);
7980
throw new Error(
80-
`[GCS] Missing required environment variables: ${missing.join(", ")}`
81+
`[GCS] Missing required configuration: ${missing.join(", ")}`
8182
);
8283
}
8384

@@ -205,7 +206,7 @@ async function getAccessToken(): Promise<string> {
205206
return cachedToken.accessToken;
206207
}
207208

208-
const config = getGCSConfig();
209+
const config = await getGCSConfig();
209210
const jwt = createServiceAccountJWT(config);
210211
cachedToken = await exchangeJWTForToken(jwt);
211212
return cachedToken.accessToken;
@@ -231,7 +232,7 @@ export async function uploadToGCS(
231232
path: string,
232233
contentType: string
233234
): Promise<UploadResult> {
234-
const config = getGCSConfig();
235+
const config = await getGCSConfig();
235236
const token = await getAccessToken();
236237

237238
const encodedPath = encodeURIComponent(path);
@@ -338,7 +339,7 @@ export async function getSignedUrl(
338339
expiresInMinutes = 60
339340
): Promise<string> {
340341
// We still validate config to fail fast if env vars are missing
341-
const config = getGCSConfig();
342+
const config = await getGCSConfig();
342343

343344
// For public objects, the public URL is sufficient
344345
void expiresInMinutes; // acknowledged but unused for public objects

0 commit comments

Comments
 (0)