1use crate::engine::export::{self, ExportOptions, Progress};
7use crate::engine::gpu::GpuRenderer;
8use crate::engine::mixer_fx::BusGraph;
9use crate::engine::prerender::PreRender;
10use crate::engine::text::TextRasterizer;
11use crate::hotkeys::{Action, Hotkeys};
12use crate::mcp;
13use crate::media::thumbs::ThumbCache;
14use crate::media::waveform::WaveformCache;
15use crate::media::{self, Backend, Frame};
16use crate::model::{
17 BlendMode, Clip, ClipKind, Effect, EffectKind, FilterKind, Id, Mask, MaskShape, NodeKind, Project, Scaler,
18 ShapeKind, TrackKind, TransitionKind, MIN_CLIP,
19};
20use crate::playback::Player;
21use crate::settings::Settings;
22use crate::theme::{self, Palette};
23use crate::ui::layout::{self, Layout, Pane};
24use crate::ui::tools::Tool;
25use crate::ui::{
26 autocut_ui, capture_ui, curves, effects_ui, export_ui, frame_ui, import_ui, inspector, library, markers_ui,
27 mixer_ui, nodes, paste_ui, planner, presets_ui, preview, retime, settings_ui, shader_ui, subtitles_ui, timeline,
28 tools, tracking_ui, transitions_ui, DragPayload,
29};
30use eframe::egui;
31use serde_json::{json, Value};
32use std::path::{Path, PathBuf};
33use std::sync::mpsc::{Receiver, Sender};
34use std::sync::{Arc, Mutex};
35use std::time::{Duration, Instant};
36
37windows::core::link!("user32.dll" "system" fn GetCursorPos(p: *mut windows::Win32::Foundation::POINT) -> windows::core::BOOL);
39
40const PROJECT_EXT: &str = "sedit";
41const MEDIA_EXTS: &[&str] = &[
42 "mp4", "mov", "mkv", "webm", "avi", "m4v", "wmv", "ts", "m2ts", "mts", "flv", "3gp", "mpg", "mpeg", "gif", "mp3",
43 "wav", "m4a", "aac", "flac", "ogg", "opus", "wma", "png", "jpg", "jpeg", "bmp", "webp", "tif", "tiff",
44];
45
46enum ExportKind {
47 File,
48 Overwrite { original: PathBuf, temp: PathBuf },
49}
50
51struct McpJob {
53 prog: Arc<Progress>,
54 reply: Sender<Result<Value, String>>,
55 out: PathBuf,
56}
57
58struct LibPreview {
61 path: PathBuf,
62 player: Player,
63 duration: f64,
64 fps: f64,
65}
66
67pub struct App {
68 project: Project,
69 project_path: Option<PathBuf>,
70 dirty: bool,
71 undo: Vec<String>,
72 redo: Vec<String>,
73 settings: Settings,
74 hotkeys: Hotkeys,
75 text: Arc<Mutex<TextRasterizer>>,
76 fonts: Vec<String>,
77 player: Player,
78 waveforms: WaveformCache,
79 thumbs: ThumbCache,
80 layout: Layout,
81 layout_json: String,
83 layout_dirty: bool,
84 timeline: timeline::TimelineState,
85 preview: preview::PreviewState,
86 library: library::LibraryState,
87 settings_ui: settings_ui::SettingsUi,
88 transitions_ui: transitions_ui::TransitionsState,
89 curves: curves::CurvesState,
90 subtitles_ui: subtitles_ui::SubtitlesState,
91 planner: planner::PlannerState,
92 presets: presets_ui::PresetsState,
93 autocut: autocut_ui::AutoCutState,
94 tracking: tracking_ui::TrackState,
95 retime: retime::RetimeUi,
96 export_ui: export_ui::ExportUi,
97 template_name: Option<String>,
99 profile_name: Option<String>,
100 fullscreen: bool,
101 selection: Vec<Id>,
102 sel_transitions: Vec<Id>,
104 playhead: f64,
105 export: Option<(Arc<Progress>, ExportKind)>,
106 encoders: Vec<String>,
107 toasts: Vec<(String, Instant)>,
108 screenshot: Option<PathBuf>,
109 started: Instant,
110 window_shown: bool,
112 first_frame_at: Option<Instant>,
113 screenshot_requested: bool,
114 close_confirmed: bool,
115 close_after_export: bool,
117 was_playing: bool,
118 last_title: String,
120 palette: Palette,
121 pending_frame: Option<Arc<Frame>>,
123 pending_actions: Vec<Action>,
125 mcp: Option<(mcp::Server, Receiver<mcp::ToolCall>)>,
126 mcp_port_running: u16,
127 mcp_jobs: Vec<McpJob>,
128 convert_jobs: Vec<(Arc<Progress>, PathBuf)>,
130 convert_dialog: Option<(Id, String)>,
132 compress: Option<Compress>,
134 ytdlp_available: Arc<std::sync::atomic::AtomicBool>,
137 url_dialog: Option<(String, bool)>,
139 downloads: Vec<crate::media::ytdlp::Download>,
141 probes: Vec<Receiver<crate::engine::import::Probed>>,
143 autocut_shown: bool,
147 autocut_drawing: bool,
148 tracking_shown: bool,
150 tracking_drawing: bool,
151 loaded_fonts: usize,
153 gl: Option<Arc<eframe::glow::Context>>,
156 gpu_name: String,
157 gpu: Option<GpuRenderer>,
159 gpu_export: (
164 std::sync::mpsc::Sender<crate::engine::export::GpuFrameRequest>,
165 std::sync::mpsc::Receiver<crate::engine::export::GpuFrameRequest>,
166 ),
167 gpu_tex: Option<(egui::TextureId, [u32; 2])>,
170 gpu_tex_ids: std::collections::HashMap<eframe::glow::Texture, egui::TextureId>,
173 effect_thumbs: Vec<egui::TextureHandle>,
174 effect_thumbs_key: Option<(String, u32)>,
175 gpu_failed: bool,
177 gpu_prev: Option<Arc<Frame>>,
179 tools: tools::ToolsState,
180 nodes: nodes::NodesState,
181 mixer: mixer_ui::MixerState,
182 markers: markers_ui::MarkersState,
183 buses: BusGraph,
184 capture_ui: capture_ui::CaptureUi,
185 frame_ui: frame_ui::FrameUi,
186 shader_ui: shader_ui::ShaderUi,
187 import_ui: import_ui::ImportUi,
188 paste_ui: paste_ui::PasteUi,
189 screen_rec: Option<(crate::engine::capture::Capture, PathBuf)>,
191 voice_rec: Option<(crate::engine::capture::Capture, PathBuf, f64)>,
192 draw_rec: Option<(Id, f64)>,
194 was_focused: bool,
196 attrs: Option<Clip>,
198 clipboard: Option<crate::settings::Template>,
201 os_clipboard: Option<String>,
206 lib_preview: Option<LibPreview>,
210 lib_preview_tex: Option<egui::TextureHandle>,
211 lib_preview_live: Option<library::PreviewFrame>,
215 prerender: PreRender,
217 movie_stall: bool,
219 buffer_stall: bool,
221 run_script_path: Option<std::path::PathBuf>,
223 proxy_job: Option<(String, std::path::PathBuf, std::sync::Arc<crate::engine::export::Progress>)>,
225 proxy_map: std::collections::HashMap<String, String>,
227 proxy_scan_at: Option<Instant>,
229 canvas: (u32, u32),
231 audio_inputs: Option<Vec<(String, bool)>>,
233 failed_panes: Vec<Pane>,
235}
236
237fn job_window(ctx: &egui::Context, title: &str, jobs: &[(Arc<Progress>, String)]) {
240 if jobs.is_empty() {
241 return;
242 }
243 egui::Window::new(title).resizable(false).default_width(320.0).show(ctx, |ui| {
244 for (prog, name) in jobs {
245 ui.label(egui::RichText::new(name).small());
246 ui.add(egui::ProgressBar::new(prog.fraction()).show_percentage().text(prog.status()));
247 if ui.button("Cancel").clicked() {
248 prog.cancel.store(true, std::sync::atomic::Ordering::SeqCst);
249 }
250 }
251 });
252 ctx.request_repaint_after(Duration::from_millis(150));
253}
254
255const LAYOUT_STEP: &str = "\u{0}layout";
260
261fn push_undo_json(undo: &mut Vec<String>, redo: &mut Vec<String>, json: String) {
263 undo.push(json);
264 if undo.len() > 200 {
265 undo.remove(0);
266 }
267 redo.clear();
268}
269
270fn relocate_assets(project: &mut Project, project_dir: Option<&Path>) -> Vec<String> {
273 let mut missing = Vec::new();
274 for a in &mut project.assets {
275 if Path::new(&a.path).exists() {
276 continue;
277 }
278 let alt = Path::new(&a.path).file_name().and_then(|n| project_dir.map(|d| d.join(n)));
279 match alt.filter(|p| p.exists()) {
280 Some(p) => a.path = p.to_string_lossy().into_owned(),
281 None => missing.push(a.path.clone()),
282 }
283 }
284 missing
285}
286
287fn guarded<T>(f: impl FnOnce() -> T) -> Option<T> {
291 std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).ok()
292}
293
294fn add_mask(project: &mut Project, clip: Id, shape: MaskShape) -> bool {
297 let Some(c) = project.clip_mut(clip).filter(|c| c.is_visual()) else { return false };
298 match c.effects.iter_mut().rev().find(|e| e.enabled) {
299 Some(e) if e.mask.is_none() => {
300 e.mask = Some(Mask::new(shape));
301 true
302 }
303 Some(_) => false,
304 None if c.mask.is_none() => {
305 c.mask = Some(Mask::new(shape));
306 true
307 }
308 None => false,
309 }
310}
311
312fn preview_canvas(canvas: (u32, u32), quality: u32) -> (u32, u32) {
315 if canvas.0 == 0 || canvas.1 == 0 {
316 return (0, 0);
317 }
318 let q = quality.clamp(25, 100) as f32 / 100.0;
319 (((canvas.0 as f32 * q) as u32).max(16), ((canvas.1 as f32 * q) as u32).max(16))
320}
321
322fn clamp_canvas(w: u32, h: u32, max_width: u32) -> (u32, u32) {
325 if max_width > 0 && w > max_width {
326 (max_width, ((h as u64 * max_width as u64) / w.max(1) as u64).max(1) as u32)
327 } else {
328 (w, h)
329 }
330}
331
332fn frame_render_size(project: (u32, u32), target: (u32, u32)) -> (u32, u32) {
336 let (pw, ph) = (project.0.max(16), project.1.max(16));
337 let (tw, th) = (target.0.max(16), target.1.max(16));
338 if tw <= pw && th <= ph {
339 (tw, th)
340 } else {
341 (pw, ph)
342 }
343}
344
345#[allow(dead_code)]
348fn effect_thumb_key(kind: EffectKind, size: (u32, u32), image: &str) -> u64 {
351 let mut h: u64 = 0xcbf2_9ce4_8422_2325; let mut eat = |b: &[u8]| {
353 for &x in b {
354 h ^= x as u64;
355 h = h.wrapping_mul(0x1000_0000_01b3);
356 }
357 };
358 eat(kind.name().as_bytes());
359 eat(&size.0.to_le_bytes());
360 eat(&size.1.to_le_bytes());
361 eat(image.as_bytes());
362 h
363}
364
365const STOCK: &[u8] = include_bytes!("../../assets/bubble_96x54.rgba");
368const STOCK_W: u32 = 96;
369const STOCK_H: u32 = 54;
370
371#[allow(dead_code)]
372fn effect_thumb_source(image: &str, w: u32, h: u32, backend: Backend) -> Frame {
374 let (w, h) = (w.max(1), h.max(1));
375 if !image.is_empty() {
376 if let Ok(mut src) = media::open_video(image, backend) {
377 let mut f = Frame::default();
378 if src.frame_at(0.0, w, h, &mut f) && !f.is_empty() {
379 return f;
380 }
381 }
382 }
383 let mut f = Frame::new(w, h);
385 for y in 0..h {
386 let sy = y * STOCK_H / h;
387 for x in 0..w {
388 let s = ((sy * STOCK_W + x * STOCK_W / w) * 4) as usize;
389 let d = ((y * w + x) * 4) as usize;
390 f.rgba[d..d + 4].copy_from_slice(&STOCK[s..s + 4]);
391 }
392 }
393 f
394}
395
396fn write_image(frame: &Frame, opts: &frame_ui::FrameExport) -> Result<(), String> {
399 if frame.is_empty() {
400 return Err("empty frame".into());
401 }
402 let exe = media::ffpipe::ffmpeg_exe().ok_or("ffmpeg.exe not found")?;
403 let ext = opts.out.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_else(|| "png".into());
404 let mut cmd = media::ffpipe::command(&exe);
405 cmd.args(["-y", "-v", "error", "-f", "rawvideo", "-pix_fmt", "rgba"]);
406 cmd.args(["-s", &format!("{}x{}", frame.width, frame.height), "-i", "-"]);
407 if (frame.width, frame.height) != opts.size {
408 let flags = if opts.resize.is_empty() { "lanczos" } else { opts.resize.as_str() };
409 cmd.args(["-vf", &format!("scale={}:{}:flags={flags}", opts.size.0, opts.size.1)]);
410 }
411 let q = opts.quality.clamp(1, 100);
412 match ext.as_str() {
413 "jpg" | "jpeg" => cmd.args(["-q:v", &format!("{}", 2 + (100 - q) * 29 / 100)]),
415 "webp" => cmd.args(["-quality", &format!("{q}")]),
416 _ => cmd.args(["-compression_level", "9"]),
417 };
418 cmd.args(["-frames:v", "1"]).arg(&opts.out);
419 cmd.stdin(std::process::Stdio::piped()).stdout(std::process::Stdio::null()).stderr(std::process::Stdio::piped());
420 let mut child = cmd.spawn().map_err(|e| format!("ffmpeg: {e}"))?;
421 if let Some(mut stdin) = child.stdin.take() {
422 use std::io::Write;
423 let _ = stdin.write_all(&frame.rgba); }
425 let out = child.wait_with_output().map_err(|e| e.to_string())?;
426 if out.status.success() {
427 Ok(())
428 } else {
429 Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
430 }
431}
432
433fn base64(data: &[u8]) -> String {
435 const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
436 let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
437 for chunk in data.chunks(3) {
438 let b = [chunk[0], chunk.get(1).copied().unwrap_or(0), chunk.get(2).copied().unwrap_or(0)];
439 let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | b[2] as u32;
440 out.push(T[(n >> 18) as usize & 63] as char);
441 out.push(T[(n >> 12) as usize & 63] as char);
442 out.push(if chunk.len() > 1 { T[(n >> 6) as usize & 63] as char } else { '=' });
443 out.push(if chunk.len() > 2 { T[n as usize & 63] as char } else { '=' });
444 }
445 out
446}
447
448fn arg_str<'a>(args: &'a Value, k: &str) -> Option<&'a str> {
451 args.get(k)?.as_str()
452}
453fn arg_f64(args: &Value, k: &str) -> Option<f64> {
454 args.get(k)?.as_f64()
455}
456fn arg_u64(args: &Value, k: &str) -> Option<u64> {
457 args.get(k)?.as_u64()
458}
459fn arg_bool(args: &Value, k: &str) -> Option<bool> {
460 args.get(k)?.as_bool()
461}
462fn arg_ids(args: &Value, k: &str) -> Option<Vec<Id>> {
463 Some(args.get(k)?.as_array()?.iter().filter_map(|v| v.as_u64()).collect())
464}
465fn req<T>(o: Option<T>, k: &str) -> Result<T, String> {
466 o.ok_or_else(|| format!("missing or invalid argument '{k}'"))
467}
468
469fn parse_ease(s: &str) -> Option<crate::model::Ease> {
471 use crate::model::Ease;
472 match s {
473 "Linear" => return Some(Ease::Linear),
474 "EaseIn" => return Some(Ease::EaseIn),
475 "EaseOut" => return Some(Ease::EaseOut),
476 "EaseInOut" => return Some(Ease::EaseInOut),
477 "Hold" => return Some(Ease::Hold),
478 _ => {}
479 }
480 let inner = s.strip_prefix("cubic-bezier(")?.strip_suffix(')')?;
481 let v: Vec<f32> = inner.split(',').filter_map(|p| p.trim().parse().ok()).collect();
482 match v[..] {
483 [x1, y1, x2, y2] => Some(Ease::Bezier { x1, y1, x2, y2 }),
484 _ => None,
485 }
486}
487
488fn anim_of<'a>(clip: &'a mut Clip, name: &str) -> Option<&'a mut crate::model::Animated> {
490 match name {
491 "Position X" => return Some(&mut clip.x),
492 "Position Y" => return Some(&mut clip.y),
493 "Scale" => return Some(&mut clip.scale),
494 "Rotation" => return Some(&mut clip.rotation),
495 "Opacity" => return Some(&mut clip.opacity),
496 "Volume" => return Some(&mut clip.volume),
497 "Pan" => return Some(&mut clip.pan),
498 _ => {}
499 }
500 let (effect, param) = name.split_once(':')?;
501 let (effect, param) = (effect.trim(), param.trim());
502 let e = clip.effects.iter_mut().find(|e| e.kind.name().eq_ignore_ascii_case(effect))?;
503 let i = e.kind.params().iter().position(|p| p.name.eq_ignore_ascii_case(param))?;
504 e.params.get_mut(i)
505}
506
507fn mask_shape(s: &str) -> Result<MaskShape, String> {
508 MaskShape::ALL
509 .into_iter()
510 .find(|m| m.name().eq_ignore_ascii_case(s) || format!("{m:?}").eq_ignore_ascii_case(s))
511 .ok_or_else(|| format!("unknown mask shape '{s}' (Rect | Ellipse | Polygon | Path)"))
512}
513
514fn mask_slot(project: &mut Project, clip: Id, effect: Option<usize>) -> Result<&mut Option<Mask>, String> {
516 let c = project.clip_mut(clip).ok_or("no such clip")?;
517 if !c.is_visual() {
518 return Err("that clip is audio — a mask shapes pixels, and audio has none".into());
519 }
520 match effect {
521 None => Ok(&mut c.mask),
522 Some(i) => Ok(&mut c.effects.get_mut(i).ok_or("no effect at that index")?.mask),
523 }
524}
525
526fn apply_mask_fields(mask: &mut Mask, fields: &Value) -> Result<(), String> {
527 let obj = fields.as_object().ok_or("'fields' must be an object")?;
528 for (k, v) in obj {
529 match k.as_str() {
530 "shape" => mask.shape = mask_shape(v.as_str().ok_or("shape: string")?)?,
531 "invert" => mask.invert = v.as_bool().ok_or("invert: bool")?,
532 "enabled" => mask.enabled = v.as_bool().ok_or("enabled: bool")?,
533 "points" => {
534 let a = v.as_array().ok_or("points: [[x, y], …]")?;
535 mask.points = a
536 .iter()
537 .filter_map(|p| {
538 let p = p.as_array()?;
539 Some((p.first()?.as_f64()? as f32, p.get(1)?.as_f64()? as f32))
540 })
541 .collect();
542 }
543 "cx" | "cy" | "rx" | "ry" | "rotation" | "feather" | "expand" | "opacity" => {
544 let val = v.as_f64().ok_or_else(|| format!("{k}: number"))?;
545 let a = match k.as_str() {
546 "cx" => &mut mask.cx,
547 "cy" => &mut mask.cy,
548 "rx" => &mut mask.rx,
549 "ry" => &mut mask.ry,
550 "rotation" => &mut mask.rotation,
551 "feather" => &mut mask.feather,
552 "expand" => &mut mask.expand,
553 _ => &mut mask.opacity,
554 };
555 a.keys.clear();
556 a.value = val;
557 }
558 _ => return Err(format!("unknown mask field '{k}'")),
559 }
560 }
561 Ok(())
562}
563
564fn node_kind(s: &str) -> Result<NodeKind, String> {
566 match s.to_ascii_lowercase().as_str() {
567 "input" => return Ok(NodeKind::Input),
568 "output" => return Err("every graph already has exactly one Output".into()),
569 "blend" => return Ok(NodeKind::Blend { mode: BlendMode::Normal, opacity: crate::model::Animated::new(1.0) }),
570 "combine" => {
571 return Ok(NodeKind::Combine { mode: BlendMode::Normal, factor: crate::model::Animated::new(0.5) })
572 }
573 "merge" => return Ok(NodeKind::Merge),
574 "matte" => return Ok(NodeKind::Matte { invert: false, use_alpha: false }),
575 "color" => return Ok(NodeKind::Color([0, 0, 0, 255])),
576 "mask" => return Ok(NodeKind::Mask(Mask::new(MaskShape::Ellipse))),
577 "text" | "string" => {
578 return Ok(NodeKind::String(crate::model::TextStyle { text: "{time}".into(), ..Default::default() }))
579 }
580 _ => {}
581 }
582 let kind = EffectKind::ALL
583 .into_iter()
584 .find(|k| k.name().eq_ignore_ascii_case(s) || format!("{k:?}").eq_ignore_ascii_case(s))
585 .ok_or_else(|| {
586 format!(
587 "unknown node kind '{s}' (an effect name, Blend, Combine, Merge, Matte, Mask, Color, Text or Input)"
588 )
589 })?;
590 Ok(NodeKind::Effect(Effect::new(kind)))
591}
592
593fn color_arg(v: &Value) -> Option<[u8; 4]> {
594 let a = v.as_array()?;
595 let mut c = [255u8; 4];
596 for (i, x) in a.iter().take(4).enumerate() {
597 c[i] = x.as_u64()? as u8;
598 }
599 (a.len() >= 3).then_some(c)
600}
601
602fn apply_clip_fields(clip: &mut Clip, fields: &Value) -> Result<(), String> {
605 let obj = fields.as_object().ok_or("'fields' must be an object")?;
606 for (k, v) in obj {
607 match k.as_str() {
608 "speed" | "reverse" => {} "name" => clip.name = v.as_str().ok_or("name: string")?.to_string(),
610 "enabled" => clip.enabled = v.as_bool().ok_or("enabled: bool")?,
611 "label" => clip.label = v.as_u64().filter(|&l| l <= 8).ok_or("label: 0..8")? as u8,
612 "freeze" => clip.freeze = if v.is_null() { None } else { Some(v.as_f64().ok_or("freeze: number|null")?) },
613 "blend" => {
614 let name = v.as_str().ok_or("blend: string")?;
615 clip.blend = BlendMode::ALL
616 .into_iter()
617 .find(|b| b.name().eq_ignore_ascii_case(name) || format!("{b:?}").eq_ignore_ascii_case(name))
618 .ok_or_else(|| format!("unknown blend mode '{name}'"))?;
619 }
620 "fade_in" => clip.fade_in = v.as_f64().ok_or("fade_in: number")?.max(0.0),
621 "fade_out" => clip.fade_out = v.as_f64().ok_or("fade_out: number")?.max(0.0),
622 "x" | "y" | "scale" | "rotation" | "opacity" | "volume" | "pan" => {
623 let val = v.as_f64().ok_or_else(|| format!("{k}: number"))?;
624 let a = match k.as_str() {
625 "x" => &mut clip.x,
626 "y" => &mut clip.y,
627 "scale" => &mut clip.scale,
628 "rotation" => &mut clip.rotation,
629 "opacity" => &mut clip.opacity,
630 "volume" => &mut clip.volume,
631 _ => &mut clip.pan,
632 };
633 a.keys.clear(); a.value = val;
635 }
636 _ => {
638 let Some(t) = clip.text.as_mut() else {
639 return Err(format!("unknown clip field '{k}' (text fields need a text clip)"));
640 };
641 match k.as_str() {
642 "text" => t.text = v.as_str().ok_or("text: string")?.to_string(),
643 "font" => t.font = v.as_str().ok_or("font: string")?.to_string(),
644 "size" => t.size = v.as_f64().ok_or("size: number")? as f32,
645 "bold" => t.bold = v.as_bool().ok_or("bold: bool")?,
646 "italic" => t.italic = v.as_bool().ok_or("italic: bool")?,
647 "color" => t.color = color_arg(v).ok_or("color: [r,g,b,a]")?,
648 "outline_width" => t.outline_width = v.as_f64().ok_or("outline_width: number")? as f32,
649 "outline_color" => t.outline_color = color_arg(v).ok_or("outline_color: [r,g,b,a]")?,
650 "shadow" => t.shadow = v.as_bool().ok_or("shadow: bool")?,
651 "shadow_color" => t.shadow_color = color_arg(v).ok_or("shadow_color: [r,g,b,a]")?,
652 "shadow_x" => t.shadow_x = v.as_f64().ok_or("shadow_x: number")? as f32,
653 "shadow_y" => t.shadow_y = v.as_f64().ok_or("shadow_y: number")? as f32,
654 "shadow_blur" => t.shadow_blur = v.as_f64().ok_or("shadow_blur: number")? as f32,
655 "align" => t.align = v.as_u64().filter(|&a| a <= 2).ok_or("align: 0..2")? as u8,
656 "line_spacing" => t.line_spacing = v.as_f64().ok_or("line_spacing: number")? as f32,
657 "letter_spacing" => t.letter_spacing = v.as_f64().ok_or("letter_spacing: number")? as f32,
658 "box_color" => t.box_color = color_arg(v).ok_or("box_color: [r,g,b,a]")?,
659 "box_padding" => t.box_padding = v.as_f64().ok_or("box_padding: number")? as f32,
660 _ => return Err(format!("unknown clip field '{k}'")),
661 }
662 }
663 }
664 }
665 Ok(())
666}
667
668fn timeline_is_empty(p: &Project) -> bool {
671 p.is_empty() && p.main_stash.as_ref().is_none_or(|s| s.tracks.iter().all(|t| t.clips.is_empty()))
672}
673
674struct Compress {
676 src: PathBuf,
677 by_size: bool,
679 crf: u32,
680 target_mb: f64,
681 overwrite: bool,
682 source_bytes: Option<u64>,
683 duration: Option<f64>,
684}
685
686impl Compress {
687 fn new(src: PathBuf, crf: u32) -> Self {
688 let source_bytes = std::fs::metadata(&src).ok().map(|m| m.len());
689 let duration = crate::engine::convert::probe_seconds(&src);
690 let target_mb = source_bytes.map_or(10.0, |b| (b as f64 / 2e6).max(0.1));
692 Self { src, by_size: false, crf: crf.max(23), target_mb, overwrite: false, source_bytes, duration }
693 }
694}
695
696fn converted_path(src: &Path, ext: &str) -> PathBuf {
699 let stem = src.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_else(|| "output".into());
700 let mut out = src.with_file_name(format!("{stem}_converted.{ext}"));
701 let mut n = 2;
702 while out.exists() {
703 out = src.with_file_name(format!("{stem}_converted_{n}.{ext}"));
704 n += 1;
705 }
706 out
707}
708
709fn step_time(playhead: f64, fps: f64, forward: bool, duration: f64) -> f64 {
711 let dt = 1.0 / fps.max(1.0);
712 if forward {
713 (playhead + dt).min(duration)
714 } else {
715 (playhead - dt).max(0.0)
716 }
717}
718
719fn scrub_time(frac: f64, duration: f64) -> f64 {
722 frac.clamp(0.0, 1.0) * duration
723}
724
725const MUTATING_TOOLS: &[&str] = &[
727 "project.set",
728 "media.set",
729 "timeline.add_clip",
730 "timeline.split",
731 "timeline.delete",
732 "timeline.move",
733 "timeline.trim",
734 "timeline.add_transition",
735 "timeline.auto_cut",
736 "timeline.nest",
737 "clip.set",
738 "clip.keyframe",
739 "clip.add_effect",
740 "clip.remove_effect",
741 "clip.apply_motion",
742 "subtitles.set",
743 "subtitles.import",
744 "plan.add",
745 "plan.set",
746 "plan.remove",
747 "notes.set",
748 "templates.apply",
749 "clip.add_mask",
750 "clip.set_mask",
751 "clip.add_node",
752 "clip.connect_nodes",
753 "markers.add",
754 "markers.remove",
755 "audio.add_bus",
756 "audio.add_filter",
757 "audio.route",
758 "shapes.add",
759 "labels.set",
760 "container.add",
761 "container.replace",
762 "container.make",
763 "container.unmake",
764];
765
766impl App {
767 pub fn new(cc: &eframe::CreationContext<'_>, open: Option<PathBuf>, screenshot: Option<PathBuf>) -> Self {
768 crate::winpos::place_on_cursor_monitor(cc);
770 let settings = Settings::load();
771 media::ffpipe::set_dir(&settings.ffmpeg_dir);
772 media::ytdlp::set_dir(&settings.ytdlp_dir);
773 theme::apply(&cc.egui_ctx, &settings.theme);
774 let backend = Backend::parse(&settings.decoder);
775 let text = Arc::new(Mutex::new(TextRasterizer::new()));
776 {
777 let t = text.clone();
779 std::thread::spawn(move || {
780 if let Ok(mut t) = t.lock() {
781 t.load_system_fonts();
782 }
783 });
784 }
785 if settings.context_menu
787 && screenshot.is_none()
788 && !cfg!(debug_assertions)
789 && !crate::contextmenu::is_installed()
790 {
791 let _ = crate::contextmenu::install();
792 }
793 let player = Player::new(cc.egui_ctx.clone(), backend, text.clone());
794 let waveforms = WaveformCache::new(cc.egui_ctx.clone(), backend);
795 let thumbs = ThumbCache::new(cc.egui_ctx.clone(), backend);
796 let hotkeys = Hotkeys::from_settings(&settings);
797 let palette = theme::palette_with(&cc.egui_ctx, &settings.palette);
798 let gl = cc.gl.clone();
801 let gpu_name = gl
802 .as_ref()
803 .map(|gl| {
804 use eframe::glow::HasContext;
805 unsafe { gl.get_parameter_string(eframe::glow::RENDERER) }
806 })
807 .unwrap_or_else(|| "no OpenGL context".into());
808 let layout = Layout::from_json(&settings.layout).unwrap_or_default();
810 let layout_json = layout.to_json();
811 let mut app = Self {
812 project: Project::new(),
813 project_path: None,
814 dirty: false,
815 undo: Vec::new(),
816 redo: Vec::new(),
817 settings,
818 hotkeys,
819 text,
820 fonts: Vec::new(),
821 player,
822 waveforms,
823 thumbs,
824 layout,
825 layout_json,
826 layout_dirty: false,
827 timeline: timeline::TimelineState::default(),
828 preview: preview::PreviewState::default(),
829 library: library::LibraryState::default(),
830 settings_ui: settings_ui::SettingsUi::default(),
831 transitions_ui: transitions_ui::TransitionsState::default(),
832 curves: curves::CurvesState::default(),
833 subtitles_ui: subtitles_ui::SubtitlesState::default(),
834 planner: planner::PlannerState::default(),
835 presets: presets_ui::PresetsState::default(),
836 autocut: autocut_ui::AutoCutState::default(),
837 tracking: tracking_ui::TrackState::default(),
838 retime: retime::RetimeUi::default(),
839 export_ui: export_ui::ExportUi::default(),
840 template_name: None,
841 profile_name: None,
842 fullscreen: false,
843 selection: Vec::new(),
844 sel_transitions: Vec::new(),
845 playhead: 0.0,
846 export: None,
847 encoders: Vec::new(),
848 toasts: Vec::new(),
849 screenshot,
850 started: Instant::now(),
851 window_shown: false,
852 first_frame_at: None,
853 screenshot_requested: false,
854 close_confirmed: false,
855 close_after_export: false,
856 was_playing: false,
857 last_title: String::new(),
858 palette,
859 pending_frame: None,
860 pending_actions: Vec::new(),
861 mcp: None,
862 mcp_port_running: 0,
863 mcp_jobs: Vec::new(),
864 convert_jobs: Vec::new(),
865 convert_dialog: None,
866 compress: None,
867 ytdlp_available: Arc::new(std::sync::atomic::AtomicBool::new(false)),
868 url_dialog: None,
869 downloads: Vec::new(),
870 probes: Vec::new(),
871 autocut_shown: false,
872 autocut_drawing: false,
873 tracking_shown: false,
874 tracking_drawing: false,
875 loaded_fonts: 0,
876 gl,
877 gpu_name,
878 gpu: None,
879 gpu_export: std::sync::mpsc::channel(),
880 gpu_tex: None,
881 gpu_tex_ids: std::collections::HashMap::new(),
882 effect_thumbs: Vec::new(),
883 effect_thumbs_key: None,
884 gpu_failed: false,
885 gpu_prev: None,
886 tools: tools::ToolsState::default(),
887 nodes: nodes::NodesState::default(),
888 mixer: mixer_ui::MixerState::default(),
889 markers: markers_ui::MarkersState::default(),
890 buses: BusGraph::new(),
891 capture_ui: capture_ui::CaptureUi::default(),
892 frame_ui: frame_ui::FrameUi::default(),
893 shader_ui: shader_ui::ShaderUi::default(),
894 import_ui: import_ui::ImportUi::default(),
895 paste_ui: paste_ui::PasteUi::default(),
896 screen_rec: None,
897 voice_rec: None,
898 draw_rec: None,
899 was_focused: true,
900 attrs: None,
901 clipboard: None,
902 os_clipboard: None,
903 lib_preview: None,
904 lib_preview_live: None,
905 lib_preview_tex: None,
906 prerender: PreRender::new(),
907 movie_stall: false,
908 buffer_stall: false,
909 run_script_path: None,
910 proxy_job: None,
911 proxy_map: std::collections::HashMap::new(),
912 proxy_scan_at: None,
913 canvas: (0, 0),
914 audio_inputs: None,
915 failed_panes: Vec::new(),
916 };
917 app.detect_ytdlp(&cc.egui_ctx);
918 app.refresh_presets();
919 app.player.set_project(&app.project);
920 if let Some(p) = open {
921 app.open_path(&p);
922 if app.screenshot.is_none() && !app.project.tracks.iter().all(|t| t.clips.is_empty()) {
924 app.fullscreen = true;
925 app.player.play();
926 }
927 }
928 app
929 }
930
931 fn toast(&mut self, msg: impl Into<String>) {
934 self.toasts.push((msg.into(), Instant::now()));
935 }
936
937 fn push_undo(&mut self) {
938 push_undo_json(&mut self.undo, &mut self.redo, self.project.to_json());
939 }
940
941 fn insert_at(&mut self, ids: Vec<Id>, mut t: f64, vt: Option<usize>) {
943 for id in ids {
944 let new = self.project.insert_asset_clips(id, t, vt);
945 if let Some(c) = new.first().and_then(|c| self.project.clip(*c)) {
946 t = c.end();
947 }
948 }
949 }
950
951 fn open_or_import(&mut self, paths: &[PathBuf]) -> Vec<Id> {
955 if self.project.is_empty() && self.project.assets.is_empty() && paths.len() == 1 {
956 self.open_media(&paths[0]);
957 return Vec::new();
958 }
959 self.import_files(paths)
960 }
961
962 fn after_edit(&mut self) {
964 self.dirty = true;
965 let p = &self.project;
966 self.selection.retain(|id| p.clip(*id).is_some());
967 self.player.set_project(&self.project);
968 if self.settings.movie_mode {
969 let end = self.project.duration();
971 let App { prerender, .. } = self;
972 guarded(|| prerender.invalidate(0.0, end));
973 self.request_prerender();
974 }
975 }
976
977 fn set_project(&mut self, project: Project, path: Option<PathBuf>) {
978 self.probes.clear(); self.project = project;
980 self.project_path = path;
981 self.dirty = false;
982 self.undo.clear();
983 self.redo.clear();
984 self.selection.clear();
985 self.playhead = 0.0;
986 self.player.pause();
987 self.player.set_project(&self.project);
988 self.player.seek(0.0);
989 self.timeline.zoom_to_fit(self.project.duration(), self.timeline.lanes_rect.width().max(800.0));
990 }
991
992 fn title(&self) -> String {
993 let name = self
994 .project_path
995 .as_ref()
996 .map(|p| p.file_name().unwrap_or_default().to_string_lossy().into_owned())
997 .unwrap_or_else(|| self.project.name.clone());
998 format!("{}{} — Simple Editor", if self.dirty { "*" } else { "" }, name)
999 }
1000
1001 fn seek(&mut self, t: f64) {
1002 self.playhead = t.clamp(0.0, self.project.duration().max(0.0));
1003 self.player.seek(self.playhead);
1004 self.timeline.follow_playhead(self.playhead);
1007 }
1008
1009 fn backend(&self) -> Backend {
1010 Backend::parse(&self.settings.decoder)
1011 }
1012
1013 fn timeline_is_empty(&self) -> bool {
1014 timeline_is_empty(&self.project)
1015 }
1016
1017 fn export_project(&self) -> Project {
1019 let mut p = self.project.clone();
1020 if p.editing.is_some() {
1021 p.close_sequence();
1022 }
1023 p
1024 }
1025
1026 fn open_path(&mut self, path: &Path) {
1029 if path.extension().map(|e| e.to_string_lossy().eq_ignore_ascii_case(PROJECT_EXT)).unwrap_or(false) {
1030 self.open_project(path);
1031 } else {
1032 self.open_media(path);
1033 }
1034 }
1035
1036 fn open_media(&mut self, path: &Path) {
1037 let p = path.to_string_lossy().into_owned();
1038 match media::probe(&p, self.backend()) {
1039 Ok(asset) => {
1040 let project = Project::from_media(asset);
1041 self.set_project(project, None);
1042 self.settings.touch_recent(&p);
1043 self.settings.save();
1044 }
1045 Err(e) => self.toast(format!("Can't open {}: {e}", path.display())),
1046 }
1047 }
1048
1049 fn open_project(&mut self, path: &Path) {
1050 match Project::load(path) {
1051 Ok(mut project) => {
1052 for m in relocate_assets(&mut project, path.parent()) {
1053 self.toast(format!("Missing media: {m}"));
1054 }
1055 self.set_project(project, Some(path.to_path_buf()));
1056 self.settings.touch_recent_project(&path.to_string_lossy());
1057 self.settings.save();
1058 }
1059 Err(e) => self.toast(format!("Can't open project: {e}")),
1060 }
1061 }
1062
1063 fn import_files(&mut self, paths: &[PathBuf]) -> Vec<Id> {
1069 let mut ids = Vec::new();
1070 let mut fresh: Vec<(Id, String)> = Vec::new();
1071 for path in paths {
1072 let p = path.to_string_lossy().into_owned();
1073 if let Some(a) = self.project.asset_by_path(&p) {
1076 ids.push(a.id);
1077 continue;
1078 }
1079 if fresh.is_empty() {
1080 self.push_undo();
1081 }
1082 let id = self.project.add_asset(crate::engine::import::placeholder(&p));
1083 ids.push(id);
1084 fresh.push((id, p));
1085 }
1086 if !fresh.is_empty() {
1087 self.probes.push(crate::engine::import::probe_async(fresh, self.backend()));
1088 self.after_edit();
1089 }
1090 ids
1091 }
1092
1093 fn poll_probes(&mut self, ctx: &egui::Context) {
1096 if self.probes.is_empty() {
1097 return;
1098 }
1099 let mut landed: Vec<crate::engine::import::Probed> = Vec::new();
1100 self.probes.retain(|rx| loop {
1101 match rx.try_recv() {
1102 Ok(p) => landed.push(p),
1103 Err(std::sync::mpsc::TryRecvError::Empty) => break true,
1104 Err(std::sync::mpsc::TryRecvError::Disconnected) => break false,
1105 }
1106 });
1107 let mut any = false;
1108 for p in landed {
1109 match p.asset {
1110 Ok(a) => {
1111 any |= crate::engine::import::adopt(&mut self.project, p.id, a);
1112 self.settings.touch_recent(&p.path);
1113 }
1114 Err(e) => {
1115 self.project.remove_asset(p.id);
1116 self.toast(format!("Can't import {}: {e}", p.path));
1117 any = true;
1118 }
1119 }
1120 }
1121 if any {
1122 if self.project.is_empty() {
1124 if let Some(a) = self.project.assets.first().cloned() {
1125 if a.has_video() && a.width > 0 {
1126 self.project.width = a.width;
1127 self.project.height = a.height;
1128 if a.fps > 1.0 {
1129 self.project.fps = a.fps;
1130 }
1131 }
1132 }
1133 }
1134 self.settings.save();
1135 self.after_edit();
1136 }
1137 if !self.probes.is_empty() {
1138 ctx.request_repaint_after(Duration::from_millis(80));
1139 }
1140 }
1141
1142 fn media_dialog() -> rfd::FileDialog {
1143 rfd::FileDialog::new()
1144 .add_filter("Media", MEDIA_EXTS)
1145 .add_filter("Simple Editor project", &[PROJECT_EXT])
1146 .add_filter("All files", &["*"])
1147 }
1148
1149 fn act_open_file(&mut self) {
1150 if !self.confirm_discard() {
1151 return;
1152 }
1153 if let Some(p) = Self::media_dialog().pick_file() {
1154 self.open_path(&p);
1155 }
1156 }
1157
1158 fn act_open_project(&mut self) {
1159 if !self.confirm_discard() {
1160 return;
1161 }
1162 if let Some(p) = rfd::FileDialog::new().add_filter("Simple Editor project", &[PROJECT_EXT]).pick_file() {
1163 self.open_project(&p);
1164 }
1165 }
1166
1167 fn act_import(&mut self) {
1168 if let Some(paths) = Self::media_dialog().pick_files() {
1169 let ids = self.import_files(&paths);
1170 if let Some(id) = ids.last() {
1171 self.library.selected = Some(*id);
1172 self.library.tab = 0;
1173 }
1174 }
1175 }
1176
1177 fn replace_container_dialog(&mut self, clip_id: Id, pair: bool) {
1178 if let Some(p) = Self::media_dialog().pick_file() {
1179 let ids = self.import_files(&[p]);
1180 let Some(&aid) = ids.first() else { return };
1181 let snap = self.project.to_json();
1182 let ok = if pair {
1183 self.project.replace_container_pair(clip_id, aid)
1184 } else {
1185 self.project.replace_container_media(clip_id, aid)
1186 };
1187 if ok {
1188 push_undo_json(&mut self.undo, &mut self.redo, snap);
1189 self.after_edit();
1190 self.toast("Container media replaced");
1191 }
1192 }
1193 }
1194
1195 fn save_project_as(&mut self) -> bool {
1196 let mut d = rfd::FileDialog::new()
1197 .add_filter("Simple Editor project", &[PROJECT_EXT])
1198 .set_file_name(format!("{}.{PROJECT_EXT}", self.project.name));
1199 if let Some(dir) = self.project_path.as_ref().and_then(|p| p.parent()) {
1200 d = d.set_directory(dir);
1201 } else if let Some(dir) = self.project.source_video.as_ref().and_then(|p| Path::new(p).parent()) {
1202 d = d.set_directory(dir);
1203 }
1204 match d.save_file() {
1205 Some(p) => {
1206 self.project_path = Some(p);
1207 self.save_project()
1208 }
1209 None => false,
1210 }
1211 }
1212
1213 fn save_project(&mut self) -> bool {
1214 let Some(path) = self.project_path.clone() else { return self.save_project_as() };
1215 match self.project.save(&path) {
1216 Ok(()) => {
1217 self.dirty = false;
1218 self.settings.touch_recent_project(&path.to_string_lossy());
1219 self.settings.save();
1220 self.toast("Project saved");
1221 true
1222 }
1223 Err(e) => {
1224 self.toast(format!("Save failed: {e}"));
1225 false
1226 }
1227 }
1228 }
1229
1230 fn open_subtitle_folder(&mut self) {
1233 let dir = match self.project_path.as_ref().and_then(|p| p.parent()) {
1234 Some(d) => d.join(format!("{} subtitles", self.project.name)),
1235 None => {
1236 self.toast("Save the project first — the subtitle folder lives next to it");
1237 return;
1238 }
1239 };
1240 if let Err(e) = std::fs::create_dir_all(&dir) {
1241 self.toast(format!("Could not create {}: {e}", dir.display()));
1242 return;
1243 }
1244 if !self.project.subtitles.is_empty() {
1245 let srt = crate::engine::subtitles::to_srt(&self.project.subtitles);
1246 let _ = std::fs::write(dir.join(format!("{}.srt", self.project.name)), srt);
1247 }
1248 let _ = std::process::Command::new("explorer").arg(&dir).spawn();
1249 }
1250
1251 fn act_save(&mut self) {
1253 if self.project_path.is_some() {
1254 self.save_project();
1255 } else if self.project.source_video.is_some() {
1256 self.act_overwrite();
1257 } else {
1258 self.save_project_as();
1259 }
1260 }
1261
1262 fn confirm_discard(&mut self) -> bool {
1264 if !self.dirty {
1265 return true;
1266 }
1267 let msg = if self.project_path.is_some() {
1269 "Save changes to the project?"
1270 } else {
1271 "Save changes as a project file (.sedit)?"
1272 };
1273 let r = rfd::MessageDialog::new()
1274 .set_title("Simple Editor")
1275 .set_description(msg)
1276 .set_buttons(rfd::MessageButtons::YesNoCancel)
1277 .set_level(rfd::MessageLevel::Warning)
1278 .show();
1279 match r {
1280 rfd::MessageDialogResult::Yes => self.save_project(),
1281 rfd::MessageDialogResult::No => true,
1282 _ => false,
1283 }
1284 }
1285
1286 fn ffmpeg_missing(&mut self) -> bool {
1287 if media::ffpipe::ffmpeg_exe().is_none() {
1288 self.toast(
1289 "ffmpeg.exe not found — install FFmpeg (winget install Gyan.FFmpeg) or set its folder in Settings",
1290 );
1291 return true;
1292 }
1293 false
1294 }
1295
1296 fn export_opts(&self, out_path: PathBuf) -> ExportOptions {
1297 ExportOptions {
1298 out_path,
1299 encoder: self.settings.encoder.clone(),
1300 crf: self.settings.crf,
1301 preset: self.settings.preset.clone(),
1302 backend: self.backend(),
1303 out_size: None,
1304 scaler: self.settings.export_scaler.clone(),
1305 frames: self.export_frames(),
1306 metadata: Vec::new(),
1307 }
1308 }
1309
1310 fn detect_encoders_once(&mut self) {
1311 if self.encoders.is_empty() && media::ffpipe::ffmpeg_exe().is_some() {
1312 self.encoders = export::detect_encoders();
1313 }
1314 }
1315
1316 fn act_export(&mut self) {
1318 if self.ffmpeg_missing() || self.timeline_is_empty() {
1319 return;
1320 }
1321 self.detect_encoders_once();
1322 self.export_ui.open = true;
1323 }
1324
1325 fn start_export_choice(&mut self, choice: export_ui::ExportChoice) {
1327 if self.export.is_some() || self.ffmpeg_missing() || self.timeline_is_empty() {
1328 return;
1329 }
1330 let out_c = std::fs::canonicalize(&choice.opts.out_path).ok();
1332 if out_c.is_some() && self.project.assets.iter().any(|a| std::fs::canonicalize(&a.path).ok() == out_c) {
1333 self.toast("That file is a source of this project — use Overwrite Original Video (Ctrl+S) instead");
1334 return;
1335 }
1336 self.player.pause();
1337 let project = self.export_project();
1338 let prog = if choice.lossless {
1339 export::start_lossless_cut(project, choice.opts.out_path.clone())
1340 } else {
1341 export::start_export(project, choice.opts, self.text.clone())
1342 };
1343 self.export = Some((prog, ExportKind::File));
1344 self.settings.save(); }
1346
1347 fn act_export_lossless(&mut self) {
1348 if self.export.is_some() || self.ffmpeg_missing() {
1349 return;
1350 }
1351 let project = self.export_project();
1352 if export::lossless_segments(&project).is_none() {
1353 self.toast("Lossless cut needs a plain cut of one video (no effects, text, overlays or extra media)");
1354 return;
1355 }
1356 let src = project.source_video.clone().or_else(|| project.assets.first().map(|a| a.path.clone()));
1357 let ext = src
1358 .as_ref()
1359 .and_then(|s| Path::new(s).extension().map(|e| e.to_string_lossy().into_owned()))
1360 .unwrap_or_else(|| "mp4".into());
1361 let mut d = rfd::FileDialog::new()
1362 .add_filter(&format!("{} (same container)", ext.to_uppercase()), &[ext.as_str()])
1363 .set_file_name(format!("{}_cut.{ext}", project.name));
1364 if let Some(dir) = src.as_ref().and_then(|p| Path::new(p).parent()) {
1365 d = d.set_directory(dir);
1366 }
1367 let Some(out) = d.save_file() else { return };
1368 self.player.pause();
1369 let prog = export::start_lossless_cut(project, out);
1370 self.export = Some((prog, ExportKind::File));
1371 }
1372
1373 fn act_export_xml(&mut self) {
1374 let Some(out) = rfd::FileDialog::new()
1375 .add_filter("Final Cut Pro 7 XML (Premiere / Resolve)", &["xml"])
1376 .set_file_name(format!("{}.xml", self.project.name))
1377 .save_file()
1378 else {
1379 return;
1380 };
1381 match std::fs::write(&out, crate::engine::xmeml::export_xmeml(&self.export_project())) {
1382 Ok(()) => {
1383 self.toast("XML exported — import it in Premiere (File > Import) or Resolve (File > Import > Timeline)")
1384 }
1385 Err(e) => self.toast(format!("XML export failed: {e}")),
1386 }
1387 }
1388
1389 fn act_export_style(&mut self) {
1390 let Some(out) = rfd::FileDialog::new()
1391 .add_filter("Markdown", &["md"])
1392 .set_file_name(format!("{}_style.md", self.project.name))
1393 .save_file()
1394 else {
1395 return;
1396 };
1397 match std::fs::write(&out, crate::engine::style::style_summary(&self.export_project())) {
1399 Ok(()) => self.toast("Style summary exported"),
1400 Err(e) => self.toast(format!("Style summary failed: {e}")),
1401 }
1402 }
1403
1404 fn act_overwrite(&mut self) {
1406 let Some(src) = self.project.source_video.clone() else {
1407 self.toast("No source video to overwrite — use Export Video As");
1408 return;
1409 };
1410 if self.export.is_some() {
1411 self.toast("An export is already running");
1412 return;
1413 }
1414 if self.timeline_is_empty() {
1415 self.toast("Timeline is empty — nothing to save");
1416 return;
1417 }
1418 if self.ffmpeg_missing() {
1419 return;
1420 }
1421 if self.settings.confirm_overwrite {
1422 let r = rfd::MessageDialog::new()
1423 .set_title("Overwrite original video?")
1424 .set_description(format!(
1425 "{src}\n\nThe file will be replaced with the edited video. This cannot be undone."
1426 ))
1427 .set_buttons(rfd::MessageButtons::OkCancel)
1428 .set_level(rfd::MessageLevel::Warning)
1429 .show();
1430 if r != rfd::MessageDialogResult::Ok {
1431 return;
1432 }
1433 }
1434 let p = &self.project;
1437 let loses = !p.plan.is_empty()
1438 || !p.notes.is_empty()
1439 || !p.subtitles.is_empty()
1440 || !p.sequences.is_empty()
1441 || p.assets.len() > 1;
1442 if loses && (self.dirty || self.project_path.is_none()) {
1443 match rfd::MessageDialog::new()
1444 .set_title("Save the project first?")
1445 .set_description(
1446 "Overwriting reloads the new file as a fresh project — subtitles, planner, notes, \
1447 sequences and imported media are not kept. Save a project file (.sedit) first?",
1448 )
1449 .set_buttons(rfd::MessageButtons::YesNoCancel)
1450 .set_level(rfd::MessageLevel::Warning)
1451 .show()
1452 {
1453 rfd::MessageDialogResult::Yes => {
1454 if !self.save_project() {
1455 return;
1456 }
1457 }
1458 rfd::MessageDialogResult::No => {}
1459 _ => return,
1460 }
1461 }
1462 let original = PathBuf::from(&src);
1463 let ext = original.extension().map(|e| e.to_string_lossy().into_owned()).unwrap_or_else(|| "mp4".into());
1464 let temp = original.with_file_name(format!(
1465 ".{}.simple-editor-tmp.{ext}",
1466 original.file_stem().unwrap_or_default().to_string_lossy()
1467 ));
1468 self.player.pause();
1469 let project = self.export_project();
1470 let lossless = self.settings.lossless_save && export::lossless_segments(&project).is_some();
1472 let prog = if lossless {
1473 export::start_lossless_cut(project, temp.clone())
1474 } else {
1475 export::start_export(project, self.export_opts(temp.clone()), self.text.clone())
1476 };
1477 self.export = Some((prog, ExportKind::Overwrite { original, temp }));
1478 }
1479
1480 fn finish_export(&mut self) {
1481 let Some((prog, kind)) = self.export.take() else { return };
1482 if let Some(e) = prog.error() {
1483 if let ExportKind::Overwrite { temp, .. } = &kind {
1484 let _ = std::fs::remove_file(temp);
1485 }
1486 if prog.is_cancelled() {
1487 self.toast("Export cancelled");
1488 } else {
1489 self.toast(format!("Export failed: {e}"));
1490 }
1491 return;
1492 }
1493 match kind {
1494 ExportKind::File => self.toast("Export finished"),
1495 ExportKind::Overwrite { original, temp } => {
1496 self.player.release_files();
1497 self.thumbs.clear();
1499 let mut r = std::fs::rename(&temp, &original);
1501 let deadline = Instant::now() + Duration::from_millis(500);
1502 while r.is_err() && Instant::now() < deadline {
1503 std::thread::sleep(Duration::from_millis(10));
1504 r = std::fs::rename(&temp, &original);
1505 }
1506 match r {
1507 Ok(()) => {
1508 self.toast("Saved over the original video");
1509 self.waveforms.clear();
1511 self.open_media(&original);
1512 }
1513 Err(e) => {
1514 self.toast(format!(
1515 "Couldn't replace the original ({e}); edited file left at {}",
1516 temp.display()
1517 ));
1518 self.player.set_project(&self.project);
1519 }
1520 }
1521 }
1522 }
1523 }
1524
1525 fn act(&mut self, a: Action) {
1528 use Action::*;
1529 if self.export.is_some() && matches!(a, Save | SaveProjectAs | ExportVideo | ExportLossless) {
1531 self.toast("An export is running — try again when it finishes");
1532 return;
1533 }
1534 match a {
1535 NewProject => {
1536 if self.confirm_discard() {
1537 self.set_project(Project::new(), None);
1538 }
1539 }
1540 OpenFile => self.act_open_file(),
1541 OpenProject => self.act_open_project(),
1542 Save => self.act_save(),
1543 SaveProjectAs => {
1544 self.save_project_as();
1545 }
1546 ExportVideo => self.act_export(),
1547 ExportLossless => self.act_export_lossless(),
1548 ExportXml => self.act_export_xml(),
1549 ImportMedia => self.act_import(),
1550 Settings => self.settings_ui.open = !self.settings_ui.open,
1551 Undo => {
1552 if let Some(json) = self.undo.pop() {
1553 if json == LAYOUT_STEP {
1554 self.layout.undo();
1555 self.layout_dirty = true;
1556 self.redo.push(json);
1557 } else {
1558 self.redo.push(self.project.to_json());
1559 if let Ok(p) = Project::from_json(&json) {
1560 self.project = p;
1561 self.after_edit();
1562 }
1563 }
1564 }
1565 }
1566 Redo => {
1567 if let Some(json) = self.redo.pop() {
1568 if json == LAYOUT_STEP {
1569 self.layout.redo();
1570 self.layout_dirty = true;
1571 self.undo.push(json);
1572 } else {
1573 self.undo.push(self.project.to_json());
1574 if let Ok(p) = Project::from_json(&json) {
1575 self.project = p;
1576 self.after_edit();
1577 }
1578 }
1579 }
1580 }
1581 PlayPause => {
1582 if self.buffer_stall {
1583 self.buffer_stall = false; } else {
1585 if !self.player.is_playing() && self.playhead >= self.project.duration() - 1e-6 {
1586 self.seek(0.0);
1587 }
1588 self.player.toggle();
1589 }
1590 }
1591 Stop => {
1592 self.buffer_stall = false;
1593 self.player.pause();
1594 }
1595 StepBack => {
1596 self.player.pause();
1597 let t = self.project.snap_frame(self.playhead - self.project.frame_dur());
1598 self.seek(t);
1599 }
1600 StepForward => {
1601 self.player.pause();
1602 let t = self.project.snap_frame(self.playhead + self.project.frame_dur());
1603 self.seek(t);
1604 }
1605 GoStart => self.seek(0.0),
1606 GoEnd => self.seek(self.project.duration()),
1607 PrevCut => {
1608 let cuts = self.project.cut_points();
1609 if let Some(&t) = cuts.iter().rev().find(|&&c| c < self.playhead - 1e-4) {
1610 self.seek(t);
1611 }
1612 }
1613 NextCut => {
1614 let cuts = self.project.cut_points();
1615 if let Some(&t) = cuts.iter().find(|&&c| c > self.playhead + 1e-4) {
1616 self.seek(t);
1617 }
1618 }
1619 Split => {
1620 let only =
1621 if self.selection.is_empty() { None } else { Some(self.project.expand_links(&self.selection)) };
1622 let snap = self.project.to_json();
1623 let mut did = !self.project.split_at(self.playhead, only.as_deref()).is_empty();
1624 for id in self.timeline.sub_sel.clone() {
1626 did |= self.project.split_cue(id, self.playhead).is_some();
1627 }
1628 if did {
1629 push_undo_json(&mut self.undo, &mut self.redo, snap);
1630 self.after_edit();
1631 }
1632 }
1633 Delete | RippleDelete => {
1634 let ids = self.project.expand_links(&self.selection);
1635 let trs = std::mem::take(&mut self.sel_transitions);
1636 if !ids.is_empty() || !trs.is_empty() {
1637 self.push_undo();
1638 for tid in trs {
1639 self.project.remove_transition(tid);
1640 }
1641 self.project.delete_clips(&ids, a == RippleDelete);
1642 self.selection.clear();
1643 self.after_edit();
1644 }
1645 }
1646 SelectAll => self.selection = self.project.all_clips().map(|(_, c)| c.id).collect(),
1647 Deselect => self.selection.clear(),
1648 MarkIn => {
1650 self.push_undo();
1651 self.project.in_point = Some(self.playhead);
1652 if let Some(o) = self.project.out_point {
1653 if o <= self.playhead {
1654 self.project.out_point = None;
1655 }
1656 }
1657 self.after_edit();
1658 }
1659 MarkOut => {
1660 self.push_undo();
1661 self.project.out_point = Some(self.playhead);
1662 if let Some(i) = self.project.in_point {
1663 if i >= self.playhead {
1664 self.project.in_point = None;
1665 }
1666 }
1667 self.after_edit();
1668 }
1669 ClearInOut => {
1670 if self.project.in_point.is_some() || self.project.out_point.is_some() {
1671 self.push_undo();
1672 self.project.in_point = None;
1673 self.project.out_point = None;
1674 self.after_edit();
1675 }
1676 }
1677 TrimToInOut | RippleDeleteInOut => {
1678 let a0 = self.project.in_point.unwrap_or(0.0);
1679 let b0 = self.project.out_point.unwrap_or(self.project.duration());
1680 if b0 > a0 {
1681 self.push_undo();
1682 if a == TrimToInOut {
1683 self.project.trim_to_range(a0, b0);
1684 self.seek(0.0);
1685 } else {
1686 self.project.ripple_delete_range(a0, b0);
1687 self.project.in_point = None;
1688 self.project.out_point = None;
1689 self.seek(a0);
1690 }
1691 self.after_edit();
1692 }
1693 }
1694 AddText => {
1695 self.push_undo();
1696 let id = self.project.add_text_clip(self.playhead, 5.0);
1697 self.selection = vec![id];
1698 self.after_edit();
1699 }
1700 ZoomIn => self.timeline.zoom_by(1.25, None),
1701 ZoomOut => self.timeline.zoom_by(0.8, None),
1702 ZoomFit => self.timeline.zoom_to_fit(self.project.duration(), self.timeline.lanes_rect.width()),
1703 LinkToggle => {
1704 if !self.selection.is_empty() {
1705 self.push_undo();
1706 let ids = self.selection.clone();
1707 self.project.toggle_link(&ids);
1708 self.after_edit();
1709 }
1710 }
1711 ToggleEnabled => {
1712 if let Some(first) = self.selection.first().and_then(|id| self.project.clip(*id)) {
1713 let en = !first.enabled;
1714 self.push_undo();
1715 let ids = self.selection.clone();
1716 self.project.set_enabled(&ids, en);
1717 self.after_edit();
1718 }
1719 }
1720 NudgeLeft | NudgeRight => {
1721 let ids = self.project.expand_links(&self.selection);
1722 if !ids.is_empty() {
1723 let dt = if a == NudgeLeft { -self.project.frame_dur() } else { self.project.frame_dur() };
1724 let snap = self.project.to_json();
1725 if self.project.move_clips(&ids, dt, 0, None) {
1726 push_undo_json(&mut self.undo, &mut self.redo, snap);
1727 self.after_edit();
1728 }
1729 }
1730 }
1731 ToggleSnap => {
1732 self.settings.snap = !self.settings.snap;
1733 self.settings.save();
1734 }
1735 AddVideoTrack | AddAudioTrack => {
1736 self.push_undo();
1737 self.project.add_track(if a == AddVideoTrack { TrackKind::Video } else { TrackKind::Audio });
1738 self.after_edit();
1739 }
1740 ToggleLibrary => self.toggle_pane(Pane::Library),
1741 ToggleMarkers => self.toggle_pane(Pane::Markers),
1742 ToggleNodes => self.toggle_pane(Pane::Nodes),
1743 ToggleMixer => self.toggle_pane(Pane::Mixer),
1744 ToggleTools => self.toggle_pane(Pane::Tools),
1745 AddLastTransition => {
1746 let (kind, dur) = (self.transitions_ui.kind(), self.transitions_ui.duration);
1748 if self.selection.is_empty() {
1749 self.toast("Select a clip next to the cut first");
1750 } else {
1751 let snap = self.project.to_json();
1752 let ids = self.selection.clone();
1753 let st = &mut self.transitions_ui;
1754 if transitions_ui::add_transitions(&mut self.project, &ids, st, kind, dur, false) > 0 {
1755 push_undo_json(&mut self.undo, &mut self.redo, snap);
1756 self.after_edit();
1757 self.toast(format!("{} ({dur:.2} s)", kind.name()));
1758 } else {
1759 self.toast("Could not add a transition here");
1760 }
1761 }
1762 }
1763 CopyAttributes => match self.selection.first().and_then(|&id| self.project.copy_attributes(id)) {
1764 Some(c) => {
1765 let name = c.name.clone();
1766 self.attrs = Some(c);
1767 self.toast(format!("Copied attributes from '{name}'"));
1768 }
1769 None => self.toast("Select a clip to copy attributes from"),
1770 },
1771 PasteAttributes => {
1772 if self.attrs.is_none() {
1773 self.toast("Copy attributes from a clip first (Ctrl+Alt+C)");
1774 } else if self.selection.is_empty() {
1775 self.toast("Select the clips to paste onto");
1776 } else {
1777 self.paste_ui.open = true;
1778 }
1779 }
1780 CopyClips | CutClips => {
1781 let ids = self.project.expand_links(&self.selection);
1782 if ids.is_empty() {
1783 self.toast("Select the clips to copy first");
1784 } else {
1785 let t = crate::engine::presets::capture_template("clipboard", &self.project, &ids);
1786 self.os_clipboard = Some(t.json.clone());
1789 self.clipboard = Some(t);
1790 if a == CutClips {
1791 self.push_undo();
1792 self.project.delete_clips(&ids, false);
1793 self.selection.clear();
1794 self.after_edit();
1795 }
1796 }
1797 }
1798 PasteClips | PasteInPlace | PasteInsert | PasteAtTop => {
1799 match self.clipboard.as_ref().and_then(crate::engine::presets::decode_template) {
1800 Some((clips, assets)) => {
1801 let target = (a == PasteClips).then_some(self.timeline.last_track).flatten();
1803 let snap = self.project.to_json();
1804 if a == PasteInsert {
1805 let span = clips.iter().map(|c| c.start + c.duration).fold(0.0_f64, f64::max);
1807 self.project.ripple_open(self.playhead, span);
1808 }
1809 if a == PasteAtTop {
1810 let kind = clips
1811 .iter()
1812 .find(|c| c.kind != ClipKind::Audio)
1813 .map_or(TrackKind::Audio, |_| TrackKind::Video);
1814 self.project.add_track(kind);
1815 }
1816 let ids = timeline::paste_clips(&mut self.project, clips, assets, self.playhead, target);
1817 if ids.is_empty() {
1818 self.toast("Nothing could be pasted here");
1819 } else {
1820 push_undo_json(&mut self.undo, &mut self.redo, snap);
1821 self.selection = ids;
1822 self.after_edit();
1823 }
1824 }
1825 None => self.toast("Nothing copied yet — Ctrl+C copies the selected clips"),
1826 }
1827 }
1828 AddMarker => {
1829 self.push_undo();
1830 let t = self.playhead;
1831 let id = self.project.add_marker(t, format!("Marker at {}", crate::ui::timecode(t, self.project.fps)));
1832 self.markers.selected = Some(id);
1833 self.layout.reveal(Pane::Markers);
1834 self.layout_dirty = true;
1835 self.after_edit();
1836 }
1837 AddShape => {
1838 let kind = match self.tools.tool {
1839 Tool::Shape(k) => k,
1840 Tool::Draw => ShapeKind::Draw,
1841 _ => ShapeKind::Rect,
1842 };
1843 self.add_shape(kind, None);
1844 }
1845 AddAdjustment => {
1846 self.push_undo();
1847 let id = self.project.add_adjustment_clip(self.playhead, 5.0);
1848 self.selection = vec![id];
1849 self.after_edit();
1850 }
1851 AddMask => {
1852 let shape = match self.tools.tool {
1853 Tool::Mask(s) => s,
1854 _ => MaskShape::Ellipse,
1855 };
1856 let Some(&id) = self.selection.first() else {
1857 self.toast("Select a clip (or an effect on it) to mask");
1858 return;
1859 };
1860 let snap = self.project.to_json();
1863 if add_mask(&mut self.project, id, shape) {
1864 push_undo_json(&mut self.undo, &mut self.redo, snap);
1865 self.tools.tool = Tool::Mask(shape);
1866 self.layout.reveal(Pane::Tools);
1867 self.layout_dirty = true;
1868 self.after_edit();
1869 } else if self.project.clip(id).is_some_and(|c| !c.is_visual()) {
1870 self.toast("A mask shapes pixels — an audio clip has none");
1871 } else {
1872 self.toast("That clip already has a mask");
1873 }
1874 }
1875 ExportFrame => {
1876 if self.timeline_is_empty() {
1877 self.toast("Timeline is empty — nothing to export");
1878 } else if !self.ffmpeg_missing() {
1879 self.frame_ui.open = true;
1880 }
1881 }
1882 ScreenCapture => self.capture_ui.screen_open = !self.capture_ui.screen_open,
1883 Voiceover => self.capture_ui.voice_open = !self.capture_ui.voice_open,
1884 ImportTimeline => self.act_import_timeline(),
1885 MovieMode => {
1886 self.settings.movie_mode = !self.settings.movie_mode;
1887 self.settings.save();
1888 if self.settings.movie_mode {
1889 self.request_prerender();
1890 } else {
1891 guarded(|| self.prerender.clear());
1892 }
1893 self.toast(if self.settings.movie_mode { "Movie mode on" } else { "Movie mode off" });
1894 }
1895 ToggleInspector => self.toggle_pane(Pane::Inspector),
1896 ToggleEffects => self.toggle_pane(Pane::Effects),
1897 ToggleTransitions => self.toggle_pane(Pane::Transitions),
1898 ToggleCurves => self.toggle_pane(Pane::Curves),
1899 ToggleSubtitles => self.toggle_pane(Pane::Subtitles),
1900 TogglePlanner => self.toggle_pane(Pane::Planner),
1901 AutoCut => {
1902 self.layout.reveal(Pane::AutoCut);
1903 self.layout_dirty = true;
1904 }
1905 Retime => self.retime.open = !self.retime.open,
1906 FreezeFrame => {
1907 if self.selection.is_empty() {
1908 self.toast("Select a clip to freeze");
1909 } else {
1910 let snap = self.project.to_json();
1911 let ids = self.selection.clone();
1912 let frozen = self.project.freeze_at(self.playhead, &ids);
1913 if frozen.is_empty() {
1914 self.toast("Nothing to freeze");
1915 } else {
1916 push_undo_json(&mut self.undo, &mut self.redo, snap);
1917 self.selection = frozen;
1918 self.after_edit();
1919 }
1920 }
1921 }
1922 Fullscreen => {
1923 self.fullscreen = !self.fullscreen;
1925 self.player.seek(self.playhead); }
1927 AddTransition | AddTransitionEnd => {
1928 let at_end = a == AddTransitionEnd;
1929 if self.selection.is_empty() {
1930 self.toast("Select a clip next to the cut first");
1931 } else {
1932 let snap = self.project.to_json();
1933 let ids = self.selection.clone();
1934 let (kind, dur) = (TransitionKind::CrossFade, 1.0);
1935 let st = &mut self.transitions_ui;
1936 let added = transitions_ui::add_transitions(&mut self.project, &ids, st, kind, dur, at_end);
1937 if added > 0 {
1938 push_undo_json(&mut self.undo, &mut self.redo, snap);
1939 self.after_edit();
1940 } else {
1941 self.toast("Could not add a transition here");
1942 }
1943 }
1944 }
1945 AddSubtitle => {
1946 self.push_undo();
1947 let id = self.project.add_cue(self.playhead, self.playhead + 2.0, "Subtitle");
1948 self.subtitles_ui.selected = Some(id);
1949 self.layout.reveal(Pane::Subtitles);
1950 self.layout_dirty = true;
1951 self.after_edit();
1952 }
1953 NestSequence => {
1954 if self.selection.is_empty() {
1955 self.toast("Select the clips to nest");
1956 } else {
1957 let snap = self.project.to_json();
1958 let name = format!("Sequence {}", self.project.sequences.len() + 1);
1959 let ids = self.selection.clone();
1960 match self.project.nest_selection(&ids, name.clone()) {
1961 Some(_) => {
1962 push_undo_json(&mut self.undo, &mut self.redo, snap);
1963 self.selection.clear();
1964 self.after_edit();
1965 self.toast(format!("Nested into '{name}' — double-click / open it to edit inside"));
1966 }
1967 None => self.toast("Nothing to nest"),
1968 }
1969 }
1970 }
1971 OpenParentSequence => {
1972 if self.project.editing.is_some() {
1973 self.project.close_sequence();
1974 self.sequence_view_changed(self.playhead); }
1976 }
1977 SaveTemplate => {
1978 if self.selection.is_empty() {
1979 self.toast("Select the clips to save as a template");
1980 } else {
1981 self.template_name = Some(String::new());
1982 }
1983 }
1984 ApplyFlow => {
1985 let mut sel: Vec<&Clip> = self.selection.iter().filter_map(|&id| self.project.clip(id)).collect();
1986 sel.sort_by(|a, b| a.start.total_cmp(&b.start));
1987 if sel.len() != 2 {
1988 self.toast("Flow needs exactly two selected clips");
1989 } else {
1990 let (a_id, b_id) = (sel[0].id, sel[1].id);
1991 let snap = self.project.to_json();
1992 if self.project.flow_clips(a_id, b_id) {
1993 push_undo_json(&mut self.undo, &mut self.redo, snap);
1994 self.after_edit();
1995 } else {
1996 self.toast("Flow needs two abutting clips (no gap at the cut)");
1997 }
1998 }
1999 }
2000 AddContainer => {
2001 let snap = self.project.to_json();
2002 let (vid, aid) = self.project.add_container_clip(self.playhead, 5.0);
2003 push_undo_json(&mut self.undo, &mut self.redo, snap);
2004 self.selection = vec![vid, aid];
2005 self.after_edit();
2006 self.toast("Container clip added");
2007 }
2008 ReplaceContainerMedia => {
2009 if let Some(&id) = self.selection.first() {
2010 self.replace_container_dialog(id, false);
2011 } else {
2012 self.toast("Select a container clip to replace");
2013 }
2014 }
2015 MakeContainer => {
2016 if !self.selection.is_empty() {
2017 let snap = self.project.to_json();
2018 self.project.make_container(&self.selection);
2019 push_undo_json(&mut self.undo, &mut self.redo, snap);
2020 self.after_edit();
2021 self.toast("Converted to container");
2022 } else {
2023 self.toast("Select clips to convert to container");
2024 }
2025 }
2026 UnmakeContainer => {
2027 if !self.selection.is_empty() {
2028 let snap = self.project.to_json();
2029 self.project.unmake_container(&self.selection);
2030 push_undo_json(&mut self.undo, &mut self.redo, snap);
2031 self.after_edit();
2032 self.toast("Container removed");
2033 }
2034 }
2035 }
2036 }
2037
2038 fn toggle_pane(&mut self, p: Pane) {
2039 self.layout.toggle(p);
2040 self.layout_dirty = true;
2041 }
2042
2043 fn draw_pane(&mut self, ui: &mut egui::Ui, pane: Pane) {
2048 if self.failed_panes.contains(&pane) {
2049 ui.weak(format!("{} is unavailable in this build.", pane.title()));
2050 return;
2051 }
2052 if guarded(|| self.draw_pane_inner(ui, pane)).is_none() {
2053 self.failed_panes.push(pane);
2054 self.toast(format!("{} failed to draw — the pane is disabled for this session", pane.title()));
2055 }
2056 }
2057
2058 fn draw_pane_inner(&mut self, ui: &mut egui::Ui, pane: Pane) {
2059 match pane {
2060 Pane::Preview => {
2061 if self.lib_preview.is_some() {
2062 self.draw_lib_preview(ui);
2063 } else {
2064 let frame = self.pending_frame.take();
2065 let proxy_busy = self.proxy_job.as_ref().map(|(_, _, p)| p.fraction());
2066 let resp = {
2067 let App {
2068 project,
2069 selection,
2070 playhead,
2071 undo,
2072 redo,
2073 preview: pv,
2074 player,
2075 palette,
2076 fullscreen,
2077 tools,
2078 settings,
2079 prerender,
2080 gpu_tex,
2081 tracking,
2082 tracking_shown,
2083 ..
2084 } = self;
2085 let mut push = |p: &Project| push_undo_json(undo, redo, p.to_json());
2086 let done = settings
2088 .movie_mode
2089 .then(|| guarded(|| prerender.progress()).unwrap_or(1.0))
2090 .filter(|&p| p < 1.0);
2091 preview::show(
2092 ui,
2093 pv,
2094 preview::PreviewCtx {
2095 project,
2096 selection,
2097 playhead: *playhead,
2098 playing: player.is_playing(),
2099 fullscreen: *fullscreen,
2100 palette,
2101 undo: &mut push,
2102 frame,
2103 gpu_texture: *gpu_tex,
2104 tool: tools.tool,
2105 quality: settings.preview_quality,
2106 movie_mode: settings.movie_mode,
2107 prerender: done,
2108 buffering: player.is_buffering(),
2109 proxy: proxy_busy,
2110 tracker: tracking_shown.then(|| tracking.box_rect()),
2111 },
2112 )
2113 };
2114 let (cw, ch) = preview_canvas(resp.canvas, self.settings.preview_quality);
2115 self.player.set_canvas(cw, ch, self.settings.preview_max_width);
2116 self.canvas = clamp_canvas(cw, ch, self.settings.preview_max_width);
2118 if let Some(t) = resp.seek {
2119 self.seek(t);
2120 }
2121 self.pending_actions.extend(resp.actions);
2122 if let Some(q) = resp.set_quality {
2123 self.settings.preview_quality = q;
2124 self.settings.save();
2125 }
2126 if resp.set_movie_mode.is_some() {
2127 self.pending_actions.push(Action::MovieMode);
2129 }
2130 if let Some((x, y)) = resp.set_tracker {
2131 (self.tracking.cx, self.tracking.cy) = (x, y);
2132 }
2133 if let Some((kind, cx, cy, w, h)) = resp.new_shape {
2134 let id = self.add_shape(kind, Some((cx, cy, w, h)));
2135 if !resp.new_points.is_empty() {
2136 if let Some(s) = self.project.clip_mut(id).and_then(|c| c.shape.as_mut()) {
2137 s.points = resp.new_points;
2138 }
2139 self.after_edit();
2140 }
2141 }
2142 if let Some(s) = resp.stroke {
2143 self.add_stroke(s);
2144 }
2145 if resp.edited {
2146 self.after_edit();
2147 }
2148 }
2149 }
2150 Pane::Timeline => {
2151 if let Some(seq) = self.project.editing {
2153 let name = self.project.sequence(seq).map(|s| s.name.clone()).unwrap_or_default();
2154 ui.horizontal(|ui| {
2155 let back = crate::ui::tools::glyph_text_button(
2156 ui,
2157 crate::ui::tools::Glyph::Tri(crate::ui::tools::Dir::Left),
2158 "Back",
2159 );
2160 if back.on_hover_text("Back to the main timeline (Alt+Up)").clicked() {
2161 self.pending_actions.push(Action::OpenParentSequence);
2162 }
2163 ui.label(format!("Main > {name}"));
2164 });
2165 }
2166 let resp = {
2167 let App {
2168 project,
2169 selection,
2170 sel_transitions,
2171 playhead,
2172 undo,
2173 redo,
2174 waveforms,
2175 thumbs,
2176 autocut,
2177 autocut_shown,
2178 timeline: tl,
2179 settings,
2180 player,
2181 palette,
2182 tools,
2183 prerender,
2184 ..
2185 } = self;
2186 let tool = tools.tool;
2187 let prerender_bar =
2188 if settings.movie_mode { guarded(|| prerender.segments()).unwrap_or_default() } else { vec![] };
2189 let mut push = |p: &Project| push_undo_json(undo, redo, p.to_json());
2190 timeline::show(
2191 ui,
2192 tl,
2193 timeline::TimelineCtx {
2194 project,
2195 selection,
2196 sel_transitions,
2197 playhead,
2198 undo: &mut push,
2199 waveforms,
2200 palette,
2201 snap: settings.snap,
2202 playing: player.is_playing(),
2203 thumbs: Some(thumbs),
2204 keep_ranges: if *autocut_shown { &autocut.overlay } else { &[] },
2207 prerender: &prerender_bar,
2208 tool,
2209 },
2210 )
2211 };
2212 if resp.seeked {
2213 self.player.pause();
2214 self.player.seek(self.playhead);
2215 }
2216 if resp.edited {
2217 self.after_edit();
2218 }
2219 if !resp.dropped_files.is_empty() {
2220 for (path, t, track) in resp.dropped_files {
2221 let ids = self.import_files(&[path]);
2222 let vt = track.filter(|&i| self.project.tracks[i].kind == TrackKind::Video);
2223 self.insert_at(ids, t, vt);
2224 }
2225 self.after_edit();
2226 }
2227 for (payload, t, track) in resp.dropped_other {
2228 let vt = track.filter(|&i| self.project.tracks[i].kind == TrackKind::Video);
2229 match payload {
2230 DragPayload::Sequence(id) => {
2231 let snap = self.project.to_json();
2232 if self.project.insert_sequence_clip(id, t, vt).is_none() {
2233 self.toast("A sequence can't contain itself");
2234 } else {
2235 push_undo_json(&mut self.undo, &mut self.redo, snap);
2236 self.after_edit();
2237 }
2238 }
2239 DragPayload::Template(name) => self.place_template(&name, t),
2240 DragPayload::Effect(kind) => {
2243 let hit = track
2244 .and_then(|ti| self.project.tracks.get(ti))
2245 .and_then(|tr| tr.clips.iter().find(|c| c.contains(t)))
2246 .filter(|c| (c.kind == ClipKind::Audio) == kind.applies_to_audio());
2247 match hit.map(|c| (c.id, c.uses_graph())) {
2248 Some((id, false)) => {
2249 let snap = self.project.to_json();
2250 if let Some(c) = self.project.clip_mut(id) {
2251 c.effects.push(Effect::new(kind));
2252 }
2253 push_undo_json(&mut self.undo, &mut self.redo, snap);
2254 self.after_edit();
2255 self.toast(format!("{} added", kind.name()));
2256 }
2257 Some((_, true)) => self.toast("That clip renders from its node graph"),
2258 None => self.toast(format!("Drop {} on a clip it applies to", kind.name())),
2259 }
2260 }
2261 DragPayload::Transition(kind) => {
2264 let hit = track
2265 .and_then(|ti| self.project.tracks.get(ti))
2266 .and_then(|tr| tr.clips.iter().find(|c| c.contains(t)))
2267 .map(|c| (c.id, transitions_ui::drop_at_end(c, t)));
2268 match hit {
2269 Some((id, at_end)) => {
2270 let snap = self.project.to_json();
2271 let dur = self.transitions_ui.duration;
2272 let st = &mut self.transitions_ui;
2273 if transitions_ui::add_transitions(&mut self.project, &[id], st, kind, dur, at_end)
2274 > 0
2275 {
2276 push_undo_json(&mut self.undo, &mut self.redo, snap);
2277 self.after_edit();
2278 self.toast(format!("{} ({dur:.2} s)", kind.name()));
2279 } else {
2280 self.toast("Could not add a transition here");
2281 }
2282 }
2283 None => self.toast("Drop a transition on the clip beside the cut"),
2284 }
2285 }
2286 _ => {}
2287 }
2288 }
2289 if let Some(id) = resp.open_sequence {
2290 self.enter_sequence(id);
2291 }
2292 if let Some((cid, pair)) = resp.replace_container {
2293 self.replace_container_dialog(cid, pair);
2294 }
2295 self.pending_actions.extend(resp.actions);
2296 }
2297 Pane::Library => {
2298 let resp = {
2299 let live = self.lib_preview_live;
2300 let App { project, settings, library: lib, ytdlp_available, thumbs, undo, redo, palette, .. } =
2301 self;
2302 let mut push = |p: &Project| push_undo_json(undo, redo, p.to_json());
2303 let ytdlp = ytdlp_available.load(std::sync::atomic::Ordering::Relaxed);
2304 library::show(ui, lib, project, settings, Some(thumbs), live.as_ref(), palette, ytdlp, &mut push)
2305 };
2306 if resp.edited {
2307 self.after_edit();
2308 }
2309 if resp.settings_changed {
2310 self.settings.save();
2311 }
2312 if resp.import {
2313 self.act_import();
2314 }
2315 if let Some(p) = resp.preview.clone() {
2317 self.start_lib_preview(ui.ctx(), p);
2318 }
2319 if !resp.add_to_timeline.is_empty() {
2320 self.push_undo();
2321 self.insert_at(resp.add_to_timeline, self.playhead, None);
2322 self.after_edit();
2323 }
2324 if resp.new_adjustment {
2327 self.pending_actions.push(Action::AddAdjustment);
2328 }
2329 if resp.open_dialog {
2330 self.pending_actions.push(Action::OpenFile);
2331 }
2332 if !resp.open_paths.is_empty() {
2333 let ids = self.open_or_import(&resp.open_paths);
2334 if !ids.is_empty() {
2335 self.library.selected = ids.last().copied();
2336 self.library.tab = 0;
2337 }
2338 }
2339 if !resp.remove.is_empty() {
2340 self.push_undo();
2341 for id in resp.remove {
2342 self.project.remove_asset(id);
2343 }
2344 self.after_edit();
2345 }
2346 if resp.clear_recent {
2347 self.settings.recent_assets.clear();
2348 self.settings.save();
2349 }
2350 for (id, ext) in resp.convert {
2351 self.start_asset_convert(id, &ext);
2352 }
2353 if !resp.regen_proxy.is_empty() {
2354 self.player.set_proxies(std::collections::HashMap::new());
2356 self.player.release_files();
2357 for src in resp.regen_proxy {
2358 let _ = std::fs::remove_file(crate::media::proxy::proxy_path(&src, self.settings.proxy_height));
2359 }
2360 self.proxy_map.clear();
2361 self.proxy_scan_at = None; }
2363 if let Some(id) = resp.convert_dialog {
2364 self.convert_dialog = Some((id, "mp4".into()));
2365 }
2366 if let Some(p) = resp.compress.and_then(|id| self.project.asset(id)).map(|a| a.path.clone()) {
2367 self.compress = Some(Compress::new(PathBuf::from(p), self.settings.crf));
2368 }
2369 if let Some(id) = resp.open_sequence {
2370 self.enter_sequence(id);
2371 }
2372 if resp.import_url {
2373 self.url_dialog = Some((String::new(), false));
2374 }
2375 for name in resp.place_template {
2376 self.place_template(&name, self.playhead);
2377 }
2378 if let Some(kind) = resp.add_effect {
2381 let targets: Vec<Id> = self
2382 .selection
2383 .iter()
2384 .copied()
2385 .filter(|&id| self.project.clip(id).is_some_and(|c| c.is_visual() && !c.uses_graph()))
2386 .collect();
2387 if targets.is_empty() {
2388 self.toast("Select a clip first");
2389 } else {
2390 self.push_undo();
2391 for id in targets {
2392 if let Some(c) = self.project.clip_mut(id) {
2393 c.effects.push(Effect::new(kind));
2394 }
2395 }
2396 self.after_edit();
2397 }
2398 }
2399 if let Some(i) = resp.apply_preset {
2400 self.apply_effect_preset(i);
2401 }
2402 if let Some(from) = resp.copy_graph {
2403 let graph = self.project.clip(from).and_then(|c| c.graph.clone());
2404 let targets: Vec<Id> = self
2405 .selection
2406 .iter()
2407 .copied()
2408 .filter(|&id| id != from && self.project.clip(id).is_some_and(|c| c.is_visual()))
2409 .collect();
2410 match graph {
2411 Some(g) if !targets.is_empty() => {
2412 self.push_undo();
2413 for id in targets {
2414 if let Some(c) = self.project.clip_mut(id) {
2415 c.graph = Some(g.clone());
2416 }
2417 }
2418 self.after_edit();
2419 }
2420 _ => self.toast("Select another clip to copy this node graph onto"),
2421 }
2422 }
2423 }
2424 Pane::Inspector => {
2425 let changed = {
2426 let App {
2427 project, selection, sel_transitions, playhead, undo, redo, fonts, palette, settings, ..
2428 } = self;
2429 let mut push = |p: &Project| push_undo_json(undo, redo, p.to_json());
2430 let mut changed = false;
2431 egui::ScrollArea::vertical().show(ui, |ui| {
2432 changed = inspector::show(
2433 ui,
2434 project,
2435 selection,
2436 sel_transitions,
2437 *playhead,
2438 fonts,
2439 palette,
2440 settings,
2441 &mut push,
2442 );
2443 });
2444 changed
2445 };
2446 if changed {
2447 self.after_edit();
2448 }
2449 if let Some(a) = inspector::take_pending_action() {
2450 self.pending_actions.push(a);
2451 }
2452 }
2453 Pane::Effects => {
2454 let resp = {
2455 let App { project, selection, playhead, undo, redo, palette, .. } = self;
2456 let mut push = |p: &Project| push_undo_json(undo, redo, p.to_json());
2457 egui::ScrollArea::vertical()
2460 .show(ui, |ui| effects_ui::show(ui, project, selection, *playhead, palette, &mut push))
2461 .inner
2462 };
2463 if let Some(i) = resp.mask_for {
2464 let shape = self
2467 .selection
2468 .first()
2469 .and_then(|&id| self.project.clip(id))
2470 .and_then(|c| c.effects.get(i))
2471 .and_then(|fx| fx.mask.as_ref())
2472 .map(|m| m.shape)
2473 .unwrap_or(MaskShape::Ellipse);
2474 self.tools.tool = Tool::Mask(shape);
2475 self.layout.reveal(Pane::Tools);
2476 self.layout_dirty = true;
2477 }
2478 if resp.open_nodes {
2479 self.layout.reveal(Pane::Nodes);
2480 self.layout_dirty = true;
2481 }
2482 if let Some(i) = resp.edit_shader {
2483 let id = self.selection.iter().copied().find(|&id| self.project.clip(id).is_some()).unwrap_or(0);
2485 let src = self
2486 .project
2487 .clip(id)
2488 .and_then(|c| c.effects.get(i))
2489 .filter(|fx| fx.kind == EffectKind::Shader)
2490 .map(|fx| fx.shader.clone());
2491 if let Some(src) = src {
2492 self.shader_ui.edit(id, i, &src);
2493 let known = self.gpu.as_ref().and_then(|g| g.shader_error(&src));
2495 self.shader_ui.error = known.unwrap_or_default().to_string();
2496 }
2497 }
2498 if resp.edited {
2499 self.after_edit();
2500 }
2501 }
2502 Pane::Transitions => {
2503 let changed = {
2504 let App { project, selection, playhead, undo, redo, transitions_ui: st, palette, .. } = self;
2505 let mut push = |p: &Project| push_undo_json(undo, redo, p.to_json());
2506 transitions_ui::show(ui, st, project, selection, *playhead, palette, &mut push)
2507 };
2508 if changed {
2509 self.after_edit();
2510 }
2511 }
2512 Pane::Curves => {
2513 let resp = {
2514 let App { project, selection, playhead, undo, redo, curves: st, palette, mixer, .. } = self;
2515 let bus = mixer.selected_bus;
2516 let mut push = |p: &Project| push_undo_json(undo, redo, p.to_json());
2517 curves::show(ui, st, project, selection, bus, playhead, palette, &mut push)
2518 };
2519 if resp.seeked {
2520 self.player.pause();
2521 self.player.seek(self.playhead);
2522 }
2523 if resp.edited {
2524 self.after_edit();
2525 }
2526 }
2527 Pane::Subtitles => {
2528 let resp = {
2529 let App { project, playhead, selection, undo, redo, subtitles_ui: st, fonts, palette, .. } = self;
2530 let mut push = |p: &Project| push_undo_json(undo, redo, p.to_json());
2531 subtitles_ui::show(ui, st, project, playhead, selection, fonts, palette, &mut push)
2532 };
2533 if resp.seeked {
2534 self.player.seek(self.playhead);
2535 if resp.play {
2536 self.player.play();
2537 } else {
2538 self.player.pause();
2539 }
2540 }
2541 if resp.open_folder {
2542 self.open_subtitle_folder();
2543 }
2544 if resp.edited {
2545 self.after_edit();
2546 }
2547 }
2548 Pane::Planner => {
2549 let resp = {
2550 let App { project, undo, redo, planner: st, thumbs, palette, .. } = self;
2551 let mut push = |p: &Project| push_undo_json(undo, redo, p.to_json());
2552 planner::show(ui, st, project, thumbs, palette, &mut push)
2553 };
2554 if !resp.add_to_timeline.is_empty() {
2555 self.push_undo();
2556 self.insert_at(resp.add_to_timeline, self.playhead, None);
2557 self.after_edit();
2558 }
2559 if resp.edited {
2560 self.after_edit();
2561 }
2562 }
2563 Pane::AutoCut => {
2564 self.autocut_drawing = true;
2565 let changed = {
2566 let App { project, selection, undo, redo, autocut: st, waveforms, palette, .. } = self;
2567 let mut push = |p: &Project| push_undo_json(undo, redo, p.to_json());
2568 autocut_ui::show(ui, st, project, selection, waveforms, palette, &mut push)
2569 };
2570 if changed {
2571 self.after_edit();
2572 }
2573 }
2574 Pane::Tracking => {
2575 self.tracking_drawing = true;
2576 let backend = self.backend();
2577 let changed = {
2578 let App { project, selection, undo, redo, tracking: st, palette, .. } = self;
2579 let mut push = |p: &Project| push_undo_json(undo, redo, p.to_json());
2580 tracking_ui::show(ui, st, project, selection, backend, palette, &mut push)
2581 };
2582 if changed {
2583 self.after_edit();
2584 }
2585 }
2586 Pane::Tools => {
2587 let was = self.tools.recording;
2588 let snap_was = self.settings.snap;
2589 {
2590 let App { tools: st, palette, settings, .. } = self;
2591 tools::show(ui, st, palette, &mut settings.snap);
2592 }
2593 if self.tools.recording != was {
2594 self.toggle_draw_recording(self.tools.recording);
2595 }
2596 if self.settings.snap != snap_was {
2597 self.settings.save();
2598 }
2599 }
2600 Pane::Nodes => {
2601 let resp = {
2602 let App { project, selection, playhead, undo, redo, nodes: st, palette, .. } = self;
2603 let mut push = |p: &Project| push_undo_json(undo, redo, p.to_json());
2604 nodes::show(ui, st, project, selection, *playhead, palette, &mut push)
2605 };
2606 if resp.edited {
2607 self.after_edit();
2608 }
2609 }
2610 Pane::Mixer => {
2611 let changed = {
2612 let App { project, selection, playhead, mixer: st, buses, palette, undo, redo, .. } = self;
2613 let mut push = |p: &Project| push_undo_json(undo, redo, p.to_json());
2614 mixer_ui::show(ui, st, project, selection, buses, *playhead, palette, &mut push)
2615 };
2616 if changed {
2617 self.after_edit();
2618 }
2619 }
2620 Pane::Presets => {
2621 let has_sel = !self.selection.is_empty();
2622 let resp = presets_ui::show(ui, &mut self.presets, &mut self.settings, has_sel);
2623 if let Some(i) = resp.apply {
2624 self.apply_effect_preset(i);
2625 }
2626 for name in resp.place {
2627 self.place_template(&name, self.playhead);
2628 }
2629 if let Some(name) = resp.save {
2630 self.save_preset(&name);
2631 }
2632 if resp.settings_changed {
2633 self.settings.save();
2634 }
2635 }
2636 Pane::Markers => {
2637 let resp = {
2638 let App { project, selection, playhead, markers: st, palette, undo, redo, .. } = self;
2639 let mut push = |p: &Project| push_undo_json(undo, redo, p.to_json());
2640 markers_ui::show(ui, st, project, selection, *playhead, palette, &mut push)
2641 };
2642 if let Some(t) = resp.seek {
2643 self.player.pause();
2644 self.seek(t);
2645 }
2646 if resp.edited {
2647 self.after_edit();
2648 }
2649 }
2650 }
2651 }
2652
2653 fn refresh_presets(&self) {
2657 curves::set_available_presets(self.settings.curve_presets.clone());
2658 let motions: Vec<crate::settings::MotionPreset> = crate::engine::presets::builtin_motions()
2659 .into_iter()
2660 .chain(self.settings.motion_presets.iter().cloned())
2661 .collect();
2662 curves::set_available_motions(motions);
2663 }
2664
2665 fn add_shape(&mut self, kind: ShapeKind, place: Option<(f32, f32, f32, f32)>) -> Id {
2671 self.push_undo();
2672 let id = self.project.add_shape_clip(kind, self.playhead, 5.0);
2673 let App { project, tools, .. } = self;
2674 if let Some(c) = project.clip_mut(id) {
2675 if let Some((cx, cy, _, _)) = place {
2676 c.x.value = cx as f64;
2677 c.y.value = cy as f64;
2678 }
2679 if let Some(s) = c.shape.as_mut() {
2680 s.fill = tools.fill;
2681 let stroke_only = matches!(kind, ShapeKind::Line | ShapeKind::Arrow | ShapeKind::Draw);
2684 s.stroke = match (stroke_only, tools.stroke[3], tools.brush[3]) {
2685 (true, 0, 0) => [tools.fill[0], tools.fill[1], tools.fill[2], 255],
2686 (true, 0, _) => tools.brush,
2687 _ => tools.stroke,
2688 };
2689 s.stroke_width = tools.stroke_width;
2690 s.sides = tools.sides;
2691 s.corner = tools.corner;
2692 s.draw_rate = tools.draw_rate;
2693 s.page = tools.page;
2694 if let Some((_, _, w, h)) = place {
2695 s.w.value = w as f64;
2696 s.h.value = h as f64;
2697 }
2698 }
2699 }
2700 self.selection = vec![id];
2701 self.after_edit();
2702 id
2703 }
2704
2705 fn toggle_draw_recording(&mut self, on: bool) {
2709 if on {
2710 let id = self.add_shape(ShapeKind::Draw, None);
2711 self.draw_rec = Some((id, self.playhead));
2712 if !self.player.is_playing() {
2713 self.pending_actions.push(Action::PlayPause);
2714 }
2715 return;
2716 }
2717 let Some((id, _)) = self.draw_rec.take() else { return };
2718 let bounds = self.project.clip(id).and_then(|c| c.shape.as_ref()).map(|s| {
2722 let (mut lo, mut hi) = (f32::INFINITY, f32::NEG_INFINITY);
2723 for st in &s.strokes {
2724 for p in &st.points {
2725 lo = lo.min(p.2);
2726 hi = hi.max(p.2);
2727 }
2728 }
2729 (lo, hi)
2730 });
2731 match bounds.filter(|(lo, _)| lo.is_finite()) {
2732 Some((lo, hi)) => {
2733 if let Some(c) = self.project.clip_mut(id) {
2734 c.start += lo as f64;
2735 c.duration = ((hi - lo).max(0.0) as f64).max(MIN_CLIP);
2736 if let Some(s) = c.shape.as_mut() {
2737 for st in &mut s.strokes {
2738 for p in &mut st.points {
2739 p.2 -= lo;
2740 }
2741 }
2742 }
2743 }
2744 }
2745 None => self.project.delete_clips(&[id], false), }
2747 if self.voice_rec.is_none() {
2748 self.player.pause();
2749 }
2750 self.after_edit();
2751 }
2752
2753 fn add_stroke(&mut self, mut stroke: crate::model::Stroke) {
2755 stroke.color = self.tools.brush;
2756 stroke.width = self.tools.brush_width.max(0.5);
2757 let rec = self.draw_rec.filter(|&(id, _)| self.project.clip(id).is_some());
2760 if let Some((_, at)) = rec {
2761 let off = ((self.playhead - at) - stroke.points.last().map_or(0.0, |p| p.2 as f64)).max(0.0) as f32;
2762 for p in &mut stroke.points {
2763 p.2 += off;
2764 }
2765 }
2766 let onto = rec.map(|(id, _)| id).or_else(|| {
2767 self.selection.first().copied().filter(|&id| {
2768 self.project.clip(id).and_then(|c| c.shape.as_ref()).is_some_and(|s| s.kind == ShapeKind::Draw)
2769 })
2770 });
2771 let id = match onto {
2772 Some(id) => {
2773 self.push_undo();
2774 id
2775 }
2776 None => self.add_shape(ShapeKind::Draw, None),
2777 };
2778 if let Some(c) = self.project.clip_mut(id) {
2779 if let Some(s) = c.shape.as_mut() {
2780 s.strokes.push(stroke);
2781 }
2782 if rec.is_some() {
2783 let len = c.shape.as_ref().map_or(0.0, |s| s.draw_duration());
2786 c.duration = c.duration.max(len);
2787 } else {
2788 let bounds = c.shape.as_mut().map(|s| {
2791 let (mut lo, mut hi) = (f32::INFINITY, f32::NEG_INFINITY);
2792 for st in &s.strokes {
2793 for p in &st.points {
2794 lo = lo.min(p.2);
2795 hi = hi.max(p.2);
2796 }
2797 }
2798 if lo > 0.0 {
2799 for st in &mut s.strokes {
2800 for p in &mut st.points {
2801 p.2 -= lo;
2802 }
2803 }
2804 hi -= lo;
2805 }
2806 (lo.max(0.0), hi.max(0.0))
2807 });
2808 if let Some((lo, hi)) = bounds {
2809 c.start += lo as f64;
2810 c.duration = (hi as f64).max(MIN_CLIP);
2811 }
2812 }
2813 }
2814 self.selection = vec![id];
2815 self.after_edit();
2816 }
2817
2818 fn poll_panels(&mut self) {
2820 if let Some(m) = effects_ui::take_pending_motion() {
2822 self.settings.motion_presets.retain(|p| p.name != m.name);
2823 self.settings.motion_presets.push(m);
2824 self.settings.save();
2825 self.toast("Motion preset saved");
2826 }
2827 if let Some(m) = curves::take_pending_motion_preset() {
2828 self.settings.motion_presets.retain(|p| p.name != m.name);
2829 self.settings.motion_presets.push(m);
2830 self.settings.save();
2831 self.refresh_presets();
2832 self.toast("Motion preset saved");
2833 }
2834 if let Some(c) = curves::take_pending_curve_preset() {
2835 self.settings.curve_presets.retain(|p| p.name != c.name);
2836 self.settings.curve_presets.push(c);
2837 self.settings.save();
2838 self.refresh_presets();
2840 self.toast("Curve preset saved");
2841 }
2842 if let Some(path) = inspector::take_pending_font_import() {
2844 if !self.settings.user_fonts.iter().any(|f| f.eq_ignore_ascii_case(&path)) {
2845 self.settings.user_fonts.push(path);
2846 self.settings.save();
2847 }
2848 }
2849 if self.loaded_fonts != self.settings.user_fonts.len() {
2850 let fonts = self.settings.user_fonts.clone();
2851 if let Ok(mut t) = self.text.try_lock() {
2852 t.load_user_fonts(&fonts);
2853 self.fonts = t.families().to_vec();
2854 self.loaded_fonts = fonts.len();
2855 }
2856 }
2857 if let Some(id) = inspector::take_open_sequence() {
2858 self.enter_sequence(id);
2859 }
2860 if let Some(id) = inspector::take_edit_mask() {
2862 let shape =
2863 self.project.clip(id).and_then(|c| c.mask.as_ref()).map(|m| m.shape).unwrap_or(MaskShape::Ellipse);
2864 self.selection = vec![id];
2865 self.tools.tool = Tool::Mask(shape);
2866 self.layout.reveal(Pane::Tools);
2867 self.layout_dirty = true;
2868 }
2869 if let Some(id) = inspector::take_open_nodes() {
2870 self.selection = vec![id];
2871 self.layout.reveal(Pane::Nodes);
2872 self.layout_dirty = true;
2873 }
2874 if let Some(id) = inspector::take_unlink_nodes() {
2875 self.push_undo();
2876 match self.project.unlink_graph(id) {
2877 Ok(n) => {
2878 self.after_edit();
2879 self.toast(format!("Unlinked — {n} effect layer{}", if n == 1 { "" } else { "s" }));
2880 }
2881 Err(e) => {
2883 self.undo.pop();
2884 self.toast(format!("Can't unlink this graph: {e}"));
2885 }
2886 }
2887 }
2888 let mut fetched: Vec<(Option<PathBuf>, Option<String>, bool)> = Vec::new();
2890 self.downloads.retain(|d| {
2891 if d.progress.is_done() {
2892 fetched.push((d.path(), d.progress.error(), d.progress.is_cancelled()));
2893 false
2894 } else {
2895 true
2896 }
2897 });
2898 for (path, err, cancelled) in fetched {
2899 match (path, err) {
2900 (Some(p), None) => {
2901 let ids = self.import_files(&[p.clone()]);
2902 self.library.selected = ids.last().copied();
2903 self.library.tab = 0;
2904 self.toast(format!("Imported {}", p.file_name().unwrap_or_default().to_string_lossy()));
2905 }
2906 (_, Some(_)) if cancelled => self.toast("Download cancelled"),
2908 (_, Some(e)) => self.toast(format!("Download failed: {e}")),
2909 (None, None) => self.toast("Download finished but produced no file"),
2910 }
2911 }
2912
2913 let mut done: Vec<(PathBuf, Option<String>)> = Vec::new();
2915 self.convert_jobs.retain(|(prog, out)| {
2916 if prog.is_done() {
2917 done.push((out.clone(), prog.error()));
2918 false
2919 } else {
2920 true
2921 }
2922 });
2923 for (out, err) in done {
2924 match err {
2925 Some(e) => self.toast(format!("Convert failed: {e}")),
2926 None => {
2927 let ids = self.import_files(&[out.clone()]);
2928 self.library.selected = ids.last().copied();
2929 self.toast(format!("Converted → {}", out.file_name().unwrap_or_default().to_string_lossy()));
2930 }
2931 }
2932 }
2933 }
2934
2935 fn enter_sequence(&mut self, id: Id) {
2937 if self.project.editing == Some(id) {
2938 return;
2939 }
2940 if self.project.open_sequence(id) {
2941 self.sequence_view_changed(0.0);
2942 } else {
2943 self.toast("That sequence no longer exists");
2944 }
2945 }
2946
2947 fn sequence_view_changed(&mut self, t: f64) {
2950 self.selection.clear();
2951 self.player.set_project(&self.project);
2952 self.seek(t);
2953 }
2954
2955 fn url_window(&mut self, ctx: &egui::Context) {
2958 let Some((mut url, mut audio_only)) = self.url_dialog.clone() else { return };
2959 let mut open = true;
2960 let mut start = false;
2961 let dir = self.download_dir();
2962 egui::Window::new("Import URL").open(&mut open).resizable(false).default_width(420.0).show(ctx, |ui| {
2963 ui.label("Paste a link (YouTube, Vimeo, X, TikTok, direct file, …)");
2964 let r = ui.add(egui::TextEdit::singleline(&mut url).desired_width(400.0).hint_text("https://…"));
2965 start |= r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
2967 if crate::media::ffpipe::ffmpeg_exe().is_some() {
2968 ui.checkbox(&mut audio_only, "Audio only (music / SFX)");
2969 }
2970 ui.horizontal(|ui| {
2971 ui.label("Save to");
2972 ui.weak(dir.to_string_lossy());
2973 if ui.small_button("Browse…").clicked() {
2974 if let Some(p) = rfd::FileDialog::new().set_directory(&dir).pick_folder() {
2975 self.settings.download_dir = p.to_string_lossy().into_owned();
2976 self.settings.save();
2977 }
2978 }
2979 });
2980 ui.add_space(4.0);
2981 ui.horizontal(|ui| {
2982 start |= ui.add_enabled(!url.trim().is_empty(), egui::Button::new("Download")).clicked();
2983 ui.weak(format!("{} running", self.downloads.len()));
2984 });
2985 });
2986 match (open, start) {
2987 (_, true) => {
2988 self.url_dialog = None;
2989 self.start_download(url.trim(), audio_only);
2990 }
2991 (true, false) => self.url_dialog = Some((url, audio_only)),
2992 (false, false) => self.url_dialog = None,
2993 }
2994 }
2995
2996 fn download_dir(&self) -> PathBuf {
2998 let d = self.settings.download_dir.trim();
2999 if d.is_empty() {
3000 crate::media::ytdlp::default_dir()
3001 } else {
3002 PathBuf::from(d)
3003 }
3004 }
3005
3006 fn detect_ytdlp(&self, ctx: &egui::Context) {
3009 let flag = self.ytdlp_available.clone();
3010 let ctx = ctx.clone();
3011 std::thread::spawn(move || {
3012 let found = media::ytdlp::exe().is_some();
3013 flag.store(found, std::sync::atomic::Ordering::Relaxed);
3014 ctx.request_repaint(); });
3016 }
3017
3018 fn start_download(&mut self, url: &str, audio_only: bool) {
3019 if !self.ytdlp_available.load(std::sync::atomic::Ordering::Relaxed) {
3020 self.toast("yt-dlp not found — set its folder in Settings");
3021 return;
3022 }
3023 let opts = crate::media::ytdlp::DownloadOptions { url: url.to_string(), dir: self.download_dir(), audio_only };
3024 self.toast("Downloading…");
3025 self.downloads.push(crate::media::ytdlp::start_download(opts));
3026 }
3027
3028 fn start_asset_convert(&mut self, asset: Id, ext: &str) {
3030 if self.ffmpeg_missing() {
3031 return;
3032 }
3033 let Some(a) = self.project.asset(asset) else { return };
3034 let src = PathBuf::from(&a.path);
3035 let out = converted_path(&src, ext);
3036 let opts = crate::engine::convert::ConvertOptions {
3037 src,
3038 out: out.clone(),
3039 encoder: self.settings.encoder.clone(),
3040 crf: self.settings.crf,
3041 preset: self.settings.preset.clone(),
3042 out_size: None,
3043 scaler: self.settings.export_scaler.clone(),
3044 gif_fps: 15,
3045 target_bytes: None,
3046 };
3047 self.toast(format!("Converting to {ext}…"));
3048 self.convert_jobs.push((crate::engine::convert::start_convert(opts), out));
3049 }
3050
3051 fn save_preset(&mut self, name: &str) {
3055 let fx = match self.selection.as_slice() {
3056 [id] => self
3057 .project
3058 .clip(*id)
3059 .filter(|c| c.kind != ClipKind::Adjustment && (c.graph.is_some() || !c.effects.is_empty()))
3060 .map(|c| crate::engine::presets::capture_effects(name, c)),
3061 _ => None,
3062 };
3063 if let Some(p) = fx {
3064 self.settings.effect_presets.retain(|x| x.name != name);
3065 self.settings.effect_presets.push(p);
3066 } else if self.selection.is_empty() {
3067 return self.toast("Select a clip first");
3068 } else {
3069 let t = crate::engine::presets::capture_template(name, &self.project, &self.selection);
3070 self.settings.templates.retain(|x| x.name != name);
3071 self.settings.templates.push(t);
3072 }
3073 self.settings.save();
3074 self.toast(format!("Saved \"{name}\""));
3075 }
3076
3077 fn apply_effect_preset(&mut self, i: usize) {
3079 let Some(p) = self.settings.effect_presets.get(i).cloned() else { return };
3080 if self.selection.is_empty() {
3081 return self.toast("Select a clip first");
3082 }
3083 self.push_undo();
3084 let mut n = 0;
3085 for id in self.selection.clone() {
3086 n += crate::engine::presets::apply_effects(&p, &mut self.project, id) as usize;
3087 }
3088 if n == 0 {
3089 return self.toast("That preset is corrupted");
3090 }
3091 self.after_edit();
3092 }
3093
3094 fn place_template(&mut self, name: &str, t: f64) {
3095 let Some(tpl) = self.settings.templates.iter().find(|x| x.name == name).cloned() else {
3096 self.toast(format!("Template '{name}' not found"));
3097 return;
3098 };
3099 match crate::engine::presets::decode_template(&tpl) {
3100 Some((clips, assets)) => {
3101 self.push_undo();
3102 let ids = self.project.place_clips(clips, assets, t);
3103 self.selection = ids;
3104 self.after_edit();
3105 }
3106 None => self.toast(format!("Template '{name}' is corrupted")),
3107 }
3108 }
3109
3110 fn sync_gpu(&mut self) {
3115 let want = self.settings.gpu && !self.gpu_failed;
3116 if want == self.gpu.is_some() {
3117 return;
3118 }
3119 if !want {
3120 self.gpu = None;
3121 self.player.set_gpu(false);
3122 return;
3123 }
3124 let Some(gl) = self.gl.clone() else {
3125 self.gpu_failed = true; self.player.set_gpu(false);
3127 return;
3128 };
3129 match guarded(|| GpuRenderer::new(gl)) {
3130 Some(Ok(mut g)) => {
3131 g.text = Some(self.text.clone()); self.gpu = Some(g);
3133 self.player.set_gpu(true);
3134 self.toast(format!("GPU preview: {}", self.gpu_name));
3135 }
3136 Some(Err(e)) => self.gpu_off(&e),
3137 None => self.gpu_off("the renderer panicked"),
3138 }
3139 }
3140
3141 fn build_effect_thumbnails(&mut self, ctx: &egui::Context) {
3145 const TW: u32 = 96;
3146 const TH: u32 = 54;
3147 let key = (self.settings.effect_thumb_image.clone(), TW);
3148 if self.gpu.is_none() || self.effect_thumbs_key.as_ref() == Some(&key) {
3149 return;
3150 }
3151 let src = effect_thumb_source(&key.0, TW, TH, self.backend());
3152 if src.is_empty() {
3153 return;
3154 }
3155 effects_ui::clear_thumbnails();
3156 self.effect_thumbs.clear();
3157 let mut out = Frame::default();
3158 for kind in EffectKind::ALL {
3159 let effect = crate::model::Effect::new(kind);
3161 let rendered = {
3162 let Some(gpu) = self.gpu.as_mut() else { break };
3163 guarded(|| gpu.effect_preview(&src, &effect, 0.35, &mut out)).unwrap_or(false)
3164 };
3165 let frame = if rendered { &out } else { &src };
3166 if frame.is_empty() || frame.rgba.len() != (frame.width * frame.height * 4) as usize {
3167 continue;
3168 }
3169 let img =
3170 egui::ColorImage::from_rgba_premultiplied([frame.width as usize, frame.height as usize], &frame.rgba);
3171 let tex = ctx.load_texture(format!("fxthumb_{}", kind.name()), img, egui::TextureOptions::LINEAR);
3172 effects_ui::set_thumbnail(kind, tex.id(), [frame.width, frame.height]);
3173 self.effect_thumbs.push(tex);
3174 }
3175 let img = egui::ColorImage::from_rgba_premultiplied([src.width as usize, src.height as usize], &src.rgba);
3177 transitions_ui::set_stock(ctx.load_texture("tr_stock", img, egui::TextureOptions::LINEAR));
3178 self.effect_thumbs_key = Some(key);
3179 }
3180
3181 fn export_frames(&self) -> crate::engine::export::FrameSource {
3184 match self.gpu {
3185 Some(_) => crate::engine::export::FrameSource::Gpu(self.gpu_export.0.clone()),
3186 None => crate::engine::export::FrameSource::Cpu,
3187 }
3188 }
3189
3190 fn serve_gpu_exports(&mut self) -> bool {
3195 let mut served = false;
3196 while let Ok(req) = self.gpu_export.1.try_recv() {
3197 served = true;
3198 let mut out = Frame::default();
3199 let ok = {
3200 let App { gpu, project, .. } = self;
3201 match gpu.as_mut() {
3202 Some(g) => {
3203 out.resize(req.w, req.h);
3204 guarded(|| g.render_frame(project, req.t, req.w, req.h, &req.layers, &mut out)).is_some()
3205 }
3206 None => false,
3207 }
3208 };
3209 if !ok {
3210 self.gpu_off("the renderer panicked");
3211 }
3212 let _ = req.reply.send(ok.then_some(out));
3214 }
3215 served
3216 }
3217
3218 fn gpu_off(&mut self, why: &str) {
3220 self.gpu = None;
3221 self.gpu_tex = None;
3222 self.gpu_tex_ids.clear();
3223 effects_ui::clear_thumbnails();
3224 self.effect_thumbs.clear();
3225 self.effect_thumbs_key = None;
3226 self.gpu_failed = true;
3227 self.player.set_gpu(false);
3228 self.toast(format!("GPU rendering unavailable ({why}) — using the CPU compositor"));
3229 }
3230
3231 fn gpu_preview_texture(
3235 &mut self,
3236 layers: &crate::engine::gpu::LayerSet,
3237 t: f64,
3238 w: u32,
3239 h: u32,
3240 frame: &mut eframe::Frame,
3241 ) -> Option<(egui::TextureId, [u32; 2])> {
3242 if self.gpu.is_none() || w == 0 || h == 0 {
3243 return None;
3244 }
3245 let made = {
3246 let App { gpu, project, .. } = self;
3247 let gpu = gpu.as_mut()?;
3248 guarded(|| gpu.render_preview_texture(project, t, w, h, layers)).flatten()
3249 };
3250 let (tex, tw, th) = made?;
3251 let id = match self.gpu_tex_ids.get(&tex) {
3252 Some(&id) => id,
3253 None => {
3254 let id = frame.register_native_glow_texture(tex);
3255 self.gpu_tex_ids.insert(tex, id);
3256 id
3257 }
3258 };
3259 Some((id, [tw, th]))
3260 }
3261
3262 fn gpu_frame(&mut self, layers: &crate::engine::gpu::LayerSet, t: f64, w: u32, h: u32) -> Option<Arc<Frame>> {
3265 if self.gpu.is_none() || w == 0 || h == 0 {
3266 return None;
3267 }
3268 let mut out = match self.gpu_prev.take().map(Arc::try_unwrap) {
3270 Some(Ok(f)) => f,
3271 _ => Frame::default(),
3272 };
3273 let ok = {
3274 let App { gpu, project, .. } = self;
3275 let gpu = gpu.as_mut()?;
3276 guarded(|| gpu.render_frame(project, t, w, h, layers, &mut out)).is_some()
3277 };
3278 if !ok {
3279 self.gpu_off("the renderer panicked");
3280 return None;
3281 }
3282 out.pts = t;
3283 let frame = Arc::new(out);
3284 self.gpu_prev = Some(frame.clone());
3285 Some(frame)
3286 }
3287
3288 fn render_frame_now(&mut self, t: f64, w: u32) -> Option<Arc<Frame>> {
3291 if self.gpu.is_some() {
3292 let (pw, ph) = (self.project.width.max(16), self.project.height.max(16));
3293 let w = w.clamp(16, pw);
3294 let h = ((ph as u64 * w as u64) / pw as u64).max(1) as u32;
3295 if let Some(layers) = self.player.layers_once(t, w) {
3296 if let Some(f) = self.gpu_frame(&layers, t, w, h) {
3297 return Some(f);
3298 }
3299 }
3300 }
3301 self.player.render_once(t, w)
3302 }
3303
3304 fn request_prerender(&mut self) {
3306 let a = self.project.in_point.unwrap_or(0.0);
3307 let b = self.project.out_point.unwrap_or_else(|| self.project.duration());
3308 if b > a {
3309 let App { prerender, project, .. } = self;
3310 if guarded(|| prerender.request(project, a, b)).is_none() {
3311 self.settings.movie_mode = false;
3312 self.toast("Movie mode is not available in this build");
3313 }
3314 }
3315 }
3316
3317 fn export_frame(&mut self, opts: frame_ui::FrameExport) {
3319 let (rw, rh) = frame_render_size((self.project.width, self.project.height), opts.size);
3320 let scaler_before = self.project.scaler;
3321 self.project.scaler = opts.scaler;
3322 let frame = if opts.with_effects {
3323 self.render_frame_now(self.playhead, rw)
3324 } else {
3325 self.source_frame(self.playhead, rw, rh)
3326 };
3327 self.project.scaler = scaler_before;
3328 let Some(frame) = frame else {
3329 self.toast("Could not render that frame");
3330 return;
3331 };
3332 match write_image(&frame, &opts) {
3333 Ok(()) => self.toast(format!("Frame saved to {}", opts.out.display())),
3334 Err(e) => self.toast(format!("Frame export failed: {e}")),
3335 }
3336 }
3337
3338 fn source_frame(&mut self, t: f64, w: u32, h: u32) -> Option<Arc<Frame>> {
3340 let clip = self
3341 .project
3342 .tracks
3343 .iter()
3344 .enumerate()
3345 .filter(|(i, tr)| tr.kind == TrackKind::Video && self.project.active(*i))
3346 .flat_map(|(_, tr)| tr.clips.iter())
3347 .filter(|c| c.enabled && c.contains(t) && c.uses_asset())
3348 .next_back()?;
3349 let asset = self.project.asset(clip.asset)?;
3350 let mut src = media::open_video(&asset.path, self.backend()).ok()?;
3351 let mut f = Frame::default();
3352 src.frame_at(clip.src_time(t).max(0.0), w, h, &mut f).then(|| Arc::new(f))
3353 }
3354
3355 fn act_import_timeline(&mut self) {
3357 let Some(path) = rfd::FileDialog::new()
3358 .add_filter("Timelines (XML, EDL, prproj)", crate::engine::import::IMPORT_EXTS)
3359 .add_filter("All files", &["*"])
3360 .pick_file()
3361 else {
3362 return;
3363 };
3364 match guarded(|| crate::engine::import::import_file(&path)) {
3365 Some(Ok(report)) => {
3366 self.toast(format!("Imported {} clips on {} tracks", report.clips, report.tracks));
3367 self.import_ui.report = Some(report);
3368 self.import_ui.open = true;
3369 }
3370 Some(Err(e)) => self.toast(format!("Import failed: {e}")),
3371 None => self.toast("Timeline import is not available in this build"),
3372 }
3373 }
3374
3375 fn start_screen_capture(&mut self, opts: crate::engine::capture::ScreenCaptureOptions) {
3378 if self.screen_rec.is_some() || self.ffmpeg_missing() {
3379 return;
3380 }
3381 let out = opts.out.clone();
3382 if let Some(dir) = out.parent() {
3383 let _ = std::fs::create_dir_all(dir);
3384 }
3385 match guarded(|| crate::engine::capture::start_screen(opts)) {
3386 Some(Ok(c)) => self.screen_rec = Some((c, out)),
3387 Some(Err(e)) => self.toast(format!("Screen recording failed: {e}")),
3388 None => self.toast("Screen recording is not available in this build"),
3389 }
3390 }
3391
3392 fn stop_screen_capture(&mut self) {
3393 let Some((c, out)) = self.screen_rec.take() else { return };
3394 if guarded(move || c.stop()).is_none() {
3395 return;
3396 }
3397 self.import_recording(out, None);
3398 }
3399
3400 fn start_voiceover(&mut self, opts: crate::engine::capture::VoiceoverOptions) {
3401 if self.voice_rec.is_some() || self.ffmpeg_missing() {
3402 return;
3403 }
3404 let out = opts.out.clone();
3405 if let Some(dir) = out.parent() {
3406 let _ = std::fs::create_dir_all(dir);
3407 }
3408 match guarded(|| crate::engine::capture::start_voiceover(opts)) {
3409 Some(Ok(c)) => {
3410 self.voice_rec = Some((c, out, self.playhead));
3411 self.player.play(); }
3413 Some(Err(e)) => self.toast(format!("Voiceover failed: {e}")),
3414 None => self.toast("Voiceover recording is not available in this build"),
3415 }
3416 }
3417
3418 fn stop_voiceover(&mut self) {
3419 let Some((c, out, at)) = self.voice_rec.take() else { return };
3420 self.player.pause();
3421 if guarded(move || c.stop()).is_none() {
3422 return;
3423 }
3424 self.import_recording(out, Some(at));
3425 }
3426
3427 fn import_recording(&mut self, out: PathBuf, at: Option<f64>) {
3429 let deadline = Instant::now() + Duration::from_secs(3);
3431 while !out.exists() && Instant::now() < deadline {
3432 std::thread::sleep(Duration::from_millis(50));
3433 }
3434 if !out.exists() {
3435 self.toast(format!("Recording not written: {}", out.display()));
3436 return;
3437 }
3438 let ids = self.import_files(&[out.clone()]);
3439 match at {
3440 Some(t) => {
3441 self.push_undo();
3442 self.insert_at(ids, t, None);
3443 self.after_edit();
3444 self.toast("Voiceover placed on the timeline");
3445 }
3446 None => {
3447 self.library.tab = 0;
3448 self.library.selected = ids.last().copied();
3449 self.toast(format!("Recording imported: {}", out.file_name().unwrap_or_default().to_string_lossy()));
3450 }
3451 }
3452 }
3453
3454 fn audio_inputs(&mut self) -> Vec<(String, bool)> {
3456 if self.audio_inputs.is_none() {
3457 self.audio_inputs = Some(guarded(crate::engine::capture::audio_devices).unwrap_or_default());
3458 }
3459 self.audio_inputs.clone().unwrap_or_default()
3460 }
3461
3462 fn blur_capture_options(&self) -> crate::engine::capture::ScreenCaptureOptions {
3465 crate::engine::capture::ScreenCaptureOptions {
3466 out: capture_ui::capture_dir(&self.settings).join(format!("screen-{}.mp4", Settings::now())),
3467 fps: self.settings.capture_fps.clamp(1, 120),
3468 bitrate_kbps: self.settings.capture_bitrate_kbps,
3469 crf: self.settings.crf,
3470 region: if self.capture_ui.area == "region" { self.capture_ui.region } else { None },
3471 mic: self.settings.capture_mic.clone(),
3472 desktop_audio: self.settings.capture_desktop_audio,
3473 cursor: self.settings.capture_cursor,
3474 }
3475 }
3476
3477 fn glyph_for(&self, a: Action) -> Option<tools::Glyph> {
3482 if let Some(name) = self.settings.icon_overrides.get(&format!("action.{}", a.id())) {
3483 return if name == "none" { None } else { tools::Glyph::from_name(name) };
3484 }
3485 tools::action_glyph(a)
3486 }
3487
3488 fn menu_item(&mut self, ui: &mut egui::Ui, a: Action, enabled: bool, out: &mut Vec<Action>) {
3489 let text = self.hotkeys.text(a);
3490 let glyph = self.glyph_for(a);
3491 let label = match glyph {
3494 Some(_) => format!(" {}", a.label()),
3495 None => a.label().to_string(),
3496 };
3497 let b = egui::Button::new(label).shortcut_text(text);
3498 let r = ui.add_enabled(enabled, b);
3499 if let Some(g) = glyph {
3500 let rect = egui::Rect::from_min_size(
3501 egui::pos2(r.rect.min.x + 4.0, r.rect.center().y - 11.0),
3502 egui::vec2(24.0, 22.0),
3503 );
3504 let fg = if enabled { ui.visuals().text_color() } else { ui.visuals().weak_text_color() };
3505 tools::draw_glyph(ui.painter(), rect, g, fg);
3506 }
3507 if r.clicked() {
3508 out.push(a);
3509 ui.close();
3510 }
3511 }
3512
3513 fn pane_glyph(&self, p: Pane) -> Option<tools::Glyph> {
3515 if let Some(name) = self.settings.icon_overrides.get(&format!("pane.{}", p.title())) {
3516 return if name == "none" { None } else { tools::Glyph::from_name(name) };
3517 }
3518 Some(p.glyph())
3519 }
3520
3521 fn view_menu(&mut self, ui: &mut egui::Ui, out: &mut Vec<Action>) {
3522 use Action::*;
3523 const PANES: [(Pane, Option<Action>); 16] = [
3524 (Pane::Preview, None),
3525 (Pane::Timeline, None),
3526 (Pane::Tools, Some(ToggleTools)),
3527 (Pane::Library, Some(ToggleLibrary)),
3528 (Pane::Inspector, Some(ToggleInspector)),
3529 (Pane::Effects, Some(ToggleEffects)),
3530 (Pane::Transitions, Some(ToggleTransitions)),
3531 (Pane::Curves, Some(ToggleCurves)),
3532 (Pane::Nodes, Some(ToggleNodes)),
3533 (Pane::Subtitles, Some(ToggleSubtitles)),
3534 (Pane::Markers, Some(ToggleMarkers)),
3535 (Pane::Mixer, Some(ToggleMixer)),
3536 (Pane::Presets, None),
3537 (Pane::Planner, Some(TogglePlanner)),
3538 (Pane::AutoCut, Some(Action::AutoCut)),
3539 (Pane::Tracking, None),
3540 ];
3541 for (pane, action) in PANES {
3542 let mut v = self.layout.is_visible(pane);
3543 let label = match action {
3544 Some(a) => format!("{} {}", pane.title(), self.hotkeys.text(a)),
3545 None => pane.title().to_string(),
3546 };
3547 let changed = ui
3548 .horizontal(|ui| {
3549 match self.pane_glyph(pane) {
3550 Some(g) => {
3551 tools::glyph_label(ui, g, ui.visuals().text_color());
3552 }
3553 None => ui.add_space(18.0),
3554 }
3555 ui.checkbox(&mut v, label).changed()
3556 })
3557 .inner;
3558 if changed {
3559 match action {
3561 Some(a) if a != Action::AutoCut => out.push(a),
3562 _ => self.toggle_pane(pane),
3563 }
3564 }
3565 }
3566 ui.separator();
3567 ui.menu_button("Pop out", |ui| {
3568 for pane in Pane::ALL {
3569 if ui.button(pane.title()).clicked() {
3570 ui.close();
3571 self.layout.popout(pane);
3572 self.layout_dirty = true;
3573 }
3574 }
3575 });
3576 ui.menu_button("Layout", |ui| {
3577 if ui.button("Save profile…").clicked() {
3578 ui.close();
3579 self.profile_name = Some(String::new());
3580 }
3581 let profiles = self.settings.layout_profiles.clone();
3582 ui.menu_button("Load profile", |ui| {
3583 if profiles.is_empty() {
3584 ui.label("(none)");
3585 }
3586 for p in profiles {
3587 if layout::profile_button(ui, &p.name).clicked() {
3588 ui.close();
3589 match Layout::from_json_migrating(&p.json) {
3590 Some(l) => {
3591 self.layout = l;
3592 self.layout_dirty = true;
3593 }
3594 None => self.toast(format!("Profile '{}' could not be read", p.name)),
3595 }
3596 }
3597 }
3598 });
3599 if ui.button("Export profile to file…").clicked() {
3600 ui.close();
3601 if let Some(out) = rfd::FileDialog::new()
3602 .add_filter("Simple Editor layout", &["sedit-layout"])
3603 .set_file_name("layout.sedit-layout")
3604 .save_file()
3605 {
3606 match std::fs::write(&out, self.layout.to_json()) {
3607 Ok(()) => self.toast("Layout exported"),
3608 Err(e) => self.toast(format!("Layout export failed: {e}")),
3609 }
3610 }
3611 }
3612 if ui.button("Import profile from file…").clicked() {
3613 ui.close();
3614 if let Some(p) =
3615 rfd::FileDialog::new().add_filter("Simple Editor layout", &["sedit-layout", "json"]).pick_file()
3616 {
3617 match std::fs::read_to_string(&p).ok().and_then(|s| Layout::from_json_migrating(&s)) {
3618 Some(l) => {
3619 let name = p
3620 .file_stem()
3621 .map(|s| s.to_string_lossy().into_owned())
3622 .unwrap_or_else(|| "Imported".into());
3623 self.settings.layout_profiles.retain(|x| x.name != name);
3624 self.settings
3625 .layout_profiles
3626 .push(crate::settings::LayoutProfile { name, json: l.to_json() });
3627 self.settings.save();
3628 self.layout = l;
3629 self.layout_dirty = true;
3630 }
3631 None => self.toast("Not a valid layout file"),
3632 }
3633 }
3634 }
3635 ui.separator();
3636 if ui.button("Reset layout").clicked() {
3637 ui.close();
3638 self.layout.reset();
3639 self.layout_dirty = true;
3640 }
3641 });
3642 ui.separator();
3643 let mut movie = self.settings.movie_mode;
3644 if ui.checkbox(&mut movie, "Movie mode (pre-rendered playback)").changed() {
3645 out.push(MovieMode);
3646 }
3647 self.menu_item(ui, Fullscreen, true, out);
3648 }
3649
3650 fn menu_bar(&mut self, ui: &mut egui::Ui) -> Vec<Action> {
3651 use Action::*;
3652 let mut out = Vec::new();
3653 let has_sel = !self.selection.is_empty();
3654 let has_clips = !self.timeline_is_empty();
3655 let can_overwrite = self.project.source_video.is_some() && has_clips;
3656 egui::MenuBar::new().ui(ui, |ui| {
3657 ui.menu_button("File", |ui| {
3658 self.menu_item(ui, NewProject, true, &mut out);
3659 self.menu_item(ui, OpenFile, true, &mut out);
3660 self.menu_item(ui, OpenProject, true, &mut out);
3661 let recents = self.settings.recent_projects.clone();
3662 let mut forget: Option<Option<String>> = None; ui.menu_button("Open Recent Project", |ui| {
3664 ui.set_max_width(420.0);
3665 if recents.is_empty() {
3666 ui.label("(none)");
3667 }
3668 for r in &recents {
3669 let p = Path::new(r);
3670 let name = p.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| r.clone());
3671 let folder = p.parent().map(|d| d.to_string_lossy().into_owned()).unwrap_or_default();
3672 let b = egui::Button::new(name).shortcut_text(folder).wrap_mode(egui::TextWrapMode::Truncate);
3673 let resp = ui.add(b);
3674 resp.context_menu(|ui| {
3675 if ui.button("Remove from recent").clicked() {
3676 ui.close();
3677 forget = Some(Some(r.clone()));
3678 }
3679 });
3680 if resp.on_hover_text(r).clicked() {
3681 ui.close();
3682 if self.confirm_discard() {
3683 self.open_project(Path::new(r));
3684 }
3685 }
3686 }
3687 if !recents.is_empty() {
3688 ui.separator();
3689 if ui.button("Clear history").clicked() {
3690 ui.close();
3691 forget = Some(None);
3692 }
3693 }
3694 });
3695 if let Some(one) = forget {
3696 match one {
3697 Some(path) => self.settings.recent_projects.retain(|p| *p != path),
3698 None => self.settings.recent_projects.clear(),
3699 }
3700 self.settings.save();
3701 }
3702 self.menu_item(ui, ImportMedia, true, &mut out);
3703 ui.separator();
3704 self.menu_item(ui, Save, true, &mut out);
3705 self.menu_item(ui, SaveProjectAs, true, &mut out);
3706 ui.separator();
3707 self.menu_item(ui, ExportVideo, has_clips, &mut out);
3708 self.menu_item(ui, ExportLossless, has_clips, &mut out);
3709 if ui.add_enabled(can_overwrite, egui::Button::new("Overwrite Original Video…")).clicked() {
3710 ui.close();
3711 self.act_overwrite();
3712 }
3713 self.menu_item(ui, ExportXml, has_clips, &mut out);
3714 self.menu_item(ui, ExportFrame, has_clips, &mut out);
3715 if ui.button("Export Style Summary (.md)…").clicked() {
3716 ui.close();
3717 self.act_export_style();
3718 }
3719 ui.separator();
3720 self.menu_item(ui, ImportTimeline, true, &mut out);
3721 self.menu_item(ui, ScreenCapture, true, &mut out);
3722 self.menu_item(ui, Voiceover, true, &mut out);
3723 ui.separator();
3724 self.menu_item(ui, Settings, true, &mut out);
3725 ui.separator();
3726 if ui.button("Exit").clicked() {
3727 ui.close();
3728 ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close);
3729 }
3730 });
3731 ui.menu_button("Edit", |ui| {
3732 self.menu_item(ui, Undo, !self.undo.is_empty(), &mut out);
3733 self.menu_item(ui, Redo, !self.redo.is_empty(), &mut out);
3734 ui.separator();
3735 self.menu_item(ui, CopyClips, has_sel, &mut out);
3736 self.menu_item(ui, CutClips, has_sel, &mut out);
3737 self.menu_item(ui, PasteClips, self.clipboard.is_some(), &mut out);
3738 self.menu_item(ui, PasteInPlace, self.clipboard.is_some(), &mut out);
3739 self.menu_item(ui, PasteInsert, self.clipboard.is_some(), &mut out);
3740 self.menu_item(ui, PasteAtTop, self.clipboard.is_some(), &mut out);
3741 ui.separator();
3742 self.menu_item(ui, Split, has_clips, &mut out);
3743 self.menu_item(ui, Delete, has_sel, &mut out);
3744 self.menu_item(ui, RippleDelete, has_sel, &mut out);
3745 self.menu_item(ui, NudgeLeft, has_sel, &mut out);
3746 self.menu_item(ui, NudgeRight, has_sel, &mut out);
3747 ui.separator();
3748 self.menu_item(ui, SelectAll, has_clips, &mut out);
3749 self.menu_item(ui, Deselect, has_sel, &mut out);
3750 self.menu_item(ui, LinkToggle, has_sel, &mut out);
3751 self.menu_item(ui, ToggleEnabled, has_sel, &mut out);
3752 ui.separator();
3753 self.menu_item(ui, CopyAttributes, has_sel, &mut out);
3754 self.menu_item(ui, PasteAttributes, has_sel && self.attrs.is_some(), &mut out);
3755 ui.separator();
3756 self.menu_item(ui, AddText, true, &mut out);
3757 self.menu_item(ui, AddShape, true, &mut out);
3758 self.menu_item(ui, AddAdjustment, true, &mut out);
3759 self.menu_item(ui, AddMask, has_sel, &mut out);
3760 self.menu_item(ui, AddMarker, true, &mut out);
3761 self.menu_item(ui, AddSubtitle, true, &mut out);
3762 self.menu_item(ui, AddTransition, has_sel, &mut out);
3763 self.menu_item(ui, AddLastTransition, has_sel, &mut out);
3764 self.menu_item(ui, AddTransitionEnd, has_sel, &mut out);
3765 self.menu_item(ui, Retime, has_sel, &mut out);
3766 self.menu_item(ui, FreezeFrame, has_sel, &mut out);
3767 self.menu_item(ui, NestSequence, has_sel, &mut out);
3768 self.menu_item(ui, OpenParentSequence, self.project.editing.is_some(), &mut out);
3769 self.menu_item(ui, SaveTemplate, has_sel, &mut out);
3770 self.menu_item(ui, ApplyFlow, self.selection.len() == 2, &mut out);
3771 ui.separator();
3772 self.menu_item(ui, MarkIn, true, &mut out);
3773 self.menu_item(ui, MarkOut, true, &mut out);
3774 self.menu_item(ui, ClearInOut, true, &mut out);
3775 self.menu_item(ui, TrimToInOut, has_clips, &mut out);
3776 self.menu_item(ui, RippleDeleteInOut, has_clips, &mut out);
3777 });
3778 ui.menu_button("Timeline", |ui| {
3779 self.menu_item(ui, AddVideoTrack, true, &mut out);
3780 self.menu_item(ui, AddAudioTrack, true, &mut out);
3781 ui.separator();
3782 self.menu_item(ui, ZoomIn, true, &mut out);
3783 self.menu_item(ui, ZoomOut, true, &mut out);
3784 self.menu_item(ui, ZoomFit, true, &mut out);
3785 ui.separator();
3786 let mut snap = self.settings.snap;
3787 if ui.checkbox(&mut snap, format!("Snapping {}", self.hotkeys.text(ToggleSnap))).changed() {
3788 out.push(ToggleSnap);
3789 }
3790 ui.menu_button("Scaling quality", |ui| {
3791 for s in Scaler::ALL {
3792 if ui.radio(self.project.scaler == s, s.name()).clicked() {
3793 ui.close();
3794 if self.project.scaler != s {
3795 self.push_undo();
3796 self.project.scaler = s;
3797 self.after_edit();
3798 }
3799 }
3800 }
3801 });
3802 });
3803 ui.menu_button("Playback", |ui| {
3804 for a in [PlayPause, Stop, StepBack, StepForward, PrevCut, NextCut, GoStart, GoEnd] {
3805 self.menu_item(ui, a, true, &mut out);
3806 }
3807 });
3808 ui.menu_button("View", |ui| self.view_menu(ui, &mut out));
3809 ui.menu_button("Scripts", |ui| {
3810 let scripts = crate::scripting::list();
3812 for p in &scripts {
3813 let label = p.file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
3814 if crate::ui::tools::glyph_text_button(ui, crate::ui::tools::Glyph::Terminal, &label).clicked() {
3815 self.run_script_path = Some(p.clone());
3816 ui.close();
3817 }
3818 }
3819 if scripts.is_empty() {
3820 ui.weak("No scripts yet");
3821 }
3822 ui.separator();
3823 if ui.button("Open Scripts Folder").clicked() {
3824 let _ = std::process::Command::new("explorer").arg(crate::scripting::scripts_dir()).spawn();
3825 ui.close();
3826 }
3827 });
3828 ui.menu_button("Help", |ui| {
3829 ui.label(format!("Simple Editor {}", env!("CARGO_PKG_VERSION")));
3830 ui.label(match media::ffpipe::ffmpeg_exe() {
3831 Some(p) => format!("ffmpeg: {}", p.display()),
3832 None => "ffmpeg: not found (export disabled)".into(),
3833 });
3834 ui.label(format!(
3835 "Context menu: {}",
3836 if crate::contextmenu::is_installed() { "installed" } else { "not installed" }
3837 ));
3838 ui.label("Mouse: Ctrl+Scroll zoom · Shift+Scroll pan · Alt+Scroll track height");
3839 });
3840 ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
3841 ui.label(
3842 egui::RichText::new(format!(
3843 "{} / {}",
3844 crate::ui::timecode(self.playhead, self.project.fps),
3845 crate::ui::timecode(self.project.duration(), self.project.fps)
3846 ))
3847 .monospace(),
3848 );
3849 if self.player.is_playing() {
3850 crate::ui::tools::glyph_label(ui, crate::ui::tools::Glyph::Play, ui.visuals().text_color());
3851 }
3852 if let Some(seq) = self.project.editing {
3853 let name = self.project.sequence(seq).map(|s| s.name.as_str()).unwrap_or("?").to_string();
3854 ui.label(egui::RichText::new(format!("editing: Main > {name}")).weak());
3855 }
3856 });
3857 });
3858 out
3859 }
3860
3861 fn handle_drops(&mut self, ctx: &egui::Context) {
3862 let dropped: Vec<PathBuf> = ctx.input(|i| i.raw.dropped_files.iter().filter_map(|f| f.path.clone()).collect());
3863 if dropped.is_empty() || self.export.is_some() {
3864 return;
3865 }
3866 let pos = ctx.input(|i| {
3868 let mut p = windows::Win32::Foundation::POINT::default();
3869 if !unsafe { GetCursorPos(&mut p) }.as_bool() {
3870 return None;
3871 }
3872 let inner = i.viewport().inner_rect?;
3873 Some(egui::pos2(p.x as f32 / i.pixels_per_point, p.y as f32 / i.pixels_per_point) - inner.min.to_vec2())
3874 });
3875 if dropped.len() == 1
3877 && dropped[0].extension().map(|e| e.to_string_lossy().eq_ignore_ascii_case(PROJECT_EXT)).unwrap_or(false)
3878 {
3879 if self.confirm_discard() {
3880 self.open_project(&dropped[0]);
3881 }
3882 return;
3883 }
3884 let ids = self.open_or_import(&dropped);
3885 if ids.is_empty() {
3886 return;
3887 }
3888 let on_timeline = pos.map(|p| self.timeline.lanes_rect.contains(p)).unwrap_or(false);
3889 if on_timeline {
3890 let p = pos.unwrap();
3891 let mut t = self.timeline.time_at(p.x).max(0.0);
3892 if self.settings.snap {
3893 t = self.project.snap_frame(t);
3894 }
3895 let track = self.timeline.track_at(p.y, &self.project);
3896 let vt = track.filter(|&i| self.project.tracks[i].kind == TrackKind::Video);
3897 self.insert_at(ids, t, vt);
3898 self.after_edit();
3899 } else {
3900 self.library.tab = 0;
3901 self.library.selected = ids.last().copied();
3902 }
3903 }
3904
3905 fn compress_window(&mut self, ctx: &egui::Context) {
3908 let Some(mut c) = self.compress.take() else { return };
3909 let (mut open, mut start) = (true, false);
3910 egui::Window::new("Compress").open(&mut open).resizable(false).default_width(320.0).show(ctx, |ui| {
3911 ui.label(c.src.file_name().unwrap_or_default().to_string_lossy());
3912 if let Some(b) = c.source_bytes {
3913 ui.weak(format!("{:.1} MB on disk", b as f64 / 1e6));
3914 }
3915 ui.separator();
3916 ui.horizontal(|ui| {
3917 ui.selectable_value(&mut c.by_size, false, "Amount");
3918 ui.selectable_value(&mut c.by_size, true, "Target size");
3919 });
3920 if c.by_size {
3921 ui.horizontal(|ui| {
3922 ui.add(egui::DragValue::new(&mut c.target_mb).speed(0.5).range(0.1..=20_000.0).suffix(" MB"));
3923 if let Some(d) = c.duration.filter(|d| *d > 0.0) {
3924 match crate::engine::convert::target_bitrate((c.target_mb * 1e6) as u64, d) {
3925 Some(bps) => ui.weak(format!("\u{2248} {} kbps video", bps / 1000)),
3926 None => ui.colored_label(ui.visuals().error_fg_color, "too small for this length"),
3927 };
3928 }
3929 });
3930 } else {
3931 ui.horizontal(|ui| {
3932 ui.add(egui::Slider::new(&mut c.crf, 18..=40).text("CRF"));
3933 });
3934 ui.weak("Higher = smaller file, more artefacts. 23 is the usual default.");
3935 }
3936 ui.separator();
3937 ui.horizontal(|ui| {
3938 ui.selectable_value(&mut c.overwrite, false, "Save a copy");
3939 ui.selectable_value(&mut c.overwrite, true, "Overwrite original");
3940 });
3941 if c.overwrite {
3942 ui.colored_label(ui.visuals().warn_fg_color, "The original file is replaced when this finishes.");
3943 } else {
3944 ui.weak("Written next to the source as <name>_compressed.<ext> and added to the library.");
3945 }
3946 ui.add_space(4.0);
3947 start = ui.button("Compress").clicked();
3948 });
3949 if start {
3950 self.start_compress(&c);
3951 return; }
3953 if open {
3954 self.compress = Some(c);
3955 }
3956 }
3957
3958 fn start_compress(&mut self, c: &Compress) {
3959 if self.ffmpeg_missing() {
3960 return;
3961 }
3962 let ext = c.src.extension().map(|e| e.to_string_lossy().into_owned()).unwrap_or_else(|| "mp4".into());
3963 let out = if c.overwrite {
3964 c.src.clone()
3965 } else {
3966 let stem = c.src.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_else(|| "output".into());
3967 let mut p = c.src.with_file_name(format!("{stem}_compressed.{ext}"));
3968 let mut n = 2;
3969 while p.exists() {
3970 p = c.src.with_file_name(format!("{stem}_compressed_{n}.{ext}"));
3971 n += 1;
3972 }
3973 p
3974 };
3975 let opts = crate::engine::convert::ConvertOptions {
3976 src: c.src.clone(),
3977 out: out.clone(),
3978 encoder: self.settings.encoder.clone(),
3979 crf: c.crf,
3980 preset: self.settings.preset.clone(),
3981 out_size: None,
3982 scaler: self.settings.export_scaler.clone(),
3983 gif_fps: 15,
3984 target_bytes: c.by_size.then(|| (c.target_mb * 1e6) as u64),
3985 };
3986 self.toast("Compressing\u{2026}");
3987 self.convert_jobs.push((crate::engine::convert::start_convert(opts), out));
3988 }
3989
3990 fn start_lib_preview(&mut self, ctx: &egui::Context, path: PathBuf) {
3994 self.player.pause();
3995 if self.lib_preview.as_ref().is_some_and(|lp| lp.path == path) {
3996 return;
3997 }
3998 let Some(asset) = self.project.assets.iter().find(|a| a.path == path.to_string_lossy()).cloned() else {
4001 self.lib_preview = None;
4002 return;
4003 };
4004 let duration = asset.duration.max(crate::model::MIN_CLIP);
4005 let fps = if asset.fps > 0.0 { asset.fps } else { 30.0 };
4006 let mut player = Player::new(ctx.clone(), self.backend(), self.text.clone());
4007 player.set_project(&Project::from_media(asset));
4008 player.play();
4009 self.lib_preview = Some(LibPreview { path, player, duration, fps });
4010 }
4011
4012 fn lib_preview_frame(&mut self, ctx: &egui::Context) -> Option<library::PreviewFrame> {
4016 let lp = self.lib_preview.as_mut()?;
4017 let playing = lp.player.is_playing();
4018 if let Some(f) = lp.player.take_frame() {
4019 let (w, h) = (f.width as usize, f.height as usize);
4020 if w > 0 && h > 0 && f.rgba.len() == w * h * 4 {
4021 let img = egui::ColorImage::from_rgba_premultiplied([w, h], &f.rgba);
4022 match self.lib_preview_tex.as_mut() {
4023 Some(t) if t.size() == [w, h] => t.set_partial([0, 0], img, egui::TextureOptions::LINEAR),
4024 Some(t) => t.set(img, egui::TextureOptions::LINEAR),
4025 None => {
4026 self.lib_preview_tex = Some(ctx.load_texture("lib_preview", img, egui::TextureOptions::LINEAR))
4027 }
4028 }
4029 }
4030 }
4031 if playing {
4032 ctx.request_repaint();
4033 }
4034 let t = self.lib_preview_tex.as_ref()?;
4035 Some(library::PreviewFrame { tex: t.id(), size: [t.size()[0] as u32, t.size()[1] as u32], playing })
4036 }
4037
4038 fn draw_lib_preview(&mut self, ui: &mut egui::Ui) {
4042 let Some(lp) = self.lib_preview.as_ref() else { return };
4043 let name = lp.path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
4044 let duration = lp.duration;
4045 let fps = lp.fps;
4046 let playhead = lp.player.time();
4047 let playing = lp.player.is_playing();
4048 let frame = self.lib_preview_live;
4049 let palette = self.palette;
4050
4051 let (mut close, mut toggle, mut stop) = (false, false, false);
4052 let mut seek_to: Option<f64> = None;
4053
4054 ui.with_layout(egui::Layout::bottom_up(egui::Align::Min), |ui| {
4055 ui.horizontal(|ui| {
4056 ui.spacing_mut().item_spacing.x = 2.0;
4057 let mut b = |ui: &mut egui::Ui, icon: tools::Glyph, label: &str| -> bool {
4058 tools::glyph_text_button(ui, icon, "").on_hover_text(label).clicked()
4059 };
4060 if b(ui, tools::Glyph::Jump(tools::Dir::Left), "Go to start") {
4061 seek_to = Some(0.0);
4062 }
4063 if b(ui, tools::Glyph::Tri(tools::Dir::Left), "Step back one frame") {
4064 seek_to = Some(step_time(playhead, fps, false, duration));
4065 }
4066 let pp_icon = if playing { tools::Glyph::Pause } else { tools::Glyph::Play };
4067 let pp_label = if playing { "Pause" } else { "Play" };
4068 if b(ui, pp_icon, pp_label) {
4069 toggle = true;
4070 }
4071 if b(ui, tools::Glyph::Stop, "Stop") {
4072 stop = true;
4073 }
4074 if b(ui, tools::Glyph::Tri(tools::Dir::Right), "Step forward one frame") {
4075 seek_to = Some(step_time(playhead, fps, true, duration));
4076 }
4077 if b(ui, tools::Glyph::Jump(tools::Dir::Right), "Go to end") {
4078 seek_to = Some(duration);
4079 }
4080 ui.add_space(8.0);
4081 let tc = crate::ui::timecode;
4082 ui.monospace(format!("{} / {}", tc(playhead, fps), tc(duration, fps)));
4083 ui.add_space(8.0);
4084 if markers_ui::x_button(ui).on_hover_text("Back to timeline").clicked() {
4085 close = true;
4086 }
4087 ui.weak(name);
4088 });
4089 let (bar, br) =
4091 ui.allocate_exact_size(egui::vec2(ui.available_width(), 10.0), egui::Sense::click_and_drag());
4092 ui.painter().rect_filled(bar, 2.0, palette.panel);
4093 let frac = (playhead / duration).clamp(0.0, 1.0) as f32;
4094 let filled = egui::Rect::from_min_max(bar.min, egui::pos2(bar.left() + bar.width() * frac, bar.bottom()));
4095 ui.painter().rect_filled(filled, 2.0, palette.accent);
4096 ui.painter().rect_stroke(bar, 2.0, egui::Stroke::new(1.0, palette.border), egui::StrokeKind::Inside);
4097 if (br.clicked() || br.dragged()) && bar.width() > 0.0 {
4098 if let Some(p) = br.interact_pointer_pos() {
4099 let f = ((p.x - bar.left()) / bar.width()) as f64;
4100 seek_to = Some(scrub_time(f, duration));
4101 }
4102 }
4103
4104 let (rect, _) = ui.allocate_exact_size(ui.available_size_before_wrap(), egui::Sense::hover());
4106 ui.painter().rect_filled(rect, 0.0, egui::Color32::BLACK);
4107 if let Some(f) = frame {
4108 let aspect = f.size[0].max(1) as f32 / f.size[1].max(1) as f32;
4109 let lb = preview::letterbox(rect, aspect, ui.pixels_per_point());
4110 ui.painter().image(
4111 f.tex,
4112 lb,
4113 egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
4114 egui::Color32::WHITE,
4115 );
4116 let cw = (lb.width() * ui.pixels_per_point()) as u32;
4117 let ch = (lb.height() * ui.pixels_per_point()) as u32;
4118 if let Some(lp) = self.lib_preview.as_mut() {
4119 lp.player.set_canvas(cw.max(16), ch.max(16), self.settings.preview_max_width);
4120 }
4121 }
4122 });
4123
4124 if let Some(lp) = self.lib_preview.as_mut() {
4125 if toggle {
4126 lp.player.toggle();
4127 }
4128 if stop {
4129 lp.player.pause();
4130 lp.player.seek(0.0);
4131 }
4132 if let Some(t) = seek_to {
4133 lp.player.seek(t.clamp(0.0, duration));
4134 }
4135 }
4136 if close {
4137 self.lib_preview = None;
4138 self.lib_preview_tex = None;
4139 self.lib_preview_live = None;
4140 }
4141 }
4142
4143 fn screenshot_tick(&mut self, ctx: &egui::Context) {
4144 let Some(path) = self.screenshot.clone() else { return };
4145 let img = ctx.input(|i| {
4147 i.events.iter().find_map(|e| match e {
4148 egui::Event::Screenshot { image, .. } => Some(image.clone()),
4149 _ => None,
4150 })
4151 });
4152 if let Some(img) = img {
4153 let [w, h] = img.size;
4154 let mut data = format!("P6\n{w} {h}\n255\n").into_bytes();
4155 for p in &img.pixels {
4156 data.extend_from_slice(&[p.r(), p.g(), p.b()]);
4157 }
4158 let _ = std::fs::write(&path, data);
4159 ctx.send_viewport_cmd(egui::ViewportCommand::Close);
4160 self.screenshot = None;
4161 return;
4162 }
4163 let elapsed = self.started.elapsed().as_secs_f32();
4166 let delay: Option<f32> = std::env::var("SE_SCREENSHOT_DELAY").ok().and_then(|v| v.parse().ok());
4167 let ready = match delay {
4168 Some(d) => elapsed > d,
4169 None => self.first_frame_at.is_some() || elapsed > 2.5,
4170 };
4171 if !self.screenshot_requested && ready {
4172 self.screenshot_requested = true;
4173 ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(Default::default()));
4174 }
4175 ctx.request_repaint_after(std::time::Duration::from_millis(100));
4176 }
4177
4178 fn sync_proxies(&mut self) {
4182 if let Some((src, _, p)) = &self.proxy_job {
4183 if !p.is_done() {
4184 return;
4185 }
4186 if let Some(e) = p.error() {
4187 eprintln!("proxy for {src}: {e}");
4188 }
4189 self.proxy_job = None;
4190 self.proxy_scan_at = None; }
4192 if self.proxy_scan_at.is_some_and(|t| t > Instant::now()) {
4193 return;
4194 }
4195 self.proxy_scan_at = Some(Instant::now() + Duration::from_secs(2));
4196 let h = self.settings.proxy_height.max(120);
4197 let mut map = std::collections::HashMap::new();
4198 let mut want: Option<(String, std::path::PathBuf)> = None;
4199 if self.settings.use_proxies {
4200 for a in &self.project.assets {
4201 if a.kind != crate::model::ClipKind::Video || a.height <= h || a.duration <= 0.0 {
4204 continue;
4205 }
4206 let dst = crate::media::proxy::proxy_path(&a.path, h);
4207 if dst.exists() {
4208 map.insert(a.path.clone(), dst.to_string_lossy().into_owned());
4209 } else if want.is_none() && std::path::Path::new(&a.path).exists() {
4210 want = Some((a.path.clone(), dst));
4211 }
4212 }
4213 }
4214 if map != self.proxy_map {
4215 self.proxy_map = map.clone();
4216 self.player.set_proxies(map);
4217 }
4218 if let Some((src, dst)) = want {
4219 if crate::media::ffpipe::ffmpeg_exe().is_some() {
4220 let job = crate::media::proxy::generate(src.clone(), dst.clone(), h);
4221 self.proxy_job = Some((src, dst, job));
4222 }
4223 }
4224 }
4225
4226 fn run_script(&mut self, path: &std::path::Path) {
4231 let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
4232 let src = match std::fs::read_to_string(path) {
4233 Ok(s) => s,
4234 Err(e) => return self.toast(format!("{name}: {e}")),
4235 };
4236 let snap = self.project.to_json();
4237 let mut logs = Vec::new();
4238 let result = {
4239 let app = std::cell::RefCell::new(&mut *self);
4240 let mut call = |tool: &str, args: &serde_json::Value| -> Result<serde_json::Value, String> {
4241 let mut app = app.borrow_mut();
4242 let before = MUTATING_TOOLS.contains(&tool).then(|| app.project.to_json());
4243 let r = app.run_tool(tool, args);
4244 if let Some(snap) = before {
4245 if r.is_ok() {
4246 app.after_edit();
4247 } else if let Ok(p) = Project::from_json(&snap) {
4248 app.project = p; }
4250 }
4251 r
4252 };
4253 crate::scripting::run(&src, &name, &mut call, &mut logs)
4254 };
4255 if self.project.to_json() != snap {
4256 push_undo_json(&mut self.undo, &mut self.redo, snap);
4257 }
4258 for l in &logs {
4259 self.toast(format!("{name}: {l}"));
4260 }
4261 match result {
4262 Ok(()) if logs.is_empty() => self.toast(format!("{name}: done")),
4263 Ok(()) => {}
4264 Err(e) => {
4265 eprintln!("script {name}: {e}");
4266 let line = e.lines().next().unwrap_or("failed").to_string();
4267 self.toast(format!("{name}: {line}"));
4268 }
4269 }
4270 }
4271
4272 fn sync_mcp(&mut self, ctx: &egui::Context) {
4276 let (want, port) = (self.settings.mcp_enabled, self.settings.mcp_port);
4277 if !want {
4278 if let Some((server, _)) = self.mcp.take() {
4279 server.stop();
4280 self.toast("MCP server stopped");
4281 }
4282 return;
4283 }
4284 if self.mcp.is_some() && self.mcp_port_running == port {
4285 return;
4286 }
4287 if let Some((server, _)) = self.mcp.take() {
4288 server.stop();
4289 }
4290 match mcp::Server::start(port, ctx.clone()) {
4291 Ok((server, rx)) => {
4292 self.toast(format!("MCP server at {}", server.url()));
4293 self.mcp = Some((server, rx));
4294 self.mcp_port_running = port;
4295 }
4296 Err(e) => {
4297 self.toast(format!("MCP server failed: {e}"));
4298 self.settings.mcp_enabled = false; self.settings.save();
4300 }
4301 }
4302 }
4303
4304 fn poll_mcp(&mut self, ctx: &egui::Context) {
4305 if !self.mcp_jobs.is_empty() {
4307 let mut i = 0;
4308 while i < self.mcp_jobs.len() {
4309 if self.mcp_jobs[i].prog.is_done() {
4310 let j = self.mcp_jobs.remove(i);
4311 let r = match j.prog.error() {
4312 None => Ok(json!({"ok": true, "path": j.out.to_string_lossy()})),
4313 Some(e) => Err(e),
4314 };
4315 let _ = j.reply.send(r);
4316 } else {
4317 i += 1;
4318 }
4319 }
4320 ctx.request_repaint_after(Duration::from_millis(200));
4321 }
4322 let call = self.mcp.as_ref().and_then(|(_, rx)| rx.try_recv().ok());
4324 if let Some(c) = call {
4325 self.handle_tool(c);
4326 ctx.request_repaint(); }
4328 }
4329
4330 fn handle_tool(&mut self, call: mcp::ToolCall) {
4331 let mcp::ToolCall { name, args, reply } = call;
4332 match name.as_str() {
4333 "export.video" | "media.convert" => match self.start_tool_job(&name, &args) {
4335 Ok((prog, out)) => self.mcp_jobs.push(McpJob { prog, reply, out }),
4336 Err(e) => {
4337 let _ = reply.send(Err(e));
4338 }
4339 },
4340 _ => {
4341 let before = MUTATING_TOOLS.contains(&name.as_str()).then(|| self.project.to_json());
4342 let r = self.run_tool(&name, &args);
4343 if let Some(snap) = before {
4344 if r.is_ok() {
4345 if snap != self.project.to_json() {
4346 push_undo_json(&mut self.undo, &mut self.redo, snap);
4347 }
4348 self.after_edit();
4349 } else if let Ok(p) = Project::from_json(&snap) {
4350 self.project = p;
4352 }
4353 }
4354 let _ = reply.send(r);
4355 }
4356 }
4357 }
4358
4359 fn start_tool_job(&mut self, name: &str, args: &Value) -> Result<(Arc<Progress>, PathBuf), String> {
4360 if media::ffpipe::ffmpeg_exe().is_none() {
4361 return Err("ffmpeg.exe not found".into());
4362 }
4363 let out_size = match (arg_u64(args, "width"), arg_u64(args, "height")) {
4364 (Some(w), Some(h)) => Some((w as u32, h as u32)),
4365 _ => None,
4366 };
4367 let scaler = arg_str(args, "scaler").unwrap_or(&self.settings.export_scaler).to_string();
4368 if name == "export.video" {
4369 if self.export.is_some() {
4370 return Err("an export is already running".into());
4371 }
4372 let out = PathBuf::from(req(arg_str(args, "path"), "path")?);
4373 let opts = ExportOptions {
4374 out_path: out.clone(),
4375 encoder: arg_str(args, "encoder").unwrap_or(&self.settings.encoder).to_string(),
4376 crf: arg_u64(args, "crf").map(|c| c as u32).unwrap_or(self.settings.crf),
4377 preset: self.settings.preset.clone(),
4378 backend: self.backend(),
4379 out_size,
4380 scaler,
4381 frames: self.export_frames(),
4382 metadata: Vec::new(),
4383 };
4384 let prog = export::start_export(self.export_project(), opts, self.text.clone());
4385 self.export = Some((prog.clone(), ExportKind::File));
4387 Ok((prog, out))
4388 } else {
4389 let src = PathBuf::from(req(arg_str(args, "path"), "path")?);
4390 let ext = req(arg_str(args, "ext"), "ext")?.trim_start_matches('.').to_string();
4391 let out = converted_path(&src, &ext);
4393 let opts = crate::engine::convert::ConvertOptions {
4394 src,
4395 out: out.clone(),
4396 encoder: self.settings.encoder.clone(),
4397 crf: self.settings.crf,
4398 preset: self.settings.preset.clone(),
4399 out_size,
4400 scaler,
4401 gif_fps: 15,
4402 target_bytes: arg_u64(args, "target_bytes"),
4403 };
4404 Ok((crate::engine::convert::start_convert(opts), out))
4405 }
4406 }
4407
4408 fn run_tool(&mut self, name: &str, args: &Value) -> Result<Value, String> {
4410 match name {
4411 "project.summary" => {
4412 let p = &self.project;
4413 let clips_per_track: Vec<Value> = p
4414 .tracks
4415 .iter()
4416 .map(|t| json!({"name": t.name, "kind": format!("{:?}", t.kind), "clips": t.clips.len()}))
4417 .collect();
4418 fn count_plan(items: &[crate::model::PlanItem]) -> (usize, usize) {
4419 let mut done = 0;
4420 let mut total = 0;
4421 for i in items {
4422 total += 1;
4423 if i.done {
4424 done += 1;
4425 }
4426 let (d, t) = count_plan(&i.children);
4427 done += d;
4428 total += t;
4429 }
4430 (done, total)
4431 }
4432 let (done, total) = count_plan(&p.plan);
4433 Ok(json!({
4434 "name": p.name, "width": p.width, "height": p.height, "fps": p.fps,
4435 "duration": p.duration(), "tracks": clips_per_track,
4436 "assets": p.assets.len(), "sequences": p.sequences.len(),
4437 "subtitles": p.subtitles.len(), "plan_done": done, "plan_total": total,
4438 "notes": p.notes, "style": crate::engine::style::style_summary(p),
4439 "editing_sequence": p.editing,
4441 }))
4442 }
4443 "project.get" => serde_json::to_value(&self.project).map_err(|e| e.to_string()),
4444 "project.new" => {
4445 let mut p = Project::new();
4446 p.width = arg_u64(args, "width").map(|w| w as u32).unwrap_or(1920);
4447 p.height = arg_u64(args, "height").map(|h| h as u32).unwrap_or(1080);
4448 p.fps = arg_f64(args, "fps").unwrap_or(30.0);
4449 self.set_project(p, None);
4450 Ok(json!({"ok": true}))
4451 }
4452 "project.open" => {
4453 let path = PathBuf::from(req(arg_str(args, "path"), "path")?);
4454 if !path.exists() {
4455 return Err(format!("no such file: {}", path.display()));
4456 }
4457 if path.extension().map(|e| e.to_string_lossy().eq_ignore_ascii_case(PROJECT_EXT)).unwrap_or(false) {
4458 let mut project = Project::load(&path)?;
4459 relocate_assets(&mut project, path.parent());
4460 self.set_project(project, Some(path));
4461 } else {
4462 let asset = media::probe(&path.to_string_lossy(), self.backend())?;
4463 self.set_project(Project::from_media(asset), None);
4464 }
4465 Ok(json!({"ok": true}))
4466 }
4467 "project.save" => {
4468 let path = match arg_str(args, "path") {
4469 Some(p) => PathBuf::from(p),
4470 None => self.project_path.clone().ok_or("no project file yet — pass a path")?,
4471 };
4472 self.project.save(&path).map_err(|e| e.to_string())?;
4473 self.project_path = Some(path.clone());
4474 self.dirty = false;
4475 Ok(json!({"ok": true, "path": path.to_string_lossy()}))
4476 }
4477 "project.set" => {
4478 if let Some(n) = arg_str(args, "name") {
4479 self.project.name = n.to_string();
4480 }
4481 if let Some(w) = arg_u64(args, "width") {
4482 self.project.width = w as u32;
4483 }
4484 if let Some(h) = arg_u64(args, "height") {
4485 self.project.height = h as u32;
4486 }
4487 if let Some(f) = arg_f64(args, "fps") {
4488 self.project.fps = f.max(1.0);
4489 }
4490 Ok(json!({"ok": true}))
4491 }
4492 "media.import" => {
4493 let paths: Vec<PathBuf> = req(args.get("paths").and_then(|v| v.as_array()), "paths")?
4494 .iter()
4495 .filter_map(|v| v.as_str().map(PathBuf::from))
4496 .collect();
4497 let ids = self.import_files(&paths);
4498 Ok(json!({"ok": true, "asset_ids": ids}))
4499 }
4500 "media.list" => {
4501 let used = self.project.used_assets();
4502 let list: Vec<Value> = self
4503 .project
4504 .assets
4505 .iter()
4506 .map(|a| {
4507 json!({
4508 "id": a.id, "path": a.path, "kind": format!("{:?}", a.kind),
4509 "duration": a.duration, "width": a.width, "height": a.height,
4510 "tags": a.tags, "label": a.label, "folder": a.folder,
4511 "description": a.description, "used": used.contains(&a.id),
4512 })
4513 })
4514 .collect();
4515 Ok(json!(list))
4516 }
4517 "media.set" => {
4518 let id = req(arg_u64(args, "id"), "id")?;
4519 let a = self.project.asset_mut(id).ok_or("no such asset")?;
4520 if let Some(d) = arg_str(args, "description") {
4521 a.description = d.to_string();
4522 }
4523 if let Some(tags) = args.get("tags").and_then(|v| v.as_array()) {
4524 a.tags = tags.iter().filter_map(|t| t.as_str().map(String::from)).collect();
4525 }
4526 if let Some(l) = arg_u64(args, "label") {
4527 a.label = l.min(8) as u8;
4528 }
4529 if let Some(f) = arg_str(args, "folder") {
4530 a.folder = f.to_string();
4531 }
4532 Ok(json!({"ok": true}))
4533 }
4534 "timeline.list" => {
4535 let p = &self.project;
4536 let tracks: Vec<Value> = p
4537 .tracks
4538 .iter()
4539 .map(|t| {
4540 let clips: Vec<Value> = t
4541 .clips
4542 .iter()
4543 .map(|c| {
4544 json!({
4545 "id": c.id, "kind": format!("{:?}", c.kind), "name": c.name,
4546 "asset": c.asset, "sequence": c.sequence, "start": c.start,
4547 "duration": c.duration, "src_in": c.src_in, "speed": c.speed,
4548 "reverse": c.reverse, "freeze": c.freeze, "enabled": c.enabled,
4549 "label": p.clip_label(c), "link": c.link,
4550 "effects": c.effects.iter().map(|e| e.kind.name()).collect::<Vec<_>>(),
4551 })
4552 })
4553 .collect();
4554 json!({"id": t.id, "name": t.name, "kind": format!("{:?}", t.kind), "clips": clips})
4555 })
4556 .collect();
4557 Ok(json!({"editing_sequence": p.editing, "duration": p.duration(), "tracks": tracks}))
4558 }
4559 "timeline.add_clip" => {
4560 let at = req(arg_f64(args, "at"), "at")?;
4561 let track = arg_u64(args, "track").map(|t| t as usize);
4562 if let Some(aid) = arg_u64(args, "asset_id") {
4563 if self.project.asset(aid).is_none() {
4564 return Err("no such asset".into());
4565 }
4566 let ids = self.project.insert_asset_clips(aid, at, track);
4567 Ok(json!({"ok": true, "clip_ids": ids}))
4568 } else if let Some(sid) = arg_u64(args, "sequence_id") {
4569 let id = self.project.insert_sequence_clip(sid, at, track).ok_or("no such sequence (or cycle)")?;
4570 Ok(json!({"ok": true, "clip_ids": [id]}))
4571 } else if let Some(text) = arg_str(args, "text") {
4572 let dur = arg_f64(args, "duration").unwrap_or(5.0).max(0.1);
4573 let id = self.project.add_text_clip(at, dur);
4574 if let Some(t) = self.project.clip_mut(id).and_then(|c| c.text.as_mut()) {
4575 t.text = text.to_string();
4576 }
4577 Ok(json!({"ok": true, "clip_ids": [id]}))
4578 } else {
4579 Err("pass asset_id, sequence_id or text".into())
4580 }
4581 }
4582 "timeline.split" => {
4583 let t = req(arg_f64(args, "t"), "t")?;
4584 let only = arg_ids(args, "clip_ids");
4585 let new = self.project.split_at(t, only.as_deref());
4586 Ok(json!({"ok": true, "new_clip_ids": new}))
4587 }
4588 "timeline.delete" => {
4589 let ids = self.project.expand_links(&req(arg_ids(args, "clip_ids"), "clip_ids")?);
4590 let ripple = arg_bool(args, "ripple").unwrap_or(false);
4591 self.project.delete_clips(&ids, ripple);
4592 Ok(json!({"ok": true}))
4593 }
4594 "timeline.move" => {
4595 let ids = self.project.expand_links(&req(arg_ids(args, "clip_ids"), "clip_ids")?);
4596 let dt = req(arg_f64(args, "dt"), "dt")?;
4597 let dtrack = args.get("dtrack").and_then(|v| v.as_i64()).unwrap_or(0) as i32;
4598 let moved = self.project.move_clips(&ids, dt, dtrack, None);
4599 if moved {
4600 Ok(json!({"ok": true}))
4601 } else {
4602 Err("move blocked (overlap or out of range)".into())
4603 }
4604 }
4605 "timeline.trim" => {
4606 let id = req(arg_u64(args, "clip_id"), "clip_id")?;
4607 let clip = self.project.clip(id).ok_or("no such clip")?.clone();
4608 let headroom = self.project.head_room(&clip);
4609 let max_dur = self.project.max_clip_duration(&clip);
4610 let c = self.project.clip_mut(id).ok_or("no such clip")?;
4611 if let Some(s) = arg_f64(args, "start") {
4612 c.trim_start(s, headroom);
4613 }
4614 if let Some(e) = arg_f64(args, "end") {
4615 c.trim_end(e, max_dur);
4616 }
4617 let (start, duration) = (c.start, c.duration);
4618 self.project.tidy();
4619 Ok(json!({"ok": true, "start": start, "end": start + duration}))
4620 }
4621 "timeline.add_transition" => {
4622 let right = req(arg_u64(args, "right_clip_id"), "right_clip_id")?;
4623 let kind = match req(arg_str(args, "kind"), "kind")? {
4624 "CrossFade" => TransitionKind::CrossFade,
4625 "FadeToColor" => TransitionKind::FadeToColor,
4626 "Push" => TransitionKind::Push,
4627 "Wipe" => TransitionKind::Wipe,
4628 k => return Err(format!("unknown transition kind '{k}'")),
4629 };
4630 let dur = arg_f64(args, "duration").unwrap_or(1.0);
4631 let id = self
4633 .project
4634 .add_transition(right, kind, dur)
4635 .or_else(|| self.project.add_edge_transition(right, kind, dur, false))
4636 .ok_or("clip not found")?;
4637 self.transitions_ui.remember(kind, dur); Ok(json!({"ok": true, "transition_id": id}))
4639 }
4640 "timeline.auto_cut" => {
4641 use crate::engine::autocut::{loud_segments, to_timeline, AutoCutParams};
4642 let ids = req(arg_ids(args, "clip_ids"), "clip_ids")?;
4643 let mut params = AutoCutParams::default();
4644 if let Some(v) = arg_f64(args, "threshold_db") {
4645 params.threshold_db = v as f32;
4646 }
4647 if let Some(v) = arg_f64(args, "min_silence") {
4648 params.min_silence = v;
4649 }
4650 if let Some(v) = arg_f64(args, "min_speech") {
4651 params.min_speech = v;
4652 }
4653 if let Some(v) = arg_f64(args, "padding") {
4654 params.padding = v;
4655 }
4656 let keep_quiet = arg_bool(args, "keep_quiet").unwrap_or(false);
4657 let ripple = arg_bool(args, "ripple").unwrap_or(true);
4658 let mut cuts = Vec::new();
4659 let mut removes = Vec::new();
4660 for &id in &ids {
4661 let c = self.project.clip(id).ok_or("no such clip")?.clone();
4662 if c.kind != ClipKind::Audio || c.reverse || c.freeze.is_some() {
4663 continue;
4664 }
4665 let a = self.project.asset(c.asset).ok_or("clip has no asset")?;
4666 let peaks = self
4667 .waveforms
4668 .get(&a.path, c.audio_stream)
4669 .ok_or("waveform still computing — try again in a moment")?;
4670 let segs = loud_segments(&peaks, c.src_in, c.src_len(), ¶ms);
4671 let (mut cs, mut rs) = to_timeline(&segs, c.start, c.src_in, c.duration, c.speed, keep_quiet);
4672 cuts.append(&mut cs);
4673 removes.append(&mut rs);
4674 }
4675 if cuts.is_empty() && removes.is_empty() {
4676 return Err("no segments found (are the clips audio clips?)".into());
4677 }
4678 let n = self.project.auto_cut(&ids, &cuts, &removes, ripple);
4679 Ok(json!({"ok": true, "removed": n}))
4680 }
4681 "timeline.nest" => {
4682 let ids = req(arg_ids(args, "clip_ids"), "clip_ids")?;
4683 let name = arg_str(args, "name")
4684 .map(String::from)
4685 .unwrap_or_else(|| format!("Sequence {}", self.project.sequences.len() + 1));
4686 let id = self.project.nest_selection(&ids, name).ok_or("nothing to nest")?;
4687 Ok(json!({"ok": true, "sequence_id": id}))
4688 }
4689 "sequence.list" => {
4690 let list: Vec<Value> = self
4691 .project
4692 .sequences
4693 .iter()
4694 .map(|s| {
4695 json!({"id": s.id, "name": s.name, "width": s.width, "height": s.height,
4696 "fps": s.fps, "duration": s.duration()})
4697 })
4698 .collect();
4699 Ok(json!(list))
4700 }
4701 "sequence.open" => {
4702 match arg_u64(args, "id") {
4703 Some(id) => {
4704 if !self.project.open_sequence(id) {
4705 return Err("no such sequence (or already open / cycle)".into());
4706 }
4707 }
4708 None => self.project.close_sequence(),
4709 }
4710 self.after_edit();
4711 Ok(json!({"ok": true, "editing": self.project.editing}))
4712 }
4713 "clip.set" => {
4714 let id = req(arg_u64(args, "clip_id"), "clip_id")?;
4715 let fields = req(args.get("fields"), "fields")?.clone();
4716 let speed = arg_f64(&fields, "speed");
4718 let reverse = arg_bool(&fields, "reverse");
4719 {
4720 let c = self.project.clip_mut(id).ok_or("no such clip")?;
4721 apply_clip_fields(c, &fields)?;
4722 }
4723 if speed.is_some() || reverse.is_some() {
4724 let cur = self.project.clip(id).ok_or("no such clip")?;
4725 let (s, r) = (speed.unwrap_or(cur.speed), reverse.unwrap_or(cur.reverse));
4726 if !self.project.set_speed(&[id], s, r) {
4727 return Err("speed change blocked by a neighbouring clip".into());
4728 }
4729 }
4730 Ok(json!({"ok": true}))
4731 }
4732 "clip.keyframe" => {
4733 let id = req(arg_u64(args, "clip_id"), "clip_id")?;
4734 let prop = req(arg_str(args, "property"), "property")?.to_string();
4735 let t = req(arg_f64(args, "t"), "t")?;
4736 let remove = arg_bool(args, "remove").unwrap_or(false);
4737 let value = arg_f64(args, "value");
4738 let ease = arg_str(args, "ease").map(|s| parse_ease(s).ok_or(format!("bad ease '{s}'"))).transpose()?;
4739 let c = self.project.clip_mut(id).ok_or("no such clip")?;
4740 let a = anim_of(c, &prop).ok_or_else(|| format!("unknown property '{prop}'"))?;
4741 if remove {
4742 if let Some(i) = a.key_index_at(t) {
4743 a.keys.remove(i);
4744 }
4745 } else {
4746 if !a.is_animated() {
4747 a.toggle_key(t); }
4749 a.set_at(t, value.unwrap_or_else(|| a.at(t)));
4750 if let Some(e) = ease {
4751 a.set_ease_at(t, e);
4752 }
4753 }
4754 Ok(json!({"ok": true}))
4755 }
4756 "clip.add_effect" => {
4757 let id = req(arg_u64(args, "clip_id"), "clip_id")?;
4758 let kind_s = req(arg_str(args, "kind"), "kind")?;
4759 let kind = EffectKind::ALL
4760 .into_iter()
4761 .find(|k| k.name().eq_ignore_ascii_case(kind_s) || format!("{k:?}").eq_ignore_ascii_case(kind_s))
4762 .ok_or_else(|| format!("unknown effect '{kind_s}'"))?;
4763 let mut effect = crate::model::Effect::new(kind);
4764 if let Some(params) = args.get("params").and_then(|v| v.as_object()) {
4765 for (pname, pval) in params {
4766 let i = kind
4767 .params()
4768 .iter()
4769 .position(|s| s.name.eq_ignore_ascii_case(pname))
4770 .ok_or_else(|| format!("unknown param '{pname}' for {}", kind.name()))?;
4771 effect.params[i].value = pval.as_f64().ok_or("param values must be numbers")?;
4772 }
4773 }
4774 let c = self.project.clip_mut(id).ok_or("no such clip")?;
4775 c.effects.push(effect);
4776 Ok(json!({"ok": true, "index": c.effects.len() - 1}))
4777 }
4778 "clip.remove_effect" => {
4779 let id = req(arg_u64(args, "clip_id"), "clip_id")?;
4780 let i = req(arg_u64(args, "index"), "index")? as usize;
4781 let c = self.project.clip_mut(id).ok_or("no such clip")?;
4782 if i >= c.effects.len() {
4783 return Err("no effect at that index".into());
4784 }
4785 c.effects.remove(i);
4786 Ok(json!({"ok": true}))
4787 }
4788 "clip.apply_motion" => {
4789 let id = req(arg_u64(args, "clip_id"), "clip_id")?;
4790 let name = req(arg_str(args, "name"), "name")?;
4791 let scaled = arg_bool(args, "scaled").unwrap_or(true);
4792 let preset = self
4793 .settings
4794 .motion_presets
4795 .iter()
4796 .find(|p| p.name.eq_ignore_ascii_case(name))
4797 .cloned()
4798 .or_else(|| {
4799 crate::engine::presets::builtin_motions()
4800 .into_iter()
4801 .find(|p| p.name.eq_ignore_ascii_case(name))
4802 })
4803 .ok_or_else(|| format!("no motion preset '{name}'"))?;
4804 let c = self.project.clip_mut(id).ok_or("no such clip")?;
4805 crate::engine::presets::apply_motion(&preset, c, scaled);
4806 Ok(json!({"ok": true}))
4807 }
4808 "subtitles.get" => {
4809 let cues: Vec<Value> = self
4810 .project
4811 .subtitles
4812 .iter()
4813 .map(|c| json!({"id": c.id, "start": c.start, "end": c.end, "text": c.text}))
4814 .collect();
4815 Ok(json!(cues))
4816 }
4817 "subtitles.set" => {
4818 let cues = req(args.get("cues").and_then(|v| v.as_array()), "cues")?.clone();
4819 self.project.subtitles.clear();
4820 for c in cues {
4821 let start = req(arg_f64(&c, "start"), "cues[].start")?;
4822 let end = req(arg_f64(&c, "end"), "cues[].end")?;
4823 let text = req(arg_str(&c, "text"), "cues[].text")?;
4824 self.project.add_cue(start, end, text);
4825 }
4826 self.project.sort_cues();
4827 Ok(json!({"ok": true, "count": self.project.subtitles.len()}))
4828 }
4829 "subtitles.import" => {
4830 let path = req(arg_str(args, "path"), "path")?;
4831 let text = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
4832 let cues = crate::engine::subtitles::parse(&text);
4833 if cues.is_empty() {
4834 return Err("no cues found".into());
4835 }
4836 self.project.subtitles.clear();
4837 for (start, end, text) in cues {
4838 self.project.add_cue(start, end, text);
4839 }
4840 self.project.sort_cues();
4841 self.project.show_subtitles = true;
4842 Ok(json!({"ok": true, "count": self.project.subtitles.len()}))
4843 }
4844 "plan.get" => Ok(json!({
4845 "plan": serde_json::to_value(&self.project.plan).map_err(|e| e.to_string())?,
4846 "notes": self.project.notes,
4847 })),
4848 "plan.add" => {
4849 let title = req(arg_str(args, "title"), "title")?.to_string();
4850 let parent = arg_u64(args, "parent");
4851 let id = self.project.plan_add(parent, title);
4852 if parent.is_some() && self.project.plan_item_mut(id).is_none() {
4853 return Err("no such parent".into());
4854 }
4855 let assets = arg_ids(args, "assets").unwrap_or_default();
4856 if let Some(item) = self.project.plan_item_mut(id) {
4857 if let Some(n) = arg_str(args, "notes") {
4858 item.notes = n.to_string();
4859 }
4860 for a in assets {
4861 item.assets.push(a);
4862 item.asset_notes.push(String::new());
4863 }
4864 }
4865 Ok(json!({"ok": true, "id": id}))
4866 }
4867 "plan.set" => {
4868 let id = req(arg_u64(args, "id"), "id")?;
4869 let item = self.project.plan_item_mut(id).ok_or("no such planner item")?;
4870 if let Some(t) = arg_str(args, "title") {
4871 item.title = t.to_string();
4872 }
4873 if let Some(d) = arg_bool(args, "done") {
4874 item.done = d;
4875 }
4876 if let Some(n) = arg_str(args, "notes") {
4877 item.notes = n.to_string();
4878 }
4879 Ok(json!({"ok": true}))
4880 }
4881 "plan.remove" => {
4882 let id = req(arg_u64(args, "id"), "id")?;
4883 self.project.plan_remove(id);
4884 Ok(json!({"ok": true}))
4885 }
4886 "notes.get" => Ok(json!({"notes": self.project.notes})),
4887 "notes.set" => {
4888 let text = req(arg_str(args, "text"), "text")?;
4889 if arg_bool(args, "append").unwrap_or(false) {
4890 if !self.project.notes.is_empty() {
4891 self.project.notes.push_str("\n\n");
4892 }
4893 self.project.notes.push_str(text);
4894 } else {
4895 self.project.notes = text.to_string();
4896 }
4897 Ok(json!({"ok": true}))
4898 }
4899 "render.frame" => {
4900 let t = req(arg_f64(args, "t"), "t")?;
4901 let w = arg_u64(args, "width").map(|w| w as u32).unwrap_or(640).clamp(16, 3840);
4902 let frame = self.render_frame_now(t, w).ok_or("render timed out")?;
4903 let png = mcp::png_encode(&frame);
4904 Ok(json!({
4905 "width": frame.width, "height": frame.height,
4906 "data_url": format!("data:image/png;base64,{}", base64(&png)),
4907 }))
4908 }
4909 "playback.seek" => {
4910 let t = req(arg_f64(args, "t"), "t")?;
4911 self.seek(t);
4912 Ok(json!({"ok": true, "t": self.playhead}))
4913 }
4914 "playback.play" => {
4915 self.player.play();
4916 Ok(json!({"ok": true}))
4917 }
4918 "playback.pause" => {
4919 self.player.pause();
4920 self.playhead = self.player.time();
4921 Ok(json!({"ok": true, "t": self.playhead}))
4922 }
4923 "style.summary" => Ok(json!({"markdown": crate::engine::style::style_summary(&self.export_project())})),
4924 "templates.list" => {
4925 let templates: Vec<&str> = self.settings.templates.iter().map(|t| t.name.as_str()).collect();
4926 let motions: Vec<String> = crate::engine::presets::builtin_motions()
4927 .iter()
4928 .map(|m| m.name.clone())
4929 .chain(self.settings.motion_presets.iter().map(|m| m.name.clone()))
4930 .collect();
4931 Ok(json!({"templates": templates, "motion_presets": motions}))
4932 }
4933 "templates.apply" => {
4934 let name = req(arg_str(args, "name"), "name")?;
4935 let at = req(arg_f64(args, "at"), "at")?;
4936 let tpl = self
4937 .settings
4938 .templates
4939 .iter()
4940 .find(|t| t.name.eq_ignore_ascii_case(name))
4941 .cloned()
4942 .ok_or_else(|| format!("no template '{name}'"))?;
4943 let (clips, assets) = crate::engine::presets::decode_template(&tpl).ok_or("template is corrupted")?;
4944 let ids = self.project.place_clips(clips, assets, at);
4945 Ok(json!({"ok": true, "clip_ids": ids}))
4946 }
4947 "clip.add_mask" => {
4949 let id = req(arg_u64(args, "clip_id"), "clip_id")?;
4950 let shape = mask_shape(arg_str(args, "shape").unwrap_or("Ellipse"))?;
4951 let slot = mask_slot(&mut self.project, id, arg_u64(args, "effect").map(|i| i as usize))?;
4952 if slot.is_some() {
4953 return Err("that clip / effect already has a mask".into());
4954 }
4955 *slot = Some(Mask::new(shape));
4956 Ok(json!({"ok": true}))
4957 }
4958 "clip.set_mask" => {
4959 let id = req(arg_u64(args, "clip_id"), "clip_id")?;
4960 let fields = req(args.get("fields"), "fields")?.clone();
4961 let slot = mask_slot(&mut self.project, id, arg_u64(args, "effect").map(|i| i as usize))?;
4962 let mask = slot.as_mut().ok_or("no mask on that clip / effect (call clip.add_mask first)")?;
4963 apply_mask_fields(mask, &fields)?;
4964 Ok(json!({"ok": true}))
4965 }
4966 "clip.add_node" => {
4967 let id = req(arg_u64(args, "clip_id"), "clip_id")?;
4968 let kind = node_kind(req(arg_str(args, "kind"), "kind")?)?;
4969 let (x, y) = (arg_f64(args, "x").unwrap_or(160.0) as f32, arg_f64(args, "y").unwrap_or(120.0) as f32);
4970 let node = self.project.add_node(id, kind, x, y).ok_or("no such clip")?;
4971 Ok(json!({"ok": true, "node_id": node}))
4972 }
4973 "clip.connect_nodes" => {
4974 let id = req(arg_u64(args, "clip_id"), "clip_id")?;
4975 let from = req(arg_u64(args, "from"), "from")?;
4976 let to = req(arg_u64(args, "to"), "to")?;
4977 let port = arg_u64(args, "port").unwrap_or(0) as usize;
4978 self.project.ensure_graph(id);
4979 let g = self.project.clip_mut(id).and_then(|c| c.graph.as_mut()).ok_or("no such clip")?;
4980 if !g.connect(from, to, port) {
4981 return Err("connection refused (unknown node, bad port or a cycle)".into());
4982 }
4983 Ok(json!({"ok": true}))
4984 }
4985 "markers.list" => {
4986 let list: Vec<Value> = self
4987 .project
4988 .markers_in_timeline()
4989 .into_iter()
4990 .map(|(id, t, dur, name, label)| json!({"id": id, "t": t, "duration": dur, "name": name,
4991 "label": label, "label_name": self.project.label_name(label)}))
4992 .collect();
4993 Ok(json!(list))
4994 }
4995 "markers.add" => {
4996 let t = req(arg_f64(args, "t"), "t")?;
4997 let name = arg_str(args, "name").unwrap_or("Marker").to_string();
4998 let id = match arg_u64(args, "clip_id") {
4999 Some(clip) => {
5000 let start = self.project.clip(clip).ok_or("no such clip")?.start;
5001 self.project.add_clip_marker(clip, t - start, name).ok_or("no such clip")?
5002 }
5003 None => self.project.add_marker(t, name),
5004 };
5005 if let Some(m) = self.project.marker_mut(id) {
5006 if let Some(n) = arg_str(args, "note") {
5007 m.note = n.to_string();
5008 }
5009 if let Some(l) = arg_u64(args, "label") {
5010 m.label = l.min(255) as u8;
5011 }
5012 if let Some(d) = arg_f64(args, "duration") {
5013 m.duration = d.max(0.0);
5014 }
5015 }
5016 Ok(json!({"ok": true, "id": id}))
5017 }
5018 "markers.remove" => {
5019 let id = req(arg_u64(args, "id"), "id")?;
5020 self.project.remove_marker(id);
5021 Ok(json!({"ok": true}))
5022 }
5023 "audio.buses" => {
5024 self.project.main_bus();
5025 let list: Vec<Value> = self
5026 .project
5027 .buses
5028 .iter()
5029 .map(|b| {
5030 json!({"id": b.id, "name": b.name, "gain": b.gain.value, "pan": b.pan.value,
5031 "muted": b.muted, "solo": b.solo, "mono": b.mono, "output": b.output,
5032 "filters": b.filters.iter().map(|f| f.kind.name()).collect::<Vec<_>>()})
5033 })
5034 .collect();
5035 Ok(json!(list))
5036 }
5037 "audio.add_bus" => {
5038 let name = arg_str(args, "name").unwrap_or("Bus").to_string();
5039 let id = self.project.add_bus(name);
5040 Ok(json!({"ok": true, "bus_id": id}))
5041 }
5042 "audio.add_filter" => {
5043 let bus = req(arg_u64(args, "bus"), "bus")?;
5044 let kind_s = req(arg_str(args, "kind"), "kind")?;
5045 let kind = FilterKind::ALL
5046 .into_iter()
5047 .find(|k| k.name().eq_ignore_ascii_case(kind_s) || format!("{k:?}").eq_ignore_ascii_case(kind_s))
5048 .ok_or_else(|| format!("unknown filter '{kind_s}'"))?;
5049 let mut f = crate::model::AudioFilter::new(kind);
5050 if let Some(params) = args.get("params").and_then(|v| v.as_object()) {
5051 for (pname, pval) in params {
5052 let i = kind
5053 .params()
5054 .iter()
5055 .position(|s| s.name.eq_ignore_ascii_case(pname))
5056 .ok_or_else(|| format!("unknown param '{pname}' for {}", kind.name()))?;
5057 f.params[i].value = pval.as_f64().ok_or("param values must be numbers")?;
5058 }
5059 }
5060 let b = self.project.bus_mut(bus).ok_or("no such bus")?;
5061 b.filters.push(f);
5062 Ok(json!({"ok": true, "index": b.filters.len() - 1}))
5063 }
5064 "audio.route" => {
5065 let bus = req(arg_u64(args, "bus"), "bus")?;
5066 if bus != 0 && self.project.bus(bus).is_none() {
5067 return Err("no such bus".into());
5068 }
5069 if let Some(clip) = arg_u64(args, "clip_id") {
5070 self.project.clip_mut(clip).ok_or("no such clip")?.bus = bus;
5071 } else if let Some(track) = arg_u64(args, "track") {
5072 let t = self.project.tracks.get_mut(track as usize).ok_or("no such track")?;
5073 t.bus = bus;
5074 } else if let Some(from) = arg_u64(args, "from_bus") {
5075 let main = self.project.main_bus();
5076 if from == main {
5077 return Err("the Main bus has no output".into());
5078 }
5079 self.project.bus_mut(from).ok_or("no such bus")?.output = bus;
5080 } else {
5081 return Err("pass clip_id, track or from_bus".into());
5082 }
5083 Ok(json!({"ok": true}))
5084 }
5085 "shapes.add" => {
5086 let kind_s = arg_str(args, "kind").unwrap_or("Rect");
5087 let kind = ShapeKind::ALL
5088 .into_iter()
5089 .find(|k| k.name().eq_ignore_ascii_case(kind_s) || format!("{k:?}").eq_ignore_ascii_case(kind_s))
5090 .ok_or_else(|| format!("unknown shape '{kind_s}'"))?;
5091 let at = req(arg_f64(args, "at"), "at")?;
5092 let dur = arg_f64(args, "duration").unwrap_or(5.0).max(0.1);
5093 let id = self.project.add_shape_clip(kind, at, dur);
5094 if let Some(s) = self.project.clip_mut(id).and_then(|c| c.shape.as_mut()) {
5095 if let Some(c) = args.get("fill").and_then(color_arg) {
5096 s.fill = c;
5097 }
5098 if let Some(c) = args.get("stroke").and_then(color_arg) {
5099 s.stroke = c;
5100 }
5101 if let Some(w) = arg_f64(args, "stroke_width") {
5102 s.stroke_width = w as f32;
5103 }
5104 if let Some(n) = arg_u64(args, "sides") {
5105 s.sides = n.clamp(3, 64) as u32;
5106 }
5107 if let Some(w) = arg_f64(args, "width") {
5108 s.w.value = w;
5109 }
5110 if let Some(h) = arg_f64(args, "height") {
5111 s.h.value = h;
5112 }
5113 }
5114 Ok(json!({"ok": true, "clip_id": id}))
5115 }
5116 "timeline.import" => {
5117 let path = PathBuf::from(req(arg_str(args, "path"), "path")?);
5118 let report = crate::engine::import::import_file(&path)?;
5119 let md = report.to_markdown();
5120 let (clips, tracks, missing) = (report.clips, report.tracks, report.missing_media);
5121 if arg_bool(args, "replace").unwrap_or(false) {
5122 self.set_project(report.project, None);
5123 } else {
5124 self.import_ui.report = Some(report);
5125 self.import_ui.open = true;
5126 }
5127 Ok(json!({"ok": true, "clips": clips, "tracks": tracks, "missing_media": missing, "report": md}))
5128 }
5129 "frame.export" => {
5130 let out = PathBuf::from(req(arg_str(args, "path"), "path")?);
5131 let t = arg_f64(args, "t").unwrap_or(self.playhead);
5132 let (pw, ph) = (self.project.width.max(16), self.project.height.max(16));
5133 let w = arg_u64(args, "width").map(|w| w as u32).unwrap_or(pw).clamp(16, 7680);
5134 let h = arg_u64(args, "height")
5135 .map(|h| h as u32)
5136 .unwrap_or_else(|| ((ph as u64 * w as u64) / pw as u64).max(1) as u32)
5137 .clamp(16, 4320);
5138 let opts = frame_ui::FrameExport {
5139 out: out.clone(),
5140 size: (w, h),
5141 scaler: self.project.scaler,
5142 resize: arg_str(args, "resize").unwrap_or(&self.settings.export_scaler).to_string(),
5143 with_effects: arg_bool(args, "with_effects").unwrap_or(true),
5144 quality: arg_u64(args, "quality").map(|q| q as u32).unwrap_or(self.settings.frame_quality),
5145 };
5146 let (rw, _) = frame_render_size((pw, ph), opts.size);
5147 let frame = self.render_frame_now(t, rw).ok_or("render timed out")?;
5148 write_image(&frame, &opts)?;
5149 Ok(json!({"ok": true, "path": out.to_string_lossy(), "width": w, "height": h}))
5150 }
5151 "labels.list" => {
5152 let list: Vec<Value> = self
5153 .project
5154 .labels
5155 .iter()
5156 .enumerate()
5157 .map(|(i, l)| json!({"index": i + 1, "name": l.name, "color": l.color}))
5158 .collect();
5159 Ok(json!(list))
5160 }
5161 "labels.set" => {
5162 let color = args.get("color").and_then(color_arg).map(|c| [c[0], c[1], c[2]]);
5163 match arg_u64(args, "index") {
5164 Some(i) if arg_bool(args, "remove").unwrap_or(false) => {
5165 self.project.remove_label(i as u8);
5166 Ok(json!({"ok": true, "labels": self.project.labels.len()}))
5167 }
5168 Some(i) => {
5169 let l = self
5170 .project
5171 .labels
5172 .get_mut((i as usize).checked_sub(1).ok_or("index is 1-based")?)
5173 .ok_or("no such label")?;
5174 if let Some(n) = arg_str(args, "name") {
5175 l.name = n.to_string();
5176 }
5177 if let Some(c) = color {
5178 l.color = c;
5179 }
5180 Ok(json!({"ok": true, "index": i}))
5181 }
5182 None => {
5183 let name = req(arg_str(args, "name"), "name")?.to_string();
5184 let idx = self.project.add_label(name, color.unwrap_or([128, 128, 128]));
5185 Ok(json!({"ok": true, "index": idx}))
5186 }
5187 }
5188 }
5189 "container.add" => {
5190 let at = req(arg_f64(args, "at"), "at")?;
5191 let dur = arg_f64(args, "duration").unwrap_or(5.0).max(0.1);
5192 let (vid, aid) = self.project.add_container_clip(at, dur);
5193 if let Some(lbl) = arg_str(args, "label") {
5194 if let Some(vc) = self.project.clip_mut(vid) {
5195 vc.container_label = lbl.to_string();
5196 }
5197 if let Some(ac) = self.project.clip_mut(aid) {
5198 ac.container_label = lbl.to_string();
5199 }
5200 }
5201 Ok(json!({"ok": true, "video_clip_id": vid, "audio_clip_id": aid}))
5202 }
5203 "container.replace" => {
5204 let clip_id = req(arg_u64(args, "clip_id"), "clip_id")?;
5205 let asset_id = req(arg_u64(args, "asset_id"), "asset_id")?;
5206 let pair = arg_bool(args, "pair").unwrap_or(false);
5207 let ok = if pair {
5208 self.project.replace_container_pair(clip_id, asset_id)
5209 } else {
5210 self.project.replace_container_media(clip_id, asset_id)
5211 };
5212 if ok {
5213 Ok(json!({"ok": true}))
5214 } else {
5215 Err("failed to replace (clip not a container or asset not found)".into())
5216 }
5217 }
5218 "container.make" => {
5219 let ids = req(arg_ids(args, "clip_ids"), "clip_ids")?;
5220 self.project.make_container(&ids);
5221 Ok(json!({"ok": true}))
5222 }
5223 "container.unmake" => {
5224 let ids = req(arg_ids(args, "clip_ids"), "clip_ids")?;
5225 self.project.unmake_container(&ids);
5226 Ok(json!({"ok": true}))
5227 }
5228 "container.list" => {
5229 let containers: Vec<Value> = self
5230 .project
5231 .all_clips()
5232 .filter(|(_, c)| c.container)
5233 .map(|(ti, c)| {
5234 json!({
5235 "clip_id": c.id,
5236 "track_index": ti,
5237 "kind": format!("{:?}", c.kind),
5238 "name": c.name,
5239 "label": c.container_label,
5240 "is_empty": c.is_empty_container(),
5241 "asset_id": c.asset,
5242 "start": c.start,
5243 "duration": c.duration,
5244 "link": c.link,
5245 })
5246 })
5247 .collect();
5248 Ok(json!(containers))
5249 }
5250 _ => Err(format!("unknown tool '{name}'")),
5251 }
5252 }
5253
5254 fn name_window(ctx: &egui::Context, title: &str, field: &mut Option<String>) -> Option<String> {
5258 let mut name = field.take()?;
5259 let mut open = true;
5260 let mut done = None;
5261 let mut cancel = false;
5262 egui::Window::new(title).open(&mut open).collapsible(false).resizable(false).show(ctx, |ui| {
5263 let id = ui.id().with("name");
5265 let first = ctx.read_response(id).is_none();
5266 let r = ui.add(egui::TextEdit::singleline(&mut name).id(id));
5267 if first {
5268 r.request_focus();
5269 }
5270 let enter = r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
5271 ui.horizontal(|ui| {
5272 if (ui.button("Save").clicked() || enter) && !name.trim().is_empty() {
5273 done = Some(name.trim().to_string());
5274 }
5275 if ui.button("Cancel").clicked() {
5276 cancel = true;
5277 }
5278 });
5279 });
5280 if done.is_some() || cancel || !open {
5281 done
5282 } else {
5283 *field = Some(name);
5284 None
5285 }
5286 }
5287
5288 fn windows(&mut self, ctx: &egui::Context) {
5289 self.url_window(ctx);
5290 self.compress_window(ctx);
5291 if let Some((id, ext)) = self.convert_dialog.clone() {
5293 let name = self.project.asset(id).map(|a| a.name()).unwrap_or_default();
5294 let mut open = true;
5295 let mut start = false;
5296 let mut ext = ext;
5297 egui::Window::new("Convert To…").open(&mut open).resizable(false).show(ctx, |ui| {
5298 ui.label(&name);
5299 ui.horizontal(|ui| {
5300 ui.label("Format");
5301 egui::ComboBox::from_id_salt("convert_ext").selected_text(&ext).show_ui(ui, |ui| {
5302 for t in crate::engine::convert::TARGETS {
5303 ui.selectable_value(&mut ext, (*t).to_string(), *t);
5304 }
5305 });
5306 });
5307 ui.weak("Saved next to the source as <name>_converted.<ext> and added to the library.");
5308 start = ui.button("Convert").clicked();
5309 });
5310 match (open, start) {
5311 (_, true) => {
5312 self.convert_dialog = None;
5313 self.start_asset_convert(id, &ext);
5314 }
5315 (true, false) => self.convert_dialog = Some((id, ext)),
5316 (false, false) => self.convert_dialog = None,
5317 }
5318 }
5319 let convert_jobs: Vec<(Arc<Progress>, String)> = self
5321 .convert_jobs
5322 .iter()
5323 .map(|(p, o)| (p.clone(), o.file_name().unwrap_or_default().to_string_lossy().into_owned()))
5324 .collect();
5325 job_window(ctx, "Converting", &convert_jobs);
5326 let downloads: Vec<(Arc<Progress>, String)> =
5327 self.downloads.iter().map(|d| (d.progress.clone(), d.url.clone())).collect();
5328 job_window(ctx, "Downloading", &downloads);
5329 if self.retime.open {
5331 let changed = {
5332 let App { project, selection, playhead, undo, redo, retime, .. } = self;
5333 let mut push = |p: &Project| push_undo_json(undo, redo, p.to_json());
5334 retime::show(ctx, retime, project, selection, *playhead, &mut push)
5335 };
5336 if changed {
5337 self.after_edit();
5338 }
5339 }
5340 if self.export_ui.open {
5342 self.detect_encoders_once();
5343 let main = self.project.editing.is_some().then(|| self.export_project());
5345 let choice = {
5346 let App { project, settings, export_ui: st, encoders, export, .. } = self;
5347 export_ui::show(ctx, st, main.as_ref().unwrap_or(project), settings, encoders, export.is_some())
5348 };
5349 if let Some(c) = choice {
5350 self.start_export_choice(c);
5351 }
5352 }
5353 if let Some(name) = Self::name_window(ctx, "Save Template", &mut self.template_name) {
5355 let t = crate::engine::presets::capture_template(&name, &self.project, &self.selection);
5356 self.settings.templates.retain(|x| x.name != name);
5357 self.settings.templates.push(t);
5358 self.settings.save();
5359 self.toast(format!("Template '{name}' saved"));
5360 }
5361 if let Some(name) = Self::name_window(ctx, "Save Layout Profile", &mut self.profile_name) {
5362 let json = self.layout.to_json();
5363 self.settings.layout_profiles.retain(|x| x.name != name);
5364 self.settings.layout_profiles.push(crate::settings::LayoutProfile { name: name.clone(), json });
5365 self.settings.save();
5366 self.toast(format!("Layout profile '{name}' saved"));
5367 }
5368 if self.settings_ui.open {
5370 let backend_before = self.settings.decoder.clone();
5371 let gpu_before = self.settings.gpu;
5372 let theme_before = self.settings.theme.clone();
5373 let ctxmenu_before = self.settings.context_menu;
5374 let ffdir_before = self.settings.ffmpeg_dir.clone();
5375 let ytdlp_dir_before = self.settings.ytdlp_dir.clone();
5376 self.detect_encoders_once();
5377 let mcp_status = match (&self.mcp, self.settings.mcp_enabled) {
5378 (Some((s, _)), _) => format!("running at {}", s.url()),
5379 (None, true) => "starting…".into(),
5380 (None, false) => "stopped".into(),
5381 };
5382 let inputs = self.audio_inputs();
5383 let changed = settings_ui::show(
5384 ctx,
5385 &mut self.settings_ui,
5386 &mut self.settings,
5387 &mut self.hotkeys,
5388 &self.encoders,
5389 &self.palette,
5390 &mcp_status,
5391 &self.gpu_name,
5392 &inputs,
5393 );
5394 if changed {
5395 self.hotkeys.to_settings(&mut self.settings);
5396 self.settings.save();
5397 if self.settings.theme != theme_before {
5398 theme::apply(ctx, &self.settings.theme);
5399 }
5400 if self.settings.ffmpeg_dir != ffdir_before {
5401 media::ffpipe::set_dir(&self.settings.ffmpeg_dir);
5402 self.encoders.clear();
5403 }
5404 if self.settings.ytdlp_dir != ytdlp_dir_before {
5405 media::ytdlp::set_dir(&self.settings.ytdlp_dir);
5406 }
5407 self.detect_ytdlp(ctx);
5409 if self.settings.gpu != gpu_before {
5410 self.gpu_failed = false; }
5412 if self.settings.decoder != backend_before {
5413 let b = self.backend();
5414 self.player.set_backend(b);
5415 self.waveforms.set_backend(b);
5416 self.thumbs.set_backend(b);
5417 }
5418 if self.settings.context_menu != ctxmenu_before {
5419 let r = if self.settings.context_menu {
5420 crate::contextmenu::install()
5421 } else {
5422 crate::contextmenu::uninstall()
5423 };
5424 if let Err(e) = r {
5425 self.toast(format!("Context menu: {e}"));
5426 }
5427 }
5428 }
5431 }
5432 if self.frame_ui.open {
5434 let choice = {
5435 let App { frame_ui: st, project, settings, .. } = self;
5436 frame_ui::show(ctx, st, project, settings)
5437 };
5438 if let Some(c) = choice {
5439 self.settings.save();
5440 self.export_frame(c);
5441 }
5442 }
5443 if shader_ui::show(ctx, &mut self.shader_ui) {
5446 if let Some((id, i)) = self.shader_ui.target {
5447 let src = self.shader_ui.src.clone();
5448 let snap = self.project.to_json();
5449 let changed = match self.project.clip_mut(id).and_then(|c| c.effects.get_mut(i)) {
5450 Some(fx) if fx.kind == EffectKind::Shader && fx.shader != src => {
5451 fx.shader = src.clone();
5452 true
5453 }
5454 _ => false,
5455 };
5456 if changed {
5457 push_undo_json(&mut self.undo, &mut self.redo, snap);
5458 self.after_edit();
5459 }
5460 self.shader_ui.error = match self.gpu.as_mut() {
5461 Some(g) => g.check_shader(&src).err().unwrap_or_default(),
5462 None => "GPU renderer is off — this shader cannot be compiled or previewed.".into(),
5463 };
5464 }
5465 }
5466 if self.paste_ui.open {
5468 let name = self.attrs.as_ref().map(|c| c.name.clone()).unwrap_or_default();
5469 let targets = self.selection.len();
5470 let chosen = paste_ui::show(ctx, &mut self.paste_ui, &name, targets);
5471 if let Some(set) = chosen {
5472 if let Some(src) = self.attrs.clone() {
5473 let snap = self.project.to_json();
5474 let ids = self.selection.clone();
5475 let n = self.project.paste_attributes(&src, &ids, set);
5476 if n > 0 {
5477 push_undo_json(&mut self.undo, &mut self.redo, snap);
5478 self.after_edit();
5479 }
5480 self.toast(format!("Pasted attributes onto {n} clip(s)"));
5481 }
5482 self.paste_ui.open = false;
5483 }
5484 }
5485 if self.capture_ui.screen_open || self.capture_ui.voice_open {
5487 let resp = {
5488 let App { capture_ui: st, settings, palette, screen_rec, voice_rec, .. } = self;
5489 capture_ui::show(ctx, st, settings, screen_rec.is_some(), voice_rec.is_some(), palette)
5490 };
5491 if resp.stop_screen {
5492 self.stop_screen_capture();
5493 }
5494 if resp.stop_voice {
5495 self.stop_voiceover();
5496 }
5497 if let Some(o) = resp.screen.filter(|_| resp.start_screen) {
5498 self.start_screen_capture(o);
5499 }
5500 if let Some(o) = resp.voice.filter(|_| resp.start_voice) {
5501 self.start_voiceover(o);
5502 }
5503 }
5504 if self.import_ui.open {
5506 let accept = {
5507 let App { import_ui: st, palette, .. } = self;
5508 import_ui::show(ctx, st, palette)
5509 };
5510 if accept && self.confirm_discard() {
5513 if let Some(r) = self.import_ui.report.take() {
5514 self.set_project(r.project, None);
5515 self.toast("Imported timeline is now the project");
5516 }
5517 self.import_ui.open = false;
5518 }
5519 }
5520 if let Some((prog, kind)) = &self.export {
5522 let prog = prog.clone();
5523 let title = match kind {
5524 ExportKind::File => "Exporting…",
5525 ExportKind::Overwrite { .. } => "Saving over the original…",
5526 };
5527 egui::Window::new(title)
5528 .collapsible(false)
5529 .resizable(false)
5530 .default_pos(ctx.content_rect().center() - egui::vec2(180.0, 60.0))
5531 .show(ctx, |ui| {
5532 ui.set_width(360.0);
5533 ui.add(egui::ProgressBar::new(prog.fraction()).show_percentage());
5534 ui.label(prog.status());
5535 if ui.button("Cancel").clicked() {
5536 prog.cancel.store(true, std::sync::atomic::Ordering::SeqCst);
5537 }
5538 });
5539 }
5540 }
5541}
5542
5543impl eframe::App for App {
5544 fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
5545 if !self.window_shown {
5546 ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true));
5548 ctx.send_viewport_cmd(egui::ViewportCommand::Focus);
5549 self.window_shown = true;
5550 }
5551 self.palette = theme::palette_with(ctx, &self.settings.palette);
5552 if self.fonts.is_empty() {
5553 if let Ok(t) = self.text.try_lock() {
5554 if t.is_loaded() {
5555 self.fonts = t.families().to_vec();
5556 }
5557 }
5558 }
5559 self.poll_panels();
5560 self.poll_probes(ctx);
5561 self.lib_preview_live = self.lib_preview_frame(ctx);
5562 self.build_effect_thumbnails(ctx);
5563 if self.serve_gpu_exports() || self.export.is_some() {
5564 ctx.request_repaint();
5566 }
5567 self.autocut_shown = self.autocut_drawing;
5569 self.autocut_drawing = false;
5570 self.tracking_shown = self.tracking_drawing;
5571 self.tracking_drawing = false;
5572
5573 if ctx.input(|i| i.viewport().close_requested()) && !self.close_confirmed {
5575 if let Some((prog, _)) = &self.export {
5576 ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
5578 prog.cancel.store(true, std::sync::atomic::Ordering::SeqCst);
5579 self.close_after_export = true;
5580 } else if self.dirty && self.screenshot.is_none() {
5581 ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
5582 if self.confirm_discard() {
5583 self.close_confirmed = true;
5584 ctx.send_viewport_cmd(egui::ViewportCommand::Close);
5585 }
5586 } else {
5587 self.close_confirmed = true;
5588 }
5589 }
5590
5591 let playing = self.player.is_playing();
5593 if playing || self.was_playing {
5594 self.playhead = self.player.time();
5595 self.timeline.ensure_visible(self.playhead);
5596 ctx.request_repaint_after(Duration::from_millis(16));
5597 }
5598 if self.draw_rec.is_some() && (self.tools.tool != Tool::Draw || (self.was_playing && !playing)) {
5600 self.tools.recording = false;
5601 self.toggle_draw_recording(false);
5602 }
5603 self.was_playing = playing;
5604 self.sync_gpu();
5607 if self.canvas.0 > 0 && self.canvas.1 > 0 {
5610 if let Some(layers) = self.player.take_layers() {
5611 let (w, h) = self.canvas;
5612 let t = self.player.time();
5613 if let Some(tex) = self.gpu_preview_texture(&layers, t, w, h, _frame) {
5616 self.gpu_tex = Some(tex);
5617 self.pending_frame = None;
5618 } else if let Some(f) = self.gpu_frame(&layers, t, w, h) {
5619 self.pending_frame = Some(f);
5620 }
5621 }
5622 }
5623 if let Some(f) = self.player.take_frame() {
5624 self.pending_frame = Some(f);
5625 }
5626 if self.pending_frame.is_some() && self.first_frame_at.is_none() {
5627 self.first_frame_at = Some(Instant::now());
5628 #[cfg(debug_assertions)]
5629 eprintln!("first frame after {} ms", self.started.elapsed().as_millis());
5630 }
5631 if self.settings.movie_mode {
5633 let t = self.playhead;
5634 let gpu_tx = self.gpu.is_some().then(|| self.gpu_export.0.clone());
5635 let App { prerender, project, .. } = self;
5636 match guarded(|| (prerender.tick(project, 4.0, gpu_tx), prerender.frame(project, t))) {
5637 Some((busy, ready)) => {
5638 match ready {
5641 Some(f) => {
5642 self.pending_frame = Some(f);
5643 if self.movie_stall {
5644 self.movie_stall = false;
5645 self.player.play();
5646 }
5647 }
5648 None if self.player.is_playing() => {
5649 self.movie_stall = true;
5650 self.player.pause();
5651 }
5652 None => {}
5653 }
5654 if busy || self.movie_stall {
5655 ctx.request_repaint_after(Duration::from_millis(16));
5656 }
5657 }
5658 None => {
5659 self.settings.movie_mode = false;
5660 self.toast("Movie mode is not available in this build");
5661 }
5662 }
5663 }
5664 if self.player.is_buffering() {
5668 if !self.buffer_stall && self.player.is_playing() {
5669 self.buffer_stall = true;
5670 self.player.pause();
5671 }
5672 ctx.request_repaint_after(Duration::from_millis(50)); } else if self.buffer_stall {
5674 self.buffer_stall = false;
5675 self.player.play();
5676 }
5677 let focused = ctx.input(|i| i.viewport().focused.unwrap_or(true));
5679 if self.settings.capture_on_blur && self.capture_ui.screen_open {
5680 if self.was_focused && !focused && self.screen_rec.is_none() {
5681 let opts = self.blur_capture_options();
5682 self.start_screen_capture(opts);
5683 } else if !self.was_focused && focused && self.screen_rec.is_some() {
5684 self.stop_screen_capture();
5685 }
5686 }
5687 self.was_focused = focused;
5688 if self.screen_rec.is_some() || self.voice_rec.is_some() {
5689 let c = self.screen_rec.as_ref().map(|(c, _)| c).or(self.voice_rec.as_ref().map(|(c, _, _)| c));
5690 self.capture_ui.elapsed = c.and_then(|c| guarded(|| c.elapsed())).unwrap_or(0.0);
5691 ctx.request_repaint_after(Duration::from_millis(250));
5692 }
5693
5694 if let Some((prog, _)) = &self.export {
5696 if prog.is_done() {
5697 self.finish_export();
5698 if std::mem::take(&mut self.close_after_export) {
5699 ctx.send_viewport_cmd(egui::ViewportCommand::Close);
5700 }
5701 } else {
5702 ctx.request_repaint_after(std::time::Duration::from_millis(100));
5703 }
5704 }
5705
5706 if let Some(p) = self.run_script_path.take() {
5707 self.run_script(&p);
5708 }
5709 self.sync_proxies();
5710 self.sync_mcp(ctx);
5712 self.poll_mcp(ctx);
5713
5714 self.handle_drops(ctx);
5715 self.screenshot_tick(ctx);
5716
5717 if let Some(t) = tools::handle_hotkeys(ctx, &mut self.tools) {
5721 self.tools.tool = t;
5722 self.layout.reveal(Pane::Tools);
5723 }
5724 if tools::handle_snap_hotkey(ctx, &mut self.settings.snap) {
5726 self.settings.save();
5727 }
5728 let mut actions = self.hotkeys.poll(ctx);
5729 if !ctx.wants_keyboard_input() && ctx.input_mut(|i| i.consume_key(egui::Modifiers::CTRL, egui::Key::Y)) {
5730 actions.push(Action::Redo);
5731 }
5732 if self.fullscreen && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) {
5733 actions.push(Action::Fullscreen);
5734 }
5735
5736 let title = self.title();
5737 if title != self.last_title {
5738 self.last_title = title.clone();
5739 ctx.send_viewport_cmd(egui::ViewportCommand::Title(title));
5740 }
5741
5742 if self.fullscreen {
5744 egui::CentralPanel::default()
5746 .frame(egui::Frame::NONE.fill(egui::Color32::BLACK))
5747 .show(ctx, |ui| self.draw_pane(ui, Pane::Preview));
5748 } else {
5749 egui::TopBottomPanel::top("menu").show(ctx, |ui| {
5750 actions.extend(self.menu_bar(ui));
5751 });
5752 egui::CentralPanel::default().show(ctx, |ui| {
5753 let mut l = std::mem::replace(&mut self.layout, Layout::new(egui_tiles::Tree::empty("layout")));
5754 let (changed, moved) = layout::show(ctx, ui, &mut l, &mut |ui, pane| self.draw_pane(ui, pane));
5755 self.layout = l;
5756 self.layout_dirty |= changed;
5757 if moved {
5758 push_undo_json(&mut self.undo, &mut self.redo, LAYOUT_STEP.to_owned());
5759 }
5760 });
5761 }
5762
5763 actions.extend(self.hotkeys.poll_late(ctx));
5766 if !ctx.wants_keyboard_input() && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Backspace))
5767 {
5768 actions.push(Action::Delete);
5769 }
5770 if let Some(text) = self.os_clipboard.take() {
5771 ctx.copy_text(text);
5772 }
5773 actions.append(&mut self.pending_actions);
5774 for a in actions {
5775 if a == Action::Fullscreen {
5776 self.act(a);
5778 ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(self.fullscreen));
5779 } else {
5780 self.act(a);
5781 }
5782 }
5783
5784 self.windows(ctx);
5786
5787 if self.layout_dirty && !ctx.input(|i| i.pointer.any_down()) {
5789 let json = self.layout.to_json();
5790 if json != self.layout_json {
5791 self.layout_json = json.clone();
5792 self.settings.layout = json;
5793 self.settings.save();
5794 }
5795 self.layout_dirty = false;
5796 }
5797
5798 self.toasts.retain(|(_, t)| t.elapsed().as_secs_f32() < 5.0);
5800 if !self.toasts.is_empty() {
5801 egui::Area::new(egui::Id::new("toasts"))
5802 .anchor(egui::Align2::RIGHT_BOTTOM, [-12.0, -12.0])
5803 .order(egui::Order::Foreground)
5804 .show(ctx, |ui| {
5805 for (msg, _) in &self.toasts {
5806 egui::Frame::popup(ui.style()).show(ui, |ui| {
5807 ui.label(msg);
5808 });
5809 }
5810 });
5811 ctx.request_repaint_after(std::time::Duration::from_millis(500));
5812 }
5813 }
5814}
5815
5816#[cfg(test)]
5817mod tests {
5818 use super::*;
5819 use crate::model::{Asset, Ease};
5820
5821 fn asset(path: &str) -> Asset {
5822 Asset {
5823 id: Id::default(),
5824 path: path.into(),
5825 kind: ClipKind::Video,
5826 duration: 1.0,
5827 width: 0,
5828 height: 0,
5829 fps: 0.0,
5830 audio_streams: Vec::new(),
5831 codec: String::new(),
5832 folder: String::new(),
5833 tags: Vec::new(),
5834 label: 0,
5835 description: String::new(),
5836 }
5837 }
5838
5839 #[test]
5843 fn copying_clips_also_writes_the_os_clipboard() {
5844 let mut p = Project::from_media(long_asset("C:/x.mp4"));
5845 let id = p.tracks[0].clips[0].id;
5846 let t = crate::engine::presets::capture_template("clipboard", &p, &[id]);
5847 assert!(!t.json.is_empty(), "a captured clip serialises to something");
5848 let os = t.json.clone();
5850 assert!(
5851 crate::engine::presets::decode_template(&t).is_some(),
5852 "the internal clipboard still decodes back into clips"
5853 );
5854 assert!(os.contains("clips") || os.contains("start"), "the OS text is the template JSON: {os:.80}");
5855 let before = p.tracks[0].clips[0].start;
5857 p.ripple_open(before, 2.0);
5858 assert!(
5859 (p.tracks[0].clips[0].start - (before + 2.0)).abs() < 1e-6,
5860 "ripple_open slides the clip right by the span: {} -> {}",
5861 before,
5862 p.tracks[0].clips[0].start
5863 );
5864 }
5865
5866 fn long_asset(path: &str) -> Asset {
5868 Asset { duration: 10.0, width: 320, height: 240, fps: 30.0, ..asset(path) }
5869 }
5870
5871 #[test]
5872 fn relocate_assets_falls_back_to_project_dir() {
5873 let dir = std::env::temp_dir().join(format!("se-relocate-{}", std::process::id()));
5874 std::fs::create_dir_all(&dir).unwrap();
5875 std::fs::write(dir.join("a.mp4"), b"x").unwrap();
5876 let mut p = Project::new();
5877 p.assets.push(asset("Z:\\gone\\a.mp4"));
5878 p.assets.push(asset("Z:\\gone\\b.mp4"));
5879 let missing = relocate_assets(&mut p, Some(&dir));
5880 assert_eq!(p.assets[0].path, dir.join("a.mp4").to_string_lossy());
5881 assert_eq!(missing, vec!["Z:\\gone\\b.mp4".to_string()]);
5882 let _ = std::fs::remove_dir_all(&dir);
5883 }
5884
5885 #[test]
5886 fn converted_path_never_hits_the_source_or_an_existing_file() {
5887 let dir = std::env::temp_dir().join(format!("se-conv-{}", std::process::id()));
5888 std::fs::create_dir_all(&dir).unwrap();
5889 let src = dir.join("clip.mp4");
5890 std::fs::write(&src, b"x").unwrap();
5891 let out = converted_path(&src, "mp4");
5893 assert_ne!(out, src);
5894 assert_eq!(out.file_name().unwrap(), "clip_converted.mp4");
5895 std::fs::write(&out, b"y").unwrap();
5897 assert_eq!(converted_path(&src, "mp4").file_name().unwrap(), "clip_converted_2.mp4");
5898 let _ = std::fs::remove_dir_all(&dir);
5899 }
5900
5901 #[test]
5902 fn an_empty_open_sequence_is_not_an_empty_timeline() {
5903 let mut p = Project::from_media(asset("a.mp4"));
5904 assert!(!timeline_is_empty(&p));
5905 let ids: Vec<Id> = p.all_clips().map(|(_, c)| c.id).collect();
5906 let seq = p.nest_selection(&ids, "S").unwrap();
5907 assert!(p.open_sequence(seq));
5908 let inner: Vec<Id> = p.all_clips().map(|(_, c)| c.id).collect();
5909 p.delete_clips(&inner, false);
5910 assert!(p.is_empty()); assert!(!timeline_is_empty(&p)); p.close_sequence();
5913 let all: Vec<Id> = p.all_clips().map(|(_, c)| c.id).collect();
5914 p.delete_clips(&all, false);
5915 assert!(timeline_is_empty(&p));
5916 }
5917
5918 #[test]
5919 fn undo_snapshot_is_capped_and_clears_redo() {
5920 let (mut undo, mut redo) = (Vec::new(), vec!["r".to_string()]);
5921 for i in 0..205 {
5922 push_undo_json(&mut undo, &mut redo, i.to_string());
5923 }
5924 assert_eq!(undo.len(), 200);
5925 assert_eq!(undo[0], "5");
5926 assert!(redo.is_empty());
5927 }
5928
5929 #[test]
5930 fn base64_rfc4648_vectors() {
5931 assert_eq!(base64(b""), "");
5932 assert_eq!(base64(b"f"), "Zg==");
5933 assert_eq!(base64(b"fo"), "Zm8=");
5934 assert_eq!(base64(b"foo"), "Zm9v");
5935 assert_eq!(base64(b"foob"), "Zm9vYg==");
5936 assert_eq!(base64(b"fooba"), "Zm9vYmE=");
5937 assert_eq!(base64(b"foobar"), "Zm9vYmFy");
5938 }
5939
5940 #[test]
5941 fn parse_ease_names_and_bezier() {
5942 assert_eq!(parse_ease("Linear"), Some(Ease::Linear));
5943 assert_eq!(parse_ease("Hold"), Some(Ease::Hold));
5944 assert_eq!(
5945 parse_ease("cubic-bezier(0.42, 0, 0.58, 1)"),
5946 Some(Ease::Bezier { x1: 0.42, y1: 0.0, x2: 0.58, y2: 1.0 })
5947 );
5948 assert_eq!(parse_ease("cubic-bezier(1,2,3)"), None);
5949 assert_eq!(parse_ease("bogus"), None);
5950 }
5951
5952 #[test]
5953 fn clip_fields_apply_and_reject() {
5954 let mut c = Clip::new(1, ClipKind::Video, "c", 0.0, 4.0);
5955 apply_clip_fields(
5956 &mut c,
5957 &json!({"name": "renamed", "enabled": false, "label": 3, "blend": "Screen",
5958 "fade_in": 0.5, "opacity": 0.25, "freeze": 1.5}),
5959 )
5960 .unwrap();
5961 assert_eq!(c.name, "renamed");
5962 assert!(!c.enabled);
5963 assert_eq!(c.label, 3);
5964 assert_eq!(c.blend, BlendMode::Screen);
5965 assert_eq!(c.fade_in, 0.5);
5966 assert_eq!(c.opacity.value, 0.25);
5967 assert_eq!(c.freeze, Some(1.5));
5968 apply_clip_fields(&mut c, &json!({"freeze": null})).unwrap();
5969 assert_eq!(c.freeze, None);
5970 c.opacity.toggle_key(1.0);
5972 assert!(c.opacity.is_animated());
5973 apply_clip_fields(&mut c, &json!({"opacity": 1.0})).unwrap();
5974 assert!(!c.opacity.is_animated());
5975 assert!(apply_clip_fields(&mut c, &json!({"nope": 1})).is_err());
5977 assert!(apply_clip_fields(&mut c, &json!({"text": "hi"})).is_err());
5978 let mut t = Clip::new(2, ClipKind::Text, "t", 0.0, 4.0);
5980 apply_clip_fields(&mut t, &json!({"text": "hello", "size": 90, "color": [10, 20, 30, 255], "align": 0}))
5981 .unwrap();
5982 let ts = t.text.as_ref().unwrap();
5983 assert_eq!(ts.text, "hello");
5984 assert_eq!(ts.size, 90.0);
5985 assert_eq!(ts.color, [10, 20, 30, 255]);
5986 assert_eq!(ts.align, 0);
5987 }
5988
5989 #[test]
5991 fn last_transition_is_remembered_and_reapplied() {
5992 let mut p = Project::from_media(long_asset("a.mp4"));
5993 let ids: Vec<Id> = p.split_at(1.0, None);
5994 let (first, second) = (p.all_clips().next().unwrap().1.id, ids[0]);
5995 let mut st = transitions_ui::TransitionsState::default();
5997 let add = transitions_ui::add_transitions;
5998 assert_eq!(add(&mut p, &[second], &mut st, TransitionKind::CrossFade, 1.0, false), 1);
5999 let tr = p.tracks[0].transitions[0].clone();
6000 assert_eq!((tr.kind, tr.duration), (TransitionKind::CrossFade, 1.0));
6001 let ids2 = p.split_at(2.0, None);
6003 assert_eq!(add(&mut p, &ids2, &mut st, TransitionKind::Wipe, 0.4, false), 1);
6004 assert_eq!(st.kind(), TransitionKind::Wipe, "Ctrl+T follows whatever went through the funnel");
6005 let tr = p.tracks[0].transitions.iter().find(|t| t.right == ids2[0]).expect("second transition");
6006 assert_eq!((tr.kind, tr.duration), (TransitionKind::Wipe, 0.4));
6007 assert_eq!(add(&mut p, &[first], &mut st, TransitionKind::Wipe, 0.4, false), 1);
6009 let tr = p.tracks[0].transitions.iter().find(|t| t.right == first).expect("edge transition");
6010 assert_eq!(tr.edge, crate::model::TransitionEdge::In);
6011 }
6012
6013 #[test]
6014 fn masks_land_on_the_last_effect_then_the_clip() {
6015 let mut p = Project::from_media(long_asset("a.mp4"));
6016 let id = p.all_clips().next().unwrap().1.id;
6017 assert!(add_mask(&mut p, id, MaskShape::Ellipse));
6019 assert_eq!(p.clip(id).unwrap().mask.as_ref().map(|m| m.shape), Some(MaskShape::Ellipse));
6020 assert!(!add_mask(&mut p, id, MaskShape::Rect), "a second mask on the same clip is refused");
6021 p.clip_mut(id).unwrap().effects.push(Effect::new(EffectKind::Blur));
6023 assert!(add_mask(&mut p, id, MaskShape::Polygon));
6024 assert_eq!(p.clip(id).unwrap().effects[0].mask.as_ref().map(|m| m.shape), Some(MaskShape::Polygon));
6025 assert!(!add_mask(&mut p, 999, MaskShape::Rect), "unknown clip");
6026 let a = p.new_id();
6028 p.tracks[1].clips.push(Clip::new(a, ClipKind::Audio, "a", 0.0, 1.0));
6029 assert!(!add_mask(&mut p, a, MaskShape::Rect), "audio clips take no mask");
6030 assert!(mask_slot(&mut p, a, None).is_err(), "and clip.add_mask / clip.set_mask refuse them");
6031 assert!(p.clip(a).unwrap().mask.is_none());
6032 }
6033
6034 #[test]
6036 fn paste_attributes_applies_only_the_chosen_fields() {
6037 let mut p = Project::from_media(long_asset("a.mp4"));
6038 let ids = p.split_at(2.0, None);
6039 let src_id = p.all_clips().next().unwrap().1.id;
6040 {
6041 let c = p.clip_mut(src_id).unwrap();
6042 c.opacity.value = 0.25;
6043 c.blend = BlendMode::Screen;
6044 c.effects.push(Effect::new(EffectKind::Blur));
6045 c.label = 3;
6046 }
6047 let src = p.copy_attributes(src_id).unwrap();
6048 let target = ids[0];
6049 let (start, dur) = (p.clip(target).unwrap().start, p.clip(target).unwrap().duration);
6050 let set = crate::model::AttrSet { opacity: true, ..crate::model::AttrSet::NONE };
6051 assert_eq!(p.paste_attributes(&src, &[target], set), 1);
6052 let c = p.clip(target).unwrap();
6053 assert_eq!(c.opacity.value, 0.25);
6054 assert_eq!(c.blend, BlendMode::Normal, "blend was not ticked");
6055 assert!(c.effects.is_empty(), "effects were not ticked");
6056 assert_eq!(c.label, 0, "label was not ticked");
6057 assert_eq!((c.start, c.duration), (start, dur), "timing is never pasted");
6058 let set = crate::model::AttrSet { effects: true, label: true, ..crate::model::AttrSet::NONE };
6060 p.paste_attributes(&src, &[target], set);
6061 let c = p.clip(target).unwrap();
6062 assert_eq!(c.effects.len(), 1);
6063 assert_eq!(c.label, 3);
6064 }
6065
6066 #[test]
6067 fn frame_export_and_preview_sizes() {
6068 assert_eq!(frame_render_size((1920, 1080), (960, 540)), (960, 540));
6070 assert_eq!(frame_render_size((1920, 1080), (1920, 1080)), (1920, 1080));
6071 assert_eq!(frame_render_size((1920, 1080), (3840, 2160)), (1920, 1080));
6073 assert_eq!(frame_render_size((1920, 1080), (4000, 100)), (1920, 1080));
6075 assert_eq!(frame_render_size((0, 0), (0, 0)), (16, 16));
6076 assert_eq!(preview_canvas((800, 600), 100), (800, 600));
6078 assert_eq!(preview_canvas((800, 600), 50), (400, 300));
6079 assert_eq!(preview_canvas((800, 600), 1), (200, 150)); assert_eq!(preview_canvas((0, 600), 50), (0, 0));
6081 assert_eq!(preview_canvas((10, 10), 25), (16, 16));
6082 }
6083
6084 #[test]
6087 fn canvas_clamp_keeps_the_aspect_ratio() {
6088 assert_eq!(clamp_canvas(800, 450, 1280), (800, 450), "under the limit: untouched");
6089 assert_eq!(clamp_canvas(800, 450, 320), (320, 180), "height scales with the width");
6090 assert_eq!(clamp_canvas(1920, 1080, 0), (1920, 1080), "0 = no limit (same as Player::set_canvas)");
6091 assert_eq!(clamp_canvas(1000, 3, 100), (100, 1), "never collapses to zero");
6092 let (w, h) = (1600u32, 900u32);
6094 let max = 640u32;
6095 assert_eq!(clamp_canvas(w, h, max), (max, ((h as u64 * max as u64) / w as u64) as u32));
6096 }
6097
6098 #[test]
6099 fn effect_thumbnail_cache_keys() {
6100 let a = effect_thumb_key(EffectKind::Blur, (96, 54), "");
6101 assert_eq!(a, effect_thumb_key(EffectKind::Blur, (96, 54), ""), "stable for the same inputs");
6102 assert_ne!(a, effect_thumb_key(EffectKind::Vhs, (96, 54), ""), "kind matters");
6103 assert_ne!(a, effect_thumb_key(EffectKind::Blur, (192, 108), ""), "size matters");
6104 assert_ne!(a, effect_thumb_key(EffectKind::Blur, (96, 54), "C:/pic.png"), "stock image matters");
6105 let mut keys: Vec<u64> = EffectKind::ALL.iter().map(|&k| effect_thumb_key(k, (96, 54), "x")).collect();
6107 keys.sort_unstable();
6108 keys.dedup();
6109 assert_eq!(keys.len(), EffectKind::ALL.len());
6110 assert_eq!(STOCK.len(), (STOCK_W * STOCK_H * 4) as usize, "embedded RGBA is not W*H*4");
6112 let card = effect_thumb_source("", STOCK_W, STOCK_H, Backend::Ffmpeg);
6113 assert_eq!(card.rgba, STOCK, "at card size the embedded image is copied through untouched");
6114 let f = effect_thumb_source("", 32, 24, Backend::Ffmpeg);
6115 assert_eq!((f.width, f.height), (32, 24));
6116 assert_eq!(f.rgba.len(), 32 * 24 * 4);
6117 assert!(f.rgba.chunks_exact(4).all(|p| p[3] == 255), "the stock image must be opaque");
6118 assert!(f.rgba.chunks_exact(4).any(|p| p[0] != p[1] || p[1] != p[2]), "the stock image has colour in it");
6119 }
6120
6121 #[test]
6123 fn write_image_scales_and_writes() {
6124 if media::ffpipe::ffmpeg_exe().is_none() {
6125 println!("write_image test: no ffmpeg, skipped");
6126 return;
6127 }
6128 let dir = std::env::temp_dir().join(format!("se-frame-{}", std::process::id()));
6129 std::fs::create_dir_all(&dir).unwrap();
6130 let frame = effect_thumb_source("", 64, 48, Backend::Ffmpeg);
6131 for (name, quality) in [("shot.png", 100), ("shot.jpg", 80), ("shot.webp", 80)] {
6132 let out = dir.join(name);
6133 let opts = frame_ui::FrameExport {
6134 out: out.clone(),
6135 size: (128, 96), scaler: Scaler::Bilinear,
6137 resize: "lanczos".into(),
6138 with_effects: true,
6139 quality,
6140 };
6141 write_image(&frame, &opts).unwrap_or_else(|e| panic!("{name}: {e}"));
6142 assert!(out.is_file(), "{name} not written");
6143 let probe = media::probe(&out.to_string_lossy(), Backend::Ffmpeg).unwrap_or_else(|e| panic!("{name}: {e}"));
6144 assert_eq!((probe.width, probe.height), (128, 96), "{name} size");
6145 }
6146 let bad = frame_ui::FrameExport {
6148 out: PathBuf::from("Z:/nope/shot.png"),
6149 size: (64, 48),
6150 scaler: Scaler::Bilinear,
6151 resize: String::new(),
6152 with_effects: true,
6153 quality: 90,
6154 };
6155 assert!(write_image(&frame, &bad).is_err());
6156 assert!(write_image(&Frame::default(), &bad).is_err(), "an empty frame is refused");
6157 let _ = std::fs::remove_dir_all(&dir);
6158 }
6159
6160 #[test]
6163 fn lib_preview_seek_math() {
6164 assert_eq!(step_time(1.0, 25.0, true, 4.0), 1.04, "forward steps by 1/fps");
6165 assert_eq!(step_time(0.02, 25.0, false, 4.0), 0.0, "backward step clamps at 0");
6166 assert_eq!(step_time(3.99, 25.0, true, 4.0), 4.0, "forward step clamps at the file's duration");
6167 assert_eq!(scrub_time(0.0, 4.0), 0.0);
6168 assert_eq!(scrub_time(1.0, 4.0), 4.0);
6169 assert_eq!(scrub_time(0.5, 4.0), 2.0);
6170 assert_eq!(scrub_time(-0.2, 4.0), 0.0, "a drag past the left edge still reads as the start");
6171 assert_eq!(scrub_time(1.2, 4.0), 4.0, "a drag past the right edge still reads as the end");
6172 }
6173
6174 #[test]
6177 fn round3_actions_are_bound() {
6178 use crate::hotkeys::Hotkeys;
6179 let h = Hotkeys::defaults();
6180 for (a, text) in [
6181 (Action::AddLastTransition, "Ctrl+T"),
6182 (Action::CopyAttributes, "Ctrl+Alt+C"),
6183 (Action::PasteAttributes, "Ctrl+Alt+V"),
6184 (Action::AddMarker, "Shift+M"),
6186 (Action::AddMask, "Ctrl+Shift+M"),
6187 (Action::ExportFrame, "Ctrl+Shift+F"),
6188 ] {
6189 assert_eq!(h.text(a), text, "{a:?}");
6190 }
6191 }
6192
6193 #[test]
6194 fn anim_of_props_and_effect_params() {
6195 let mut c = Clip::new(1, ClipKind::Video, "c", 0.0, 4.0);
6196 assert!(anim_of(&mut c, "Position X").is_some());
6197 assert!(anim_of(&mut c, "Volume").is_some());
6198 assert!(anim_of(&mut c, "Nope").is_none());
6199 c.effects.push(crate::model::Effect::new(EffectKind::Blur));
6200 let pname = EffectKind::Blur.params()[0].name;
6201 assert!(anim_of(&mut c, &format!("Blur: {pname}")).is_some());
6202 assert!(anim_of(&mut c, "Blur: Nope").is_none());
6203 }
6204}