M3.12: Match Export Frame timecode to its monitor

Display the captured frame with the same frame rate and drop-frame timecode format as Source or Program, instead of decimal seconds. Keep the captured timecode fixed while the export dialog is open.

Add a UI regression comparing both monitor labels at standard and NTSC rates, with Program drop-frame formatting and playhead movement after capture.

Assisted-by: Codex
This commit is contained in:
Cathy Shapiro committed 2026-10-08 21:11:44 -05:00
1 parent 9f0ed51b49
commit 6c0e3ecc39
3 files changed
+57 -3

No files matched your search

+14 -2
View File
@@ -10,6 +10,7 @@ use serde_json::{Value, json};
#[derive(Clone)]
struct Draft {
target: FrameExportTarget,
timecode: String,
name: String,
format: StillFormat,
depth: u8,
@@ -26,6 +27,14 @@ fn clean(name: &str) -> String {
pub fn open(app: &mut FilmcraftApp, ctx: &egui::Context, params: &Value) -> Result<Value, String> {
let target = frame_export_target(&app.session, params).map_err(|e| e.to_string())?;
let (rate, drop_frame) = if target.source {
let view = filmcraft_engine::clip_ops::source_view(&app.session, target.item).ok_or("Source clip is unavailable")?;
(view.rate, false)
} else {
let sequence = app.session.project.sequence(target.item).ok_or("Program sequence is unavailable")?;
(sequence.settings.frame_rate, sequence.settings.drop_frame)
};
let timecode = filmcraft_time::format_time(target.time, rate, drop_frame, filmcraft_time::TimeDisplay::Timecode, 48000);
if target.source {
app.stop_source();
} else if app.playback.playing {
@@ -39,7 +48,9 @@ pub fn open(app: &mut FilmcraftApp, ctx: &egui::Context, params: &Value) -> Resu
});
let name = if target.source { std::path::Path::new(&target.name).file_stem().and_then(|s| s.to_str()).unwrap_or(&target.name) } else { &target.name };
let name = clean(name);
ctx.data_mut(|m| m.insert_temp(draft_id(), Some(Draft { target, name, format: StillFormat::Png, depth: 8, folder, import: false, replace: None })));
ctx.data_mut(|m| {
m.insert_temp(draft_id(), Some(Draft { target, timecode, name, format: StillFormat::Png, depth: 8, folder, import: false, replace: None }))
});
ctx.request_repaint();
Ok(json!({"dialog":"exportFrame"}))
}
@@ -167,7 +178,8 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) {
ui.separator();
ui.label(format!("{} — {} × {}", if d.target.source { "Source" } else { "Program" }, d.target.width, d.target.height));
ui.label(&d.target.name);
ui.label(format!("Frame time: {:.3} seconds", d.target.time.seconds()));
let r = ui.label(format!("Frame time: {}", d.timecode));
elems.push(("exportFrame.timecode".into(), r.rect, d.timecode.clone()));
});
});
if shown.is_some() && movement != egui::Vec2::ZERO {
+40
View File
@@ -222,3 +222,43 @@ fn white_header_moves_both_dialogs_and_controls_still_work() {
assert!(!has(&h, "exportFrame.name"));
}
}
#[test]
fn exported_frame_timecode_matches_each_monitor_and_stays_captured() {
for (rate, drop_frame) in [
(FrameRate::FPS_24, false),
(FrameRate::FPS_25, false),
(FrameRate::FPS_23_976, false),
(FrameRate::FPS_29_97, false),
(FrameRate::FPS_29_97, true),
(FrameRate::FPS_59_94, true),
] {
let mut h = harness();
let seq = h.state().session.state.active_sequence.unwrap();
let settings = &mut Arc::make_mut(&mut h.state_mut().session.project).sequence_mut(seq).unwrap().settings;
settings.frame_rate = rate;
settings.drop_frame = drop_frame;
h.state_mut().session.set_playhead(rate.tick_of(rate.timecode_base() * 65 + 4));
h.state_mut().session.state.source_playhead = FrameRate::FPS_24.tick_of(67);
step(&mut h);
for monitor in ["source", "program"] {
let expected = h.state().auto.elements.iter().find(|e| e.id == format!("{monitor}.timecode")).unwrap().label.clone();
click(&mut h, &format!("{monitor}.transport.exportFrame"));
let displayed = h.state().auto.elements.iter().find(|e| e.id == "exportFrame.timecode").unwrap().label.clone();
assert_eq!(displayed, expected, "{monitor}, {rate:?}, drop-frame {drop_frame}");
if monitor == "source" {
assert_eq!(displayed, "00:00:02:19");
h.state_mut().session.state.source_playhead = Tick::ZERO;
} else {
h.state_mut().session.set_playhead(Tick::ZERO);
}
step(&mut h);
assert_eq!(
h.state().auto.elements.iter().find(|e| e.id == "exportFrame.timecode").unwrap().label,
displayed,
"captured time must remain fixed after either playhead moves"
);
click(&mut h, "exportFrame.close");
}
}
}
+3 -1
View File
@@ -128,7 +128,9 @@ The suggested name is the source filename without its extension, or the sequence
use Browse to choose the destination folder (or edit Path), and click OK. Existing files require
an explicit Replace confirmation. Cancel and the header close button write nothing. The white
header and dark body use original generic controls. Source details and frame time appear below
the settings. The last successful folder is reused during the current app session.
the settings. Frame time uses the same hours/minutes/seconds/frames timecode as its monitor,
including the Program sequence's drop-frame setting, and stays captured while the dialog is open.
The last successful folder is reused during the current app session.
PNG and TIFF support 8 or 16 bits per channel. The 16-bit path quantizes directly from the
float image, preserving precision beyond 8 bits. JPEG (quality 95) and BMP support 8-bit output;