UNPKG

@henteko/kumiki

Version:

A video generation tool that creates videos from JSON configurations

677 lines 26.3 kB
import { spawn } from 'node:child_process'; import { existsSync } from 'node:fs'; import { mkdir, unlink, writeFile } from 'node:fs/promises'; import path from 'node:path'; import { getTmpDir, ensureDir } from '../utils/app-dirs.js'; import { ProcessError } from '../utils/errors.js'; import { logger } from '../utils/logger.js'; export class FFmpegService { static instance; constructor() { } /** * Get singleton instance */ static getInstance() { if (!FFmpegService.instance) { FFmpegService.instance = new FFmpegService(); } return FFmpegService.instance; } /** * Check if FFmpeg is installed */ async checkInstallation() { return new Promise((resolve) => { const ffmpeg = spawn('ffmpeg', ['-version']); ffmpeg.on('error', () => { logger.error('FFmpeg not found. Please install FFmpeg.'); resolve(false); }); ffmpeg.on('close', (code) => { resolve(code === 0); }); }); } /** * Convert image to video */ async imageToVideo(options) { const { input, output, duration, fps, resolution } = options; if (!existsSync(input)) { throw new ProcessError(`Input file not found: ${input}`, 'INPUT_NOT_FOUND'); } await this.ensureOutputDirectory(output); const args = [ '-loop', '1', '-i', input, '-c:v', 'libx264', '-t', duration.toString(), '-r', fps.toString(), '-s', resolution, '-pix_fmt', 'yuv420p', '-preset', 'fast', '-y', output, ]; logger.info('Converting image to video', { input, output, duration }); await this.execute('ffmpeg', args, options.onProgress); } /** * Trim video */ async trimVideo(options) { const { input, output, start, duration, resolution } = options; if (!existsSync(input)) { throw new ProcessError(`Input file not found: ${input}`, 'INPUT_NOT_FOUND'); } await this.ensureOutputDirectory(output); const args = [ '-ss', start.toString(), '-i', input, '-t', duration.toString(), '-c:v', 'libx264', '-preset', 'fast', ]; // Add scaling if resolution is specified if (resolution) { const [width, height] = resolution.split('x'); args.push('-vf', `scale=${width}:${height}:force_original_aspect_ratio=decrease,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2:black`); } args.push('-c:a', 'aac', '-y', output); logger.info('Trimming video', { input, output, start, duration, resolution }); await this.execute('ffmpeg', args, options.onProgress); } /** * Concatenate videos */ async concatenate(options) { const { inputs, output } = options; // Check all input files exist for (const input of inputs) { if (!existsSync(input)) { throw new ProcessError(`Input file not found: ${input}`, 'INPUT_NOT_FOUND'); } } await this.ensureOutputDirectory(output); // Check if any input has audio const audioChecks = await Promise.all(inputs.map(input => this.hasAudioStream(input))); const hasAnyAudio = audioChecks.some(hasAudio => hasAudio); if (hasAnyAudio) { // Use filter_complex to handle mixed audio/video streams const filterComplexParts = []; const inputArgs = []; // Add all inputs for (const input of inputs) { inputArgs.push('-i', input); } // Build filter for video concatenation const videoConcat = inputs.map((_, i) => `[${i}:v]`).join(''); filterComplexParts.push(`${videoConcat}concat=n=${inputs.length}:v=1:a=0[outv]`); // Build filter for audio concatenation with silent audio for videos without audio const audioSources = []; for (let i = 0; i < inputs.length; i++) { if (audioChecks[i]) { audioSources.push(`[${i}:a]`); } else { // Generate silent audio for this video const duration = await this.getVideoDuration(inputs[i]); filterComplexParts.push(`aevalsrc=0:channel_layout=stereo:sample_rate=48000:duration=${duration}[s${i}]`); audioSources.push(`[s${i}]`); } } const audioConcat = audioSources.join(''); filterComplexParts.push(`${audioConcat}concat=n=${inputs.length}:v=0:a=1[outa]`); const filterComplex = filterComplexParts.join(';'); const args = [ ...inputArgs, '-filter_complex', filterComplex, '-map', '[outv]', '-map', '[outa]', '-c:v', 'libx264', '-c:a', 'aac', '-y', output, ]; logger.info('Concatenating videos with mixed audio', { count: inputs.length, output, hasAudio: audioChecks, }); try { await this.execute('ffmpeg', args, options.onProgress); } catch (error) { logger.error('Failed to concatenate videos', { error: error instanceof Error ? error.message : String(error), }); throw error; } } else { // No videos have audio, use simple concatenation await this.concatenateWithList(inputs, output, options.onProgress); } } /** * Helper method to concatenate videos using concat list */ async concatenateWithList(inputs, output, onProgress) { // Create temporary concat list file const tmpDir = getTmpDir(); ensureDir(tmpDir); const concatListPath = path.join(tmpDir, `concat-list-${Date.now()}.txt`); const concatList = inputs.map(input => `file '${path.resolve(input)}'`).join('\n'); await writeFile(concatListPath, concatList); try { // Use concat demuxer const args = [ '-f', 'concat', '-safe', '0', '-i', concatListPath, '-c', 'copy', '-y', output, ]; logger.info('Concatenating videos', { count: inputs.length, output }); await this.execute('ffmpeg', args, onProgress); } finally { // Clean up temp file if (existsSync(concatListPath)) { await unlink(concatListPath); } } } /** * Apply fade transition between two videos */ async fadeTransition(video1, video2, output, duration) { if (!existsSync(video1) || !existsSync(video2)) { throw new ProcessError('Input video files not found', 'INPUT_NOT_FOUND'); } await this.ensureOutputDirectory(output); // Get duration of first video to calculate offset const video1Duration = await this.getVideoDuration(video1); const offset = Math.max(0, video1Duration - duration); // Check if videos have audio streams const hasAudio = await this.hasAudioStream(video1) && await this.hasAudioStream(video2); // Create complex filter for fade transition let filter; let args; if (hasAudio) { filter = `[0:v][1:v]xfade=transition=fade:duration=${duration}:offset=${offset}[v];[0:a][1:a]acrossfade=d=${duration}:c1=tri:c2=tri[a]`; args = [ '-i', video1, '-i', video2, '-filter_complex', filter, '-map', '[v]', '-map', '[a]', '-c:v', 'libx264', '-preset', 'fast', '-y', output, ]; } else { filter = `[0:v][1:v]xfade=transition=fade:duration=${duration}:offset=${offset}[v]`; args = [ '-i', video1, '-i', video2, '-filter_complex', filter, '-map', '[v]', '-c:v', 'libx264', '-preset', 'fast', '-y', output, ]; } logger.info('Applying fade transition', { video1, video2, duration, offset }); await this.execute('ffmpeg', args); } /** * Apply wipe transition between two videos */ async wipeTransition(video1, video2, output, duration, direction = 'left') { if (!existsSync(video1) || !existsSync(video2)) { throw new ProcessError('Input video files not found', 'INPUT_NOT_FOUND'); } await this.ensureOutputDirectory(output); // Get duration of first video to calculate offset const video1Duration = await this.getVideoDuration(video1); const offset = Math.max(0, video1Duration - duration); // Check if videos have audio streams const hasAudio = await this.hasAudioStream(video1) && await this.hasAudioStream(video2); // Map direction to xfade transition names const transitionMap = { left: 'wipeleft', right: 'wiperight', up: 'wipeup', down: 'wipedown', }; let filter; let args; if (hasAudio) { filter = `[0:v][1:v]xfade=transition=${transitionMap[direction]}:duration=${duration}:offset=${offset}[v];[0:a][1:a]acrossfade=d=${duration}:c1=tri:c2=tri[a]`; args = [ '-i', video1, '-i', video2, '-filter_complex', filter, '-map', '[v]', '-map', '[a]', '-c:v', 'libx264', '-preset', 'fast', '-y', output, ]; } else { filter = `[0:v][1:v]xfade=transition=${transitionMap[direction]}:duration=${duration}:offset=${offset}[v]`; args = [ '-i', video1, '-i', video2, '-filter_complex', filter, '-map', '[v]', '-c:v', 'libx264', '-preset', 'fast', '-y', output, ]; } logger.info('Applying wipe transition', { video1, video2, duration, direction, offset }); await this.execute('ffmpeg', args); } /** * Apply dissolve transition between two videos */ async dissolveTransition(video1, video2, output, duration) { if (!existsSync(video1) || !existsSync(video2)) { throw new ProcessError('Input video files not found', 'INPUT_NOT_FOUND'); } await this.ensureOutputDirectory(output); // Get duration of first video to calculate offset const video1Duration = await this.getVideoDuration(video1); const offset = Math.max(0, video1Duration - duration); // Check if videos have audio streams const hasAudio = await this.hasAudioStream(video1) && await this.hasAudioStream(video2); let filter; let args; if (hasAudio) { filter = `[0:v][1:v]xfade=transition=dissolve:duration=${duration}:offset=${offset}[v];[0:a][1:a]acrossfade=d=${duration}:c1=tri:c2=tri[a]`; args = [ '-i', video1, '-i', video2, '-filter_complex', filter, '-map', '[v]', '-map', '[a]', '-c:v', 'libx264', '-preset', 'fast', '-y', output, ]; } else { filter = `[0:v][1:v]xfade=transition=dissolve:duration=${duration}:offset=${offset}[v]`; args = [ '-i', video1, '-i', video2, '-filter_complex', filter, '-map', '[v]', '-c:v', 'libx264', '-preset', 'fast', '-y', output, ]; } logger.info('Applying dissolve transition', { video1, video2, duration, offset }); await this.execute('ffmpeg', args); } /** * Add audio to video */ async addAudio(videoPath, audioPath, outputPath, volume = 1.0) { if (!existsSync(videoPath)) { throw new ProcessError(`Video file not found: ${videoPath}`, 'VIDEO_NOT_FOUND'); } if (!existsSync(audioPath)) { throw new ProcessError(`Audio file not found: ${audioPath}`, 'AUDIO_NOT_FOUND'); } await this.ensureOutputDirectory(outputPath); const args = [ '-i', videoPath, '-i', audioPath, '-c:v', 'copy', '-c:a', 'aac', '-filter:a', `volume=${volume}`, '-shortest', '-y', outputPath, ]; logger.info('Adding audio to video', { video: videoPath, audio: audioPath }); await this.execute('ffmpeg', args); } /** * Mix background music with existing audio (e.g., narration) */ async mixBackgroundMusic(videoPath, musicPath, outputPath, options = {}) { const { musicVolume = 0.3, existingAudioVolume = 1.0, fadeIn = 0, fadeOut = 0, } = options; if (!existsSync(videoPath)) { throw new ProcessError(`Video file not found: ${videoPath}`, 'VIDEO_NOT_FOUND'); } if (!existsSync(musicPath)) { throw new ProcessError(`Music file not found: ${musicPath}`, 'MUSIC_NOT_FOUND'); } await this.ensureOutputDirectory(outputPath); // Get video duration for fade calculations const videoDuration = await this.getVideoDuration(videoPath); // Check if video has audio stream const hasAudio = await this.hasAudioStream(videoPath); // Build music filter chain // For looped audio, we need to ensure the fade out works correctly let musicFilter = ''; // First, trim the looped audio to match video duration // Add a small buffer to ensure fade out completes const audioDuration = videoDuration + 0.5; musicFilter = `atrim=0:${audioDuration}`; // Apply fade in at the beginning if (fadeIn > 0) { musicFilter = `${musicFilter},afade=t=in:st=0:d=${fadeIn}`; } // Apply fade out at the end if (fadeOut > 0) { // Calculate fade out to complete just before video ends const fadeOutStart = Math.max(0, videoDuration - fadeOut); musicFilter = `${musicFilter},afade=t=out:st=${fadeOutStart}:d=${fadeOut}`; } // Apply volume adjustment last musicFilter = `${musicFilter},volume=${musicVolume}`; // Trim to exact video duration after all effects musicFilter = `${musicFilter},atrim=0:${videoDuration}`; let args; if (hasAudio) { // Mix existing audio with background music args = [ '-i', videoPath, '-stream_loop', '-1', '-i', musicPath, '-filter_complex', `[1:a]${musicFilter}[music];[0:a]volume=${existingAudioVolume}[voice];[voice][music]amix=inputs=2:duration=first:dropout_transition=2[out]`, '-map', '0:v', '-map', '[out]', '-c:v', 'copy', '-c:a', 'aac', '-t', videoDuration.toString(), '-y', outputPath, ]; } else { // Add background music as the only audio track args = [ '-i', videoPath, '-stream_loop', '-1', '-i', musicPath, '-filter_complex', `[1:a]${musicFilter}[out]`, '-map', '0:v', '-map', '[out]', '-c:v', 'copy', '-c:a', 'aac', '-t', videoDuration.toString(), '-y', outputPath, ]; } logger.info('Mixing background music', { video: videoPath, music: musicPath, hasExistingAudio: hasAudio, options, }); await this.execute('ffmpeg', args); } /** * Add narration track to video with existing audio */ async addNarrationTrack(videoPath, narrationPath, outputPath, options = {}) { const { narrationVolume = 0.8, bgmVolume = 0.3, delay = 0, fadeIn = 0, fadeOut = 0, } = options; if (!existsSync(videoPath)) { throw new ProcessError(`Video file not found: ${videoPath}`, 'VIDEO_NOT_FOUND'); } if (!existsSync(narrationPath)) { throw new ProcessError(`Narration file not found: ${narrationPath}`, 'NARRATION_NOT_FOUND'); } await this.ensureOutputDirectory(outputPath); // Get video duration for fade calculations const videoDuration = await this.getVideoDuration(videoPath); // Build narration filter with delay and fade let narrationFilter = `adelay=${delay * 1000}|${delay * 1000}`; if (fadeIn > 0) { narrationFilter = `${narrationFilter},afade=t=in:st=${delay}:d=${fadeIn}`; } if (fadeOut > 0) { const fadeOutStart = Math.max(0, videoDuration - fadeOut); narrationFilter = `${narrationFilter},afade=t=out:st=${fadeOutStart}:d=${fadeOut}`; } narrationFilter = `${narrationFilter},volume=${narrationVolume}`; // Check if video has audio stream const hasAudio = await this.hasAudioStream(videoPath); let args; if (hasAudio) { // Mix narration with existing audio args = [ '-i', videoPath, '-i', narrationPath, '-filter_complex', `[0:a]volume=${bgmVolume}[bgm];[1:a]${narrationFilter},apad=whole_dur=${videoDuration}[narr];[bgm][narr]amix=inputs=2:duration=first[out]`, '-map', '0:v', '-map', '[out]', '-c:v', 'copy', '-c:a', 'aac', '-y', outputPath, ]; } else { // Just add narration as the only audio track // Use apad to extend audio with silence to match video duration args = [ '-i', videoPath, '-i', narrationPath, '-filter_complex', `[1:a]${narrationFilter},apad=whole_dur=${videoDuration}[out]`, '-map', '0:v', '-map', '[out]', '-c:v', 'copy', '-c:a', 'aac', '-y', outputPath, ]; } logger.info('Adding narration track', { video: videoPath, narration: narrationPath, hasExistingAudio: hasAudio, options, }); await this.execute('ffmpeg', args); } /** * Add audio to video with fade in/out effects */ async addAudioWithFade(videoPath, audioPath, outputPath, volume = 1.0, fadeIn, fadeOut) { if (!existsSync(videoPath)) { throw new ProcessError(`Video file not found: ${videoPath}`, 'VIDEO_NOT_FOUND'); } if (!existsSync(audioPath)) { throw new ProcessError(`Audio file not found: ${audioPath}`, 'AUDIO_NOT_FOUND'); } await this.ensureOutputDirectory(outputPath); // Get video duration to calculate fade out timing const videoDuration = await this.getVideoDuration(videoPath); // Build audio filter chain let audioFilter = `volume=${volume}`; if (fadeIn && fadeIn > 0) { audioFilter = `afade=t=in:st=0:d=${fadeIn},${audioFilter}`; } if (fadeOut && fadeOut > 0) { const fadeOutStart = Math.max(0, videoDuration - fadeOut); audioFilter = `${audioFilter},afade=t=out:st=${fadeOutStart}:d=${fadeOut}`; } const args = [ '-i', videoPath, '-i', audioPath, '-c:v', 'copy', '-c:a', 'aac', '-filter:a', audioFilter, '-shortest', '-y', outputPath, ]; logger.info('Adding audio with fade effects', { video: videoPath, audio: audioPath, fadeIn, fadeOut, volume, videoDuration }); await this.execute('ffmpeg', args); } /** * Execute FFmpeg command */ execute(command, args, onProgress, stdin) { return new Promise((resolve, reject) => { logger.debug('Executing FFmpeg command', { command, args }); const proc = spawn(command, args); let stderr = ''; let duration = 0; if (stdin) { proc.stdin.write(stdin); proc.stdin.end(); } proc.stderr.on('data', (data) => { const output = data.toString(); stderr += output; // Parse duration from stderr if (duration === 0) { const durationMatch = output.match(/Duration: (\d{2}):(\d{2}):(\d{2})/); if (durationMatch && durationMatch[1] && durationMatch[2] && durationMatch[3]) { const [, hours, minutes, seconds] = durationMatch; duration = parseInt(hours, 10) * 3600 + parseInt(minutes, 10) * 60 + parseInt(seconds, 10); } } // Parse progress if (onProgress && duration > 0) { const timeMatch = output.match(/time=(\d{2}):(\d{2}):(\d{2})/); if (timeMatch && timeMatch[1] && timeMatch[2] && timeMatch[3]) { const [, hours, minutes, seconds] = timeMatch; const current = parseInt(hours, 10) * 3600 + parseInt(minutes, 10) * 60 + parseInt(seconds, 10); const progress = Math.min((current / duration) * 100, 100); onProgress(progress); } } }); proc.on('error', (error) => { reject(new ProcessError(`Failed to execute ${command}: ${error.message}`, 'FFMPEG_EXECUTION_ERROR')); }); proc.on('close', (code) => { if (code === 0) { resolve(); } else { reject(new ProcessError(`FFmpeg process exited with code ${code}`, 'FFMPEG_ERROR', { stderr })); } }); }); } /** * Check if video has audio stream */ async hasAudioStream(videoPath) { return new Promise((resolve) => { const args = [ '-i', videoPath, '-show_streams', '-select_streams', 'a', '-v', 'quiet', '-of', 'json', ]; const proc = spawn('ffprobe', args); let stdout = ''; proc.stdout.on('data', (data) => { stdout += data.toString(); }); proc.on('error', (error) => { // If ffprobe fails, assume no audio logger.warning('Failed to check audio stream', { error: error instanceof Error ? error.message : String(error) }); resolve(false); }); proc.on('close', (code) => { if (code === 0) { try { const result = JSON.parse(stdout); const hasAudio = Boolean(result.streams && result.streams.length > 0); resolve(hasAudio); } catch { resolve(false); } } else { resolve(false); } }); }); } /** * Get video duration in seconds */ async getVideoDuration(videoPath) { return new Promise((resolve, reject) => { const args = [ '-i', videoPath, '-show_entries', 'format=duration', '-v', 'quiet', '-of', 'csv=p=0', ]; const proc = spawn('ffprobe', args); let stdout = ''; let stderr = ''; proc.stdout.on('data', (data) => { stdout += data.toString(); }); proc.stderr.on('data', (data) => { stderr += data.toString(); }); proc.on('error', (error) => { reject(new ProcessError(`Failed to get video duration: ${error.message}`, 'FFPROBE_ERROR')); }); proc.on('close', (code) => { if (code === 0) { const duration = parseFloat(stdout.trim()); if (isNaN(duration)) { reject(new ProcessError('Failed to parse video duration', 'DURATION_PARSE_ERROR', { stdout, stderr })); } else { resolve(duration); } } else { reject(new ProcessError(`ffprobe process exited with code ${code}`, 'FFPROBE_ERROR', { stderr })); } }); }); } /** * Ensure output directory exists */ async ensureOutputDirectory(outputPath) { const dir = path.dirname(outputPath); if (!existsSync(dir)) { await mkdir(dir, { recursive: true }); } } } //# sourceMappingURL=ffmpeg.js.map