use std::process::Command; use std::path::{Path, PathBuf}; use std::fs; use anyhow::{Result, anyhow}; use crate::scene::Scene; use crate::progress_tracker::ProgressTracker; use crate::renderer::image_processor; use crate::renderer::vfx; use crate::renderer::VfxProfile; use crate::config::Config; use crate::audio_analyzer::{AudioAnalyzer, AudioAnalysis}; use std::sync::Arc; use chrono::Local; pub struct VideoAssembler { audio_analyzer: AudioAnalyzer, } impl VideoAssembler { pub fn new() -> Self { Self { audio_analyzer: AudioAnalyzer::new(), } } #[allow(clippy::too_many_arguments)] pub async fn assemble_video( &self, story_path: &Path, scenes: &[Scene], images_dir: &Path, output_filename: &str, music_file: Option<&Path>, progress: &ProgressTracker, config: Arc, ) -> Result { println!("🚀 Studio Engine: Stable Assembly with Improved Visibility"); let story_name = story_path.file_name() .ok_or_else(|| anyhow!("Failed to get file name from story path: {:?}", story_path))? .to_string_lossy().to_string(); let audio_info = if let Some(music) = music_file { if music.exists() { self.audio_analyzer.analyze(music).ok() } else { None } } else { None }; let output_path = story_path.join(output_filename); let temp_dir = story_path.join("temp_clips"); if !temp_dir.exists() { fs::create_dir_all(&temp_dir)?; } let mut clip_paths = Vec::new(); let scene_duration = 5.0; let sounds_dir = story_path.join("assets").join("sounds"); let voice_effect = std::env::var("VOICE_EFFECT").unwrap_or_else(|_| "none".to_string()); let effect_ext = match voice_effect.as_str() { "radio" => ",highpass=f=300,lowpass=f=2500,volume=1.5", "reverb" => ",aecho=0.8:0.8:60:0.5,aecho=0.8:0.8:100:0.3", "robot" => ",vibrato=f=20:d=1,lowpass=f=3000", "ghost" => ",aecho=0.8:0.8:150:0.5,atempo=0.9", "demon" => ",asetrate=24000*0.65,aresample=44100,atempo=1.5,bass=g=15", "alien" => ",vibrato=f=50:d=0.8,lowpass=f=4000,highpass=f=500", "whisper" => ",highpass=f=800,volume=2.5", "giant" => ",asetrate=24000*0.55,aresample=44100,atempo=1.8,bass=g=20", "cyborg" => ",equalizer=f=400:t=q:w=1:g=10,vibrato=f=15:d=0.4,aecho=0.8:0.5:10:0.5", _ => "" }; let demonic_filter = format!( "[1:a]asetrate=24000*{},aresample=44100,aecho=0.8:0.7:{}:0.3{}{},volume={}[a_demonic]", config.audio_demonic_pitch, config.audio_echo_delay, "", effect_ext, config.audio_voice_vol ); for (i, scene) in scenes.iter().enumerate() { let scene_idx = i + 1; let img_path = images_dir.join(format!("scene_{}.png", scene_idx)); if !img_path.exists() { continue; } let marked_img_path = temp_dir.join(format!("marked_scene_{}.png", scene_idx)); let title = if i == 0 { story_name.to_uppercase() } else { "None".to_string() }; let sub = scene.prompt.replace("'", "").replace("\"", ""); // RENDU NATIF RUST (MIGRATION PHASE 2) let img = image_processor::load_image(&img_path)?; let titled_img = image_processor::add_text_overlay(img, Some(&title), Some(&sub))?; // APPLIQUER VFX SI CONFIGURÉ let final_img = if config.vfx_profile != VfxProfile::None { vfx::apply_vfx(titled_img, config.vfx_profile)? } else { titled_img }; image_processor::save_image(&final_img, &marked_img_path)?; let marked_img_str = marked_img_path.to_str().ok_or_else(|| anyhow!("Invalid marked image path: {:?}", marked_img_path))?; let clip_path = temp_dir.join(format!("clip_{}.mp4", scene_idx)); let voice_path = sounds_dir.join(format!("voice_{}.mp3", scene_idx)); let voice_path_sylvie = sounds_dir.join(format!("voice_{}_sylvie.mp3", scene_idx)); let actual_voice = if voice_path.exists() { voice_path.clone() } else if voice_path_sylvie.exists() { voice_path_sylvie.clone() } else { PathBuf::new() }; let zoom_speed = if let Some(ref info) = audio_info { let time_offset = i as f32 * scene_duration; 0.001 + (self.get_intensity_at(info, time_offset) * 0.003) } else { 0.0015 }; // FILTRE STABLE SANS VIBRATION POUR ÉVITER LES ERREURS let video_filters = format!( "scale=1080:1920,zoompan=z='min(zoom+{},1.5)':x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':d=150:s=1080x1920", zoom_speed ); let mut cmd = Command::new(&config.ffmpeg_path); cmd.args(["-y", "-loop", "1", "-i", marked_img_str]); if !actual_voice.as_os_str().is_empty() { let voice_str = actual_voice.to_str().ok_or_else(|| anyhow!("Invalid voice path: {:?}", actual_voice))?; cmd.args(["-i", voice_str]); cmd.args([ "-filter_complex", &format!("[0:v]{} [v];{}", video_filters, demonic_filter), "-map", "[v]", "-map", "[a_demonic]", "-c:v", "libx264", "-pix_fmt", "yuv420p", "-shortest" ]); } else { cmd.args([ "-vf", &video_filters, "-t", &scene_duration.to_string(), "-c:v", "libx264", "-pix_fmt", "yuv420p" ]); } let clip_str = clip_path.to_str().ok_or_else(|| anyhow!("Invalid clip path: {:?}", clip_path))?; cmd.arg(clip_str); if cmd.status()?.success() { clip_paths.push(clip_path); } progress.update_task_status("renderer", (scene_idx as f32 / scenes.len() as f32 * 100.0) as u32).await.ok(); } if clip_paths.is_empty() { return Err(anyhow!("No clips rendered")); } let list_path = temp_dir.join("clips.txt"); let mut list_content = String::new(); for clip in &clip_paths { let clip_name = clip.file_name() .ok_or_else(|| anyhow!("Failed to get file name from clip path: {:?}", clip))? .to_str() .ok_or_else(|| anyhow!("Invalid clip file name: {:?}", clip))?; list_content.push_str(&format!("file '{}'\n", clip_name)); } fs::write(&list_path, list_content)?; let video_with_voices = temp_dir.join("video_with_voices.mp4"); let video_voices_str = video_with_voices.to_str().ok_or_else(|| anyhow!("Invalid video with voices path: {:?}", video_with_voices))?; Command::new(&config.ffmpeg_path).args(["-y", "-f", "concat", "-safe", "0", "-i", "clips.txt", "-c", "copy", video_voices_str]).current_dir(&temp_dir).status()?; if let Some(music) = music_file { if music.exists() { let music_str = music.to_str().ok_or_else(|| anyhow!("Invalid music path: {:?}", music))?; let output_str = output_path.to_str().ok_or_else(|| anyhow!("Invalid output path: {:?}", output_path))?; Command::new(&config.ffmpeg_path).args([ "-y", "-i", video_voices_str, "-stream_loop", "-1", "-i", music_str, "-filter_complex", &format!("[0:a]volume=1.0[a1];[1:a]volume={}[a2];[a1][a2]amix=inputs=2:duration=first", config.audio_music_vol), "-c:v", "copy", "-c:a", "aac", output_str ]).status()?; } else { fs::copy(&video_with_voices, &output_path)?; } } else { fs::copy(&video_with_voices, &output_path)?; } let _ = fs::remove_dir_all(&temp_dir); self.export_to_cloud(&output_path, story_path).ok(); Ok(output_path) } fn get_intensity_at(&self, info: &AudioAnalysis, time: f32) -> f32 { if info.intensity_curve.is_empty() { return 0.5; } let hop_duration = 512.0 / 44100.0; let index = (time / hop_duration) as usize; if index < info.intensity_curve.len() { info.intensity_curve[index] } else { 0.5 } } fn export_to_cloud(&self, video_path: &Path, story_path: &Path) -> Result<()> { let export_dir = if cfg!(windows) { PathBuf::from("G:\\Mon disque\\DarkMedia-X_Cloud\\EXPORTS") } else { // Chemin Linux par défaut (à adapter si besoin) let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string()); PathBuf::from(home).join("DarkMedia-X_Cloud").join("EXPORTS") }; if !export_dir.exists() { println!(" ⚠️ Cloud export directory not found, skipping cloud export."); return Ok(()); } let story_name = story_path.file_name() .ok_or_else(|| anyhow!("Failed to get file name from story path: {:?}", story_path))? .to_string_lossy(); let timestamp = Local::now().format("%Y-%m-%d_%Hh%M").to_string(); let export_filename = format!("{}_{}.mp4", timestamp, story_name.replace(" ", "_")); let destination = export_dir.join(export_filename); println!(" ☁️ Exporting to Google Drive: {}", destination.display()); fs::copy(video_path, destination)?; Ok(()) } } #[cfg(test)] mod tests { use super::*; use crate::audio_analyzer::AudioAnalysis; #[test] fn test_get_intensity_at() { let assembler = VideoAssembler::new(); let info = AudioAnalysis { bpm: 120.0, beat_times: vec![], intensity_curve: vec![0.1, 0.5, 0.9], duration: 10.0, }; let hop_duration = 512.0 / 44100.0; assert_eq!(assembler.get_intensity_at(&info, 0.0), 0.1); assert_eq!(assembler.get_intensity_at(&info, hop_duration), 0.5); assert_eq!(assembler.get_intensity_at(&info, hop_duration * 2.0), 0.9); // Out of bounds assert_eq!(assembler.get_intensity_at(&info, 10.0), 0.5); } #[test] fn test_voice_filter_logic() { let cases = vec![ ("radio", "highpass=f=300"), ("demon", "bass=g=15"), ("alien", "vibrato=f=50"), ("none", ""), ]; for (effect, expected) in cases { let filter = match effect { "radio" => "highpass=f=300,lowpass=f=2500,volume=1.5", "reverb" => "aecho=0.8:0.8:60:0.5,aecho=0.8:0.8:100:0.3", "robot" => "vibrato=f=20:d=1,lowpass=f=3000", "ghost" => "aecho=0.8:0.8:150:0.5,atempo=0.9", "demon" => "asetrate=24000*0.65,aresample=44100,atempo=1.5,bass=g=15", "alien" => "vibrato=f=50:d=0.8,lowpass=f=4000,highpass=f=500", _ => "" }; assert!(filter.contains(expected)); } } #[test] fn test_get_intensity_empty_curve() { let assembler = VideoAssembler::new(); let info = AudioAnalysis { bpm: 120.0, beat_times: vec![], intensity_curve: vec![], duration: 10.0, }; assert_eq!(assembler.get_intensity_at(&info, 0.0), 0.5); } }