UNPKG

@henteko/kumiki

Version:

A video generation tool that creates videos from JSON configurations

342 lines 14.3 kB
import { existsSync } from 'node:fs'; import { mkdir, rm } from 'node:fs/promises'; import path from 'node:path'; import PQueue from 'p-queue'; import { parseProjectFile } from '../core/parser.js'; import { SceneFactory } from '../scenes/factory.js'; import { FFmpegService } from '../services/ffmpeg.js'; import { geminiMusicService } from '../services/gemini-music.js'; import { musicCache, generateMusicCacheKey } from '../services/music-cache.js'; import { narrationService } from '../services/narration.js'; import { TransitionService } from '../services/transition.js'; import { getTmpDir } from '../utils/app-dirs.js'; import { ProcessError } from '../utils/errors.js'; import { isGenerateMusicUrl, parseGenerateMusicUrl } from '../utils/generate-music-url-parser.js'; import { logger } from '../utils/logger.js'; export class Renderer { projectPath; project; // Will be initialized in render() options; tempDir; ffmpeg; narrationResults = new Map(); constructor(projectPath, options) { this.projectPath = projectPath; this.options = options; this.tempDir = options.tempDir || path.join(getTmpDir(), `kumiki-render-${Date.now()}`); this.ffmpeg = FFmpegService.getInstance(); } /** * Render the complete video */ async render() { // Load project this.project = await parseProjectFile(this.projectPath); logger.info('Starting video generation', { scenes: this.project.scenes.length, output: this.options.outputPath, }); try { // Check FFmpeg installation const ffmpegInstalled = await this.ffmpeg.checkInstallation(); if (!ffmpegInstalled) { throw new ProcessError('FFmpeg is not installed or not in PATH', 'FFMPEG_NOT_FOUND'); } // Create temp directory await this.ensureTempDirectory(); // Parse resolution and fps const [width, height] = this.project.settings.resolution.split('x').map(Number); if (!width || !height) { throw new ProcessError(`Invalid resolution format: ${this.project.settings.resolution}`, 'INVALID_RESOLUTION'); } const fps = this.project.settings.fps; // Process narrations for all scenes await this.processNarrations(); // Render all scenes to videos const scenePaths = await this.renderScenes({ width, height, fps }); // Concatenate all scene videos await this.concatenateScenes(scenePaths); // Add audio if specified if (this.project.audio?.backgroundMusic) { await this.addBackgroundMusic(); } logger.info('Video generation completed', { output: this.options.outputPath, }); } finally { // Cleanup temp directory if not keeping if (!this.options.keepTemp) { await this.cleanupTempDirectory(); } } } /** * Render all scenes to individual videos */ async renderScenes(settings) { const queue = new PQueue({ concurrency: this.options.concurrency || 2 }); const scenePaths = []; logger.info('Rendering scenes', { count: this.project.scenes.length }); const renderPromises = this.project.scenes.map((scene, index) => queue.add(async () => { logger.info(`Rendering scene ${index + 1}/${this.project.scenes.length}`, { sceneId: scene.id, type: scene.type, }); const renderer = SceneFactory.create(scene, { resolution: this.project.settings.resolution, fps: settings.fps, tempDir: this.tempDir, }); // Set narration path if available const narrationResult = this.narrationResults.get(scene.id); if (narrationResult?.audioPath) { renderer.setNarrationPath(narrationResult.audioPath); } const videoPath = await renderer.renderVideo(); scenePaths[index] = videoPath; // Update progress if (this.options.onProgress) { const progress = ((index + 1) / this.project.scenes.length) * 80; // 80% for scene rendering this.options.onProgress(progress); } logger.info(`Scene rendered`, { sceneId: scene.id, path: videoPath, }); })); await Promise.all(renderPromises); return scenePaths; } /** * Concatenate all scene videos into final output */ async concatenateScenes(scenePaths) { logger.info('Concatenating scenes', { count: scenePaths.length }); const tempOutput = path.join(this.tempDir, 'combined.mp4'); // Apply transitions if specified let processedPaths = [...scenePaths]; if (scenePaths.length > 1) { const transitionService = TransitionService.getInstance(); // Process transitions in reverse order to handle overlapping correctly for (let i = scenePaths.length - 2; i >= 0; i--) { const currentScene = this.project.scenes[i]; if (currentScene?.transition) { const transitionOutput = path.join(this.tempDir, `transition_${i}_${i + 1}.mp4`); logger.info('Applying transition between scenes', { from: currentScene.id, to: this.project.scenes[i + 1]?.id, type: currentScene.transition.type, direction: currentScene.transition.direction, }); // Apply transition between current and next scene await transitionService.applyTransition({ transition: currentScene.transition, scene1Path: processedPaths[i], scene2Path: processedPaths[i + 1], outputPath: transitionOutput, resolution: this.project.settings.resolution, fps: this.project.settings.fps, }); // Replace the two scenes with the transition output processedPaths = [ ...processedPaths.slice(0, i), transitionOutput, ...processedPaths.slice(i + 2) ]; } } } // Concatenate all videos (with transitions applied) await this.ffmpeg.concatenate({ inputs: processedPaths, output: tempOutput, onProgress: (progress) => { if (this.options.onProgress) { // Concatenation is 80-95% of total progress this.options.onProgress(80 + (progress * 0.15)); } }, }); // If no audio, this is our final output if (!this.project.audio?.backgroundMusic) { await this.moveToFinalOutput(tempOutput); } } /** * Add background music to the video */ async addBackgroundMusic() { const tempVideo = path.join(this.tempDir, 'combined.mp4'); const bgMusic = this.project.audio.backgroundMusic; // Resolve music path (handle generate:// URLs) let musicPath; if (isGenerateMusicUrl(bgMusic.src)) { musicPath = await this.resolveGenerateMusicUrl(bgMusic.src); } else { musicPath = path.resolve(process.cwd(), bgMusic.src); } logger.info('Adding background music', { music: musicPath, volume: bgMusic.volume, fadeIn: bgMusic.fadeIn, fadeOut: bgMusic.fadeOut, }); // Check if video already has audio (from narration) const hasAudio = await this.ffmpeg.hasAudioStream(tempVideo); if (hasAudio) { // Mix background music with existing narration audio const volumeMix = this.project.settings.narrationDefaults?.volumeMix || { narration: 0.8, bgm: 0.3 }; await this.ffmpeg.mixBackgroundMusic(tempVideo, musicPath, this.options.outputPath, { musicVolume: bgMusic.volume || volumeMix.bgm, existingAudioVolume: 1.0, // Keep narration at full volume fadeIn: bgMusic.fadeIn, fadeOut: bgMusic.fadeOut, }); } else { // No existing audio, just add background music if (bgMusic.fadeIn || bgMusic.fadeOut) { await this.ffmpeg.addAudioWithFade(tempVideo, musicPath, this.options.outputPath, bgMusic.volume, bgMusic.fadeIn, bgMusic.fadeOut); } else { // Use the original method for backward compatibility await this.ffmpeg.addAudio(tempVideo, musicPath, this.options.outputPath, bgMusic.volume); } } if (this.options.onProgress) { this.options.onProgress(100); } } /** * Move temp file to final output */ async moveToFinalOutput(tempPath) { const { rename } = await import('node:fs/promises'); // Ensure output directory exists const outputDir = path.dirname(this.options.outputPath); if (!existsSync(outputDir)) { await mkdir(outputDir, { recursive: true }); } await rename(tempPath, this.options.outputPath); if (this.options.onProgress) { this.options.onProgress(100); } } /** * Ensure temp directory exists */ async ensureTempDirectory() { if (!existsSync(this.tempDir)) { await mkdir(this.tempDir, { recursive: true }); } } /** * Process narrations for all scenes */ async processNarrations() { const scenesWithNarration = this.project.scenes.filter(scene => scene.narration); if (scenesWithNarration.length === 0) { logger.info('No narrations to process'); return; } logger.info('Processing narrations', { count: scenesWithNarration.length }); for (const scene of scenesWithNarration) { try { const result = await narrationService.processSceneNarration({ scene, narrationDefaults: this.project.settings.narrationDefaults, outputDir: this.tempDir, }); this.narrationResults.set(scene.id, result); } catch (error) { logger.error('Failed to process narration', { sceneId: scene.id, error: error instanceof Error ? error.message : String(error), }); // Continue with other narrations } } } /** * Resolve generate:// URL to actual music path */ async resolveGenerateMusicUrl(src) { // Initialize cache if needed await musicCache.initialize(); // Parse generate URL const params = parseGenerateMusicUrl(src); // Generate cache key const cacheKey = generateMusicCacheKey(params); // Check cache first const cachedPath = await musicCache.get(cacheKey, this.projectPath); if (cachedPath) { logger.info('Using cached generated music', { prompt: params.prompt || 'weighted prompts', cachedPath, }); logger.info('To replace: Change src to your actual music file path in the JSON file'); return cachedPath; } // Calculate duration if not specified let videoDuration; if (params.duration) { // If duration is explicitly specified in the generate config videoDuration = params.duration; } else { // Calculate total project duration videoDuration = this.project.scenes.reduce((total, scene) => total + scene.duration, 0); } // Add 10 seconds buffer to ensure smooth looping and fades const generationDuration = videoDuration + 10; params.duration = generationDuration; // Generate new music logger.info('Generating background music', { prompt: params.prompt, prompts: params.prompts, duration: generationDuration, videoDuration: videoDuration, config: params.config, }); try { const musicData = await geminiMusicService.generateMusic(params); // Save to cache const musicPath = await musicCache.save(cacheKey, musicData, params, this.projectPath); logger.info('Generated music saved', { prompt: params.prompt || 'weighted prompts', path: musicPath, generatedDuration: generationDuration, videoDuration: videoDuration, }); logger.info('To replace: Change src to your actual music file path in the JSON file'); return musicPath; } catch (error) { logger.error('Failed to generate music', { error: error instanceof Error ? error.message : String(error), errorDetails: error, }); throw new ProcessError(error instanceof Error ? error.message : 'Failed to generate music', 'MUSIC_GENERATION_FAILED', { prompt: params.prompt, prompts: params.prompts, error: error instanceof Error ? error.message : String(error), }); } } /** * Cleanup temp directory */ async cleanupTempDirectory() { if (existsSync(this.tempDir)) { logger.debug('Cleaning up temp directory', { path: this.tempDir }); await rm(this.tempDir, { recursive: true, force: true }); } } } //# sourceMappingURL=renderer.js.map