mirror of
https://github.com/storytold/filmcraft.git
synced 2026-10-14 19:26:59 -04:00
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:
1 parent
9f0ed51b49
commit
6c0e3ecc39
3 files changed
+57
-3
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user