diff --git a/crates/media/src/probe.rs b/crates/media/src/probe.rs index f695ff0d0..41b50b0f4 100644 --- a/crates/media/src/probe.rs +++ b/crates/media/src/probe.rs @@ -23,6 +23,7 @@ pub fn probe(path: impl AsRef) -> Result { let turn = header.as_deref().map_or(Orientation::NoTransforms, video_turn); turn_footage(&mut f, turn); correct_rate(&mut f, header.as_deref().and_then(video_rate)); + trim_to_edits(&mut f, header.as_deref().and_then(video_shown)); return Ok(f); } let bytes = crate::read_regular_file(path).map_err(|e| MediaError::Io(format!("{}: {e}", path.display())))?; @@ -107,6 +108,7 @@ pub fn probe_bytes(path: &str, bytes: Arc<[u8]>) -> Result { let name = Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string()); let turn = video_turn(&bytes); let rate = video_rate(&bytes); + let shown = video_shown(&bytes); // A WAV's cue points and XMP markers become the footage's markers. let markers = crate::wav_markers::read(&bytes); let src = filmcraft_codecs::open_bytes(&name, bytes)?; @@ -114,9 +116,37 @@ pub fn probe_bytes(path: &str, bytes: Arc<[u8]>) -> Result { f.markers = markers; turn_footage(&mut f, turn); correct_rate(&mut f, rate); + trim_to_edits(&mut f, shown); Ok(f) } +/// A movie lasts as long as its video edit list shows (#2592): the reader reports the media +/// duration (`mdhd`), which for H.264 with B-frames runs past the last shown frame by the +/// encoder delay the edit list trims (75 frames at 25 fps read as 3.04 s). Never lengthens. +fn trim_to_edits(f: &mut Footage, shown: Option) { + if f.kind == FootageKind::Video + && let Some(shown) = shown.filter(|s| *s > Tick::ZERO && *s < f.duration) + { + f.duration = shown; + } +} + +/// How long the video track's edit list shows its media (`elst` segments, movie timescale): +/// `None` without edits, or with an empty edit (a delayed start keeps the reader's duration). +fn video_shown(bytes: &[u8]) -> Option { + let file = filmcraft_isobmff::open(bytes).ok()?; + let t = file.tracks.iter().find(|t| t.kind == filmcraft_isobmff::TrackKind::Video && !t.samples.is_empty())?; + edits_shown(&t.edits, file.timescale) +} + +fn edits_shown(edits: &[filmcraft_isobmff::Edit], timescale: u32) -> Option { + if edits.is_empty() || timescale == 0 || edits.iter().any(|e| e.media_time < 0) { + return None; + } + let units = edits.iter().try_fold(0u64, |acc, e| acc.checked_add(e.segment_duration))?; + Some(Tick::from_units(i64::try_from(units).ok()?, i64::from(timescale))) +} + /// A movie's frame rate as its frame durations average to ([`video_rate`]), where the reader's /// own estimate misreads it. fn correct_rate(f: &mut Footage, rate: Option) { @@ -322,6 +352,24 @@ fn averaged_rate(timescale: u32, durations: impl Iterator) -> Option mod tests { use super::*; + /// #2592: an encoder-delayed H.264 track's edit list shows 75 frames at 25 fps (3 s at a + /// 1000 timescale), though its media runs a frame longer; empty edits and no edits leave it. + #[test] + fn the_edit_list_trims_a_movie_to_the_frames_it_shows() { + let edit = |segment_duration, media_time| filmcraft_isobmff::Edit { segment_duration, media_time, media_rate: 0x0001_0000 }; + let shown = edits_shown(&[edit(3000, 1024)], 1000); + assert_eq!(shown, Some(Tick::from_units(3, 1))); + let mut f = Footage { kind: FootageKind::Video, duration: Tick::from_units(76, 25), ..Footage::default() }; + trim_to_edits(&mut f, shown); + assert_eq!(f.duration, Tick::from_units(75, 25)); + // Never longer than the reader's duration, and only for video. + trim_to_edits(&mut f, Some(Tick::from_units(4, 1))); + assert_eq!(f.duration, Tick::from_units(75, 25)); + assert_eq!(edits_shown(&[edit(1000, -1), edit(3000, 0)], 1000), None); + assert_eq!(edits_shown(&[], 1000), None); + assert_eq!(edits_shown(&[edit(3000, 0)], 0), None); + } + /// Durations (in 1/`timescale` s) of `n` frames at `rate` whose times are rounded to the timescale. fn rounded(rate: FrameRate, timescale: i64, n: i64) -> Vec { let at = |k: i64| (2 * k * timescale * rate.den + rate.num) / (2 * rate.num); diff --git a/crates/script/src/prelude.js b/crates/script/src/prelude.js index a33714314..b9108b2a1 100644 --- a/crates/script/src/prelude.js +++ b/crates/script/src/prelude.js @@ -1706,7 +1706,8 @@ Object.defineProperty(FootageItem.prototype, "mainSource", { return this.__mainSource; }, }); -FootageItem.prototype.replace = function (f) { __call("file.replaceFootage", { item: this.__id, path: __str(f) }); }; +// replace() swaps in one file, even a numbered still (replaceWithSequence brings a run), as in After Effects. +FootageItem.prototype.replace = function (f) { __call("file.replaceFootage", { item: this.__id, path: __str(f), sequence: false }); }; FootageItem.prototype.replaceWithSequence = function (f, forceAlphabetical) { __call("file.replaceFootage", { item: this.__id, path: __str(f), sequence: true, alphabetical: !!forceAlphabetical }); }; diff --git a/crates/script/src/tests.rs b/crates/script/src/tests.rs index 875885c11..3f8190a73 100644 --- a/crates/script/src/tests.rs +++ b/crates/script/src/tests.rs @@ -1062,6 +1062,11 @@ fn footage_proxies_sequences_and_conform_rate() { let paths: Vec = s.project.items.values().filter_map(|i| if let ItemKind::Footage(f) = &i.kind { Some(f.path.replace('\\', "/")) } else { None }).collect(); assert!(paths.iter().any(|f| f.contains("take_")), "replaced with the take sequence: {paths:?}"); + // replace() swaps in the one file, even a numbered still, as in After Effects. + ok(&mut s, &format!("app.project.item(1).replace(new File('{}'))", p("shot_0002.png"))); + let kinds: Vec<_> = + s.project.items.values().filter_map(|i| if let ItemKind::Footage(f) = &i.kind { Some((f.kind, f.path.replace('\\', "/"))) } else { None }).collect(); + assert!(kinds.iter().any(|(k, f)| *k == effectcraft_engine::project::FootageKind::Still && f.ends_with("shot_0002.png")), "{kinds:?}"); let _ = std::fs::remove_dir_all(&dir); } diff --git a/crates/ui-egui/src/panels/render_queue.rs b/crates/ui-egui/src/panels/render_queue.rs index 5e39a30c2..c7cce6e01 100644 --- a/crates/ui-egui/src/panels/render_queue.rs +++ b/crates/ui-egui/src/panels/render_queue.rs @@ -451,6 +451,22 @@ fn queue_drop(app: &EffectcraftApp, ui: &egui::Ui, rect: Rect, p: &egui::Painter } } +/// Elapsed and estimated remaining seconds at UI time `now`. The job reports them as frames +/// finish; between reports they count on from the last one, as a clock does, so a slow frame +/// doesn't read as a stalled render (#2593). +fn live_times(ctx: &egui::Context, pr: &effectcraft_engine::render_queue::JobState, now: f64) -> (f64, Option) { + let report = (pr.current, pr.done, pr.item_elapsed.to_bits()); + let since = ctx.data_mut(|d| { + let seen = d.get_temp_mut_or_insert_with(egui::Id::unique("rq-progress-seen"), || (report, now)); + if seen.0 != report { + *seen = (report, now); + } + seen.1 + }); + let dt = if pr.finished { 0.0 } else { (now - since).max(0.0) }; + (pr.item_elapsed + dt, pr.remaining.map(|r| (r - dt).max(0.0))) +} + /// The Current Render strip inside `top`: title, elapsed / remaining time, Render, Stop, Notify /// and the progress bar. fn current_render_strip(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, top: Rect, rq: &RqView, actions: &mut Vec) { @@ -461,11 +477,15 @@ fn current_render_strip(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::P let cur_item = progress.as_ref().and_then(|p| p.current).and_then(|id| queue.iter().find(|i| i.id == id)); let cur_name = cur_item.and_then(|i| app.session.project.item(i.comp)).map(|i| i.name.clone()); if let Some(pr) = &progress { - let rem = pr.remaining.map(hms).unwrap_or_else(|| "–".into()); + let (elapsed, remaining) = live_times(ui.ctx(), pr, ui.input(|i| i.time)); + if rendering { + ui.ctx().request_repaint_after(std::time::Duration::from_millis(500)); + } + let rem = remaining.map(hms).unwrap_or_else(|| "–".into()); p.text( pos2(top.max.x - 12.0, top.min.y + 16.0), Align2::RIGHT_CENTER, - format!("Elapsed: {} Est. Remain: {rem}", hms(pr.item_elapsed)), + format!("Elapsed: {} Est. Remain: {rem}", hms(elapsed)), Tokens::ui(11.5), t.text_dim, ); @@ -903,4 +923,26 @@ mod tests { assert_eq!(super::utc_stamp(u64::MAX), "584554051223-11-09 07:00:15"); assert_eq!(super::hms(3725.4), "1:02:05"); } + + /// #2593: between progress reports the times count on from the last report, and a new + /// report starts the count again. + #[test] + fn render_times_tick_between_reports() { + let ctx = egui::Context::default(); + let mut pr = effectcraft_engine::render_queue::JobState { + current: Some(1), + done: 2, + total: 10, + item_elapsed: 20.0, + remaining: Some(80.0), + ..Default::default() + }; + assert_eq!(super::live_times(&ctx, &pr, 100.0), (20.0, Some(80.0))); + assert_eq!(super::live_times(&ctx, &pr, 103.0), (23.0, Some(77.0))); + assert_eq!(super::live_times(&ctx, &pr, 300.0).1, Some(0.0), "never below zero"); + (pr.done, pr.item_elapsed, pr.remaining) = (3, 31.0, Some(72.0)); + assert_eq!(super::live_times(&ctx, &pr, 301.0), (31.0, Some(72.0))); + pr.finished = true; + assert_eq!(super::live_times(&ctx, &pr, 400.0), (31.0, Some(72.0)), "a finished job stands still"); + } }