Spaces:
Sleeping
Sleeping
| 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(), | |
| } | |
| } | |
| 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<Config>, | |
| ) -> Result<PathBuf> { | |
| 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(()) | |
| } | |
| } | |
| mod tests { | |
| use super::*; | |
| use crate::audio_analyzer::AudioAnalysis; | |
| 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); | |
| } | |
| 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)); | |
| } | |
| } | |
| 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); | |
| } | |
| } | |