diff --git a/README.md b/README.md index 8c498e3d..a4381a8d 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ FilmCraft is a non-linear editor for people who know Premiere: the same panels, - **Three-point editing.** Mark In and Out in the Source monitor, then insert (`,`) or overwrite (`.`) onto the patched tracks. Lift (`;`) and extract (`'`) take ranges back out. - **Every trim.** Ripple, roll, slip, slide, rate stretch and razor tools. Trim mode selects edit points as ripple, roll or trim, nudges them a frame at a time (`⌥←` `⌥→`, ×5 with `⇧`), toggles the trim type with `⌃T` and extends them to the playhead with `E`. `Q` and `W` ripple-trim to the playhead. The **Trim Monitor** shows both sides of the edit, and **dynamic trimming** trims live while it plays: `L` forward, `J` back, `K` to stop and commit as one undo step. - **Exact time.** Every edit is computed on integer ticks: 254,016,000,000 per second, which divides evenly by every common frame rate and sample rate. 23.976, 29.97 drop-frame and 59.94 are exact, not approximate. -- **The details pros rely on.** Markers with colours, names and durations; add edit (`⌘K`) on one or all tracks; nesting; copy, paste and paste insert; ripple delete and close gap; snapping; unlimited undo with a History panel. +- **The details pros rely on.** Markers with colours, names and durations; export sequence review notes with **Markers ▸ Export Markers as CSV…**; add edit (`⌘K`) on one or all tracks; nesting; copy, paste and paste insert; ripple delete and close gap; snapping; unlimited undo with a History panel. - **Your keys.** A Keyboard Shortcuts editor (`⌥⌘K`) with a drawn keyboard, panel-specific shortcuts, conflict warnings and presets for FilmCraft, Premiere Pro, Final Cut Pro and Avid key layouts. - **Never lose work.** Saves are atomic, auto-save keeps a rolling set of versions, and a crash-recovery journal written about a second after each edit brings back unsaved changes after a crash or power cut. diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index b5e4c847..0cc155b0 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -1837,6 +1837,15 @@ fn build() -> Vec { })?; Ok(Value::Null) }), + cmd!( + "markers.exportCsv", + "Export Markers as CSV…", + ["Markers"], + None, + r#"{"path":str} (all markers in the active sequence)"#, + has_seq, + crate::marker_export::export + ), cmd!("markers.clearAll", "Clear Markers", ["Markers"], Some("Cmd+Alt+Shift+M"), r#"{"target":"program|source"}"#, always, |s, p| { if let Some(v) = crate::source_monitor::route(s, "markers.clearAll", p)? { return Ok(v); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 2578420b..3df16723 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -23,6 +23,7 @@ pub mod graphic_templates; pub mod graphics; pub mod interchange; pub mod keyboard; +mod marker_export; pub mod masks; pub mod media_browser; pub mod media_pool; diff --git a/crates/engine/src/marker_export.rs b/crates/engine/src/marker_export.rs new file mode 100644 index 00000000..44012543 --- /dev/null +++ b/crates/engine/src/marker_export.rs @@ -0,0 +1,311 @@ +//! Sequence review notes as a spreadsheet-safe CSV, using the host's atomic file service. + +use filmcraft_project::Label; +use filmcraft_time::{Tick, format_timecode_frames}; +use serde_json::{Value, json}; + +use crate::commands::bad; +use crate::{EngineError, Result, Session}; + +const CMD: &str = "markers.exportCsv"; +const MAX_BYTES: usize = 16 * 1024 * 1024; +const MAX_MARKERS: usize = 100_000; + +fn label_name(s: &Session, color: Label) -> &str { + s.prefs.labels.colors.get(&crate::settings::label_id(color)).map(|c| c.name.trim()).filter(|n| !n.is_empty()).unwrap_or(color.name()) +} + +pub(crate) fn export(s: &mut Session, p: &Value) -> Result { + let path = p + .get("path") + .and_then(Value::as_str) + .filter(|v| !v.trim().is_empty() && !v.contains('\0')) + .ok_or_else(|| bad(CMD, "need a non-empty `path` without NUL characters"))?; + let seq = s.active_sequence().ok_or(EngineError::NoSequence)?; + seq.settings.validate().map_err(|e| bad(CMD, e))?; + let name = s.state.active_sequence.and_then(|id| s.project.item(id)).map(|i| i.name.as_str()).unwrap_or("Sequence"); + if seq.markers.len() > MAX_MARKERS { + return Err(bad(CMD, "too many markers (maximum 100000)")); + } + // Bound the worst-case quoted output before sorting or allocating it. 1024 covers numeric, + // timecode and enum fields; arbitrary text may double in size when every character is a quote. + let mut bound = 512usize; + for m in &seq.markers { + let row = name + .len() + .checked_add(m.name.len()) + .and_then(|n| n.checked_add(m.comment.len())) + .and_then(|n| n.checked_add(label_name(s, m.color).len())) + .and_then(|n| n.checked_mul(2)) + .and_then(|n| n.checked_add(1024)); + bound = row.and_then(|n| bound.checked_add(n)).filter(|n| *n <= MAX_BYTES).ok_or_else(|| bad(CMD, "marker report exceeds the 16 MiB limit"))?; + } + let rate = seq.settings.frame_rate; + let mut markers: Vec<_> = seq.markers.iter().collect(); + markers.sort_by_key(|m| (m.start, m.id)); + let mut csv = + String::from("Sequence,Marker ID,Name,Comment,Kind,Color,Start Timecode,End Timecode,Duration Frames,Start Ticks,Duration Ticks,Frame Rate\r\n"); + for m in markers { + if m.start.0 < 0 || m.duration.0 < 0 { + return Err(bad(CMD, format!("marker {} has a negative start or duration", m.id.0))); + } + let end = m.start.0.checked_add(m.duration.0).ok_or_else(|| bad(CMD, format!("marker {} end overflows", m.id.0)))?; + let tc = |t: Tick| -> Result { + let frame = rate.frame_at(t).checked_add(seq.start_timecode).ok_or_else(|| bad(CMD, "sequence timecode overflows"))?; + Ok(format_timecode_frames(frame, rate, seq.settings.drop_frame)) + }; + let fields = [ + name.to_string(), + m.id.0.to_string(), + m.name.clone(), + m.comment.clone(), + format!("{:?}", m.kind), + label_name(s, m.color).to_string(), + tc(m.start)?, + tc(Tick(end))?, + rate.frame_at(m.duration).to_string(), + m.start.0.to_string(), + m.duration.0.to_string(), + format!("{}/{}", rate.num, rate.den), + ]; + for (i, field) in fields.iter().enumerate() { + if i > 0 { + csv.push(','); + } + csv.push('"'); + // Quoting alone does not stop spreadsheet formulas. Escape only arbitrary text, + // including formulas preceded by whitespace; numeric/timecode fields stay numeric. + if matches!(i, 0 | 2 | 3 | 5) && field.trim_start().starts_with(['=', '+', '-', '@']) { + csv.push('\''); + } + for ch in field.chars() { + if ch == '"' { + csv.push('"'); + } + csv.push(ch); + } + csv.push('"'); + } + csv.push_str("\r\n"); + } + s.services.write_file(path, csv.as_bytes()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + Ok(json!({"path": path, "markers": seq.markers.len(), "bytes": csv.len()})) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Services; + use filmcraft_project::{Label, Marker, MarkerId, MarkerKind}; + use filmcraft_time::FrameRate; + use std::sync::{Arc, Mutex}; + + #[derive(Default)] + struct Files { + writes: Mutex)>>, + fail: bool, + } + impl Services for Files { + fn read_file(&self, _: &str) -> std::io::Result> { + Err(std::io::ErrorKind::NotFound.into()) + } + fn write_file(&self, path: &str, data: &[u8]) -> std::io::Result<()> { + if self.fail { + return Err(std::io::ErrorKind::PermissionDenied.into()); + } + self.writes.lock().unwrap().push((path.into(), data.to_vec())); + Ok(()) + } + } + fn session(files: Arc) -> Session { + let mut s = Session::default(); + s.services = files; + s.execute("file.newSequence", json!({"name":"Review, 日本語", "fps":24})).unwrap(); + s + } + fn marker(id: u64, start: Tick, duration: Tick, name: &str) -> Marker { + Marker { + id: MarkerId(id), + start, + duration, + name: name.into(), + comment: "First line\r\nSecond, \"quoted\"".into(), + kind: MarkerKind::Chapter, + color: Label::Iris, + } + } + fn set_markers(s: &mut Session, markers: Vec) { + s.edit_sequence("Review markers", |q, _, _| { + q.markers = markers; + Ok(()) + }) + .unwrap(); + } + fn written(files: &Files) -> String { + String::from_utf8(files.writes.lock().unwrap().last().unwrap().1.clone()).unwrap() + } + + #[test] + fn command_exports_sorted_unicode_review_notes_without_changing_the_edit() { + let files = Arc::new(Files::default()); + let mut s = session(files.clone()); + let rate = FrameRate::FPS_24; + set_markers(&mut s, vec![marker(100, rate.tick_of(48), rate.tick_of(24), "Later"), marker(101, rate.tick_of(24), Tick::ZERO, "早い")]); + s.execute("markers.markIn", json!({"frame":12})).unwrap(); + s.state.hidden_marker_colors.push(Label::Iris); + let before = s.project.clone(); + let state = serde_json::to_value(&s.state).unwrap(); + let undo = s.history.undo.len(); + let result = s.execute(CMD, json!({"path":"review.csv"})).unwrap(); + let csv = written(&files); + assert_eq!(result["markers"], 2); + assert_eq!(result["bytes"], csv.len()); + assert!(csv.contains("\"Review, 日本語\",\"101\",\"早い\",\"First line\r\nSecond, \"\"quoted\"\"\"")); + assert!(csv.find("\"101\"").unwrap() < csv.find("\"100\"").unwrap()); + assert!(csv.contains("\"00:00:02:00\",\"00:00:03:00\",\"24\",\"508032000000\",\"254016000000\",\"24/1\"\r\n")); + assert_eq!(*s.project, *before); + assert_eq!(serde_json::to_value(&s.state).unwrap(), state); + assert_eq!(s.history.undo.len(), undo); + s.undo(); + s.redo(); + assert_eq!(*s.project, *before); + } + + #[test] + fn drop_frame_and_sequence_start_timecode_are_used_for_both_bounds() { + let files = Arc::new(Files::default()); + let mut s = session(files.clone()); + let rate = FrameRate::FPS_29_97; + s.edit_sequence("Timing", |q, _, _| { + q.settings.frame_rate = rate; + q.settings.drop_frame = true; + q.start_timecode = 107892; + q.markers = vec![marker(100, rate.tick_of(1800), rate.tick_of(30), "DF")]; + Ok(()) + }) + .unwrap(); + s.execute(CMD, json!({"path":"df.csv"})).unwrap(); + assert!(written(&files).contains("\"01;01;00;02\",\"01;01;01;02\",\"30\"")); + assert!(written(&files).contains("\"30000/1001\"")); + } + + #[test] + fn spreadsheet_formulas_are_escaped_in_all_user_text_columns() { + let files = Arc::new(Files::default()); + let mut s = session(files.clone()); + for text in ["=1+1", "+1+1", "-1+1", "@SUM(1)", "\t =1+1"] { + let mut m = marker(100, Tick::ZERO, Tick::ZERO, text); + m.comment = text.into(); + set_markers(&mut s, vec![m]); + let id = s.state.active_sequence.unwrap(); + s.edit("Name", |p, _| { + p.items.get_mut(&id).unwrap().name = text.into(); + Ok(()) + }) + .unwrap(); + s.execute(CMD, json!({"path":"safe.csv"})).unwrap(); + assert_eq!(written(&files).matches(&format!("\"'{text}\"")).count(), 3); + s.prefs.labels.colors.get_mut("iris").unwrap().name = text.into(); + s.execute(CMD, json!({"path":"safe-label.csv"})).unwrap(); + assert!(written(&files).contains(&format!("\"Chapter\",\"'{}\"", text.trim()))); + s.prefs.labels.colors.get_mut("iris").unwrap().name = "Iris".into(); + } + } + + #[test] + fn empty_sequence_writes_a_header_and_no_sequence_is_disabled() { + let files = Arc::new(Files::default()); + let mut s = session(files.clone()); + let spec = crate::find_command(CMD).unwrap(); + assert_eq!(spec.menu, ["Markers"]); + assert!(spec.params.contains("path")); + assert!(spec.shortcut.is_none()); + assert_eq!(s.execute(CMD, json!({"path":"empty.csv"})).unwrap()["markers"], 0); + assert_eq!(written(&files).lines().count(), 1); + assert!((spec.enabled)(&Session::default()).is_err()); + assert!(matches!(Session::default().execute(CMD, json!({"path":"none.csv"})), Err(EngineError::Disabled(..)))); + } + + #[test] + fn hostile_parameters_and_damaged_timing_fail_before_writing() { + let files = Arc::new(Files::default()); + let mut s = session(files.clone()); + for p in [Value::Null, json!({}), json!({"path":0}), json!({"path":" "}), json!({"path":"bad\0.csv"})] { + assert!(std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| s.execute(CMD, p))).unwrap().is_err()); + } + let id = s.state.active_sequence.unwrap(); + for (start, duration) in [(-1, 0), (0, -1), (i64::MAX, 1)] { + Arc::make_mut(&mut s.project).sequence_mut(id).unwrap().markers = vec![marker(100, Tick(start), Tick(duration), "Bad")]; + assert!(s.execute(CMD, json!({"path":"bad.csv"})).is_err()); + } + let q = Arc::make_mut(&mut s.project).sequence_mut(id).unwrap(); + q.markers = vec![marker(100, Tick::ZERO, Tick::ZERO, "Bad")]; + q.settings.frame_rate.num = i64::MAX; + assert!(s.execute(CMD, json!({"path":"bad.csv"})).is_err()); + let q = Arc::make_mut(&mut s.project).sequence_mut(id).unwrap(); + q.settings.frame_rate = FrameRate::FPS_24; + q.start_timecode = i64::MAX; + q.markers[0].start = FrameRate::FPS_24.tick_of(1); + assert!(s.execute(CMD, json!({"path":"bad.csv"})).is_err()); + q_reset(&mut s, id); + assert!(files.writes.lock().unwrap().is_empty()); + s.execute(CMD, json!({"path":"recovered.csv"})).unwrap(); + } + fn q_reset(s: &mut Session, id: filmcraft_project::ItemId) { + let q = Arc::make_mut(&mut s.project).sequence_mut(id).unwrap(); + q.start_timecode = 0; + q.markers.clear(); + } + + #[test] + fn oversized_reports_and_write_errors_are_actionable() { + let files = Arc::new(Files::default()); + let mut s = session(files.clone()); + set_markers(&mut s, vec![marker(100, Tick::ZERO, Tick::ZERO, &"x".repeat(MAX_BYTES / 2))]); + assert!(s.execute(CMD, json!({"path":"huge.csv"})).unwrap_err().to_string().contains("16 MiB")); + assert!(files.writes.lock().unwrap().is_empty()); + s.services = Arc::new(Files { fail: true, ..Default::default() }); + set_markers(&mut s, vec![]); + s.saved_revision = s.revision; + assert!(!s.is_dirty()); + let before = s.project.clone(); + assert!(s.execute(CMD, json!({"path":"denied.csv"})).unwrap_err().to_string().contains("denied.csv")); + assert_eq!(*s.project, *before); + assert!(!s.is_dirty()); + } + + #[test] + fn locked_tracks_custom_labels_and_unusual_paths_export_without_dirtying() { + let files = Arc::new(Files::default()); + let mut s = session(files.clone()); + let rate = FrameRate::FPS_23_976; + s.edit_sequence("Locked review", |q, _, _| { + q.settings.frame_rate = rate; + for t in q.video_tracks.iter_mut().chain(&mut q.audio_tracks) { + t.locked = true; + } + q.markers = + vec![marker(101, Tick(rate.tick_of(24).0 + 1), Tick(1), "A, \"B\"\n字幕"), marker(100, Tick(rate.tick_of(24).0 + 1), Tick::ZERO, "Point")]; + Ok(()) + }) + .unwrap(); + s.prefs.labels.colors.get_mut("iris").unwrap().name = "Review, \"urgent\"\n紫".into(); + let id = s.state.active_sequence.unwrap(); + s.edit("Review name", |p, _| { + p.items.get_mut(&id).unwrap().name = "Cut, \"night\"\n日本語".into(); + Ok(()) + }) + .unwrap(); + s.saved_revision = s.revision; + let path = "review, \"notes\"\n日本語.csv"; + s.execute(CMD, json!({"path":path})).unwrap(); + assert_eq!(files.writes.lock().unwrap().last().unwrap().0, path); + let csv = written(&files); + assert!(csv.contains("\"Cut, \"\"night\"\"\n日本語\"")); + assert!(csv.contains("\"Review, \"\"urgent\"\"\n紫\"")); + assert!(csv.contains("\"A, \"\"B\"\"\n字幕\"")); + assert!(csv.find("\"100\"").unwrap() < csv.find("\"101\"").unwrap()); + assert!(csv.contains(&format!("\"00:00:01:00\",\"00:00:01:00\",\"0\",\"{}\",\"1\",\"24000/1001\"", rate.tick_of(24).0 + 1))); + assert!(!s.is_dirty()); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 83d18261..66b31fd8 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -204,9 +204,10 @@ const SCREENSHOT_TIMEOUT_S: f64 = 10.0; /// timeline zoom animation finished) before capturing what is there. const SCREENSHOT_SETTLE_MAX_S: f64 = 5.0; -/// File ▸ Export entries that run from the menus through the save panel: command, filter label, +/// Export entries that run from the menus through the save panel: command, filter label, /// extension. -pub(crate) const EXPORT_SAVE_DIALOGS: [(&str, &str, &str); 6] = [ +pub(crate) const EXPORT_SAVE_DIALOGS: [(&str, &str, &str); 7] = [ + ("markers.exportCsv", "Marker report (CSV)", "csv"), ("file.exportEdl", "EDL", "edl"), ("file.exportFcp7Xml", "Final Cut Pro XML", "xml"), ("file.exportFcpxml", "FCPXML", "fcpxml"), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index d6046935..035d4980 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -695,13 +695,14 @@ pub fn menu_bar(app: &mut FilmcraftApp, ui: &mut egui::Ui) { egui::MenuBar::new().config(egui::containers::menu::MenuConfig::new().style(crate::theme::menu_style)).ui(ui, |ui| { for top in MENUS { let mine: Vec<&MenuItem> = items.iter().filter(|i| i.path.first().map(String::as_str) == Some(top)).collect(); - ui.menu_button(app.ui.language.tr(top), |ui| { + let r = ui.menu_button(app.ui.language.tr(top), |ui| { ui.set_min_width(260.0); if mine.is_empty() { ui.add_enabled(false, egui::Button::new(tl!("(empty)"))); } - menu_level(ui, &mine, 1, &mut clicked); + menu_level(ui, &mine, 1, &mut clicked, &mut app.auto); }); + app.auto.add(&format!("menu.{top}"), r.response.rect, top); } }); if let Some(id) = clicked { @@ -711,7 +712,7 @@ pub fn menu_bar(app: &mut FilmcraftApp, ui: &mut egui::Ui) { /// One menu level: items whose path ends here, and a submenu (at its first item's position) for /// each deeper path segment, recursively (e.g. Clip ▸ Video Options ▸ Time Interpolation). -fn menu_level(ui: &mut egui::Ui, items: &[&MenuItem], depth: usize, clicked: &mut Option) { +fn menu_level(ui: &mut egui::Ui, items: &[&MenuItem], depth: usize, clicked: &mut Option, auto: &mut crate::automation::Registry) { let mut subs: Vec<&str> = Vec::new(); for it in items { if let Some(sub) = it.path.get(depth).map(String::as_str) { @@ -723,16 +724,16 @@ fn menu_level(ui: &mut egui::Ui, items: &[&MenuItem], depth: usize, clicked: &mu let language = ui.ctx().data(|d| d.get_temp::(egui::Id::new("interface-language"))).unwrap_or_default(); ui.menu_button(language.tr(sub), |ui| { ui.set_min_width(220.0); - menu_level(ui, &inner, depth + 1, clicked); + menu_level(ui, &inner, depth + 1, clicked, auto); }); - } else if menu_entry(ui, it) { + } else if menu_entry(ui, it, auto) { *clicked = Some(it.id.clone()); ui.close(); } } } -fn menu_entry(ui: &mut egui::Ui, it: &MenuItem) -> bool { +fn menu_entry(ui: &mut egui::Ui, it: &MenuItem, auto: &mut crate::automation::Registry) -> bool { // checkable items leave room for a checkmark drawn at the left let label = if it.checked.is_some() { format!(" {}", it.label) } else { it.label.clone() }; let mut b = egui::Button::new(label); @@ -740,6 +741,7 @@ fn menu_entry(ui: &mut egui::Ui, it: &MenuItem) -> bool { b = b.shortcut_text(shortcut_text(s)); } let r = ui.add_enabled(it.enabled, b); + auto.add(&format!("menu.{}", it.id), r.rect, &it.label); if it.checked == Some(true) { let c = r.rect.left_center() + egui::vec2(10.0, 0.0); let col = ui.visuals().text_color(); diff --git a/crates/ui-egui/tests/export_menu_ui.rs b/crates/ui-egui/tests/export_menu_ui.rs index 4c1ef8cc..75122089 100644 --- a/crates/ui-egui/tests/export_menu_ui.rs +++ b/crates/ui-egui/tests/export_menu_ui.rs @@ -29,7 +29,11 @@ impl Driver { asked.lock().unwrap().push((filter.to_string(), exts.iter().map(|e| e.to_string()).collect(), suggested.to_string())); save_to.as_ref().map(|d| d.join(suggested).to_string_lossy().into_owned()) })); - let harness = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).with_max_steps(10_000).build_eframe(move |_cc| app); + let mut builder = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).with_max_steps(10_000); + if std::env::var_os("FILMCRAFT_UI_SNAPSHOT_DIR").is_some() { + builder = builder.with_wgpu_render(); + } + let harness = builder.build_eframe(move |_cc| app); let mut d = Driver { harness, tx }; d.frames(4); d @@ -142,3 +146,59 @@ fn file_export_entries_need_an_open_sequence() { assert_eq!(d.harness.state().ui.mode, Mode::Edit, "{id}"); } } + +#[test] +fn marker_csv_menu_exports_sequence_review_notes() { + let dir = tmp("markers"); + let asked = Asked::default(); + let mut s = session(); + s.execute("markers.clearAll", json!({"target":"program"})).unwrap(); + s.execute("markers.add", json!({"target":"program", "frame":24, "name":"Review, 日本語", "comment":"Change \"this\"\nNext line"})).unwrap(); + let before = s.project.clone(); + let undo = s.history.undo.len(); + let mut d = Driver::new(s, Some(dir.clone()), asked.clone()); + let menu = d.ok("ui.menu.list", json!({})); + let entry = menu.as_array().unwrap().iter().find(|i| i["id"] == "markers.exportCsv").unwrap(); + assert_eq!(entry["path"], json!(["Markers"])); + assert_eq!(entry["enabled"], true); + d.ok("ui.click", json!({"id":"menu.Markers"})); + d.frames(3); + let elements = d.ok("ui.elements", json!({"prefix":"menu.markers.exportCsv"})); + assert!(!elements.as_array().unwrap().is_empty(), "the menu command has an automation id"); + if let Some(dir) = std::env::var_os("FILMCRAFT_UI_SNAPSHOT_DIR") { + let dir = std::path::PathBuf::from(dir); + std::fs::create_dir_all(&dir).unwrap(); + d.harness.render().unwrap().save(dir.join("marker-csv-menu.png")).unwrap(); + } + d.ok("ui.click", json!({"id":"menu.markers.exportCsv"})); + d.frames(3); + let (filter, exts, suggested) = asked.lock().unwrap().last().cloned().unwrap(); + assert_eq!(filter, "Marker report (CSV)"); + assert_eq!(exts, ["csv"]); + assert!(suggested.ends_with(".csv")); + let bytes = std::fs::read(dir.join(suggested)).unwrap(); + let csv = String::from_utf8(bytes).unwrap(); + assert!(csv.contains("\"Review, 日本語\",\"Change \"\"this\"\"\nNext line\"")); + assert_eq!(csv.matches("\"Review, 日本語\"").count(), 1); + assert_eq!(*d.harness.state().session.project, *before); + assert_eq!(d.harness.state().session.history.undo.len(), undo); + assert!(d.harness.state().ui.status.starts_with("Exported ")); + let direct = dir.join("direct, 字幕.csv"); + d.ok("engine.execute", json!({"command":"markers.exportCsv", "params":{"path":direct.to_string_lossy()}})); + assert_eq!(std::fs::read(direct).unwrap(), csv.as_bytes()); + assert_eq!(asked.lock().unwrap().len(), 1); +} + +#[test] +fn marker_csv_save_cancel_and_disabled_menu_leave_the_session_alone() { + let asked = Asked::default(); + let mut d = Driver::new(session(), None, asked.clone()); + let before = d.harness.state().session.project.clone(); + assert_eq!(d.ok("ui.menu.invoke", json!({"id":"markers.exportCsv"})), Value::Null); + assert_eq!(*d.harness.state().session.project, *before); + assert_eq!(asked.lock().unwrap().len(), 1); + let asked = Asked::default(); + let mut d = Driver::new(Session::default(), None, asked.clone()); + assert_eq!(d.call("ui.menu.invoke", json!({"id":"markers.exportCsv"}))["ok"], false); + assert!(asked.lock().unwrap().is_empty(), "disabled exports must not open a save dialog"); +} diff --git a/docs/agents.md b/docs/agents.md index 9ad0b2bc..c0431ff1 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -176,6 +176,7 @@ filmcraft-cli describe timeline.razor # one command: menu, shortcut, filmcraft-cli --demo inspect sequence # project / sequence as JSON filmcraft-cli --project p.fcproj --save exec timeline.razor seconds=3.5 filmcraft-cli --project p.fcproj exec effects.apply '{"effect":"Gaussian Blur"}' +filmcraft-cli --project p.fcproj exec markers.exportCsv path=review.csv filmcraft-cli --project p.fcproj --save import a.mov b.wav filmcraft-cli --project p.fcproj export out.mp4 # format from the extension; waits for the job filmcraft-cli --project p.fcproj export out --preset "YouTube 1080p Full HD" --start 0 --end 10 @@ -190,6 +191,22 @@ strings; dotted keys nest (`color.r=1`). `run` prints one JSON line per command (`{"line","id","ok","result"|"error"}`) and stops at the first failure unless `--keep-going`. `--save` writes back to `--project`; `--save-as path` writes elsewhere. +`markers.exportCsv {path}` exports all markers in the active sequence, including colours hidden +in the UI and markers outside In/Out, sorted by start then id. The UTF-8 CSV has a header and CRLF +records; quotes, commas and embedded newlines are escaped. Columns are Sequence, Marker ID, Name, +Comment, Kind, Color, Start Timecode, End Timecode, Duration Frames, Start Ticks, Duration Ticks and +Frame Rate (exact numerator/denominator). Color uses the user's display label from Settings ▸ +Labels. Timecodes include the sequence's start timecode and +drop-frame setting; End is exclusive, and a point marker's End equals Start. Duration Frames is +rounded down; tick columns preserve sub-frame timing (254016000000 ticks per second). +An empty sequence writes just the header. User text starting with a spreadsheet formula character +(`=`, `+`, `-`, `@`, including after whitespace) gets a leading apostrophe in the exported cell. +The export leaves the project, dirty flag and undo history untouched, works with locked tracks, +and uses the host's file service (atomic +on desktop, a download on web). It exports sequence markers only, not clip/source markers; +reports are limited to 100000 markers and a conservative 16 MiB output budget. Invalid timing or a +failed write reports an error. The menu opens the normal save dialog; cancelling writes nothing. + ## 3. Verifying UI work For any UI change, look at the result: