const ffmpeg = require('fluent-ffmpeg'); const ffmpegStatic = require('ffmpeg-static'); const fs = require('fs').promises; const path = require('path'); const { spawn } = require('child_process'); const EventEmitter = require('events'); ffmpeg.setFfmpegPath(ffmpegStatic); class RenderService extends EventEmitter { constructor() { super(); this.tempDir = path.join(__dirname, '../../../temp'); this.rendersDir = path.join(__dirname, '../../../renders'); this.activeRenders = new Map(); } async ensureDirs() { await fs.mkdir(this.tempDir, { recursive: true }); await fs.mkdir(this.rendersDir, { recursive: true }); } async renderEdit(editPlan, clipPaths, outputPath, audioPath = null) { console.log('🎬 Rendering video with edit plan...'); await this.ensureDirs(); const fullOutputPath = path.join(this.rendersDir, path.basename(outputPath)); const renderId = path.basename(outputPath, '.mp4'); return new Promise((resolve, reject) => { try { // Create complex filter for the edit const filterComplex = this.buildComplexFilter(editPlan, clipPaths.length); // Build FFmpeg command let command = ffmpeg(); // Add all input clips clipPaths.forEach((clip, index) => { command = command.input(clip); }); // Add audio if provided if (audioPath) { command = command.input(audioPath); } // Set up progress tracking let lastProgress = 0; command .on('start', (commandLine) => { console.log('FFmpeg command:', commandLine); this.activeRenders.set(renderId, { progress: 0, command: commandLine }); }) .on('progress', (progress) => { const percent = progress.percent || 0; if (percent - lastProgress > 5) { console.log(`Rendering: ${percent.toFixed(1)}%`); lastProgress = percent; this.activeRenders.set(renderId, { progress: percent }); this.emit('progress', { renderId, progress: percent }); } }) .on('end', () => { console.log('✅ Rendering complete:', fullOutputPath); this.activeRenders.delete(renderId); resolve(fullOutputPath); }) .on('error', (err) => { console.error('FFmpeg error:', err); this.activeRenders.delete(renderId); // If FFmpeg fails, try simple concatenation this.simpleConcat(clipPaths, fullOutputPath, audioPath) .then(resolve) .catch(reject); }) .complexFilter(filterComplex.filter, filterComplex.outputs) .outputOptions([ '-c:v libx264', '-preset medium', '-crf 23', '-pix_fmt yuv420p', '-movflags +faststart', '-metadata', 'title=TikVibe Creator Pro', '-metadata', 'artist=AI Editor', '-metadata', 'comment=Created with TikVibe AI' ]) .save(fullOutputPath); } catch (error) { console.error('Render error:', error); // Fallback: simple concat this.simpleConcat(clipPaths, fullOutputPath, audioPath) .then(resolve) .catch(reject); } }); } buildComplexFilter(editPlan, numClips) { const filters = []; const outputs = []; // Add trim and speed filters for each timeline segment editPlan.timeline.forEach((segment, index) => { const clipIndex = segment.clipIndex; const start = segment.startTime || 0; const duration = segment.duration || 2; const speed = segment.speed || 1.0; // Trim the clip const trimFilter = `[${clipIndex}:v]trim=start=${start}:duration=${duration},setpts=PTS-STARTPTS[trimmed${index}]`; filters.push(trimFilter); // Apply speed change if (speed !== 1.0) { const pts = 1 / speed; const speedFilter = `[trimmed${index}]setpts=${pts}*PTS[speed${index}]`; filters.push(speedFilter); outputs.push(`[speed${index}]`); } else { outputs.push(`[trimmed${index}]`); } }); // Add effects if (editPlan.effects && editPlan.effects.length > 0) { editPlan.effects.forEach((effect, index) => { if (effect.type === 'flash' && effect.applyTo !== 'all') { const flashFilter = `[${outputs[effect.time ? 0 : index]}]eq=brightness=0.2:enable='between(t,${effect.time},${effect.time + effect.duration})'[flash${index}]`; filters.push(flashFilter); outputs[index] = `[flash${index}]`; } if (effect.type === 'glitch') { const glitchFilter = `[${outputs[index]}]geq=if(eq(mod(X,10),0),Y,lum(X,Y)):if(eq(mod(X,10),0),cb(X,Y),cb(X,Y)):if(eq(mod(X,10),0),cr(X,Y),cr(X,Y)):enable='between(t,${effect.time},${effect.time + effect.duration})'[glitch${index}]`; filters.push(glitchFilter); outputs[index] = `[glitch${index}]`; } }); } // Add color grading if (editPlan.colorGrading) { const grade = editPlan.colorGrading; const colorFilter = this.buildColorFilter(grade); filters.push(colorFilter); } // Add text overlays if (editPlan.textOverlays && editPlan.textOverlays.length > 0) { editPlan.textOverlays.forEach((overlay, index) => { // Check if we have a font file, otherwise use default const fontFile = '/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf'; const yPos = overlay.position === 'bottom' ? 'h-text_h-50' : overlay.position === 'top' ? '50' : '(h-text_h)/2'; const textFilter = `drawtext=text='${overlay.text}':fontcolor=${overlay.color || 'white'}:fontsize=${overlay.fontSize || 36}:fontfile=${fontFile}:x=(w-text_w)/2:y=${yPos}:enable='between(t,${overlay.time},${overlay.time + overlay.duration})'`; filters.push(textFilter); }); } // Add audio processing if needed if (editPlan.soundDesign && editPlan.soundDesign.length > 0) { editPlan.soundDesign.forEach((sound, index) => { if (sound.type === 'whoosh') { // This would require external audio files - simplified for now } }); } // Concatenate all segments if (outputs.length > 0) { const concatInput = outputs.join(''); const concatFilter = `${concatInput}concat=n=${outputs.length}:v=1:a=0[finalv]`; filters.push(concatFilter); // Handle audio if (editPlan.timeline.length > 0) { // For now, just use audio from first clip or external audio filters.push(`[0:a]aresample=48000[finala]`); return { filter: filters.join(';'), outputs: ['[finalv]', '[finala]'] }; } } return { filter: filters.join(';'), outputs: ['[finalv]'] }; } buildColorFilter(grade) { const { saturation = 1.0, contrast = 1.0, brightness = 0.0 } = grade; return `eq=saturation=${saturation}:contrast=${contrast}:brightness=${brightness}`; } async simpleConcat(clipPaths, outputPath, audioPath) { console.log('⚠️ Using simple concatenation fallback'); return new Promise((resolve, reject) => { const command = ffmpeg(); clipPaths.forEach(clip => command.input(clip)); if (audioPath) { command.input(audioPath); } // Create concat file const concatContent = clipPaths.map(p => `file '${p}'`).join('\n'); const concatFile = path.join(this.tempDir, `concat-${Date.now()}.txt`); fs.writeFile(concatFile, concatContent) .then(() => { const ffCommand = ffmpeg() .input(concatFile) .inputOptions(['-f', 'concat', '-safe', '0']); if (audioPath) { ffCommand .input(audioPath) .outputOptions([ '-c:v copy', '-c:a aac', '-map 0:v', '-map 1:a', '-shortest' ]); } else { ffCommand.outputOptions(['-c copy']); } ffCommand .on('end', () => { fs.unlink(concatFile).catch(console.error); console.log('✅ Simple concat complete'); resolve(outputPath); }) .on('error', (err) => { fs.unlink(concatFile).catch(console.error); reject(err); }) .save(outputPath); }) .catch(reject); }); } async getRenderProgress(renderId) { const render = this.activeRenders.get(renderId); return render ? render.progress : 0; } async cancelRender(renderId) { const render = this.activeRenders.get(renderId); if (render && render.process) { render.process.kill(); this.activeRenders.delete(renderId); return true; } return false; } async cleanup() { const files = await fs.readdir(this.tempDir); const now = Date.now(); for (const file of files) { const filePath = path.join(this.tempDir, file); const stats = await fs.stat(filePath); // Delete files older than 1 hour if (now - stats.mtimeMs > 3600000) { await fs.unlink(filePath); console.log('🧹 Cleaned up temp file:', file); } } // Also clean up old renders const renders = await fs.readdir(this.rendersDir); for (const file of renders) { const filePath = path.join(this.rendersDir, file); const stats = await fs.stat(filePath); // Delete renders older than 24 hours if (now - stats.mtimeMs > 86400000) { await fs.unlink(filePath); console.log('🧹 Cleaned up old render:', file); } } } } module.exports = RenderService;