Export sequence markers to a spreadsheet-safe CSV.

The report is sorted, uses the sequence timecode, escapes formula-like text, and does not change the edit.
This commit is contained in:
Vikram Bhaskaran 2026-10-10 04:37:44 +11:00
parent 7b6c134472
commit c855c50ba9
No known key found for this signature in database
GPG Key ID: 5CFB5F821425464B
8 changed files with 411 additions and 10 deletions

View File

@ -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.

View File

@ -1837,6 +1837,15 @@ fn build() -> Vec<CommandSpec> {
})?;
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);

View File

@ -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;

View File

@ -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<Value> {
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<String> {
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<Vec<(String, Vec<u8>)>>,
fail: bool,
}
impl Services for Files {
fn read_file(&self, _: &str) -> std::io::Result<Vec<u8>> {
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<Files>) -> 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<Marker>) {
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());
}
}

View File

@ -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"),

View File

@ -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<String>) {
fn menu_level(ui: &mut egui::Ui, items: &[&MenuItem], depth: usize, clicked: &mut Option<String>, 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::<crate::i18n::Language>(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();

View File

@ -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");
}

View File

@ -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: