From 8bc09ee6241c774bfd00b063b7fd432854eb4ea4 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Mon, 12 Oct 2026 20:15:20 +0200 Subject: [PATCH] fix(export): put the whole timeline on one frame grid Each speed span rounded its own frame count up, so an export ran up to one frame per span longer than its timeline (+7 frames on the 14-span v2.1.0 QA project). Spans now take their frames from a single programme grid carried across trims and clips, and sample the source where that grid puts them. The progress total mirrors it. Part of #1162. --- crates/compositor/src/regions.rs | 177 ++++++++++++++---- crates/compositor/src/timeline_walk.rs | 15 +- src/lib/exporter/outputFrameCount.test.ts | 39 +++- src/lib/exporter/outputFrameCount.ts | 73 +++++--- .../architecture/export-pipeline.md | 9 + .../testing/manual-e2e-checklist.md | 5 +- 6 files changed, 254 insertions(+), 64 deletions(-) diff --git a/crates/compositor/src/regions.rs b/crates/compositor/src/regions.rs index d7881ba07..c4791cdec 100644 --- a/crates/compositor/src/regions.rs +++ b/crates/compositor/src/regions.rs @@ -10,7 +10,10 @@ use crate::camera::Follow; use crate::cursor::CursorTrack; use crate::scene::{SceneCameraFullscreenRegion, SceneSpeedRegion, SceneZoomRegion}; -/// Quantification commune vidéo/audio : le web retranche exactement 1 ms avant `ceil`. +/// Quantification commune vidéo/audio, sur UNE grille pour toute la timeline : la frame de sortie +/// `k` tombe au temps programme `k / fps`, et appartient au span dont l'intervalle programme +/// `[début, fin)`, décalé de cette marge, la contient. Une frame n'est donc due que s'il reste plus +/// d'1 ms de programme ; c'est ce qui absorbe l'erreur flottante d'une frontière de span. pub const SPEED_FRAME_EPSILON_SEC: f64 = 0.001; const MIN_SPEED_SEGMENT_SEC: f64 = 0.0001; @@ -19,16 +22,47 @@ pub struct SpeedSegment { pub start_sec: f64, pub end_sec: f64, pub speed: f64, + /// Temps programme continu (secondes de sortie) au début du span. + pub output_start_sec: f64, + /// Index programme de sa première frame. + pub first_frame: u64, pub frame_count: u64, } +impl SpeedSegment { + /// Temps programme continu à la fin du span : le début du suivant, clip suivant compris. + pub fn output_end_sec(&self) -> f64 { + self.output_start_sec + (self.end_sec - self.start_sec) / self.speed + } + + /// Temps source de la `i`-ème frame du span : là où la grille programme le fait tomber, pas + /// `début + i·vitesse/fps` — un span ne recommence pas sa cadence à zéro. + pub fn source_time(&self, i: u64, fps: f64) -> f64 { + let programme_sec = (self.first_frame + i) as f64 / fps; + (self.start_sec + (programme_sec - self.output_start_sec) * self.speed).max(self.start_sec) + } +} + +/// Frames de la grille dues avant le temps programme `t`. +fn frames_due(t: f64, fps: f64) -> u64 { + ((t - SPEED_FRAME_EPSILON_SEC) * fps).ceil().max(0.0) as u64 +} + /// Découpe toute la fenêtre gardée en spans contigus ; hors région la vitesse vaut 1×. /// Les régions sont ordonnées par début et, si un ancien payload en superpose, la première /// conserve la portion déjà couverte pour ne jamais émettre deux fois le même temps source. +/// +/// `output_start_sec` est le temps programme continu où la fenêtre commence : la fin du clip +/// précédent (`output_end_sec` de son dernier span), 0 pour le premier. Le compte de frames de +/// chaque span vient de la grille programme, pas d'un arrondi par span : arrondir chaque span +/// vers le haut ajoutait jusqu'à une frame par span, et l'erreur grossissait avec le nombre de +/// coupes et de régions (+7 frames sur 14 spans au QA de la v2.1.0). Le total de la timeline +/// est ainsi celui de sa durée programme, à une frame près, quel que soit le nombre de spans. pub fn speed_segments_for_window( regions: &[SceneSpeedRegion], source_start_sec: f64, source_end_sec: f64, + output_start_sec: f64, fps: f64, ) -> Vec { if source_end_sec <= source_start_sec || !fps.is_finite() || fps <= 0.0 { @@ -45,6 +79,7 @@ pub fn speed_segments_for_window( }); let mut spans = Vec::new(); + let mut output_sec = output_start_sec; let mut cursor = source_start_sec; for region in overlapping { let start = region.start_sec.max(source_start_sec).max(cursor); @@ -56,18 +91,18 @@ pub fn speed_segments_for_window( continue; } if start > cursor { - push_speed_segment(&mut spans, cursor, start, 1.0, fps); + push_speed_segment(&mut spans, cursor, start, 1.0, &mut output_sec, fps); } let speed = if region.speed.is_finite() && region.speed > 0.0 { region.speed } else { 1.0 }; - push_speed_segment(&mut spans, start, end, speed, fps); + push_speed_segment(&mut spans, start, end, speed, &mut output_sec, fps); cursor = end; } if cursor < source_end_sec { - push_speed_segment(&mut spans, cursor, source_end_sec, 1.0, fps); + push_speed_segment(&mut spans, cursor, source_end_sec, 1.0, &mut output_sec, fps); } spans } @@ -107,9 +142,9 @@ pub fn speed_at(regions: &[SceneSpeedRegion], active_clip_index: usize, t: f64) /// L'export le connaît sans calcul : c'est `frames / out_fps`, le compteur de /// `walk_composited_timeline`. La preview n'a pas ce compteur — sa lecture libre avance au pts /// du décodeur et le renderer ne pousse un seek qu'en pause — donc elle le RECONSTRUIT depuis -/// la scène : les frames des clips précédents, puis celles des spans de vitesse du clip actif, +/// la scène : la fin programme des clips précédents, puis les spans de vitesse du clip actif, /// avec les mêmes `speed_segments_for_window` que l'export. Pour un temps source que l'export -/// vise (`start + k·speed/fps`), `at` rend exactement `(frames avant + k) / fps`. +/// vise (`SpeedSegment::source_time`), `at` rend exactement `k / fps`, `k` la frame programme. /// /// Fonction pure du temps source : la même image donne le même temps programme, qu'on y /// arrive en lecture ou par un seek. C'est tout ce que l'horloge garantit — pas un intégrateur. @@ -119,7 +154,7 @@ pub fn speed_at(regions: &[SceneSpeedRegion], active_clip_index: usize, t: f64) /// clips suivants d'autant. Même limite que `outputFrameCount.ts`. #[derive(Debug, Clone)] pub struct ProgrammeClock { - /// Frames de sortie de tous les clips qui précèdent. + /// Première frame programme du clip. frames_before: u64, segments: Vec, fps: f64, @@ -133,7 +168,7 @@ impl ProgrammeClock { } // Même appartenance que `Scene::for_clip_window` : l'index décide quand il est là, // sinon le chevauchement de fenêtre (que `speed_segments_for_window` filtre déjà). - let segments_of = |i: usize| { + let segments_of = |i: usize, output_start_sec: f64| { let clip = &scene.clips[i]; let regions: Vec = scene .speed_regions @@ -141,15 +176,24 @@ impl ProgrammeClock { .filter(|r| r.clip_index.map(|c| c == i).unwrap_or(true)) .copied() .collect(); - speed_segments_for_window(®ions, clip.source_start_sec, clip.source_end_sec, fps) + speed_segments_for_window( + ®ions, + clip.source_start_sec, + clip.source_end_sec, + output_start_sec, + fps, + ) }; - let frames_before = (0..clip_index) - .flat_map(&segments_of) - .map(|s| s.frame_count) - .sum(); + // Les clips précédents, enchaînés comme dans la marche d'export. + let mut output_sec = 0.0; + for i in 0..clip_index { + if let Some(last) = segments_of(i, output_sec).last() { + output_sec = last.output_end_sec(); + } + } Some(Self { - frames_before, - segments: segments_of(clip_index), + frames_before: frames_due(output_sec, fps), + segments: segments_of(clip_index, output_sec), fps, }) } @@ -157,16 +201,17 @@ impl ProgrammeClock { /// Temps programme au temps source `source_sec` du clip. Borné à la fenêtre du clip : /// avant, le premier frame ; après (ou sous une coupe en fin), le premier du clip suivant. pub fn at(&self, source_sec: f64) -> f64 { - let mut frames = self.frames_before as f64; + let mut frames = self.frames_before; for s in &self.segments { + let next = s.first_frame + s.frame_count; if source_sec < s.end_sec { - let k = ((source_sec - s.start_sec).max(0.0) / s.speed * self.fps) - .min(s.frame_count as f64); - return (frames + k) / self.fps; + let k = (s.output_start_sec + (source_sec - s.start_sec).max(0.0) / s.speed) + * self.fps; + return k.clamp(s.first_frame as f64, next as f64) / self.fps; } - frames += s.frame_count as f64; + frames = next; } - frames / self.fps + frames as f64 / self.fps } } @@ -253,20 +298,23 @@ fn push_speed_segment( start_sec: f64, end_sec: f64, speed: f64, + output_sec: &mut f64, fps: f64, ) { let duration = end_sec - start_sec; if duration <= MIN_SPEED_SEGMENT_SEC { return; } - let frames = (((duration - SPEED_FRAME_EPSILON_SEC) / speed) * fps) - .ceil() - .max(0.0) as u64; + let output_start_sec = *output_sec; + *output_sec += duration / speed; + let first_frame = frames_due(output_start_sec, fps); spans.push(SpeedSegment { start_sec, end_sec, speed, - frame_count: frames, + output_start_sec, + first_frame, + frame_count: frames_due(*output_sec, fps).saturating_sub(first_frame), }); } @@ -2087,9 +2135,9 @@ mod zoom_focus_tests { let clock = ScreenClock::new(speeds, 0); let (mut frames, mut prev, mut worst_step) = (0usize, 1.0f32, 0.0f32); // Les instants que l'export compose : `début + k·vitesse/fps` dans chaque span. - for seg in speed_segments_for_window(speeds, 0.0, 20.0, 60.0) { + for seg in speed_segments_for_window(speeds, 0.0, 20.0, 0.0, 60.0) { for k in 0..seg.frame_count { - let t = (seg.start_sec + k as f64 * seg.speed / 60.0) as f32; + let t = seg.source_time(k, 60.0) as f32; let scale = zoom_state_in(&[r.clone()], t, None, &CameraFrame::NONE, &clock).scale; if scale > 1.0 && scale < 2.0 { @@ -3179,10 +3227,21 @@ mod exporter_frame_totals { } fn frames(start_sec: f64, end_sec: f64, regions: &[SceneSpeedRegion], fps: f64) -> u64 { - speed_segments_for_window(regions, start_sec, end_sec, fps) - .iter() - .map(|segment| segment.frame_count) - .sum() + timeline_frames(&[(start_sec, end_sec, regions)], fps) + } + + /// Des fenêtres enchaînées comme les clips de `walk_composited_timeline`. + fn timeline_frames(windows: &[(f64, f64, &[SceneSpeedRegion])], fps: f64) -> u64 { + let mut output_sec = 0.0; + let mut total = 0; + for &(start_sec, end_sec, regions) in windows { + let segments = speed_segments_for_window(regions, start_sec, end_sec, output_sec, fps); + total += segments.iter().map(|s| s.frame_count).sum::(); + if let Some(last) = segments.last() { + output_sec = last.output_end_sec(); + } + } + total } /// Le total que la barre d'export doit viser, mesuré sur ce que `walk_composited_timeline` @@ -3225,9 +3284,18 @@ mod exporter_frame_totals { &[region(44.818, 44.818, 1.5)], issue_876_fps, ); - let second_clip = frames(0.0, 47.833333, &[region(0.0, 1.87, 1.5)], issue_876_fps); assert_eq!(first_clip, 2690, "une région vide ne double pas le premier clip"); - assert_eq!(first_clip + second_clip, 5523, "total corrigé du repro #876"); + assert_eq!( + timeline_frames( + &[ + (0.0, 44.81830642526596, &[region(44.818, 44.818, 1.5)]), + (0.0, 47.833333, &[region(0.0, 1.87, 1.5)]), + ], + issue_876_fps + ), + 5522, + "total corrigé du repro #876 : 5521,7 frames programme" + ); assert_eq!( frames( 0.0, @@ -3246,6 +3314,42 @@ mod exporter_frame_totals { assert_eq!(frames(4.0, 4.0, &[], FPS), 0, "fenêtre vide"); assert_eq!(frames(0.0, 10.0, &[], 0.0), 0, "fps non positif"); } + + /// Le projet du QA de la v2.1.0 : deux clips du même fichier, six coupes, une région 1,5× et + /// une 4× par clip. 2444,86 frames programme ; arrondir chacun des 14 spans vers le haut en + /// rendait 2452. + #[test] + fn a_cut_up_timeline_lands_on_its_programme_duration() { + let end = 48.789333; + let speeds = [region(33.711, 36.202, 1.5), region(36.202, 38.693, 4.0)]; + let windows: Vec<(f64, f64, &[SceneSpeedRegion])> = [ + (0.0, 10.614275104787234), + (13.105645300531915, 14.88), + (16.03, 21.54), + (21.85, 24.83), + (26.53, 43.806592608510634), + (46.34986635, end), + (0.0, 10.614275104787234), + (13.105645300531915, end), + ] + .into_iter() + .map(|(start, end)| (start, end, &speeds[..])) + .collect(); + assert_eq!(timeline_frames(&windows, 30.0), 2445); + } + + /// L'erreur ne grossit plus avec le nombre de spans : 200 fenêtres de 0,35 s, chacune à + /// moitié en 1,5×, font 1 750 frames programme. Arrondir par span en rendait 2 000. + #[test] + fn the_error_stays_under_a_frame_whatever_the_span_count() { + let regions: Vec = (0..200) + .map(|i| region(i as f64 + 0.1, i as f64 + 0.275, 1.5)) + .collect(); + let windows: Vec<(f64, f64, &[SceneSpeedRegion])> = (0..200) + .map(|i| (i as f64, i as f64 + 0.35, ®ions[..])) + .collect(); + assert_eq!(timeline_frames(&windows, 30.0), 1750); + } } #[cfg(test)] @@ -3284,6 +3388,7 @@ mod programme_clock { fn the_clock_matches_the_export_walk_at_every_frame() { let scene = scene(); let mut frames: u64 = 0; + let mut output_sec = 0.0; for (i, clip) in scene.clips.iter().enumerate() { let window = scene.for_clip_window(i, clip.source_start_sec, clip.source_end_sec); let clock = ProgrammeClock::for_clip(&scene, i, FPS).expect("horloge"); @@ -3291,11 +3396,15 @@ mod programme_clock { &window.speed_regions, clip.source_start_sec, clip.source_end_sec, + output_sec, FPS, ); + if let Some(last) = segments.last() { + output_sec = last.output_end_sec(); + } for s in &segments { for k in 0..s.frame_count { - let target = s.start_sec + k as f64 * s.speed / FPS; + let target = s.source_time(k, FPS); let export = (frames as f64 / FPS) as f32; let preview = clock.at(target) as f32; assert!( diff --git a/crates/compositor/src/timeline_walk.rs b/crates/compositor/src/timeline_walk.rs index a16271c8b..0953656ce 100644 --- a/crates/compositor/src/timeline_walk.rs +++ b/crates/compositor/src/timeline_walk.rs @@ -168,6 +168,9 @@ pub(crate) unsafe fn walk_composited_timeline( let mut cursor_active_path: Option = None; let mut frames: u64 = 0; + // Temps programme continu où commence le prochain clip : toute la timeline partage une + // seule grille de frames (cf. `speed_segments_for_window`). + let mut programme_sec = 0.0f64; // L'export doit être reproductible : deux rendus du même projet, les mêmes pixels. Cette // boucle avance aussi vite que la machine décode, sans rapport avec le temps réel, alors que @@ -266,6 +269,7 @@ pub(crate) unsafe fn walk_composited_timeline( .unwrap_or(&[]), clip.source_start_sec, source_end_sec, + programme_sec, out_fps as f64, ); if clip_scene.is_some() { @@ -308,8 +312,7 @@ pub(crate) unsafe fn walk_composited_timeline( let frames_before_clip = frames; 'clip_frames: for segment in &speed_segments { for segment_frame in 0..segment.frame_count { - let target_source_time = - segment.start_sec + segment_frame as f64 * segment.speed / out_fps as f64; + let target_source_time = segment.source_time(segment_frame, out_fps as f64); { let _p = crate::export_probe::scope(crate::export_probe::Stage::DecodeScreen); if !advance_decoder_to(sdec, target_source_time, 0.0)? { @@ -345,6 +348,14 @@ pub(crate) unsafe fn walk_composited_timeline( } } + let planned: u64 = speed_segments.iter().map(|s| s.frame_count).sum(); + // Un clip coupé court (décodeur vide) laisse la grille en avance sur le compteur : le + // clip suivant repart du compteur réel. + programme_sec = match speed_segments.last() { + Some(last) if frames - frames_before_clip == planned => last.output_end_sec(), + Some(_) => frames as f64 / out_fps as f64, + None => programme_sec, + }; on_clip_end( clip_index, source_end_sec, diff --git a/src/lib/exporter/outputFrameCount.test.ts b/src/lib/exporter/outputFrameCount.test.ts index 038eb4b42..ff58c5abe 100644 --- a/src/lib/exporter/outputFrameCount.test.ts +++ b/src/lib/exporter/outputFrameCount.test.ts @@ -72,7 +72,8 @@ describe("outputFrameCount", () => { // The first region is empty after trim projection. Before the fix it caused the first // clip's normal-speed prefix to be counted twice, yielding the reported 8,213 frames. - expect(outputFrameCount(clips, regions, 60)).toBe(5_523); + // 5,521.7 programme frames. + expect(outputFrameCount(clips, regions, 60)).toBe(5_522); }); it("never renders the same source time twice when two regions overlap", () => { @@ -117,6 +118,42 @@ describe("outputFrameCount", () => { ).toBe(300); }); + it("lands a cut-up timeline on its programme duration (v2.1.0 QA project)", () => { + // Two clips of one file, six trims, a 1.5x and a 4x region per clip: 2,444.86 + // programme frames. Rounding each of the 14 spans up used to make it 2,452. + const end = 48.789333; + const clips = [ + [0, 10.614275104787234], + [13.105645300531915, 14.88], + [16.03, 21.54], + [21.85, 24.83], + [26.53, 43.806592608510634], + [46.34986635, end], + [0, 10.614275104787234], + [13.105645300531915, end], + ].map(([sourceStartSec, sourceEndSec]) => ({ sourceStartSec, sourceEndSec })); + const regions = [ + { startSec: 33.711, endSec: 36.202, speed: 1.5 }, + { startSec: 36.202, endSec: 38.693, speed: 4 }, + ]; + expect(outputFrameCount(clips, regions, FPS)).toBe(2_445); + }); + + it("keeps the error under a frame whatever the span count", () => { + // 200 windows of 0.35 s, each half at 1.5x: 1,750 programme frames. Per-span + // rounding made it 2,000. + const clips = Array.from({ length: 200 }, (_, i) => ({ + sourceStartSec: i, + sourceEndSec: i + 0.35, + })); + const regions = clips.map(({ sourceStartSec }) => ({ + startSec: sourceStartSec + 0.1, + endSec: sourceStartSec + 0.275, + speed: 1.5, + })); + expect(outputFrameCount(clips, regions, FPS)).toBe(1_750); + }); + it("never returns a total the callers would divide by zero", () => { expect(outputFrameCount([], [], FPS)).toBe(1); expect(outputFrameCount([{ sourceStartSec: 4, sourceEndSec: 4 }], [], FPS)).toBe(1); diff --git a/src/lib/exporter/outputFrameCount.ts b/src/lib/exporter/outputFrameCount.ts index 54216f012..f0e65a051 100644 --- a/src/lib/exporter/outputFrameCount.ts +++ b/src/lib/exporter/outputFrameCount.ts @@ -18,13 +18,18 @@ // the Rust `speed_segments_match_the_exporter_frame_totals` test share one fixture table so // a change on either side shows up as a failure rather than as a drifting progress bar. // +// The exporter puts the whole timeline on one frame grid: frame `k` sits at programme time +// `k / fps`, and each span emits the grid frames that fall inside it. Span counts telescope, +// so the total is the grid frames due before the timeline's programme end — which is all this +// file needs to compute. +// // One difference is unavoidable: the walk clamps each clip's end to the source's real // duration, which only a decoder can know. A source shorter than its declared window makes // this count slightly high — the same limitation the old formula had, and the same one that // already makes `build_audio_concat_plan` work off produced frames rather than declared ones. -/** `SPEED_FRAME_EPSILON_SEC` in `crates/compositor/src/regions.rs`. Absorbs the float error - * of a span boundary so a segment does not gain a frame it never renders. */ +/** `SPEED_FRAME_EPSILON_SEC` in `crates/compositor/src/regions.rs`: a frame is due only while + * more than this much programme time is left, which absorbs the float error of a boundary. */ const SPEED_FRAME_EPSILON_SEC = 0.001; /** `MIN_SPEED_SEGMENT_SEC` in the same file: a span thinner than this emits nothing. */ @@ -45,32 +50,34 @@ export interface OutputFrameSpeedRegion { clipIndex?: number; } -/** Frames one contiguous span renders. Port of `push_speed_segment`. */ -function segmentFrames(startSec: number, endSec: number, speed: number, fps: number): number { +/** Grid frames due before programme time `sec`. Port of `frames_due`. */ +function framesDue(sec: number, fps: number): number { + return Math.max(0, Math.ceil((sec - SPEED_FRAME_EPSILON_SEC) * fps)); +} + +/** Programme time one span adds. The skip mirrors `push_speed_segment`. */ +function spanSec(startSec: number, endSec: number, speed: number): number { const duration = endSec - startSec; - if (duration <= MIN_SPEED_SEGMENT_SEC) { - return 0; - } - return Math.max(0, Math.ceil(((duration - SPEED_FRAME_EPSILON_SEC) / speed) * fps)); + return duration <= MIN_SPEED_SEGMENT_SEC ? 0 : duration / speed; } -/** Frames one clip renders across its speed spans. Port of `speed_segments_for_window`, - * including its overlap rule: regions are walked in start order and a later one never - * reclaims source time an earlier one already covered. */ -export function clipOutputFrameCount( +/** Programme time at the end of a clip that starts at `programmeSec`. Port of + * `speed_segments_for_window`, including its overlap rule: regions are walked in start order + * and a later one never reclaims source time an earlier one already covered. */ +function clipProgrammeEndSec( clip: OutputFrameClip, regions: OutputFrameSpeedRegion[], - fps: number, + programmeSec: number, ): number { const { sourceStartSec, sourceEndSec } = clip; - if (!(sourceEndSec > sourceStartSec) || !Number.isFinite(fps) || fps <= 0) { - return 0; + if (!(sourceEndSec > sourceStartSec)) { + return programmeSec; } const overlapping = regions .filter((region) => region.startSec < sourceEndSec && region.endSec > sourceStartSec) .sort((a, b) => a.startSec - b.startSec); - let frames = 0; + let sec = programmeSec; let cursor = sourceStartSec; for (const region of overlapping) { const start = Math.max(region.startSec, sourceStartSec, cursor); @@ -82,16 +89,28 @@ export function clipOutputFrameCount( continue; } if (start > cursor) { - frames += segmentFrames(cursor, start, 1, fps); + sec += spanSec(cursor, start, 1); } const speed = Number.isFinite(region.speed) && region.speed > 0 ? region.speed : 1; - frames += segmentFrames(start, end, speed, fps); + sec += spanSec(start, end, speed); cursor = end; } if (cursor < sourceEndSec) { - frames += segmentFrames(cursor, sourceEndSec, 1, fps); + sec += spanSec(cursor, sourceEndSec, 1); + } + return sec; +} + +/** Frames one clip renders when it opens the timeline. */ +export function clipOutputFrameCount( + clip: OutputFrameClip, + regions: OutputFrameSpeedRegion[], + fps: number, +): number { + if (!Number.isFinite(fps) || fps <= 0) { + return 0; } - return frames; + return framesDue(clipProgrammeEndSec(clip, regions, 0), fps); } /** Frames the whole export will emit, for turning the native running count into a @@ -102,18 +121,20 @@ export function outputFrameCount( speedRegions: OutputFrameSpeedRegion[] | undefined, fps: number, ): number { + if (!Number.isFinite(fps) || fps <= 0) { + return 1; + } const regions = speedRegions ?? []; - const total = clips.reduce( - (sum, clip, clipIndex) => - sum + - clipOutputFrameCount( + const programmeSec = clips.reduce( + (sec, clip, clipIndex) => + clipProgrammeEndSec( clip, regions.filter( (region) => region.clipIndex === undefined || region.clipIndex === clipIndex, ), - fps, + sec, ), 0, ); - return Math.max(1, total); + return Math.max(1, framesDue(programmeSec, fps)); } diff --git a/technical-documentation/architecture/export-pipeline.md b/technical-documentation/architecture/export-pipeline.md index ad8bc0bb2..e43778772 100644 --- a/technical-documentation/architecture/export-pipeline.md +++ b/technical-documentation/architecture/export-pipeline.md @@ -110,6 +110,15 @@ and **one** encoder + muxer pair: table, asserted by `outputFrameCount.test.ts` and by `speed_segments_match_the_exporter_frame_totals`. +- **One frame grid for the whole timeline.** Frame `k` sits at programme + time `k / fps`, and each span (a stretch of one clip at one speed) + emits the grid frames that fall inside it, carrying the programme time + across trims and clip junctions. An export of *P* programme seconds is + therefore `⌈(P − 1 ms) × fps⌉` frames, however many spans it has. Each + span used to round its own count up instead, which added up to a frame + per span: +7 frames on a 14-span timeline. Audio is stretched to each + span's frame count, so it follows the same grid. + - **Cancel stops between frames, and only a finished file is published.** The dialog's Cancel reaches the same `ExportControl` the GIF export uses ([`export_control.rs`](../../crates/compositor/src/export_control.rs)), diff --git a/technical-documentation/testing/manual-e2e-checklist.md b/technical-documentation/testing/manual-e2e-checklist.md index 14c63ebd6..6442e9589 100644 --- a/technical-documentation/testing/manual-e2e-checklist.md +++ b/technical-documentation/testing/manual-e2e-checklist.md @@ -636,7 +636,10 @@ The percentage is computed in the renderer against a predicted frame total, and - [ ] Probe the finished file and confirm the **audio and video durations agree to within one frame**; a stretch that fell short would show as an audio track measurably shorter than the video. Record the export's frame rate alongside the two durations — "within one frame" is a claim about `1/fps`, and without the rate written down it cannot be checked (at 30 fps the tolerance is 33 ms; at 60 fps it is half that, and the same numbers would fail). - [ ] **Record something that is deliberately noisy across each speed boundary** — count out loud, or play a continuous tone — before running `silencedetect` over the exported audio. A screen recording with only ambient mic is mostly silence, so the detector reports silence at the boundary whether or not `atempo` left a hole, and the check passes or fails for reasons that have nothing to do with the stretch. - [ ] With that audio, confirm no silence block coincides with a speed region's boundary. If one does, run the same `silencedetect` over the **source** and map the boundary back through the trims before recording a failure: silence that was already there is not a defect the stretch introduced. -- [ ] Confirm the exported duration matches the source minus the trimmed spans, adjusted for each speed region. Compute it in **frames**, not seconds, and state the arithmetic: source frames − trimmed frames − (speed span × (1 − 1/N)) at the export's rate. A discrepancy of a handful of frames is not "close enough" — it is either explained or it is an open question, and the results log has to say which. +- [ ] Confirm the exported duration matches the source minus the trimmed spans, adjusted for each speed region. Compute it in **frames**, not seconds, and state the arithmetic: source frames − trimmed frames − (speed span × (1 − 1/N)) at the export's rate. A discrepancy of a handful of frames is not "close enough" — it is either explained or it is an open question, and the results log has to say which. The count is exact, not approximate: with *P* the programme seconds (each kept span divided by its speed, summed over every clip), the export emits **⌈(P − 0.001) × fps⌉** frames, whatever the number of trims, regions and clips. Three things make a correct export look off: + - **The clip end is the screen stream's real length**, not the asset's `durationSec`, which can be the audio track's. Read it with `ffprobe -show_entries stream=codec_type,duration` on the source. A recording whose video ends 25 ms before its audio exports a frame fewer than the declared length predicts, and the progress total (computed from the declared length) is one frame high. + - **Count packets, not decoded frames**: `ffprobe -count_packets -select_streams v:0 -show_entries stream=nb_read_packets`. `-count_frames` read one frame fewer than the file holds on a 60 fps export. + - **The video stream's `duration` is one frame short** by the muxer's convention (it is the last frame's timestamp); the audio track lasts exactly frames ÷ fps. A 2451-frame 30 fps export reads 81.667 s video against 81.700 s audio, and that is in sync. - [ ] Cancel an export mid-render and confirm no audio decode threads outlive the attempt. ## Settings, shortcuts, themes, i18n