// Video Assembly System for Puppet Productions
// Combines scene images with audio to create final video production
import fs from 'fs/promises';
import path from 'path';
export class VideoAssembler {
constructor(outputPath = './production_output/videos') {
this.outputPath = outputPath;
this.ensureOutputDirectory();
}
async ensureOutputDirectory() {
try {
await fs.mkdir(this.outputPath, { recursive: true });
} catch (error) {
console.log('Video output directory exists');
}
}
// Create video production manifest for external video tools
async createVideoManifest(characterName, sceneImages, audioFiles, options = {}) {
console.log(`🎬 Creating video manifest for ${characterName}...`);
try {
const scenes = [];
const defaultDuration = 3; // seconds per scene if no audio
for (let i = 0; i < sceneImages.length; i++) {
const image = sceneImages[i];
const audio = audioFiles.find(a => a.scene_number === image.shot_number);
scenes.push({
scene_number: i + 1,
image_path: image.image_url,
audio_path: audio?.audio_path || null,
duration: audio?.duration_estimate || defaultDuration,
emotion: image.emotion,
camera_angle: image.camera_angle,
dialogue: audio?.dialogue || null,
transition: options.transition || 'fade',
timing: {
start_time: scenes.reduce((sum, s) => sum + s.duration, 0),
end_time: scenes.reduce((sum, s) => sum + s.duration, 0) + (audio?.duration_estimate || defaultDuration)
}
});
}
const manifest = {
character_name: characterName,
production_info: {
total_scenes: scenes.length,
total_duration: scenes.reduce((sum, scene) => sum + scene.duration, 0),
created_at: new Date().toISOString(),
format: options.format || 'mp4',
resolution: options.resolution || '1920x1080',
fps: options.fps || 30
},
scenes: scenes,
rendering_instructions: {
image_duration_per_scene: defaultDuration,
audio_sync: true,
transition_type: options.transition || 'fade',
background_music: options.backgroundMusic || null,
end_credits: options.endCredits || false
},
export_settings: {
output_filename: `${characterName}_production_${Date.now()}.mp4`,
output_path: this.outputPath,
quality: options.quality || 'high'
}
};
// Save manifest
const manifestPath = path.join(this.outputPath, `${characterName}_video_manifest.json`);
await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2));
// Generate FFmpeg command
const ffmpegCmd = this.generateFFmpegCommand(manifest);
manifest.ffmpeg_command = ffmpegCmd.command;
manifest.output_file = ffmpegCmd.output_file;
// Save updated manifest with FFmpeg command
await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2));
console.log(`✅ Video manifest created: ${manifestPath}`);
console.log(`📊 Total duration: ${manifest.production_info.total_duration} seconds`);
console.log(`🎬 Total scenes: ${manifest.production_info.total_scenes}`);
console.log(`🎯 FFmpeg command generated`);
return {
success: true,
manifest_path: manifestPath,
manifest: manifest,
total_duration: manifest.production_info.total_duration,
total_scenes: manifest.production_info.total_scenes,
ffmpeg_command: ffmpegCmd.command,
output_file: ffmpegCmd.output_file,
ready_for_video_rendering: true
};
} catch (error) {
console.error('❌ Video manifest creation failed:', error.message);
return {
success: false,
error: error.message
};
}
}
// Generate FFmpeg command for video assembly (for external use)
generateFFmpegCommand(manifest) {
const scenes = manifest.scenes;
const outputFile = manifest.export_settings.output_filename;
// Create input files list
let inputFiles = [];
let filterComplex = [];
let inputIndex = 0;
scenes.forEach((scene, index) => {
// Add image input
inputFiles.push(`-loop 1 -t ${scene.duration} -i "${scene.image_path}"`);
// Add audio input if available
if (scene.audio_path) {
inputFiles.push(`-i "${scene.audio_path}"`);
// Create video with synced audio
filterComplex.push(
`[${inputIndex}:v]scale=1920:1080:force_original_aspect_ratio=decrease,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,setsar=1,fps=30[v${index}];` +
`[${inputIndex + 1}:a]volume=1.0[a${index}]`
);
inputIndex += 2;
} else {
// Video only
filterComplex.push(
`[${inputIndex}:v]scale=1920:1080:force_original_aspect_ratio=decrease,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,setsar=1,fps=30[v${index}]`
);
inputIndex += 1;
}
});
// Concatenate all scenes
const videoInputs = scenes.map((_, i) => `[v${i}]`).join('');
const audioInputs = scenes.filter(s => s.audio_path).map((_, i) => `[a${i}]`).join('');
if (audioInputs) {
filterComplex.push(`${videoInputs}concat=n=${scenes.length}:v=1:a=0[outv]`);
filterComplex.push(`${audioInputs}concat=n=${scenes.filter(s => s.audio_path).length}:v=0:a=1[outa]`);
} else {
filterComplex.push(`${videoInputs}concat=n=${scenes.length}:v=1:a=0[outv]`);
}
const command = [
'ffmpeg',
...inputFiles,
'-filter_complex',
`"${filterComplex.join('')}"`,
audioInputs ? '-map "[outv]" -map "[outa]"' : '-map "[outv]"',
'-c:v libx264 -preset medium -crf 23',
audioInputs ? '-c:a aac -b:a 192k' : '',
`-r ${manifest.production_info.fps}`,
`"${path.join(manifest.export_settings.output_path, outputFile)}"`
].filter(Boolean).join(' ');
return {
command: command,
output_file: path.join(manifest.export_settings.output_path, outputFile),
estimated_duration: manifest.production_info.total_duration
};
}
// Create simple slideshow video (basic implementation)
async createSlideshowVideo(characterName, sceneImages, options = {}) {
console.log(`🎞️ Creating slideshow video for ${characterName}...`);
try {
const duration = options.imageDuration || 3; // seconds per image
const transition = options.transition || 'fade';
const videoInstructions = {
type: 'slideshow',
character_name: characterName,
images: sceneImages.map(img => ({
path: img.image_url,
duration: duration,
emotion: img.emotion,
scene_number: img.shot_number
})),
settings: {
total_duration: sceneImages.length * duration,
transition_type: transition,
resolution: options.resolution || '1920x1080',
fps: options.fps || 30
},
output_filename: `${characterName}_slideshow_${Date.now()}.mp4`
};
const instructionsPath = path.join(this.outputPath, `${characterName}_slideshow_instructions.json`);
await fs.writeFile(instructionsPath, JSON.stringify(videoInstructions, null, 2));
console.log(`✅ Slideshow instructions created: ${instructionsPath}`);
return {
success: true,
instructions_path: instructionsPath,
estimated_duration: videoInstructions.settings.total_duration,
total_images: sceneImages.length,
instructions: videoInstructions,
note: 'Use external video editing software or FFmpeg to render the final video'
};
} catch (error) {
console.error('❌ Slideshow creation failed:', error.message);
return {
success: false,
error: error.message
};
}
}
// Get video production summary
async getProductionSummary(characterName) {
try {
const files = await fs.readdir(this.outputPath);
const characterFiles = files.filter(f => f.includes(characterName));
const manifests = characterFiles.filter(f => f.includes('manifest'));
const instructions = characterFiles.filter(f => f.includes('instructions'));
const videos = characterFiles.filter(f => f.endsWith('.mp4'));
return {
character_name: characterName,
manifests: manifests.length,
instruction_files: instructions.length,
rendered_videos: videos.length,
files: characterFiles,
output_directory: this.outputPath
};
} catch (error) {
return {
error: error.message,
output_directory: this.outputPath
};
}
}
}