use chrono::Utc; use diesel::prelude::*; use std::sync::Arc; use tokio::sync::broadcast; use tracing::{error, info}; use uuid::Uuid; use crate::security::command_guard::SafeCommand; use crate::shared::utils::DbPool; use super::models::*; use super::schema::*; pub struct VideoEngine { pub db: DbPool, pub cache: Option>, pub progress_tx: Option>, } impl VideoEngine { pub fn new(db: DbPool) -> Self { Self { db, cache: None, progress_tx: None, } } pub fn with_cache(db: DbPool, cache: Arc) -> Self { Self { db, cache: Some(cache), progress_tx: None, } } fn get_conn( &self, ) -> Result< diesel::r2d2::PooledConnection>, diesel::result::Error, > { self.db.get().map_err(|e| { error!("DB connection error: {e}"); diesel::result::Error::DatabaseError( diesel::result::DatabaseErrorKind::Unknown, Box::new(e.to_string()), ) }) } pub async fn create_project( &self, organization_id: Option, created_by: Option, req: CreateProjectRequest, ) -> Result { let mut conn = self.get_conn()?; let project = VideoProject { id: Uuid::new_v4(), organization_id, created_by, name: req.name, description: req.description, resolution_width: req.resolution_width.unwrap_or(1920), resolution_height: req.resolution_height.unwrap_or(1080), fps: req.fps.unwrap_or(30), total_duration_ms: 0, timeline_json: serde_json::json!({"clips": []}), layers_json: serde_json::json!([]), audio_tracks_json: serde_json::json!([]), playhead_ms: 0, selection_json: serde_json::json!({"type": "None"}), zoom_level: 1.0, thumbnail_url: None, status: ProjectStatus::Draft.to_string(), created_at: Utc::now(), updated_at: Utc::now(), }; diesel::insert_into(video_projects::table) .values(&project) .execute(&mut conn)?; info!("Created video project: {} ({})", project.name, project.id); Ok(project) } pub async fn get_project(&self, project_id: Uuid) -> Result { let mut conn = self.get_conn()?; video_projects::table.find(project_id).first(&mut conn) } pub async fn list_projects( &self, org_id: Option, filters: ProjectFilters, ) -> Result, diesel::result::Error> { let mut conn = self.get_conn()?; let mut query = video_projects::table.into_boxed(); if let Some(org) = org_id { query = query.filter(video_projects::organization_id.eq(org)); } if let Some(status) = filters.status { query = query.filter(video_projects::status.eq(status)); } if let Some(search) = filters.search { let pattern = format!("%{search}%"); query = query.filter(video_projects::name.ilike(pattern)); } query = query.order(video_projects::updated_at.desc()); if let Some(limit) = filters.limit { query = query.limit(limit); } if let Some(offset) = filters.offset { query = query.offset(offset); } let projects: Vec = query.load(&mut conn)?; let responses: Vec = projects .into_iter() .map(|p| ProjectResponse { id: p.id, name: p.name, description: p.description, resolution_width: p.resolution_width, resolution_height: p.resolution_height, fps: p.fps, total_duration_ms: p.total_duration_ms, playhead_ms: p.playhead_ms, zoom_level: p.zoom_level, thumbnail_url: p.thumbnail_url, status: p.status, clips_count: 0, layers_count: 0, created_at: p.created_at, updated_at: p.updated_at, }) .collect(); Ok(responses) } pub async fn update_project( &self, project_id: Uuid, req: UpdateProjectRequest, ) -> Result { let mut conn = self.get_conn()?; let project: VideoProject = video_projects::table.find(project_id).first(&mut conn)?; diesel::update(video_projects::table.find(project_id)) .set(( video_projects::name.eq(req.name.unwrap_or(project.name)), video_projects::description.eq(req.description.or(project.description)), video_projects::playhead_ms.eq(req.playhead_ms.unwrap_or(project.playhead_ms)), video_projects::selection_json.eq(req.selection_json.unwrap_or(project.selection_json)), video_projects::zoom_level.eq(req.zoom_level.unwrap_or(project.zoom_level)), video_projects::status.eq(req.status.unwrap_or(project.status)), video_projects::updated_at.eq(Utc::now()), )) .execute(&mut conn)?; self.get_project(project_id).await } pub async fn delete_project(&self, project_id: Uuid) -> Result<(), diesel::result::Error> { let mut conn = self.get_conn()?; diesel::delete(video_clips::table.filter(video_clips::project_id.eq(project_id))) .execute(&mut conn)?; diesel::delete(video_layers::table.filter(video_layers::project_id.eq(project_id))) .execute(&mut conn)?; diesel::delete(video_audio_tracks::table.filter(video_audio_tracks::project_id.eq(project_id))) .execute(&mut conn)?; diesel::delete(video_exports::table.filter(video_exports::project_id.eq(project_id))) .execute(&mut conn)?; diesel::delete(video_command_history::table.filter(video_command_history::project_id.eq(project_id))) .execute(&mut conn)?; diesel::delete(video_analytics::table.filter(video_analytics::project_id.eq(project_id))) .execute(&mut conn)?; diesel::delete(video_projects::table.find(project_id)).execute(&mut conn)?; info!("Deleted video project: {project_id}"); Ok(()) } pub async fn get_project_detail( &self, project_id: Uuid, ) -> Result { let project = self.get_project(project_id).await?; let clips = self.get_clips(project_id).await?; let layers = self.get_layers(project_id).await?; let audio_tracks = self.get_audio_tracks(project_id).await?; Ok(ProjectDetailResponse { project: ProjectResponse { id: project.id, name: project.name, description: project.description, resolution_width: project.resolution_width, resolution_height: project.resolution_height, fps: project.fps, total_duration_ms: project.total_duration_ms, playhead_ms: project.playhead_ms, zoom_level: project.zoom_level, thumbnail_url: project.thumbnail_url, status: project.status, clips_count: clips.len(), layers_count: layers.len(), created_at: project.created_at, updated_at: project.updated_at, }, clips, layers, audio_tracks, }) } pub async fn add_clip( &self, project_id: Uuid, req: AddClipRequest, ) -> Result { let mut conn = self.get_conn()?; let max_order: Option = video_clips::table .filter(video_clips::project_id.eq(project_id)) .select(diesel::dsl::max(video_clips::clip_order)) .first(&mut conn)?; let clip = VideoClip { id: Uuid::new_v4(), project_id, name: req.name.unwrap_or_else(|| "Clip".to_string()), source_url: req.source_url, start_ms: req.at_ms.unwrap_or(0), duration_ms: req.duration_ms.unwrap_or(5000), trim_in_ms: 0, trim_out_ms: 0, volume: 1.0, clip_order: max_order.unwrap_or(0) + 1, transition_in: None, transition_out: None, created_at: Utc::now(), }; diesel::insert_into(video_clips::table) .values(&clip) .execute(&mut conn)?; self.recalculate_duration(project_id).await?; Ok(clip) } pub async fn get_clips(&self, project_id: Uuid) -> Result, diesel::result::Error> { let mut conn = self.get_conn()?; video_clips::table .filter(video_clips::project_id.eq(project_id)) .order(video_clips::clip_order.asc()) .load(&mut conn) } pub async fn update_clip( &self, clip_id: Uuid, req: UpdateClipRequest, ) -> Result { let mut conn = self.get_conn()?; let clip: VideoClip = video_clips::table.find(clip_id).first(&mut conn)?; diesel::update(video_clips::table.find(clip_id)) .set(( video_clips::name.eq(req.name.unwrap_or(clip.name)), video_clips::start_ms.eq(req.start_ms.unwrap_or(clip.start_ms)), video_clips::duration_ms.eq(req.duration_ms.unwrap_or(clip.duration_ms)), video_clips::trim_in_ms.eq(req.trim_in_ms.unwrap_or(clip.trim_in_ms)), video_clips::trim_out_ms.eq(req.trim_out_ms.unwrap_or(clip.trim_out_ms)), video_clips::volume.eq(req.volume.unwrap_or(clip.volume)), video_clips::transition_in.eq(req.transition_in.or(clip.transition_in)), video_clips::transition_out.eq(req.transition_out.or(clip.transition_out)), )) .execute(&mut conn)?; self.recalculate_duration(clip.project_id).await?; video_clips::table.find(clip_id).first(&mut conn) } pub async fn delete_clip(&self, clip_id: Uuid) -> Result<(), diesel::result::Error> { let mut conn = self.get_conn()?; let clip: VideoClip = video_clips::table.find(clip_id).first(&mut conn)?; let project_id = clip.project_id; diesel::delete(video_clips::table.find(clip_id)).execute(&mut conn)?; self.recalculate_duration(project_id).await?; Ok(()) } pub async fn split_clip( &self, clip_id: Uuid, at_ms: i64, ) -> Result<(VideoClip, VideoClip), diesel::result::Error> { let mut conn = self.get_conn()?; let clip: VideoClip = video_clips::table.find(clip_id).first(&mut conn)?; let split_point = at_ms - clip.start_ms; if split_point <= 0 || split_point >= clip.duration_ms { return Err(diesel::result::Error::NotFound); } let first_duration = split_point; let second_duration = clip.duration_ms - split_point; diesel::update(video_clips::table.find(clip_id)) .set(( video_clips::duration_ms.eq(first_duration), video_clips::trim_out_ms.eq(clip.trim_out_ms + second_duration), video_clips::transition_out.eq(None::), )) .execute(&mut conn)?; let second_clip = VideoClip { id: Uuid::new_v4(), project_id: clip.project_id, name: format!("{} (split)", clip.name), source_url: clip.source_url.clone(), start_ms: clip.start_ms + first_duration, duration_ms: second_duration, trim_in_ms: clip.trim_in_ms + first_duration, trim_out_ms: clip.trim_out_ms, volume: clip.volume, clip_order: clip.clip_order + 1, transition_in: None, transition_out: clip.transition_out, created_at: Utc::now(), }; diesel::insert_into(video_clips::table) .values(&second_clip) .execute(&mut conn)?; let first_clip: VideoClip = video_clips::table.find(clip_id).first(&mut conn)?; self.recalculate_duration(clip.project_id).await?; info!("Split clip {} at {}ms", clip_id, at_ms); Ok((first_clip, second_clip)) } pub async fn add_layer( &self, project_id: Uuid, req: AddLayerRequest, ) -> Result { let mut conn = self.get_conn()?; let max_track: Option = video_layers::table .filter(video_layers::project_id.eq(project_id)) .select(diesel::dsl::max(video_layers::track_index)) .first(&mut conn)?; let layer = VideoLayer { id: Uuid::new_v4(), project_id, name: req.name.unwrap_or_else(|| format!("{} Layer", req.layer_type)), layer_type: req.layer_type, track_index: max_track.unwrap_or(0) + 1, start_ms: req.start_ms.unwrap_or(0), end_ms: req.end_ms.unwrap_or(5000), x: req.x.unwrap_or(0.5), y: req.y.unwrap_or(0.5), width: req.width.unwrap_or(0.5), height: req.height.unwrap_or(0.2), rotation: 0.0, opacity: 1.0, properties_json: req.properties.unwrap_or(serde_json::json!({})), animation_in: None, animation_out: None, locked: false, keyframes_json: None, created_at: Utc::now(), }; diesel::insert_into(video_layers::table) .values(&layer) .execute(&mut conn)?; Ok(layer) } pub async fn get_layers(&self, project_id: Uuid) -> Result, diesel::result::Error> { let mut conn = self.get_conn()?; video_layers::table .filter(video_layers::project_id.eq(project_id)) .order(video_layers::track_index.asc()) .load(&mut conn) } pub async fn update_layer( &self, layer_id: Uuid, req: UpdateLayerRequest, ) -> Result { let mut conn = self.get_conn()?; let layer: VideoLayer = video_layers::table.find(layer_id).first(&mut conn)?; diesel::update(video_layers::table.find(layer_id)) .set(( video_layers::name.eq(req.name.unwrap_or(layer.name)), video_layers::start_ms.eq(req.start_ms.unwrap_or(layer.start_ms)), video_layers::end_ms.eq(req.end_ms.unwrap_or(layer.end_ms)), video_layers::x.eq(req.x.unwrap_or(layer.x)), video_layers::y.eq(req.y.unwrap_or(layer.y)), video_layers::width.eq(req.width.unwrap_or(layer.width)), video_layers::height.eq(req.height.unwrap_or(layer.height)), video_layers::rotation.eq(req.rotation.unwrap_or(layer.rotation)), video_layers::opacity.eq(req.opacity.unwrap_or(layer.opacity)), video_layers::properties_json.eq(req.properties.unwrap_or(layer.properties_json)), video_layers::animation_in.eq(req.animation_in.or(layer.animation_in)), video_layers::animation_out.eq(req.animation_out.or(layer.animation_out)), video_layers::locked.eq(req.locked.unwrap_or(layer.locked)), )) .execute(&mut conn)?; video_layers::table.find(layer_id).first(&mut conn) } pub async fn delete_layer(&self, layer_id: Uuid) -> Result<(), diesel::result::Error> { let mut conn = self.get_conn()?; diesel::delete(video_keyframes::table.filter(video_keyframes::layer_id.eq(layer_id))) .execute(&mut conn)?; diesel::delete(video_layers::table.find(layer_id)).execute(&mut conn)?; Ok(()) } pub async fn add_audio_track( &self, project_id: Uuid, req: AddAudioRequest, ) -> Result { let mut conn = self.get_conn()?; let track = VideoAudioTrack { id: Uuid::new_v4(), project_id, name: req.name.unwrap_or_else(|| "Audio Track".to_string()), source_url: req.source_url, track_type: req.track_type.unwrap_or_else(|| "music".to_string()), start_ms: req.start_ms.unwrap_or(0), duration_ms: req.duration_ms.unwrap_or(10000), volume: req.volume.unwrap_or(1.0), fade_in_ms: 0, fade_out_ms: 0, waveform_json: None, beat_markers_json: None, created_at: Utc::now(), }; diesel::insert_into(video_audio_tracks::table) .values(&track) .execute(&mut conn)?; Ok(track) } pub async fn get_audio_tracks( &self, project_id: Uuid, ) -> Result, diesel::result::Error> { let mut conn = self.get_conn()?; video_audio_tracks::table .filter(video_audio_tracks::project_id.eq(project_id)) .order(video_audio_tracks::start_ms.asc()) .load(&mut conn) } pub async fn delete_audio_track(&self, track_id: Uuid) -> Result<(), diesel::result::Error> { let mut conn = self.get_conn()?; diesel::delete(video_audio_tracks::table.find(track_id)).execute(&mut conn)?; Ok(()) } pub async fn add_keyframe( &self, layer_id: Uuid, req: AddKeyframeRequest, ) -> Result { let mut conn = self.get_conn()?; let keyframe = VideoKeyframe { id: Uuid::new_v4(), layer_id, property_name: req.property_name, time_ms: req.time_ms, value_json: req.value, easing: req.easing.unwrap_or_else(|| "linear".to_string()), created_at: Utc::now(), }; diesel::insert_into(video_keyframes::table) .values(&keyframe) .execute(&mut conn)?; Ok(keyframe) } pub async fn get_keyframes( &self, layer_id: Uuid, ) -> Result, diesel::result::Error> { let mut conn = self.get_conn()?; video_keyframes::table .filter(video_keyframes::layer_id.eq(layer_id)) .order(video_keyframes::time_ms.asc()) .load(&mut conn) } pub async fn delete_keyframe(&self, keyframe_id: Uuid) -> Result<(), diesel::result::Error> { let mut conn = self.get_conn()?; diesel::delete(video_keyframes::table.find(keyframe_id)).execute(&mut conn)?; Ok(()) } pub async fn generate_preview_frame( &self, project_id: Uuid, at_ms: i64, width: i32, height: i32, output_dir: &str, ) -> Result> { let clips = self.get_clips(project_id).await?; let clip = clips .iter() .find(|c| at_ms >= c.start_ms && at_ms < c.start_ms + c.duration_ms) .ok_or("No clip at this timestamp")?; let seek_time = (at_ms - clip.start_ms + clip.trim_in_ms) as f64 / 1000.0; let output_filename = format!("preview_{}_{}.jpg", project_id, at_ms); let output_path = format!("{}/{}", output_dir, output_filename); let cmd = SafeCommand::new("ffmpeg") .map_err(|e| format!("Command creation failed: {e}"))? .arg("-y").map_err(|e| format!("Arg error: {e}"))? .arg("-ss").map_err(|e| format!("Arg error: {e}"))? .arg(&format!("{:.3}", seek_time)).map_err(|e| format!("Arg error: {e}"))? .arg("-i").map_err(|e| format!("Arg error: {e}"))? .arg(&clip.source_url).map_err(|e| format!("Arg error: {e}"))? .arg("-vframes").map_err(|e| format!("Arg error: {e}"))? .arg("1").map_err(|e| format!("Arg error: {e}"))? .arg("-vf").map_err(|e| format!("Arg error: {e}"))? .arg(&format!("scale={}:{}", width, height)).map_err(|e| format!("Arg error: {e}"))? .arg("-q:v").map_err(|e| format!("Arg error: {e}"))? .arg("2").map_err(|e| format!("Arg error: {e}"))? .arg(&output_path).map_err(|e| format!("Arg error: {e}"))?; let result = cmd.execute().map_err(|e| format!("Execution failed: {e}"))?; if !result.status.success() { let stderr = String::from_utf8_lossy(&result.stderr); return Err(format!("FFmpeg error: {stderr}").into()); } Ok(format!("/video/previews/{}", output_filename)) } pub async fn transcribe_audio( &self, project_id: Uuid, clip_id: Option, language: Option, ) -> Result> { let clips = self.get_clips(project_id).await?; let clip = if let Some(id) = clip_id { clips.iter().find(|c| c.id == id).ok_or("Clip not found")? } else { clips.first().ok_or("No clips in project")? }; let botmodels_url = std::env::var("BOTMODELS_URL").unwrap_or_else(|_| "http://localhost:8001".to_string()); let client = reqwest::Client::new(); let response = client .post(format!("{}/api/v1/audio/transcribe", botmodels_url)) .json(&serde_json::json!({ "audio_url": &clip.source_url, "language": language.unwrap_or_else(|| "auto".to_string()), "word_timestamps": true, })) .send() .await?; if !response.status().is_success() { let error = response.text().await.unwrap_or_default(); return Err(format!("Transcription service error: {error}").into()); } let result: serde_json::Value = response.json().await?; let segments: Vec = result["segments"] .as_array() .unwrap_or(&vec![]) .iter() .map(|s| TranscriptionSegment { start_ms: (s["start"].as_f64().unwrap_or(0.0) * 1000.0) as i64, end_ms: (s["end"].as_f64().unwrap_or(0.0) * 1000.0) as i64, text: s["text"].as_str().unwrap_or("").to_string(), confidence: s["confidence"].as_f64().unwrap_or(0.9) as f32, }) .collect(); let full_text = segments .iter() .map(|s| s.text.clone()) .collect::>() .join(" "); let duration_ms = segments.last().map(|s| s.end_ms).unwrap_or(0); Ok(TranscriptionResponse { segments, full_text, language: result["language"].as_str().unwrap_or("en").to_string(), duration_ms, }) } pub async fn generate_captions_from_transcription( &self, project_id: Uuid, transcription: &TranscriptionResponse, _style: &str, _max_chars: i32, font_size: i32, color: &str, with_background: bool, ) -> Result, diesel::result::Error> { let mut layers = Vec::new(); let bg_color = if with_background { Some("rgba(0,0,0,0.7)") } else { None }; for segment in &transcription.segments { let layer = self .add_layer( project_id, AddLayerRequest { name: Some(format!("Caption {}", layers.len() + 1)), layer_type: "text".to_string(), start_ms: Some(segment.start_ms), end_ms: Some(segment.end_ms), x: Some(0.5), y: Some(0.85), width: Some(0.9), height: Some(0.1), properties: Some(serde_json::json!({ "content": segment.text.trim(), "font_family": "Arial", "font_size": font_size, "color": color, "text_align": "center", "background_color": bg_color, })), }, ) .await?; layers.push(layer); } info!("Generated {} caption layers for project {}", layers.len(), project_id); Ok(layers) } pub async fn text_to_speech( &self, text: &str, voice: &str, speed: f32, language: &str, output_dir: &str, ) -> Result> { let botmodels_url = std::env::var("BOTMODELS_URL").unwrap_or_else(|_| "http://localhost:8001".to_string()); let client = reqwest::Client::new(); let response = client .post(format!("{}/api/v1/audio/tts", botmodels_url)) .json(&serde_json::json!({ "text": text, "voice": voice, "speed": speed, "language": language, })) .send() .await?; if !response.status().is_success() { let error = response.text().await.unwrap_or_default(); return Err(format!("TTS service error: {error}").into()); } let audio_data = response.bytes().await?; let filename = format!("tts_{}.mp3", Uuid::new_v4()); let file_path = format!("{}/{}", output_dir, filename); std::fs::write(&file_path, &audio_data)?; let duration_ms = self.get_audio_duration(&file_path).unwrap_or(0); Ok(TTSResponse { audio_url: format!("/video/audio/{}", filename), duration_ms, }) } fn get_audio_duration(&self, path: &str) -> Result> { let cmd = SafeCommand::new("ffprobe") .map_err(|e| format!("Command creation failed: {e}"))? .arg("-v").map_err(|e| format!("Arg error: {e}"))? .arg("error").map_err(|e| format!("Arg error: {e}"))? .arg("-show_entries").map_err(|e| format!("Arg error: {e}"))? .arg("format=duration").map_err(|e| format!("Arg error: {e}"))? .arg("-of").map_err(|e| format!("Arg error: {e}"))? .arg("default=noprint_wrappers=1:nokey=1").map_err(|e| format!("Arg error: {e}"))? .arg(path).map_err(|e| format!("Arg error: {e}"))?; let result = cmd.execute().map_err(|e| format!("Execution failed: {e}"))?; let stdout = String::from_utf8_lossy(&result.stdout); let duration_secs: f64 = stdout.trim().parse().unwrap_or(0.0); Ok((duration_secs * 1000.0) as i64) } pub async fn detect_scenes( &self, project_id: Uuid, threshold: f32, output_dir: &str, ) -> Result> { info!("Detecting scenes for project {project_id} with threshold {threshold}, output_dir: {output_dir}"); let clips = self.get_clips(project_id).await?; let clip = clips.first().ok_or("No clips in project")?; let cmd = SafeCommand::new("ffmpeg") .map_err(|e| format!("Command creation failed: {e}"))? .arg("-i").map_err(|e| format!("Arg error: {e}"))? .arg(&clip.source_url).map_err(|e| format!("Arg error: {e}"))? .arg("-vf").map_err(|e| format!("Arg error: {e}"))? .arg(&format!("select='gt(scene,{})',showinfo", threshold)).map_err(|e| format!("Arg error: {e}"))? .arg("-f").map_err(|e| format!("Arg error: {e}"))? .arg("null").map_err(|e| format!("Arg error: {e}"))? .arg("-").map_err(|e| format!("Arg error: {e}"))?; let result = cmd.execute().map_err(|e| format!("Execution failed: {e}"))?; let mut scenes = Vec::new(); let mut last_time: f64 = 0.0; let stderr = String::from_utf8_lossy(&result.stderr); for line in stderr.lines() { if line.contains("pts_time:") { if let Some(time_str) = line.split("pts_time:").nth(1) { if let Some(time_end) = time_str.find(char::is_whitespace) { if let Ok(time) = time_str[..time_end].parse::() { if time > last_time + 0.5 { scenes.push(SceneInfo { start_ms: (last_time * 1000.0) as i64, end_ms: (time * 1000.0) as i64, thumbnail_url: None, description: None, }); last_time = time; } } } } } } let duration_ms = self.get_audio_duration(&clip.source_url).unwrap_or(0); if last_time > 0.0 { scenes.push(SceneInfo { start_ms: (last_time * 1000.0) as i64, end_ms: duration_ms, thumbnail_url: None, description: None, }); } info!("Detected {} scenes in project {}", scenes.len(), project_id); Ok(SceneDetectionResponse { scenes }) } pub async fn auto_reframe( &self, project_id: Uuid, clip_id: Uuid, target_width: i32, target_height: i32, output_dir: &str, ) -> Result> { let clips = self.get_clips(project_id).await?; let clip = clips.iter().find(|c| c.id == clip_id).ok_or("Clip not found")?; let output_filename = format!("reframed_{}_{}.mp4", clip_id, target_width); let output_path = format!("{}/{}", output_dir, output_filename); let cmd = SafeCommand::new("ffmpeg") .map_err(|e| format!("Command creation failed: {e}"))? .arg("-i").map_err(|e| format!("Arg error: {e}"))? .arg(&clip.source_url).map_err(|e| format!("Arg error: {e}"))? .arg("-vf").map_err(|e| format!("Arg error: {e}"))? .arg(&format!( "scale={}:{}:force_original_aspect_ratio=decrease,pad={}:{}:(ow-iw)/2:(oh-ih)/2", target_width, target_height, target_width, target_height )).map_err(|e| format!("Arg error: {e}"))? .arg("-c:a").map_err(|e| format!("Arg error: {e}"))? .arg("copy").map_err(|e| format!("Arg error: {e}"))? .arg(&output_path).map_err(|e| format!("Arg error: {e}"))?; let result = cmd.execute().map_err(|e| format!("Execution failed: {e}"))?; if !result.status.success() { let stderr = String::from_utf8_lossy(&result.stderr); return Err(format!("Auto-reframe failed: {stderr}").into()); } Ok(format!("/video/reframed/{}", output_filename)) } pub fn get_available_templates(&self) -> Vec { vec![ TemplateInfo { id: "social-promo".to_string(), name: "Social Media Promo".to_string(), description: "Quick promo video with text animations".to_string(), thumbnail_url: "/templates/social-promo.jpg".to_string(), duration_ms: 15000, category: "social".to_string(), }, TemplateInfo { id: "youtube-intro".to_string(), name: "YouTube Intro".to_string(), description: "Professional channel intro".to_string(), thumbnail_url: "/templates/youtube-intro.jpg".to_string(), duration_ms: 5000, category: "intro".to_string(), }, TemplateInfo { id: "product-showcase".to_string(), name: "Product Showcase".to_string(), description: "Clean product presentation".to_string(), thumbnail_url: "/templates/product-showcase.jpg".to_string(), duration_ms: 30000, category: "business".to_string(), }, TemplateInfo { id: "talking-head".to_string(), name: "Talking Head".to_string(), description: "Speaker with lower thirds".to_string(), thumbnail_url: "/templates/talking-head.jpg".to_string(), duration_ms: 60000, category: "presentation".to_string(), }, ] } pub async fn apply_template( &self, project_id: Uuid, template_id: &str, customizations: Option, ) -> Result<(), diesel::result::Error> { let custom = customizations.unwrap_or(serde_json::json!({})); let title = custom.get("title").and_then(|v| v.as_str()).unwrap_or("Title"); match template_id { "social-promo" | "youtube-intro" => { self.add_layer(project_id, AddLayerRequest { name: Some("Title".to_string()), layer_type: "text".to_string(), start_ms: Some(0), end_ms: Some(3000), x: Some(0.5), y: Some(0.5), width: Some(0.8), height: Some(0.2), properties: Some(serde_json::json!({ "content": title, "font_size": 72, "color": "#FFFFFF", "text_align": "center", })), }).await?; }, _ => {} } info!("Applied template {} to project {}", template_id, project_id); Ok(()) } pub async fn add_transition( &self, from_clip_id: Uuid, to_clip_id: Uuid, transition_type: &str, duration_ms: i64, ) -> Result<(), diesel::result::Error> { let mut conn = self.get_conn()?; diesel::update(video_clips::table.find(from_clip_id)) .set(video_clips::transition_out.eq(Some(format!("{}:{}", transition_type, duration_ms)))) .execute(&mut conn)?; diesel::update(video_clips::table.find(to_clip_id)) .set(video_clips::transition_in.eq(Some(format!("{}:{}", transition_type, duration_ms)))) .execute(&mut conn)?; info!("Added {} transition between clips {} and {}", transition_type, from_clip_id, to_clip_id); Ok(()) } pub async fn start_export( &self, project_id: Uuid, req: ExportRequest, cache: Option<&Arc>, ) -> Result { let mut conn = self.get_conn()?; let export = VideoExport { id: Uuid::new_v4(), project_id, format: req.format.unwrap_or_else(|| "mp4".to_string()), quality: req.quality.unwrap_or_else(|| "high".to_string()), status: ExportStatus::Pending.to_string(), progress: 0, output_url: None, gbdrive_path: None, error_message: None, created_at: Utc::now(), completed_at: None, }; diesel::insert_into(video_exports::table) .values(&export) .execute(&mut conn)?; diesel::update(video_projects::table.find(project_id)) .set(video_projects::status.eq(ProjectStatus::Exporting.to_string())) .execute(&mut conn)?; if let Some(redis_client) = cache { if let Ok(mut redis_conn) = redis_client.get_connection() { let job = serde_json::json!({ "export_id": export.id.to_string(), "project_id": project_id.to_string(), "format": &export.format, "quality": &export.quality, "save_to_library": req.save_to_library.unwrap_or(true), "created_at": Utc::now().to_rfc3339(), }); let _: Result = redis::cmd("LPUSH") .arg("video:export:queue") .arg(job.to_string()) .query(&mut redis_conn); info!("Queued export job {} to Valkey", export.id); } } info!("Started export for project {project_id}: {}", export.id); Ok(export) } pub async fn get_export_status(&self, export_id: Uuid) -> Result { let mut conn = self.get_conn()?; video_exports::table.find(export_id).first(&mut conn) } async fn recalculate_duration(&self, project_id: Uuid) -> Result<(), diesel::result::Error> { let mut conn = self.get_conn()?; let clips: Vec = video_clips::table .filter(video_clips::project_id.eq(project_id)) .load(&mut conn)?; let max_duration = clips .iter() .map(|c| c.start_ms + c.duration_ms) .max() .unwrap_or(0); diesel::update(video_projects::table.find(project_id)) .set(( video_projects::total_duration_ms.eq(max_duration), video_projects::updated_at.eq(Utc::now()), )) .execute(&mut conn)?; Ok(()) } pub async fn remove_background( &self, project_id: Uuid, clip_id: Uuid, replacement: Option, ) -> Result> { let clips = self.get_clips(project_id).await?; let clip = clips.iter().find(|c| c.id == clip_id).ok_or("Clip not found")?; let botmodels_url = std::env::var("BOTMODELS_URL").unwrap_or_else(|_| "http://localhost:8001".to_string()); let client = reqwest::Client::new(); let response = client .post(format!("{}/api/v1/video/remove-background", botmodels_url)) .json(&serde_json::json!({ "video_url": &clip.source_url, "replacement": replacement, })) .send() .await?; if !response.status().is_success() { let error = response.text().await.unwrap_or_default(); return Err(format!("Background removal failed: {error}").into()); } let result: serde_json::Value = response.json().await?; Ok(BackgroundRemovalResponse { processed_url: result["processed_url"].as_str().unwrap_or("").to_string(), duration_ms: result["duration_ms"].as_i64().unwrap_or(0), }) } pub async fn enhance_video( &self, project_id: Uuid, req: VideoEnhanceRequest, ) -> Result> { let clips = self.get_clips(project_id).await?; let clip = clips.iter().find(|c| c.id == req.clip_id).ok_or("Clip not found")?; let botmodels_url = std::env::var("BOTMODELS_URL").unwrap_or_else(|_| "http://localhost:8001".to_string()); let client = reqwest::Client::new(); let response = client .post(format!("{}/api/v1/video/enhance", botmodels_url)) .json(&serde_json::json!({ "video_url": &clip.source_url, "upscale_factor": req.upscale_factor, "denoise": req.denoise, "stabilize": req.stabilize, "color_correct": req.color_correct, })) .send() .await?; if !response.status().is_success() { let error = response.text().await.unwrap_or_default(); return Err(format!("Enhancement failed: {error}").into()); } let result: serde_json::Value = response.json().await?; Ok(VideoEnhanceResponse { enhanced_url: result["enhanced_url"].as_str().unwrap_or("").to_string(), enhancements_applied: result["enhancements"] .as_array() .map(|a| a.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect()) .unwrap_or_default(), }) } pub async fn detect_beats( &self, project_id: Uuid, audio_track_id: Uuid, sensitivity: Option, ) -> Result> { let tracks = self.get_audio_tracks(project_id).await?; let track = tracks.iter().find(|t| t.id == audio_track_id).ok_or("Audio track not found")?; let botmodels_url = std::env::var("BOTMODELS_URL").unwrap_or_else(|_| "http://localhost:8001".to_string()); let client = reqwest::Client::new(); let response = client .post(format!("{}/api/v1/audio/detect-beats", botmodels_url)) .json(&serde_json::json!({ "audio_url": &track.source_url, "sensitivity": sensitivity.unwrap_or(0.5), })) .send() .await?; if !response.status().is_success() { let error = response.text().await.unwrap_or_default(); return Err(format!("Beat detection failed: {error}").into()); } let result: serde_json::Value = response.json().await?; let beats: Vec = result["beats"] .as_array() .unwrap_or(&vec![]) .iter() .map(|b| BeatMarker { time_ms: b["time_ms"].as_i64().unwrap_or(0), strength: b["strength"].as_f64().unwrap_or(1.0) as f32, beat_type: b["type"].as_str().unwrap_or("beat").to_string(), }) .collect(); Ok(BeatSyncResponse { beats, tempo_bpm: result["tempo_bpm"].as_f64().unwrap_or(120.0) as f32, }) } pub async fn generate_waveform( &self, project_id: Uuid, audio_track_id: Uuid, samples_per_second: Option, ) -> Result> { let tracks = self.get_audio_tracks(project_id).await?; let track = tracks.iter().find(|t| t.id == audio_track_id).ok_or("Audio track not found")?; let botmodels_url = std::env::var("BOTMODELS_URL").unwrap_or_else(|_| "http://localhost:8001".to_string()); let client = reqwest::Client::new(); let response = client .post(format!("{}/api/v1/audio/waveform", botmodels_url)) .json(&serde_json::json!({ "audio_url": &track.source_url, "samples_per_second": samples_per_second.unwrap_or(10), })) .send() .await?; if !response.status().is_success() { let error = response.text().await.unwrap_or_default(); return Err(format!("Waveform generation failed: {error}").into()); } let result: serde_json::Value = response.json().await?; Ok(WaveformResponse { samples: result["samples"] .as_array() .map(|a| a.iter().filter_map(|v| v.as_f64().map(|f| f as f32)).collect()) .unwrap_or_default(), duration_ms: result["duration_ms"].as_i64().unwrap_or(0), sample_rate: result["sample_rate"].as_i64().unwrap_or(10) as i32, }) } pub async fn process_chat_command( &self, project_id: Uuid, message: &str, playhead_ms: Option, selection: Option, ) -> Result> { let lower_msg = message.to_lowercase(); let mut commands_executed = Vec::new(); if lower_msg.contains("add text") || lower_msg.contains("add title") { let content = extract_quoted_text(message).unwrap_or_else(|| "Text".to_string()); let at_ms = playhead_ms.unwrap_or(0); self.add_layer( project_id, AddLayerRequest { name: Some("Text".to_string()), layer_type: "text".to_string(), start_ms: Some(at_ms), end_ms: Some(at_ms + 5000), x: Some(0.5), y: Some(0.5), width: Some(0.8), height: Some(0.2), properties: Some(serde_json::json!({ "content": content, "font_family": "Arial", "font_size": 48, "color": "#FFFFFF", })), }, ) .await?; commands_executed.push("Added text layer".to_string()); } if lower_msg.contains("delete") || lower_msg.contains("remove") { if let Some(sel) = &selection { if let Some(layer_id) = sel.get("layer_id").and_then(|v| v.as_str()) { if let Ok(id) = Uuid::parse_str(layer_id) { self.delete_layer(id).await?; commands_executed.push("Deleted layer".to_string()); } } else if let Some(clip_id) = sel.get("clip_id").and_then(|v| v.as_str()) { if let Ok(id) = Uuid::parse_str(clip_id) { self.delete_clip(id).await?; commands_executed.push("Deleted clip".to_string()); } } } } if lower_msg.contains("split") { if let Some(sel) = &selection { if let Some(clip_id) = sel.get("clip_id").and_then(|v| v.as_str()) { if let Ok(id) = Uuid::parse_str(clip_id) { let at = playhead_ms.unwrap_or(0); self.split_clip(id, at).await?; commands_executed.push("Split clip".to_string()); } } } } if lower_msg.contains("bigger") || lower_msg.contains("larger") { if let Some(sel) = &selection { if let Some(layer_id) = sel.get("layer_id").and_then(|v| v.as_str()) { if let Ok(id) = Uuid::parse_str(layer_id) { let layer = video_layers::table.find(id).first::(&mut self.get_conn()?)?; self.update_layer( id, UpdateLayerRequest { width: Some(layer.width * 1.2), height: Some(layer.height * 1.2), ..Default::default() }, ) .await?; commands_executed.push("Made layer bigger".to_string()); } } } } if lower_msg.contains("smaller") { if let Some(sel) = &selection { if let Some(layer_id) = sel.get("layer_id").and_then(|v| v.as_str()) { if let Ok(id) = Uuid::parse_str(layer_id) { let layer = video_layers::table.find(id).first::(&mut self.get_conn()?)?; self.update_layer( id, UpdateLayerRequest { width: Some(layer.width * 0.8), height: Some(layer.height * 0.8), ..Default::default() }, ) .await?; commands_executed.push("Made layer smaller".to_string()); } } } } let response_message = if commands_executed.is_empty() { "I couldn't understand that command. Try: add text \"Hello\", delete, split, make it bigger/smaller".to_string() } else { commands_executed.join(", ") }; let project_detail = self.get_project_detail(project_id).await.ok(); Ok(ChatEditResponse { success: !commands_executed.is_empty(), message: response_message, commands_executed, project: project_detail, }) } } fn extract_quoted_text(message: &str) -> Option { let chars: Vec = message.chars().collect(); let mut start = None; let mut end = None; for (i, c) in chars.iter().enumerate() { if *c == '"' || *c == '\'' || *c == '"' || *c == '"' { if start.is_none() { start = Some(i + 1); } else { end = Some(i); break; } } } match (start, end) { (Some(s), Some(e)) if e > s => Some(chars[s..e].iter().collect()), _ => None, } }