darkmedia-x-api / engine /src /video_assembler.rs
cybermedia's picture
Upload folder using huggingface_hub
343eed9 verified
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<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(())
}
}
#[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);
}
}