import { loadStoryConfig } from "./config"; import { validatePublicDomain } from "./validator"; import { sanitizeText } from "./sanitizer"; import { chunkText } from "./chunker"; import { generateAudio, generateSingleAudio } from "./tts"; import { combineAudio, getChunkDuration } from "./audio"; import { generateImage } from "./images"; import { createVideo } from "./video"; import { createSrt } from "./subtitles"; import { generateYouTubeMetadata, uploadToYouTube, YouTubeMetadata } from "./uploader"; import * as path from "path"; import * as fs from "fs"; export interface RunPipelineOptions { force?: boolean; skipUpload?: boolean; concurrency?: number; baseDir?: string; } export interface RunPipelineResult { storyName: string; audioFiles: string[]; imageFiles: string[]; srtPath: string; finalAudioPath: string; videoPath: string; metadata: YouTubeMetadata; } async function mapWithConcurrency( items: T[], limit: number, mapper: (item: T, index: number) => Promise ): Promise { if (items.length === 0) return; let nextIndex = 0; const inFlight: Promise[] = []; const launchNext = () => { if (nextIndex >= items.length) return; const current = nextIndex++; const p = mapper(items[current], current).finally(() => { const idx = inFlight.indexOf(p); if (idx >= 0) inFlight.splice(idx, 1); }); inFlight.push(p); }; for (let i = 0; i < Math.min(limit, items.length); i++) { launchNext(); } while (inFlight.length > 0 || nextIndex < items.length) { while (inFlight.length < limit && nextIndex < items.length) { launchNext(); } await Promise.race(inFlight); } } export async function runStoryPipeline( storyName: string, options: RunPipelineOptions = {} ): Promise { const force = !!options.force; const skipUpload = !!options.skipUpload; const concurrency = Math.max(1, options.concurrency ?? 3); const originalCwd = process.cwd(); const targetCwd = options.baseDir || originalCwd; if (targetCwd && targetCwd !== originalCwd) { process.chdir(targetCwd); } try { const storyConfig = loadStoryConfig(storyName); const validationResult = validatePublicDomain(storyConfig); if (!validationResult.is_public_domain) { throw new Error(validationResult.message); } const storyRoot = path.resolve("stories", storyName); ["audio", "images", "final_audio", "video"].forEach((d) => fs.mkdirSync(path.join(storyRoot, d), { recursive: true }) ); const sanitizedText = sanitizeText(storyName); const chunks = chunkText(sanitizedText, storyConfig.config.chunk_size); const introFile = path.join("stories", storyName, storyConfig.config.intro_audio_file); const outroFile = path.join("stories", storyName, storyConfig.config.outro_audio_file); if (!fs.existsSync(introFile) || force) { await generateSingleAudio(storyConfig, "This is the intro.", introFile); } if (!fs.existsSync(outroFile) || force) { await generateSingleAudio(storyConfig, "This is the outro.", outroFile); } const audioFiles: string[] = new Array(chunks.length); const chunkDurations: number[] = new Array(chunks.length); await mapWithConcurrency(chunks, concurrency, async (chunk, i) => { const audioPath = path.join("stories", storyName, "audio", `chunk_${i}.mp3`); if (!fs.existsSync(audioPath) || force) { await generateAudio(storyConfig, storyName, chunk, i); } const duration = await getChunkDuration(audioPath); audioFiles[i] = audioPath; chunkDurations[i] = duration; }); const imageFiles: string[] = new Array(chunks.length); await mapWithConcurrency(chunks, concurrency, async (chunk, i) => { const imagePath = path.join("stories", storyName, "images", `chunk_${i}_img0.png`); if (!fs.existsSync(imagePath) || force) { const generated = await generateImage(storyName, storyConfig, chunk, i, 0); imageFiles[i] = generated; } else { imageFiles[i] = imagePath; } }); const srtPath = createSrt(storyName, chunks, chunkDurations); await combineAudio(storyName, storyConfig, audioFiles); const finalAudioPath = path.resolve("stories", storyName, "final_audio", "final.mp3"); await createVideo(storyName, storyConfig, imageFiles, chunkDurations, srtPath); const videoPath = path.resolve("stories", storyName, "video", "final.mp4"); const metadata = generateYouTubeMetadata(storyConfig); if (!skipUpload) { await uploadToYouTube(videoPath, metadata); } return { storyName, audioFiles, imageFiles, srtPath, finalAudioPath, videoPath, metadata, }; } finally { if (targetCwd !== originalCwd) { process.chdir(originalCwd); } } }