realeditz / src /services /video /render-service.js
andevs's picture
Update src/services/video/render-service.js
3ea8aae verified
Raw
History Blame Contribute Delete
10.1 kB
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;