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