import { parseScript, validateScript } from './lib/script-parser'; import { fetchVisualsForScene, downloadMedia, getVideoMetadata, invalidateCachedVisual } from './lib/visual-fetcher'; import { generateVoiceovers, DEFAULT_VOICE_CONFIG } from './lib/voice-generator'; import * as fs from 'fs'; import * as path from 'path'; import { logError, logInfo, resolveProjectPath } from './runtime'; import { createPipelineWorkspace, ensurePipelineWorkspace, toPublicRelativePath } from './pipeline-workspace'; const console = { log: (...args: unknown[]) => logInfo(...args), error: (...args: unknown[]) => logError(...args), }; interface GenerationResult { success: boolean; outputPath?: string; error?: string; metadata?: { scenes: number; duration: number; visualsFound: number; }; } interface GenerationOptions { /** Callback for progress updates */ onProgress?: (step: string, percent: number, message: string) => void; /** Output video orientation */ orientation?: 'portrait' | 'landscape'; /** Voice for TTS */ voice?: string; /** Video Title */ title?: string; /** Show Text */ showText?: boolean; /** Default Video Fallback */ defaultVideo?: string; /** Stable public/output identifier */ publicId?: string; } // console.log('\nšŸŽ¬ [VIDEO-GEN] Module loaded'); // console.log(`šŸŽ¬ [VIDEO-GEN] Working directory: ${process.cwd()}`); /** * Main video generation orchestrator */ export async function generateVideo( script: string, outputDir: string = resolveProjectPath('output'), options: GenerationOptions = {} ): Promise { const { onProgress, orientation = 'portrait', voice, title, showText = true, defaultVideo = 'default.mp4', publicId } = options; const totalStartTime = Date.now(); const workspace = createPipelineWorkspace(outputDir, publicId); // console.log('\n'); // console.log('╔════════════════════════════════════════════════════════════════╗'); // console.log('ā•‘ šŸŽ¬ VIDEO GENERATION PIPELINE STARTED ā•‘'); // console.log('ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•'); // console.log(`\nšŸŽ¬ [VIDEO-GEN] Start time: ${new Date().toISOString()}`); // console.log(`šŸŽ¬ [VIDEO-GEN] Output directory: ${outputDir}`); // console.log(`šŸŽ¬ [VIDEO-GEN] Script length: ${script.length} characters`); // console.log(`šŸŽ¬ [VIDEO-GEN] Orientation: ${orientation}\n`); try { onProgress?.('init', 0, 'Starting video generation'); // ══════════════════════════════════════════════════════════════════ // STEP 1: VALIDATE SCRIPT // ══════════════════════════════════════════════════════════════════ // console.log('\n╔══════════════════════════════════════════╗'); // console.log('ā•‘ STEP 1: VALIDATING SCRIPT ā•‘'); // console.log('ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•'); const step1Start = Date.now(); onProgress?.('validate', 5, 'Validating script'); validateScript(script); const step1Time = Date.now() - step1Start; // console.log(`āœ… [STEP 1] Script validated in ${step1Time}ms\n`); // ══════════════════════════════════════════════════════════════════ // STEP 2: PARSE SCRIPT // ══════════════════════════════════════════════════════════════════ // console.log('\n╔══════════════════════════════════════════╗'); // console.log('ā•‘ STEP 2: PARSING SCRIPT ā•‘'); // console.log('ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•'); const step2Start = Date.now(); onProgress?.('parse', 10, 'Parsing script into scenes'); const parsed = await parseScript(script); const step2Time = Date.now() - step2Start; // console.log(`āœ… [STEP 2] Created ${parsed.scenes.length} scenes in ${step2Time}ms`); // console.log(`āœ… [STEP 2] Total duration: ${parsed.totalDuration}s`); // console.log(`āœ… [STEP 2] Video style: ${parsed.videoStyle}\n`); // Log all scenes summary // console.log('šŸ“‹ [STEP 2] Scene Summary:'); parsed.scenes.forEach((scene, idx) => { // console.log(` Scene ${idx + 1}: ${scene.duration}s - "${scene.voiceoverText.substring(0, 40)}..."`); }); // console.log(''); // ══════════════════════════════════════════════════════════════════ // STEP 3: FETCH VISUALS // ══════════════════════════════════════════════════════════════════ // console.log('\n╔══════════════════════════════════════════╗'); // console.log('ā•‘ STEP 3: FETCHING STOCK VIDEOS ā•‘'); // console.log('ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•'); const step3Start = Date.now(); const videoDir = workspace.videosDir; const visualsDir = workspace.visualsDir; const visuals: (import('./lib/visual-fetcher').MediaAsset | null)[] = []; ensurePipelineWorkspace(workspace); // console.log(`šŸŽ¬ [STEP 3] Video output directory: ${videoDir}`); // console.log(`šŸŽ¬ [STEP 3] Visuals directory: ${visualsDir}`); // console.log(`šŸŽ¬ [STEP 3] Processing ${parsed.scenes.length} scenes...\n`); // Helper for concurrent processing with limit const CONCURRENCY = 5; let activePromises: Promise[] = []; const processScene = async (i: number) => { const scene = parsed.scenes[i]; const progressPercent = 15 + Math.floor((i / parsed.scenes.length) * 35); onProgress?.('visuals', progressPercent, `Downloading video ${i + 1}/${parsed.scenes.length}`); // console.log(`\n═══ [SCENE ${i + 1}/${parsed.scenes.length}] ═══════════════════════════`); // console.log(`šŸŽ¬ Keywords: [${scene.searchKeywords.join(', ')}]`); try { let visual: any = null; if (scene.localAsset) { const assetsDir = resolveProjectPath('input', 'input-assests'); const sourcePath = path.join(assetsDir, scene.localAsset); const ext = path.extname(scene.localAsset).toLowerCase(); const isVideo = ['.mp4', '.mov', '.webm', '.m4v'].includes(ext); // Use original filename in public/visuals for reuse const targetFilename = scene.localAsset; const targetPath = path.join(visualsDir, targetFilename); // console.log(`šŸ“ø [SCENE ${i + 1}] Using local asset: ${scene.localAsset}`); if (fs.existsSync(sourcePath)) { // Only copy if it doesn't already exist in public/visuals if (!fs.existsSync(targetPath)) { fs.copyFileSync(sourcePath, targetPath); } visual = { type: isVideo ? 'video' : 'image', url: `local://${scene.localAsset}`, width: orientation === 'landscape' ? 1920 : 1080, height: orientation === 'landscape' ? 1080 : 1920, localPath: toPublicRelativePath(targetPath), }; if (isVideo) { const videoMetadata = getVideoMetadata(targetPath); visual.videoDuration = videoMetadata.durationSeconds; visual.videoTrimAfterFrames = videoMetadata.trimAfterFrames; // console.log(`ā±ļø [SCENE ${i + 1}] Local video duration: ${visual.videoDuration.toFixed(2)}s`); } } else { // console.error(`āš ļø [SCENE ${i + 1}] Local asset NOT FOUND: ${sourcePath}`); } } if (!visual) { visual = await fetchVisualsForScene(scene.searchKeywords, true, orientation); if (visual && visual.type === 'video') { try { const filename = `scene_${i + 1}.mp4`; // console.log(`ā¬‡ļø [SCENE ${i + 1}] Downloading: ${filename}`); const downloadResult = await downloadMedia(visual.url, videoDir, filename); if (downloadResult.videoTrimAfterFrames && downloadResult.videoTrimAfterFrames < 5) { throw new Error(`Downloaded video clip is too short to render reliably: ${filename}`); } visual.localPath = toPublicRelativePath(downloadResult.path); visual.videoDuration = downloadResult.videoDuration; visual.videoTrimAfterFrames = downloadResult.videoTrimAfterFrames; // console.log(`āœ… [SCENE ${i + 1}] Saved: ${filename}`); if (downloadResult.videoDuration) { // console.log(`ā±ļø [SCENE ${i + 1}] Video duration: ${downloadResult.videoDuration.toFixed(2)}s`); } } catch (err: any) { // console.error(`āš ļø [SCENE ${i + 1}] Download failed: ${err.message}`); invalidateCachedVisual(scene.searchKeywords, orientation); const imageFallback = await fetchVisualsForScene(scene.searchKeywords, false, orientation); visual = imageFallback && imageFallback.type === 'image' ? imageFallback : null; } } else if (visual) { // console.log(`šŸ–¼ļø [SCENE ${i + 1}] Using image: ${visual.url}`); } else { // console.log(`āš ļø [SCENE ${i + 1}] No visual found`); } } // --- DEFAULT VIDEO FALLBACK --- if (!visual) { const fallbackPathInput = resolveProjectPath('input', 'input-assests', defaultVideo); const fallbackPathVisuals = path.join(visualsDir, defaultVideo); // console.log(`āš ļø [SCENE ${i + 1}] Attempting default video fallback: ${defaultVideo}`); if (fs.existsSync(fallbackPathInput)) { if (!fs.existsSync(fallbackPathVisuals)) { fs.copyFileSync(fallbackPathInput, fallbackPathVisuals); } visual = { type: 'video', url: `local://${defaultVideo}`, width: orientation === 'landscape' ? 1920 : 1080, height: orientation === 'landscape' ? 1080 : 1920, localPath: toPublicRelativePath(fallbackPathVisuals), }; const videoMetadata = getVideoMetadata(fallbackPathVisuals); visual.videoDuration = videoMetadata.durationSeconds; visual.videoTrimAfterFrames = videoMetadata.trimAfterFrames; // console.log(`āœ… [SCENE ${i + 1}] Fallback successful`); } else { // console.warn(`āŒ [SCENE ${i + 1}] Default video ${defaultVideo} not found in input-assests`); } } if (visual?.type === 'video' && !visual.localPath) { visual = null; } visuals[i] = visual; } catch (err: any) { // console.error(`āŒ [SCENE ${i + 1}] Error fetching visual: ${err.message}`); visuals[i] = null; } }; // Execute with concurrency limit for (let i = 0; i < parsed.scenes.length; i++) { const p = processScene(i).then(() => { activePromises.splice(activePromises.indexOf(p), 1); }); activePromises.push(p); if (activePromises.length >= CONCURRENCY) { await Promise.race(activePromises); } } await Promise.all(activePromises); const visualsFound = visuals.filter(v => v !== null).length; const step3Time = Date.now() - step3Start; // console.log(`\nāœ… [STEP 3] Downloaded ${visualsFound}/${parsed.scenes.length} visuals in ${step3Time}ms\n`); // ══════════════════════════════════════════════════════════════════ // STEP 4: GENERATE VOICEOVERS // ══════════════════════════════════════════════════════════════════ // console.log('\n╔══════════════════════════════════════════╗'); // console.log('ā•‘ STEP 4: GENERATING VOICEOVERS ā•‘'); // console.log('ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•'); const step4Start = Date.now(); onProgress?.('audio', 55, 'Generating voiceovers'); const audioDir = workspace.audioDir; // console.log(`šŸŽ¤ [STEP 4] Audio output directory: ${audioDir}`); const voiceConfig = { ...DEFAULT_VOICE_CONFIG, voice: voice || DEFAULT_VOICE_CONFIG.voice }; const audioFiles = await generateVoiceovers(parsed.scenes, audioDir, voiceConfig); const step4Time = Date.now() - step4Start; // console.log(`āœ… [STEP 4] Generated ${audioFiles.size} voice tracks in ${step4Time}ms\n`); // ══════════════════════════════════════════════════════════════════ // STEP 5: SAVE SCENE DATA (with actual audio durations) // ══════════════════════════════════════════════════════════════════ // console.log('\n╔══════════════════════════════════════════╗'); // console.log('ā•‘ STEP 5: SAVING SCENE DATA ā•‘'); // console.log('ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•'); const step5Start = Date.now(); onProgress?.('prepare', 90, 'Preparing scene data'); // Build scene data with actual audio durations let totalActualDuration = 0; const scenesWithAudio = parsed.scenes.map((scene, index) => { const audioResult = audioFiles.get(scene.sceneNumber); const actualDuration = audioResult?.duration || scene.duration; totalActualDuration += actualDuration; return { ...scene, duration: actualDuration, // Use actual audio duration visual: visuals[index], audioPath: audioResult?.path ? toPublicRelativePath(audioResult.path) : undefined, }; }); const sceneData = { scenes: scenesWithAudio, totalDuration: totalActualDuration, style: parsed.videoStyle, orientation, title, showText, assetNamespace: workspace.publicNamespace, }; // Ensure output directory exists if (!fs.existsSync(outputDir)) { // console.log(`šŸ“ [STEP 5] Creating output directory: ${outputDir}`); fs.mkdirSync(outputDir, { recursive: true }); } const dataPath = path.join(outputDir, 'scene-data.json'); const jsonData = JSON.stringify(sceneData, null, 2); fs.writeFileSync(dataPath, jsonData); const step5Time = Date.now() - step5Start; // console.log(`šŸ“„ [STEP 5] Scene data file: ${dataPath}`); // console.log(`šŸ“„ [STEP 5] File size: ${(jsonData.length / 1024).toFixed(2)} KB`); // console.log(`āœ… [STEP 5] Saved in ${step5Time}ms\n`); // ══════════════════════════════════════════════════════════════════ // STEP 6: GENERATE METADATA (Description & Hashtags) // ══════════════════════════════════════════════════════════════════ // console.log('\n╔══════════════════════════════════════════╗'); // console.log('ā•‘ STEP 6: GENERATING METADATA ā•‘'); // console.log('ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•'); const step6Start = Date.now(); onProgress?.('metadata', 95, 'Generating metadata'); // Generate Description (First 3 sentences) // Split by periods but handle common abbreviations if possible, or just a simple split const sentences = parsed.scenes.map(s => s.voiceoverText).join(' ').split('. '); const description = sentences.slice(0, 3).join('. ') + (sentences.length > 3 ? '.' : ''); // Generate Hashtags const uniqueKeywords = new Set(); parsed.scenes.forEach(scene => { scene.searchKeywords.forEach(k => uniqueKeywords.add(k.replace(/\s+/g, '').toLowerCase())); }); // Add generic tags uniqueKeywords.add('ai'); uniqueKeywords.add('future'); uniqueKeywords.add('technology'); const hashtags = Array.from(uniqueKeywords).map(k => `#${k}`).join(' '); const metadataContent = `${title || 'Video'}\n\n${description}\n\n${hashtags}`; // Sanitize title for filename (preserve spaces, remove illegal chars) const safeTitle = (title || 'video').replace(/[<>:"/\\|?*]/g, '').trim(); const metadataPath = path.join(outputDir, `${safeTitle} details.txt`); fs.writeFileSync(metadataPath, metadataContent); const step6Time = Date.now() - step6Start; // console.log(`āœ… [STEP 6] Metadata saved to: ${metadataPath}`); // ══════════════════════════════════════════════════════════════════ // COMPLETE // ══════════════════════════════════════════════════════════════════ const totalTime = Date.now() - totalStartTime; // console.log('\n╔════════════════════════════════════════════════════════════════╗'); // console.log('ā•‘ āœ… PRE-PROCESSING COMPLETE! ā•‘'); // console.log('ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•'); // console.log('\n [TIMING SUMMARY]'); // console.log(` Step 1 (Validation): ${step1Time}ms`); // console.log(` Step 2 (Parsing): ${step2Time}ms`); // console.log(` Step 3 (Visuals): ${step3Time}ms`); // console.log(` Step 4 (Audio): ${step4Time}ms`); // console.log(` Step 5 (Save Data): ${step5Time}ms`); // console.log(` ─────────────────────────────`); // console.log(` TOTAL TIME: ${totalTime}ms (${(totalTime / 1000).toFixed(1)}s)`); // console.log('\nšŸ“‹ Next Steps:'); // console.log(` 1. Review: ${dataPath}`); // console.log(' 2. Run: npm run remotion:render'); // console.log('\n'); onProgress?.('complete', 100, 'Pre-processing complete'); return { success: true, outputPath: dataPath, metadata: { scenes: parsed.scenes.length, duration: parsed.totalDuration, visualsFound, }, }; } catch (error: any) { const totalTime = Date.now() - totalStartTime; console.log('\n╔════════════════════════════════════════════════════════════════╗'); console.log('ā•‘ āŒ VIDEO GENERATION FAILED! ā•‘'); console.log('ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•'); console.error(`\nāŒ [ERROR] ${error.message}`); console.error(`āŒ [ERROR] Stack trace:\n${error.stack}`); console.log(`\nā±ļø Failed after: ${totalTime}ms`); onProgress?.('error', 0, error.message); return { success: false, error: error.message, }; } }