simple_editor\ui/
app.rs

1//! The application: owns the project, undo stack, settings, player, dockable layout and wires the panels
2//! together. Non-blocking windows (Settings, Retime, Export, Save Template, Save Profile, export/convert
3//! progress) are plain `egui::Window`s — the editor stays usable while they are open. Also executes MCP
4//! tool calls against the live project (one undo step per mutating call).
5
6use 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
37// OLE drops deliver no pointer events (winit ignores the drop point), so handle_drops asks the OS for the cursor.
38windows::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
51/// A blocking MCP tool job (export.video / media.convert): the reply is sent when the job finishes.
52struct McpJob {
53    prog: Arc<Progress>,
54    reply: Sender<Result<Value, String>>,
55    out: PathBuf,
56}
57
58/// The library's own preview player: a file, its own decoder/audio pipeline, and the duration/fps a
59/// transport needs (an asset's own probed values, since this project is a synthetic single-clip one).
60struct 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    /// Last layout JSON written to settings (persist only on change, debounced to gesture end).
82    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    /// Some = the "Save Template" / "Save Profile" name windows are open (the String is the name field).
98    template_name: Option<String>,
99    profile_name: Option<String>,
100    fullscreen: bool,
101    selection: Vec<Id>,
102    /// Selected transitions (timeline bands) — separate from the clip selection.
103    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 starts hidden (see main.rs); shown once the first frame has been painted.
111    window_shown: bool,
112    first_frame_at: Option<Instant>,
113    screenshot_requested: bool,
114    close_confirmed: bool,
115    /// Close was requested during an export: cancel it, then re-request the close once it has finished.
116    close_after_export: bool,
117    was_playing: bool,
118    /// Last title sent to the OS — `send_viewport_cmd` forces a repaint, so only send on change.
119    last_title: String,
120    palette: Palette,
121    /// Newest rendered frame, handed to the preview pane when it draws.
122    pending_frame: Option<Arc<Frame>>,
123    /// Actions requested by panels this frame (transport, context menus, breadcrumb).
124    pending_actions: Vec<Action>,
125    mcp: Option<(mcp::Server, Receiver<mcp::ToolCall>)>,
126    mcp_port_running: u16,
127    mcp_jobs: Vec<McpJob>,
128    /// Library "Convert To…" jobs: (progress, output path) — polled each frame, imported when done.
129    convert_jobs: Vec<(Arc<Progress>, PathBuf)>,
130    /// Asset id + target extension for the Convert To… options window.
131    convert_dialog: Option<(Id, String)>,
132    /// Compress… window state (None = closed).
133    compress: Option<Compress>,
134    /// A working yt-dlp was found — gates the Library's URL import. Detected on a background thread
135    /// (it spawns `yt-dlp --version`) at start-up and again when the setting changes.
136    ytdlp_available: Arc<std::sync::atomic::AtomicBool>,
137    /// Import-URL window state: (url, audio only).
138    url_dialog: Option<(String, bool)>,
139    /// Running URL downloads — polled each frame, imported into the library when they finish.
140    downloads: Vec<crate::media::ytdlp::Download>,
141    /// One receiver per import batch: ffprobe runs on a worker, `poll_probes` adopts the results.
142    probes: Vec<Receiver<crate::engine::import::Probed>>,
143    /// Was the Auto-cut pane drawn last frame? (its keep-range shading is only valid while it is open).
144    /// `autocut_drawing` accumulates this frame; the timeline reads `autocut_shown` so the shading does
145    /// not depend on which pane the tile tree draws first.
146    autocut_shown: bool,
147    autocut_drawing: bool,
148    /// Same trick for the Tracking pane: the preview only draws its box while the pane is on screen.
149    tracking_shown: bool,
150    tracking_drawing: bool,
151    /// Fonts already handed to the rasterizer (so we only reload when the list grows).
152    loaded_fonts: usize,
153    // ---------------- round 3 ----------------
154    /// The eframe glow context (None when eframe runs without one) and the renderer it reports.
155    gl: Option<Arc<eframe::glow::Context>>,
156    gpu_name: String,
157    /// GPU renderer, built lazily from `gl` while `settings.gpu` is on; None = CPU compositor.
158    gpu: Option<GpuRenderer>,
159    /// Effect catalogue thumbnails: the egui textures (kept alive while the panel shows them) and the
160    /// key set they were built from, so they are re-rendered only when the stock image or size changes.
161    /// GPU frame requests from export threads and movie-mode prerender workers (they decode; we composite
162    /// on the GL context) — shared, since both are served identically.
163    gpu_export: (
164        std::sync::mpsc::Sender<crate::engine::export::GpuFrameRequest>,
165        std::sync::mpsc::Receiver<crate::engine::export::GpuFrameRequest>,
166    ),
167    /// The GPU canvas the preview paints (zero-copy): id + pixel size. Stays valid until the next GPU
168    /// render, which is also when it is replaced.
169    gpu_tex: Option<(egui::TextureId, [u32; 2])>,
170    /// glow texture -> egui id. The renderer's pool reuses a handful of textures, so registering each one
171    /// once keeps eframe's texture map small (registering per frame would grow it forever).
172    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    /// The GPU path failed once — do not retry until the setting is switched off and on again.
176    gpu_failed: bool,
177    /// The frame the GPU rendered last: its buffer is reused once the preview released it.
178    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    /// Running screen recording / voiceover (voiceover remembers the timeline time it started at).
190    screen_rec: Option<(crate::engine::capture::Capture, PathBuf)>,
191    voice_rec: Option<(crate::engine::capture::Capture, PathBuf, f64)>,
192    /// Running Draw take: the drawing every stroke joins, and the timeline time it started at.
193    draw_rec: Option<(Id, f64)>,
194    /// Viewport focus last frame (record-on-blur watches this).
195    was_focused: bool,
196    /// Ctrl+Alt+C clipboard for Paste Attributes.
197    attrs: Option<Clip>,
198    /// Ctrl+C / Ctrl+X clip clipboard — a template (clips + the assets they use), so paste reuses
199    /// `Project::place_clips` and its fresh clip / link ids.
200    clipboard: Option<crate::settings::Template>,
201    /// Text to hand the OS clipboard at the end of the frame. egui-winit only emits `Event::Paste` when
202    /// the system clipboard holds text (egui-winit-0.33.3 src/lib.rs:823 returns without pushing the key
203    /// event either way), so a Ctrl+V after an internal-only copy produced NO event at all and could
204    /// never be bound. Copying clips therefore also writes them out as text.
205    os_clipboard: Option<String>,
206    /// The library's own preview: a player of its own so it never disturbs the program monitor or the
207    /// timeline playhead, and the texture the pane paints this frame. While it is Some, the Preview
208    /// pane shows this instead of the timeline (see `draw_lib_preview`).
209    lib_preview: Option<LibPreview>,
210    lib_preview_tex: Option<egui::TextureHandle>,
211    /// This update's uploaded frame, computed once (`Player::take_frame` consumes the buffered frame, so
212    /// pulling it twice in one update would starve whichever call came second). Both the library pane's
213    /// own preview box and the viewport override read this same value.
214    lib_preview_live: Option<library::PreviewFrame>,
215    /// Movie mode pre-render cache.
216    prerender: PreRender,
217    /// Movie mode paused the clock because the frame under the playhead was not rendered yet.
218    movie_stall: bool,
219    /// True while playback is held because the player reported buffering (spinner shown).
220    buffer_stall: bool,
221    /// A script picked from the Scripts menu, run on the next update (outside menu layout).
222    run_script_path: Option<std::path::PathBuf>,
223    /// Proxy build in flight: (source path, proxy file, job). One transcode at a time.
224    proxy_job: Option<(String, std::path::PathBuf, std::sync::Arc<crate::engine::export::Progress>)>,
225    /// source path -> proxy file, as last pushed to the player.
226    proxy_map: std::collections::HashMap<String, String>,
227    /// Next time the asset list is rescanned for missing proxies.
228    proxy_scan_at: Option<Instant>,
229    /// Preview canvas size in px, as the pane last reported it (the GPU renders at this size).
230    canvas: (u32, u32),
231    /// dshow audio inputs, listed once when the Settings / capture windows first need them.
232    audio_inputs: Option<Vec<(String, bool)>>,
233    /// Panes whose draw panicked: shown as a message instead of taking the whole editor down.
234    failed_panes: Vec<Pane>,
235}
236
237/// Non-blocking progress window for background jobs (conversions, downloads): one row per job with a
238/// progress bar and Cancel. Draws nothing when there are no jobs.
239fn 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
255/// Marker in the undo stack for "a pane was dragged somewhere else". The arrangement itself lives in
256/// `Layout`'s own (much shorter) history — this only keeps Ctrl+Z stepping back in the right order.
257/// ponytail: once the layout history has scrolled past its 20 entries the marker undoes nothing; deepen
258/// the layout stack if that ever bites.
259const LAYOUT_STEP: &str = "\u{0}layout";
260
261/// Push an undo snapshot (capped) and clear the redo history.
262fn 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
270/// Moved/renamed sources: re-point assets to `project_dir/<file name>` when that exists (a silent black
271/// preview is the alternative); returns the paths that are still missing.
272fn 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
287/// Run something that may panic (GPU driver, pre-render, a panel widget) without taking the editor
288/// down — same policy as the decoder threads. None = it panicked.
289/// ponytail: the panic message goes to the default hook (stderr); the caller toasts and degrades.
290fn guarded<T>(f: impl FnOnce() -> T) -> Option<T> {
291    std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).ok()
292}
293
294/// Give the clip's last effect a mask, or the clip itself when it has no effects. False = there is one
295/// already, or the clip is audio (a mask shapes pixels, and audio has none).
296fn 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
312/// Preview render size for a pane of `canvas` px at `quality` percent (25..100). Zero stays zero
313/// (nothing to render), and the aspect ratio is kept.
314fn 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
322/// `preview_max_width` applied to a canvas size, keeping the aspect ratio. Must match
323/// `Player::set_canvas`, or the GPU renders at a different shape than the player decodes at.
324fn 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
332/// Render size for an image export: downscales are rendered straight at the target (the compositor's
333/// `Scaler` does the filtering), upscales are rendered at project size and enlarged by ffmpeg with the
334/// chosen resize flag — rendering a 4K frame from a 1080p timeline gains nothing but time.
335fn 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// TODO(ui-panels-fx): call these from `effects_ui::set_thumbnail(kind, ...)` once that hook exists —
346// the app renders each kind once on the GPU from this source and hands the result over.
347#[allow(dead_code)]
348/// Cache key of one effect thumbnail: kind, source image and size. Changing the stock image (or the
349/// grid size) invalidates every thumbnail; two different effects never share a key.
350fn effect_thumb_key(kind: EffectKind, size: (u32, u32), image: &str) -> u64 {
351    let mut h: u64 = 0xcbf2_9ce4_8422_2325; // FNV-1a
352    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
365/// The embedded stock picture, already decoded: `assets/bubble.webp` converted once to raw RGBA at card
366/// size, because nothing in the binary can decode a webp (no image crate, and ffmpeg may be missing).
367const STOCK: &[u8] = include_bytes!("../../assets/bubble_96x54.rgba");
368const STOCK_W: u32 = 96;
369const STOCK_H: u32 = 54;
370
371#[allow(dead_code)]
372/// The picture the effect thumbnails are rendered from: the user's stock image, else the embedded one.
373fn 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    // ponytail: nearest rescale — the catalogue asks for exactly STOCK_W x STOCK_H, so it is a plain copy
384    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
396/// Write one rendered frame as PNG / JPG / WebP through ffmpeg (raw RGBA on stdin), resizing to
397/// `opts.size` with `opts.resize` when the render came out at a different size.
398fn 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        // ffmpeg's mjpeg qscale is 2 (best) .. 31 (worst)
414        "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); // a broken pipe shows up as a non-zero exit below
424    }
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
433/// Standard base64 (RFC 4648, with padding) — for the `render.frame` PNG data url. Tool path, not hot.
434fn 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
448// ---------------- MCP tool argument helpers ----------------
449
450fn 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
469/// "Linear" | "EaseIn" | … | "cubic-bezier(x1,y1,x2,y2)"
470fn 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
488/// The animated property `name` of a clip: inspector labels or "<Effect name>: <Param name>".
489fn 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
514/// The mask slot of a clip, or of one of its effects (`effect` = index in the effect stack).
515fn 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
564/// "Blur" / "Color" / "Blend" / "Combine" / "Merge" / "Matte" / "Mask" / "Text" / "Input" → a node kind.
565fn 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
602/// Apply `clip.set` fields to a clip (everything except `speed`/`reverse`, which the caller routes
603/// through `Project::set_speed` so neighbour collisions are respected).
604fn 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" => {} // handled by the caller
609            "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(); // "constant value": drop any animation
634                a.value = val;
635            }
636            // text style fields
637            _ => {
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
668/// Nothing to save/export: the MAIN timeline is empty. `Project::is_empty()` only sees the open sequence,
669/// and `main_stash` is Some exactly while one is open — so this is `export_project().is_empty()` without the clone.
670fn 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
674/// Compress… window state: what to shrink, how hard, and where the result goes.
675struct Compress {
676    src: PathBuf,
677    /// false = quality (CRF), true = size target.
678    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        // default target: half the current size, which is what "compress this" usually means
691        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
696/// Where "Convert To…" writes: `<stem>_converted.<ext>` next to the source, uniquified so a convert can
697/// never overwrite the source itself or a file already on disk (possibly one the timeline is using).
698fn 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
709/// Time the library preview's "step back/forward one frame" buttons seek to.
710fn 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
719/// Time a library preview scrub-bar click/drag at fractional position `frac` (0..1 across the bar,
720/// unclamped so a drag past either end still reads as 0 or `duration`) seeks to.
721fn scrub_time(frac: f64, duration: f64) -> f64 {
722    frac.clamp(0.0, 1.0) * duration
723}
724
725/// Tools whose success means "one undo step + after_edit" (media.import pushes its own undo).
726const 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        // eframe restores the window rect from the last session, which may be on another monitor
769        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            // warm the font list off-thread so the first text clip / inspector doesn't hitch
778            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        // first-run install points the entry at this exe; skip for screenshot/debug runs so they don't re-point it
786        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        // GL belongs to this (UI) thread; the renderer itself is built on first use so a driver that
799        // rejects the shaders only costs a toast.
800        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        // an old layout profile has no Tools / Nodes / Mixer / Markers pane: reset to the new default
809        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            // launched from Explorer ("Open with"): behave like a player — full screen, rolling
923            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    // ---------------- helpers ----------------
932
933    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    /// Insert each asset's clips at `t` (video on `vt` if given), chaining them end to end.
942    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    /// Empty project + one media file: open it as the project (returns empty); otherwise import into the library.
952    /// ponytail: that single file is still probed on this thread — it settles the project format, size
953    /// and zoom before anything is drawn; give it a placeholder too if opening ever feels slow.
954    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    /// After any project mutation.
963    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            // the picture changed: drop what was pre-rendered and render the range again
970            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(); // import probes belong to the project that started them
979        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        // an explicit seek always brings the playhead back into view (a user pan only suspends the
1005        // follow while playing)
1006        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    /// The project with any open sequence closed — exports always render the MAIN timeline.
1018    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    // ---------------- file operations ----------------
1027
1028    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    /// Import media files into the library. Probing spawns ffprobe per file (~100 ms), so each path
1064    /// lands as a placeholder asset now and `poll_probes` folds in the real metadata a few frames
1065    /// later — dropping ten files costs this thread nothing. Returns the asset ids.
1066    /// ponytail: an MCP `media.import` reply therefore quotes duration 0 until the probe lands;
1067    /// blocking the tool call on it is the fix if an agent ever needs the number in the same reply.
1068    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            // a re-import of a file already in the library must not re-probe it: adopting the result
1074            // would rebuild clips the user has since trimmed
1075            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    /// Import probes that finished on their worker: fill the placeholder in, drop it if the file
1094    /// turned out to be unreadable, and re-place any clip that was laid down from the placeholder.
1095    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            // first media into an empty project: adopt its format (only knowable now)
1123            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    /// "Open folder" in the subtitles panel: write the current cues as an .srt sidecar into
1231    /// `<project dir>\<name> subtitles\` and open that folder in Explorer.
1232    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    /// Ctrl+S: project file if there is one; otherwise overwrite the opened video; otherwise Save As.
1252    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    /// Ask to save unsaved changes. Returns false if the user cancelled.
1263    fn confirm_discard(&mut self) -> bool {
1264        if !self.dirty {
1265            return true;
1266        }
1267        // Yes saves a .sedit (the video itself only changes via Save / Overwrite Original Video) — say so
1268        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    /// Open the (non-blocking) Export window.
1317    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    /// The Export window confirmed: start the export (options include the chosen output path).
1326    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        // writing over a file the player/decoders are reading from is the Overwrite path's job (release + reopen)
1331        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(); // the window remembers resolution/scaler in settings
1345    }
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        // the summary describes the MAIN timeline, even while a nested sequence is open
1398        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    /// Re-encode the timeline over the opened video file (temp file in the same folder, then replace).
1405    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        // the new file is reloaded as a fresh project afterwards, so state that isn't burned into the video
1435        // (subtitles, planner, notes, sequences, imported media, undo) is dropped — offer to save a .sedit first
1436        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        // opt-in: a plain cut can be saved instantly with `-c copy` (keyframe-accurate) instead of re-encoding
1471        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                // the thumbnail worker holds a decoder (ffmpeg child) on the source — drop it while we retry
1498                self.thumbs.clear();
1499                // ponytail: killed ffmpeg children release their file handle a few ms after wait() returns — retry briefly
1500                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                        // in-memory peaks are keyed by path only and the file behind it just changed
1510                        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    // ---------------- editing actions ----------------
1526
1527    fn act(&mut self, a: Action) {
1528        use Action::*;
1529        // an export writes the timeline: block only saving/exporting, keep editing usable
1530        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; // buffering held the clock: space means "stop waiting"
1584                } 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                // cues selected on the timeline's subtitle lane split too, like clips
1625                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            // in/out marks are saved with the project: undoable, and they make it dirty like any other edit
1649            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                // the panel state IS the memory: every apply path records into it (transitions_ui)
1747                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                    // the JSON is what makes Ctrl+V fire at all (see App::os_clipboard); it is also
1787                    // readable, so a copy can be pasted into another instance by hand
1788                    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                        // Paste In Place ignores the clicked row: place_clips takes the first track with room
1802                        let target = (a == PasteClips).then_some(self.timeline.last_track).flatten();
1803                        let snap = self.project.to_json();
1804                        if a == PasteInsert {
1805                            // ripple: everything at or after the playhead slides right by the paste's span
1806                            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                // snapshot first, commit on success: popping the undo entry afterwards would leave the
1861                // redo stack cleared for an edit that never happened
1862                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                // the caller sends ViewportCommand::Fullscreen (needs the ctx)
1924                self.fullscreen = !self.fullscreen;
1925                self.player.seek(self.playhead); // re-render at the new canvas size
1926            }
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); // clamps into the parent timeline
1975                }
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    // ---------------- panes ----------------
2044
2045    /// Draw one pane, containing a panic in its widget so the rest of the editor keeps working
2046    /// (same policy as the decoder threads). A pane that panicked once is not drawn again this session.
2047    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                        // only while movie mode is on: progress() walks the requested ranges
2087                        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                    // same clamp the player applies, so the GPU renders at the aspect the player decodes at
2117                    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                        // the action toggles the setting and starts / clears the pre-render
2128                        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                // sequence breadcrumb: a thin strip above the timeline while editing a nested sequence
2152                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                            // only while the Auto-cut pane is on screen: a stale overlay would keep
2205                            // shading the timeline after the pane is hidden or a new project is opened
2206                            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                        // dropped onto a clip that can actually take this kind (see effects_ui's own
2241                        // click-to-add gate); a miss says so rather than swallowing the gesture
2242                        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                        // a transition belongs to a cut, so the half of the clip it lands on picks which
2262                        // one: left half = the cut at its start, right half = the cut at its end
2263                        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                // single-clicked in the Library: the file the preview pane should show (source viewer)
2316                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                // both used to sit inside the open_paths branch, so the Library's "New ▸ Adjustment layer"
2325                // and "Open…" only ever fired on a frame that also opened a file — i.e. never
2326                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                    // decoders hold the proxy files open on Windows: release them before deleting
2355                    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; // rebuild + re-push on the next update
2362                }
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                // Recent tab, reusable sections: an effect / saved preset / node graph goes onto the
2379                // selection (a clip rendering from a graph ignores its linear stack, so it is skipped).
2380                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                    // the always-open catalogue is taller than the pane: without this the per-clip
2458                    // effect stack under it is unreachable
2459                    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                    // ponytail: the viewport's mask tool edits clip.mask, not the effect's own mask —
2465                    // targeting an effect index is a preview.rs change, not an app one.
2466                    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                    // same clip the panel showed the stack of
2484                    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                        // a source the renderer already rejected opens with its log, not blank
2494                        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    /// Place a saved template (by name) on the timeline at `t`.
2654    /// Hand the saved curve/motion presets (built-ins first) to the curve editor. Called on start and
2655    /// whenever the lists change — never per frame.
2656    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    /// Add a shape clip at the playhead, styled from the tool strip. `place` = (centre x, centre y, half
2666    /// width, half height) in project px when the shape was dragged out in the viewport (Action::AddShape
2667    /// passes None and keeps the default size). For `ShapeKind::Draw` the playhead/5s here are only a
2668    /// placeholder — `add_stroke` / `toggle_draw_recording` re-pin start and duration to the strokes
2669    /// actually recorded once there is ink to measure.
2670    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                // line / arrow / drawing have no fill, so a transparent stroke would draw nothing at all:
2682                // fall back to the brush colour (and then the fill) instead of an invisible clip
2683                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    /// The Draw tool's play/record button: the video plays and every stroke joins one drawing until the
2706    /// take is stopped (button, video stopped, or the tool put away). A voiceover running at the same
2707    /// time owns the transport, so it is left playing and its take is not cut short.
2708    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        // the clip was placed at record-press time with a placeholder length; pin its real bounds to
2719        // exactly the first and last point drawn (`add_stroke` already times every point from that press)
2720        // instead of the press-to-stop span, which pads the clip with dead time on either side.
2721        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), // nothing was drawn: leave no stub behind
2746        }
2747        if self.voice_rec.is_none() {
2748            self.player.pause();
2749        }
2750        self.after_edit();
2751    }
2752
2753    /// A stroke drawn in the viewport: append it to the take (or the selected drawing), or start a new one.
2754    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        // during a take the stroke is timed from where the playhead was when the pen went down: its own
2758        // points are timed from the press, so the last one dates the whole stroke
2759        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                // still recording: keep the preview at least as long as what's drawn so far; the exact
2784                // start/end are pinned once the take stops (toggle_draw_recording)
2785                let len = c.shape.as_ref().map_or(0.0, |s| s.draw_duration());
2786                c.duration = c.duration.max(len);
2787            } else {
2788                // not part of a running take (a plain drag): pin the clip to exactly what's drawn, instead
2789                // of leaving it at add_shape's placeholder duration if the stroke was shorter than that
2790                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    /// Per-frame hand-offs from panels that can't reach Settings, plus background convert jobs.
2819    fn poll_panels(&mut self) {
2820        // saved presets from the effects / curves panels
2821        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            // hand them over only when they change (this used to clone the whole list every frame)
2839            self.refresh_presets();
2840            self.toast("Curve preset saved");
2841        }
2842        // font import from the inspector
2843        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        // "Edit in viewport" / "Open node editor" from the inspector
2861        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                // nothing changed, so the snapshot above would be a no-op undo entry
2882                Err(e) => {
2883                    self.undo.pop();
2884                    self.toast(format!("Can't unlink this graph: {e}"));
2885                }
2886            }
2887        }
2888        // URL downloads: import the finished file, report failures
2889        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                // cancelling is not a failure — matches finish_export's wording
2907                (_, 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        // library conversions
2914        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    /// Open a nested timeline for editing (keeps the player/preview in sync).
2936    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    /// Navigating in/out of a nested sequence is a view change, not an edit: re-sync the player,
2948    /// but no undo step and no dirty flag (that made "just looking" prompt to save on exit).
2949    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    /// Import URL window (non-blocking): paste a link, pick a folder, download with yt-dlp, and the
2956    /// finished file is imported into the library like any other media.
2957    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            // Enter in the field downloads, like every other URL box
2966            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    /// Configured download folder, or the user's Videos folder.
2997    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    /// Look for a working yt-dlp on a background thread (it spawns `yt-dlp --version`, and a candidate
3007    /// that hangs must not hang the editor); the Library button appears once it reports success.
3008    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(); // the Library button appears without waiting for the next input
3015        });
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    /// "Convert To…" on a library asset: transcode next to the source, then import the result.
3029    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    /// "Save from selection" in the Presets pane: one clip's node graph or effect stack becomes an
3052    /// effect preset, anything else (adjustment layers, several clips) becomes a clip template — that is
3053    /// the only flavour that can be *placed* rather than applied.
3054    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    /// Apply a saved effect chain / node graph to every selected clip (one undo entry).
3078    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    // ---------------- round 3: GPU, capture, frames, movie mode ----------------
3111
3112    /// Build / drop the GPU renderer to match `settings.gpu`, and tell the player which kind of output
3113    /// the UI wants (decoded layers for the GPU, a composited frame for the CPU compositor).
3114    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; // no GL context at all (software / headless run)
3126            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()); // Text nodes rasterise with the same fonts as everything else
3132                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    /// Render one catalogue thumbnail per `EffectKind` over the stock image and hand them to the effects
3142    /// panel. Runs once per (stock image, size) — the GPU renderer owns the GL context, so this happens on
3143    /// the UI thread. Without a GPU the panel keeps its neutral named cards.
3144    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            // geometric kinds move the layer instead of touching pixels: show the plain source
3160            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        // the transitions catalogue previews its wipes over the same picture (painted, no GPU pass)
3176        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    /// Where a new export should get its pictures: the GPU renderer when it is running (so the file
3182    /// matches the preview), otherwise the export thread's own CPU compositor.
3183    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    /// Serve the frames export threads and movie-mode prerender workers are waiting on. Called every
3191    /// frame; each request is answered on the GL context, so both run the same shaders as the preview.
3192    /// Returns true if any were served (the caller keeps repainting so a background export is never
3193    /// starved; prerender already repaints on its own while busy).
3194    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            // None tells the export thread to finish on the CPU compositor
3213            let _ = req.reply.send(ok.then_some(out));
3214        }
3215        served
3216    }
3217
3218    /// Fall back to the CPU compositor and say why, once.
3219    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    /// Render the timeline into a GL texture and register it with egui, so the preview paints the GPU's
3232    /// own canvas — no glReadPixels, no re-upload. None when there is no GPU (the caller falls back to
3233    /// `gpu_frame`). The texture is only valid for this frame, which is exactly how long it is painted.
3234    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    /// Render decoded layers with the GPU into a frame the preview can upload. None = the GPU path died
3263    /// (already switched off).
3264    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        // reuse the buffer of the frame handed out last time, once the preview has uploaded it
3269        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    /// One frame at `t`, `w` px wide, through the same path the preview uses (GPU when it is on, the
3289    /// player's compositor otherwise) — export-frame and the MCP tools.
3290    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    /// Movie mode: ask for the in/out range, or the whole timeline when there is none.
3305    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    /// "Export Frame…" confirmed: render at the chosen size and write the image with ffmpeg.
3318    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    /// The decoded frame of the top-most visual clip under the playhead ("source frame only").
3339    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    /// Import a timeline from another editor (FCP7 XML / EDL / .prproj) and show the report.
3356    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    /// Start / stop the screen recorder with the options the window built (also driven by focus when
3376    /// `capture_on_blur` is on, which rebuilds them from settings + the window's region).
3377    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(); // the take lines up with what you hear
3412            }
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    /// A finished recording: import it and (for a voiceover) drop it on the timeline at `at`.
3428    fn import_recording(&mut self, out: PathBuf, at: Option<f64>) {
3429        // ffmpeg finalises the container a moment after it is asked to stop
3430        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    /// dshow audio inputs, asked for once (the ffmpeg device probe takes ~a second).
3455    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    /// Screen-capture options for the record-on-blur path, which has no window response to take them
3463    /// from. Same folder the Screen Recording window shows (`capture_ui::capture_dir`).
3464    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    // ---------------- UI pieces ----------------
3478
3479    /// Icon shown next to a menu action: the user's pick from Settings → Appearance → Icons wins,
3480    /// then the built-in defaults below. Abstract actions stay text-only.
3481    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        // ponytail: the glyph is painted over a left gutter made of spaces in the label — that keeps
3492        // egui's own menu-button sizing/shortcut layout instead of reimplementing the widget
3493        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    /// Icon shown next to a pane (View menu, icon picker), with the user's Settings override first.
3514    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                // AutoCut's action only reveals; go through the layout directly so unchecking works too
3560                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; // Some(path) = drop one, None = clear all
3663                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                // re-reading the folder on every open IS the refresh mechanism
3811                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        // drop point in window points: OS cursor (physical px) / ppp − client-area origin
3867        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        // a project file: open it
3876        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    /// Compress… — re-encode one file smaller, either by quality (CRF) or to a size target, writing a
3906    /// copy or replacing the original.
3907    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; // window closes; the job window takes over
3952        }
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    /// Point the library's preview player at `path` and roll it. A file already playing is left alone,
3991    /// so re-clicking the selected row does not restart it. Pauses the timeline: previewing a source and
3992    /// the program monitor should not both be making sound at once.
3993    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        // an asset the project already knows carries its probed duration; anything else would need a
3999        // blocking ffprobe on the UI thread, so it keeps the still thumbnail until it is imported
4000        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    /// The library preview's current frame, uploaded for the pane to paint. None = nothing is previewing.
4013    /// Called exactly once per update (see `self.lib_preview_live`) - `Player::take_frame` consumes the
4014    /// buffered frame, so a second call in the same frame would come back empty.
4015    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    /// The Preview pane while a library asset is being previewed: the timeline's player and project are
4039    /// left untouched underneath (still decoding in the background, so resuming is instant), and this
4040    /// draws the previewed file's own frame with a transport bound to its own `Player` instead.
4041    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            // scrub bar: click or drag anywhere on it seeks
4090            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            // video, filling whatever is left
4105            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        // save when the screenshot event arrives
4146        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        // shoot as soon as the first rendered frame is on screen (or after the timeout when nothing renders).
4164        // SE_SCREENSHOT_DELAY=<seconds> waits instead, for checks that need caches (thumbnails) warmed up.
4165        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    /// Proxy media: keep an all-intra low-res proxy built for every video asset and hand the map to
4179    /// the player. Rescans every 2 s (a handful of stat calls); one ffmpeg transcode at a time.
4180    /// ponytail: no per-asset badge yet — the preview shows one aggregate "Building proxy" line.
4181    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; // pick up the finished file (and start the next) right away
4191        }
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                // only real video that out-sizes the proxy: images/audio gain nothing, and neither
4202                // does footage already at or below proxy resolution
4203                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    // ---------------- scripting ----------------
4227
4228    /// Run one Luau script against the live project. The whole run is a single undo step; a tool
4229    /// that fails mid-script rolls its own mutation back (same policy as MCP) and stops the script.
4230    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; // a failed tool is a no-op
4249                    }
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    // ---------------- MCP ----------------
4273
4274    /// Start/stop/restart the MCP server to match the settings; runs every frame (cheap when in sync).
4275    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; // don't retry every frame
4299                self.settings.save();
4300            }
4301        }
4302    }
4303
4304    fn poll_mcp(&mut self, ctx: &egui::Context) {
4305        // finish blocking jobs (export.video / media.convert) — reply when their thread is done
4306        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        // one per frame: render.frame blocks the UI thread for up to 3 s, so a queue must not run in one go
4323        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(); // anything else queued runs on the next frames
4327        }
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            // blocking jobs: start them and reply when they finish (polled per frame)
4334            "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                        // a failed tool is a no-op: some arms mutate before returning Err (subtitles.set, clip.set)
4351                        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            // same slot the UI uses: exclusion, the progress/Cancel window and the close guard all key off it
4386            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            // never write over the source (converting to its own container used to do exactly that)
4392            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    /// Execute one (non-job) MCP tool by name. The caller handles undo/after_edit for mutating tools.
4409    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                    // non-null = these tracks/size are a nested sequence's, not the main timeline's
4440                    "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                // no abutting left neighbour → the clip blends in from nothing instead
4632                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); // Ctrl+T repeats this one too
4638                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(), &params);
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                // speed/reverse go through Project::set_speed so neighbour collisions are respected
4717                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); // first key: set_at alone would only change the constant value
4748                    }
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            // ---------------- round 3 ----------------
4948            "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    // ---------------- non-blocking windows ----------------
5255
5256    /// A small egui::Window asking for a name. Returns Some(name) once confirmed.
5257    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            // focus only on the window's first frame — every frame would steal it from the panels behind it
5264            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        // "Convert To…" options (non-blocking; the timeline stays usable)
5292        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        // background conversions / downloads: progress + cancel
5320        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        // retime (Ctrl+R)
5330        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        // export window
5341        if self.export_ui.open {
5342            self.detect_encoders_once();
5343            // the export always renders the MAIN timeline — show its size/lossless state, not the open sequence's
5344            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        // save template / save layout profile
5354        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        // settings window
5369        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                // the folder may have changed, and yt-dlp may have been installed since we last looked
5408                self.detect_ytdlp(ctx);
5409                if self.settings.gpu != gpu_before {
5410                    self.gpu_failed = false; // an explicit toggle retries the renderer
5411                }
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                // TODO(integration): text.lock().load_user_fonts(&settings.user_fonts) + refresh self.fonts
5429                // once the text rasterizer grows user-font support (engine-video agent).
5430            }
5431        }
5432        // "Export Frame…" (Ctrl+Shift+F)
5433        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        // GLSL editor — Apply is an effect edit like any other (undo + re-render), and the compile log
5444        // goes straight back into the window so a rejected shader is never a silent no-op
5445        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        // "Paste Attributes" (Ctrl+Alt+V) — one undo step for the whole paste
5467        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        // screen recording / voiceover
5486        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        // imported timeline report — "Use this project" swaps it in
5505        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            // ask first: cancelling the unsaved-changes prompt must keep the report (and the ffprobes
5511            // that built it), not throw the whole import away
5512            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        // export / convert progress — non-modal: keep editing while it runs
5521        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            // viewport commands apply after this frame is painted, so no white flash
5547            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            // a GPU export needs this thread to keep coming back to serve its frames
5565            ctx.request_repaint();
5566        }
5567        // carry last frame's "the Auto-cut pane was on screen" into this frame's timeline drawing
5568        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        // close handling: confirm unsaved changes
5574        if ctx.input(|i| i.viewport().close_requested()) && !self.close_confirmed {
5575            if let Some((prog, _)) = &self.export {
5576                // let the export thread stop and clean up first; the close is re-requested once it has finished
5577                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        // playback clock (one extra read after it stops, so the playhead lands on the final time)
5592        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        // a Draw take runs until the video stops or the tool is put away — not one stroke at a time
5599        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        // GPU on: the player hands over decoded layers and we render them here (this thread owns GL);
5605        // GPU off / unavailable: the render thread already composited the frame on the CPU.
5606        self.sync_gpu();
5607        // leave the layers in place until the preview pane has reported its size (first frame), so the
5608        // very first decode is not thrown away
5609        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                // zero copy: render into a GL texture and let egui paint it directly. Only when nothing
5614                // else needs the pixels on the CPU (movie mode reads from its own cache).
5615                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        // movie mode: keep rendering the requested range in small slices and show what is ready
5632        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                    // movie mode plays every frame at the project rate: rather than let the wall clock
5639                    // run past a second that is not rendered yet, hold it and resume when it lands.
5640                    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        // buffering: the render thread fell behind decode — hold the clock (the audio ring flushes
5665        // with the pause) and show a spinner until the read-ahead refills, instead of letting audio
5666        // play on over a frozen frame. Same shape as the movie-mode stall above.
5667        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)); // keep polling for the refill
5673        } else if self.buffer_stall {
5674            self.buffer_stall = false;
5675            self.player.play();
5676        }
5677        // record-on-blur: start when the editor loses focus, stop (and import) when it comes back
5678        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        // export progress
5695        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        // MCP server + queued tool calls (executed here, on the UI thread, against the live project)
5711        self.sync_mcp(ctx);
5712        self.poll_mcp(ctx);
5713
5714        self.handle_drops(ctx);
5715        self.screenshot_tick(ctx);
5716
5717        // hotkeys
5718        // the tool strip claims the bare letters (V/T/D/M, Shift+S) before the action table is polled, so
5719        // a rebound action can never shadow a tool
5720        if let Some(t) = tools::handle_hotkeys(ctx, &mut self.tools) {
5721            self.tools.tool = t;
5722            self.layout.reveal(Pane::Tools);
5723        }
5724        // bare S is snapping's own key, claimed the same way (see tools::handle_snap_hotkey)
5725        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        // ---- layout ----
5743        if self.fullscreen {
5744            // same pane as the docked preview (it reads self.fullscreen) — no second copy to drift
5745            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        // clipboard and Delete last: the curve and node editors claim those while the pointer is over
5764        // them, and only what they leave behind should reach the timeline
5765        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                // the viewport command needs the ctx; keep act() ctx-free
5777                self.act(a);
5778                ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(self.fullscreen));
5779            } else {
5780                self.act(a);
5781            }
5782        }
5783
5784        // also in fullscreen: an export's progress + Cancel must not disappear behind it
5785        self.windows(ctx);
5786
5787        // persist the layout when it changed, debounced to the end of drag gestures
5788        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        // toasts
5799        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    /// Ctrl+V was dead because egui-winit only emits `Event::Paste` when the SYSTEM clipboard holds
5840    /// text, and an internal clip copy never wrote to it — so the chord produced no event at all and no
5841    /// binding could see it. Copying clips must therefore also queue text for the OS clipboard.
5842    #[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        // what act(CopyClips) stores: the same JSON goes to both clipboards
5849        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        // and the ripple that Paste Insert performs opens exactly the span it is given
5856        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    /// A 10 s video asset, long enough to split a few times.
5867    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        // converting to the same container must not write over the source
5892        let out = converted_path(&src, "mp4");
5893        assert_ne!(out, src);
5894        assert_eq!(out.file_name().unwrap(), "clip_converted.mp4");
5895        // nor over a file that is already there (the timeline may be using it)
5896        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()); // this sequence is empty…
5911        assert!(!timeline_is_empty(&p)); // …but the main timeline still holds the Sequence clip
5912        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        // setting a constant clears animation
5971        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        // unknown fields / text fields on a non-text clip are errors
5976        assert!(apply_clip_fields(&mut c, &json!({"nope": 1})).is_err());
5977        assert!(apply_clip_fields(&mut c, &json!({"text": "hi"})).is_err());
5978        // text clip accepts text style fields
5979        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    /// Ctrl+T repeats the last transition: same kind and duration, on the selected clip's left cut.
5990    #[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        // the "Add Transition" action's default: cross fade, 1 s — remembered in the panel state
5996        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        // a different choice replaces the memory and Ctrl+T applies exactly that at the next cut
6002        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        // nothing abuts the very first clip's left edge → it blends in from nothing instead
6008        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        // no effects: the clip itself gets the mask
6018        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        // with an effect, the mask goes on the effect (that is what a mask usually means)
6022        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        // a mask shapes pixels: audio takes none, through either route (Ctrl+Shift+M or MCP)
6027        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    /// Paste Attributes only touches the boxes that were ticked (and never timing or media).
6035    #[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        // ticking effects + label copies those too
6059        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        // downscale: render straight at the target
6069        assert_eq!(frame_render_size((1920, 1080), (960, 540)), (960, 540));
6070        assert_eq!(frame_render_size((1920, 1080), (1920, 1080)), (1920, 1080));
6071        // upscale (2x / 4x buttons): render at project size, ffmpeg enlarges with the chosen flag
6072        assert_eq!(frame_render_size((1920, 1080), (3840, 2160)), (1920, 1080));
6073        // mixed (wider but shorter) counts as an upscale, and degenerate sizes are clamped
6074        assert_eq!(frame_render_size((1920, 1080), (4000, 100)), (1920, 1080));
6075        assert_eq!(frame_render_size((0, 0), (0, 0)), (16, 16));
6076        // preview quality scales the canvas, keeps zero at zero and never goes below 16 px
6077        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)); // clamped to 25 %
6080        assert_eq!(preview_canvas((0, 600), 50), (0, 0));
6081        assert_eq!(preview_canvas((10, 10), 25), (16, 16));
6082    }
6083
6084    /// The GPU renders at `self.canvas`, the player decodes at its own clamp — they must agree, or the
6085    /// preview comes out squashed whenever the pane is wider than preview_max_width.
6086    #[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        // the same numbers the player would land on
6093        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        // every kind gets its own key at one size
6106        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        // no stock image set => the embedded default, unscaled at card size and resampled elsewhere
6111        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    /// The frame writer really produces an image of the requested size (needs ffmpeg; skipped without).
6122    #[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), // upscaled by ffmpeg, like a 2x frame export
6136                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        // a path ffmpeg cannot write is an error, not a panic
6147        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    /// Library-preview transport math: frame-step clamps at both ends of the file, and a scrub-bar
6161    /// fraction (even one dragged past the bar's edge) maps to a time within the file.
6162    #[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    /// Every action is dispatched (`act` matches exhaustively) and every pane can be toggled from
6175    /// the View menu; here we only check the round-3 actions still carry their advertised bindings.
6176    #[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            // the bare letters V/T/S/D/M belong to the tool strip, so this moved to Shift+M
6185            (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}