| 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 { |
| |
| const filterComplex = this.buildComplexFilter(editPlan, clipPaths.length); |
| |
| |
| let command = ffmpeg(); |
| |
| |
| clipPaths.forEach((clip, index) => { |
| command = command.input(clip); |
| }); |
| |
| |
| if (audioPath) { |
| command = command.input(audioPath); |
| } |
| |
| |
| 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); |
| |
| |
| 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); |
| |
| |
| this.simpleConcat(clipPaths, fullOutputPath, audioPath) |
| .then(resolve) |
| .catch(reject); |
| } |
| }); |
| } |
|
|
| buildComplexFilter(editPlan, numClips) { |
| const filters = []; |
| const outputs = []; |
| |
| |
| 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; |
| |
| |
| const trimFilter = `[${clipIndex}:v]trim=start=${start}:duration=${duration},setpts=PTS-STARTPTS[trimmed${index}]`; |
| filters.push(trimFilter); |
| |
| |
| 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}]`); |
| } |
| }); |
| |
| |
| 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}]`; |
| } |
| }); |
| } |
| |
| |
| if (editPlan.colorGrading) { |
| const grade = editPlan.colorGrading; |
| const colorFilter = this.buildColorFilter(grade); |
| filters.push(colorFilter); |
| } |
| |
| |
| if (editPlan.textOverlays && editPlan.textOverlays.length > 0) { |
| editPlan.textOverlays.forEach((overlay, index) => { |
| |
| 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); |
| }); |
| } |
| |
| |
| if (editPlan.soundDesign && editPlan.soundDesign.length > 0) { |
| editPlan.soundDesign.forEach((sound, index) => { |
| if (sound.type === 'whoosh') { |
| |
| } |
| }); |
| } |
| |
| |
| if (outputs.length > 0) { |
| const concatInput = outputs.join(''); |
| const concatFilter = `${concatInput}concat=n=${outputs.length}:v=1:a=0[finalv]`; |
| filters.push(concatFilter); |
| |
| |
| if (editPlan.timeline.length > 0) { |
| |
| 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); |
| } |
| |
| |
| 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); |
| |
| |
| if (now - stats.mtimeMs > 3600000) { |
| await fs.unlink(filePath); |
| console.log('🧹 Cleaned up temp file:', file); |
| } |
| } |
| |
| |
| 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); |
| |
| |
| if (now - stats.mtimeMs > 86400000) { |
| await fs.unlink(filePath); |
| console.log('🧹 Cleaned up old render:', file); |
| } |
| } |
| } |
| } |
|
|
| module.exports = RenderService; |