diff --git a/crates/editor/src/editor_instance.rs b/crates/editor/src/editor_instance.rs index 3f1c61bacbf..d8b10c6f5e1 100644 --- a/crates/editor/src/editor_instance.rs +++ b/crates/editor/src/editor_instance.rs @@ -1118,12 +1118,23 @@ impl EditorInstance { _ = preview_rx.changed() => { continue; } - frames = outgoing_media.decoders.get_frames_initial( - outgoing.source_time as f32, - project.requires_camera(), - true, - outgoing_offsets, - ) => frames, + frames = async { + let decoders = if outgoing.segment.recording_clip + == segment.recording_clip + { + outgoing_media.decoders.transition_side().await + } else { + &outgoing_media.decoders + }; + decoders + .get_frames_initial( + outgoing.source_time as f32, + project.requires_camera(), + true, + outgoing_offsets, + ) + .await + } => frames, }; if let Some(outgoing_frames) = outgoing_frames { let outgoing_zoom = outgoing_zoom_timelines diff --git a/crates/editor/src/playback.rs b/crates/editor/src/playback.rs index 2c5fc701319..8dfe8fa5fca 100644 --- a/crates/editor/src/playback.rs +++ b/crates/editor/src/playback.rs @@ -478,9 +478,13 @@ async fn decode_prefetched_frame(request: PrefetchDecodeRequest) -> PrefetchDeco }; let transition = async { let transition = transition?; + let transition_decoders = if transition.segment_index == segment_index { + transition.decoders.transition_side().await + } else { + &transition.decoders + }; let segment_frames = if is_initial { - transition - .decoders + transition_decoders .get_frames_initial( transition.segment_time as f32, !hide_camera, @@ -489,8 +493,7 @@ async fn decode_prefetched_frame(request: PrefetchDecodeRequest) -> PrefetchDeco ) .await } else { - transition - .decoders + transition_decoders .get_frames( transition.segment_time as f32, !hide_camera, diff --git a/crates/export/src/preview.rs b/crates/export/src/preview.rs index 45befeaa688..d406dce7deb 100644 --- a/crates/export/src/preview.rs +++ b/crates/export/src/preview.rs @@ -303,8 +303,12 @@ async fn render_preview_frame( .find(|clip| clip.index == outgoing.segment.recording_clip) .map(|clip| clip.offsets) .unwrap_or_default(); - let outgoing_frames = outgoing_media - .decoders + let outgoing_decoders = if outgoing.segment.recording_clip == segment.recording_clip { + outgoing_media.decoders.transition_side().await + } else { + outgoing_media.decoders + }; + let outgoing_frames = outgoing_decoders .get_frames( outgoing.source_time as f32, project_config.requires_camera() && !settings.cursor_only, diff --git a/crates/rendering/src/decoder/ffmpeg.rs b/crates/rendering/src/decoder/ffmpeg.rs index ec14a464110..5a13503e976 100644 --- a/crates/rendering/src/decoder/ffmpeg.rs +++ b/crates/rendering/src/decoder/ffmpeg.rs @@ -593,6 +593,9 @@ impl FfmpegDecoder { // Previous vended frame number since the last reset, for pts-hole // discovery (the reader vends in pts order between resets). let mut prev_vended: Option = None; + // Newest frame the reader has vended since its last reset: where the + // reader is. The last sent frame is not, as it can be a cache hit. + let mut reader_at: Option = None; let last_sent_frame = Rc::new(RefCell::new(None::)); let first_ever_frame = Rc::new(RefCell::new(None::)); @@ -636,6 +639,9 @@ impl FfmpegDecoder { let mut sw_gap_hold: Option = None; // Previous vended frame number since the last reset. let mut sw_prev_vended: Option = None; + // Newest frame the reader has vended since its last reset: where the + // reader is. The last sent frame is not, as it can be a cache hit. + let mut sw_reader_at: Option = None; let sw_last_sent_frame = Rc::new(RefCell::new(None::)); let sw_first_ever_frame = Rc::new(RefCell::new(None::)); let mut sw_frames = sw_this.frames(); @@ -669,6 +675,7 @@ impl FfmpegDecoder { // The pre-decoded frame is a real vend: without seeding // this, an opening static hold would never be recorded. sw_prev_vended = Some(current_frame); + sw_reader_at = Some(current_frame); } let sw_decoder_type = DecoderType::FFmpegSoftware; @@ -748,6 +755,16 @@ impl FfmpegDecoder { continue; } + // Nothing precedes the stream's first frame, so it holds + // for any earlier request; a later one needs a seek. + if let Some(first_frame) = sw_first_ever_frame.borrow().clone() + && requested_frame <= first_frame.number + { + *sw_last_sent_frame.borrow_mut() = Some(first_frame.clone()); + let _ = reply.send(first_frame.frame); + continue; + } + if is_backward_seek { let best_cached_frame = sw_cache .range(..=requested_frame) @@ -766,14 +783,6 @@ impl FfmpegDecoder { continue; } - if requested_frame <= MAX_FRAME_LOOKBACK_TOLERANCE - && let Some(first_frame) = sw_first_ever_frame.borrow().clone() - { - *sw_last_sent_frame.borrow_mut() = Some(first_frame.clone()); - let _ = reply.send(first_frame.frame); - continue; - } - if let Err(error) = sw_this.reset(requested_time) && let Some(control) = &managed { @@ -787,6 +796,7 @@ impl FfmpegDecoder { *sw_last_sent_frame.borrow_mut() = None; sw_cache.clear(); sw_prev_vended = None; + sw_reader_at = None; } // Requests inside a KNOWN pts hole are answered with the @@ -836,6 +846,11 @@ impl FfmpegDecoder { let cache_min = requested_frame.saturating_sub(FRAME_CACHE_SIZE as u32 / 2); let cache_max = requested_frame + FRAME_CACHE_SIZE as u32 / 2; + // The reader has already passed this uncached frame: it fell + // outside the cache window, or the last sent frame was a cache + // hit away from the reader. Reading on cannot reach it. + let behind_reader = sw_reader_at.is_some_and(|at| requested_frame <= at); + let is_far_forward = sw_last_sent_frame.borrow().as_ref().map_or_else( || { if sw_first_ever_frame.borrow().is_some() { @@ -855,7 +870,7 @@ impl FfmpegDecoder { }, ); - if is_far_forward { + if is_far_forward || behind_reader { if let Err(error) = sw_this.reset(requested_time) && let Some(control) = &managed { @@ -869,6 +884,7 @@ impl FfmpegDecoder { *sw_last_sent_frame.borrow_mut() = None; sw_cache.clear(); sw_prev_vended = None; + sw_reader_at = None; } let mut exit = false; @@ -895,6 +911,13 @@ impl FfmpegDecoder { // the vend continuity that hole discovery // relies on. sw_prev_vended = None; + // The reader has still moved past it. + if let Ok(frame) = &frame + && let Some(pts) = frame.pts() + { + sw_reader_at = + Some(pts_to_frame(pts - sw_start_time, sw_time_base, fps)); + } break; } @@ -917,6 +940,7 @@ impl FfmpegDecoder { record_pts_hole(&mut sw_pts_holes, prev, current_frame); } sw_prev_vended = Some(current_frame); + sw_reader_at = Some(current_frame); let mut cache_frame = CachedFrame::Raw { frame, @@ -1146,6 +1170,7 @@ impl FfmpegDecoder { // The pre-decoded frame is a real vend: without seeding this, // an opening static hold would never be recorded. prev_vended = Some(current_frame); + reader_at = Some(current_frame); info!( "FFmpeg decoder '{}': pre-decoded first frame {} ({}x{})", name, current_frame, video_width, video_height @@ -1210,6 +1235,7 @@ impl FfmpegDecoder { record_pts_hole(&mut pts_holes, prev, current_frame); } prev_vended = Some(current_frame); + reader_at = Some(current_frame); let mut cache_frame = CachedFrame::Raw { frame, number: current_frame, @@ -1312,6 +1338,16 @@ impl FfmpegDecoder { continue; } + // Nothing precedes the stream's first frame, so it holds + // for any earlier request; a later one needs a seek. + if let Some(first_frame) = first_ever_frame.borrow().clone() + && requested_frame <= first_frame.number + { + *last_sent_frame.borrow_mut() = Some(first_frame.clone()); + let _ = reply.send(first_frame.frame); + continue; + } + if is_backward_seek { let best_cached_frame = cache .range(..=requested_frame) @@ -1330,14 +1366,6 @@ impl FfmpegDecoder { continue; } - if requested_frame <= MAX_FRAME_LOOKBACK_TOLERANCE - && let Some(first_frame) = first_ever_frame.borrow().clone() - { - *last_sent_frame.borrow_mut() = Some(first_frame.clone()); - let _ = reply.send(first_frame.frame); - continue; - } - if let Err(error) = this.reset(requested_time) && let Some(control) = &managed { @@ -1352,6 +1380,7 @@ impl FfmpegDecoder { *last_sent_frame.borrow_mut() = None; cache.clear(); prev_vended = None; + reader_at = None; } // Requests inside a KNOWN pts hole are answered with the @@ -1406,6 +1435,11 @@ impl FfmpegDecoder { let cache_min = requested_frame.saturating_sub(FRAME_CACHE_SIZE as u32 / 2); let cache_max = requested_frame + FRAME_CACHE_SIZE as u32 / 2; + // The reader has already passed this uncached frame: it fell + // outside the cache window, or the last sent frame was a cache + // hit away from the reader. Reading on cannot reach it. + let behind_reader = reader_at.is_some_and(|at| requested_frame <= at); + let is_far_forward = last_sent_frame.borrow().as_ref().map_or_else( || { if first_ever_frame.borrow().is_some() { @@ -1423,7 +1457,7 @@ impl FfmpegDecoder { |last| requested_frame.saturating_sub(last.number) > FRAME_CACHE_SIZE as u32, ); - if is_far_forward { + if is_far_forward || behind_reader { if let Err(error) = this.reset(requested_time) && let Some(control) = &managed { @@ -1438,6 +1472,7 @@ impl FfmpegDecoder { *last_sent_frame.borrow_mut() = None; cache.clear(); prev_vended = None; + reader_at = None; } let mut exit = false; @@ -1463,6 +1498,12 @@ impl FfmpegDecoder { // The frame just pulled is discarded: it breaks the // vend continuity that hole discovery relies on. prev_vended = None; + // The reader has still moved past it. + if let Ok(frame) = &frame + && let Some(pts) = frame.pts() + { + reader_at = Some(pts_to_frame(pts - start_time, time_base, fps)); + } break; } @@ -1485,6 +1526,7 @@ impl FfmpegDecoder { record_pts_hole(&mut pts_holes, prev, current_frame); } prev_vended = Some(current_frame); + reader_at = Some(current_frame); let mut cache_frame = CachedFrame::Raw { frame, diff --git a/crates/rendering/src/decoder/mod.rs b/crates/rendering/src/decoder/mod.rs index 8d383a3b627..3ea42e0a298 100644 --- a/crates/rendering/src/decoder/mod.rs +++ b/crates/rendering/src/decoder/mod.rs @@ -2806,6 +2806,7 @@ mod managed_worker_tests { screen: display.with_max_fallback_distance(crate::SCREEN_MAX_FALLBACK_DISTANCE), camera: Some(camera.with_max_fallback_distance(crate::CAMERA_MAX_FALLBACK_DISTANCE)), segment_offset: 10.25, + transition_readers: None, }; let input = ManagedSegmentVideoInput::new( 0, diff --git a/crates/rendering/src/decoder/model/expectations.toml b/crates/rendering/src/decoder/model/expectations.toml index f2fb6d9a13c..a4e0bc344fa 100644 --- a/crates/rendering/src/decoder/model/expectations.toml +++ b/crates/rendering/src/decoder/model/expectations.toml @@ -53,50 +53,29 @@ link = "" [[xfail]] case = "decoder-model/ffmpeg/camera-fast/random-seeks/playback" class = "grid-rounding" -seeds = "2 11-13 20 23 26 33 47" +seeds = "2 11-13 20 23 26 33" reason = "NEW: the FFmpeg decoder seeks to the exact request time, so after a reset it answers with the frame on screen at that time even when its rounded grid label is after the request; sequential playback shows the previous frame for the same request" link = "" -[[xfail]] -case = "decoder-model/ffmpeg/camera-fast/random-seeks/playback" -class = "overread" -seeds = "47" -reason = "Camera runaway: when the pts-hole map is full it evicts the narrowest hole, so with two requests per source frame the reader reads one frame further ahead per hole until answers come from the future" -link = "https://github.com/CapSoftware/Cap/pull/2595" - -[[xfail]] -case = "decoder-model/ffmpeg/camera-fast/random-seeks/playback" -class = "stale" -seeds = "47" -reason = "NEW: the FFmpeg decoder answers backward requests with a cached frame up to 2 grid frames older than the requested one, answers grid frames 0 to 2 with the first frame, and after a backward request served from the cache it reads forward from a reader that is still far ahead, answering with an older cached frame" -link = "" - [[xfail]] case = "decoder-model/ffmpeg/cfr/keyframe-seeks/playback" class = "future" -seeds = "0 1 4 6 17 18 20 23 38 41 42 46 48 50 53 56 62 63 65 69 83 90 93 101 104 108 110 115 118 119 122 124 130 133 136 140 142 145 147 149-151 154 161 167 169 181 183 186 188 192 199-201 203 205 207 209 212 216 219 223 235 237 239 245 248 250 253 256 260 261 263 268 270 271 275 285 288 290 291 299" +seeds = "0-1 4 6 17-18 20 23 38 41-42 46 48 50 53 56 62-63 65 69 83 90 93 101 104 108 110 115 118-119 122 124 126 130 133 136 140 142 145 147 149-151 154 161 167 169 181 183 186 188 192 199-201 203 205 207 209 212 215-216 219 223 235 237 239 245 248 250 253 256 260-261 263 268 270-271 275 285 288 290-291 299" reason = "NEW: the FFmpeg decoder seeks with the request time rounded to the stream tick, so a request a fraction of a tick before a keyframe lands on that keyframe and is answered with a later frame" link = "" [[xfail]] case = "decoder-model/ffmpeg/cfr/keyframe-seeks/playback" class = "grid-rounding" -seeds = "0 2 4-6 8 9 12 17 19 20 23 27-31 33 38 39 41 42 46 48 50 52-54 56 60-63 65 68 69 71 72 74 75 79 82-84 86 90 92 93 96 98 99 101-104 106-108 110 111 115 119 121 122 124 125 129-131 133 135-138 140-143 145 147-152 154 156 161 164 165 167-173 177-179 181-183 186 188-192 194 195 197-201 203 205-207 209-212 216 219 222 223 225-227 234-239 241 243 245 248 250 253-256 258 263 265 266 268 270 273-275 278 283-285 287 290 291 294 295 297 299" +seeds = "0 4 6 8-9 12 15 17 20 23 28 30-31 39 41-42 46 48 50 52-54 56 61-63 65 75 79 83-84 86 90 93 96 102 107-108 110-111 115 117 119 121-122 124-125 129 136 138 140-143 145 147-151 154 156 161 167 169 172 177 181 183 186 188-192 194-195 197-201 203 205-207 209 211-212 216 219 222-223 226 234-235 237 239 241 245 248 250 253-256 263 265-266 268 270 273-275 283-285 290-291 294 299" reason = "NEW: the FFmpeg decoder seeks to the exact request time, so after a reset it answers with the frame on screen at that time even when its rounded grid label is after the request; sequential playback shows the previous frame for the same request" link = "" -[[xfail]] -case = "decoder-model/ffmpeg/cfr/keyframe-seeks/playback" -class = "overread" -seeds = "0 2 4-8 11 12 14-16 19 20 26 27 29-31 33 37-39 46 47 50-54 56 59-63 67-72 74 80 82 83 86 88 90-93 95 96 98-104 106 108 110-113 115 117 119 121 125-127 129-131 133 135-139 142 144 145 147 148 152 153 155 156 161 162 164 165 167-173 175 177-179 181-183 185 186 189 190 196-198 200 202-205 207-212 216 219 222 225-227 229 230 234-241 243-245 247 253-256 258 260 263 265 266 274 275 278 285-287 291 292 294 295 297 299" -reason = "NEW: after a backward request served from the FFmpeg decoder's cache, the reader is left far ahead and the next request reads past the frame it needs" -link = "" - [[xfail]] case = "decoder-model/ffmpeg/cfr/keyframe-seeks/playback" class = "stale" -seeds = "0 2 4-8 12 14-16 19 20 26 27 29-31 33 37-39 46 47 51-54 56 59-63 67-72 74 80 82 83 86 88 90-93 95 96 98-104 106 108 110-113 115 117 119 121 125-127 129-131 133 135-139 144 145 147 148 152-156 161 162 164 165 168-173 175 177-179 181-183 186 189 190 196-198 200 202-205 207-212 215 216 219 222 225-227 229 230 234-241 243-245 247 253-256 258-260 265 269 274 275 278 286 287 291 292 294 295 297 299" -reason = "NEW: the FFmpeg decoder answers backward requests with a cached frame up to 2 grid frames older than the requested one, answers grid frames 0 to 2 with the first frame, and after a backward request served from the cache it reads forward from a reader that is still far ahead, answering with an older cached frame" +seeds = "5 8 96 161" +reason = "NEW: the FFmpeg decoder answers a backward request whose frame is not cached with a cached frame up to 2 grid frames older (MAX_FRAME_LOOKBACK_TOLERANCE)" link = "" [[xfail]] @@ -109,50 +88,36 @@ link = "" [[xfail]] case = "decoder-model/ffmpeg/cfr/random-seeks/playback" class = "grid-rounding" -seeds = "13 31 40 52 57 61 63 67 69 74 80 93 100 117 120 126 140 153 166 180 182 188 196 251 254 258 276 279 299" +seeds = "31 40 52 57 61 63 69 74 80 93 117 120 126 153 166 180 182 188 196 251 276 279 297 299" reason = "NEW: the FFmpeg decoder seeks to the exact request time, so after a reset it answers with the frame on screen at that time even when its rounded grid label is after the request; sequential playback shows the previous frame for the same request" link = "" -[[xfail]] -case = "decoder-model/ffmpeg/cfr/random-seeks/playback" -class = "overread" -seeds = "0 4 10-13 16 18 25 27 30 32 42 50 55 67 73 82 89 100 108 126 128-129 134 137-140 146-148 151 164 166 171 174 178-181 207 217 220 228 232 246-247 250 254 262 273 279 281 289-290 297" -reason = "NEW: after a backward request served from the FFmpeg decoder's cache, the reader is left far ahead and the next request reads past the frame it needs" -link = "" - [[xfail]] case = "decoder-model/ffmpeg/cfr/random-seeks/playback" class = "stale" -seeds = "0 4 10-13 16 18 20 25 27 30 32 42 43 50 55 61 62 67 73 82 89 100 108 126 128 129 134 137-140 146-148 151 164 166 171 173 174 178-181 184 198 207 217 220 228 232 233 243 246 247 250 254 258 262 267 273 279 281 285 289 290 297" -reason = "NEW: the FFmpeg decoder answers backward requests with a cached frame up to 2 grid frames older than the requested one, answers grid frames 0 to 2 with the first frame, and after a backward request served from the cache it reads forward from a reader that is still far ahead, answering with an older cached frame" +seeds = "12 67 129 198 233 281" +reason = "NEW: the FFmpeg decoder answers a backward request whose frame is not cached with a cached frame up to 2 grid frames older (MAX_FRAME_LOOKBACK_TOLERANCE)" link = "" [[xfail]] case = "decoder-model/ffmpeg/cfr/scrub/playback" class = "future" seeds = "88" -reason = "Found once each sequence keeps running after its first wrong answer (full failure sets, runs 38851482697 and 38851482675): a later request is answered with a sample ahead of the right one (seed index 88 request 220). FFmpeg decoder request handling, the mechanisms of this decoder's other entries (backward lookback tolerance, first-frame shortcut, reset decisions)" +reason = "Found once each sequence keeps running after its first wrong answer (full failure sets, runs 38851482697 and 38851482675): a later request is answered with a sample ahead of the right one (seed index 88 request 220). FFmpeg decoder request handling, the mechanisms of this decoder's other entries (backward lookback tolerance, reset decisions)" link = "" [[xfail]] case = "decoder-model/ffmpeg/cfr/scrub/playback" class = "grid-rounding" -seeds = "27 30 33 39 41 50 62 63 68 84 86 90 93 101 104 106 109 121-124 126 133 143 144 149 155 160 165 170 173 176 179 180 183 186 187 192 199" +seeds = "13 30 50 62-63 93 107 119 121-122 126 143-144 149 176 187 199" reason = "NEW: the FFmpeg decoder seeks to the exact request time, so after a reset it answers with the frame on screen at that time even when its rounded grid label is after the request; sequential playback shows the previous frame for the same request" link = "" -[[xfail]] -case = "decoder-model/ffmpeg/cfr/scrub/playback" -class = "overread" -seeds = "0-1 3-4 7-8 11 13-14 16-21 26-27 33-43 46 50-56 58 64 67-68 70-71 73-75 77-78 80 82-86 88 90-94 97-101 103-110 113-115 117-119 121-124 127-128 131-135 137-139 141-142 144-145 147-149 151-152 154-155 157 160-163 165 167-168 170 173-177 179-181 183 185-187 189 192 197-198" -reason = "NEW: after a backward request served from the FFmpeg decoder's cache, the reader is left far ahead and the next request reads past the frame it needs" -link = "" - [[xfail]] case = "decoder-model/ffmpeg/cfr/scrub/playback" class = "stale" -seeds = "0 1 3 4 7 8 11 13-22 26 27 33 35-43 46 49-56 58 60 63 64 66-68 70-75 77 78 80-86 88 90-95 97-101 104-110 113-115 117-119 121-125 127 128 131-135 137 139 141 142 144 145 147-152 154 155 157 160-165 167 168 170 173-177 179-181 183 185-189 191-194 197-199" -reason = "NEW: the FFmpeg decoder answers backward requests with a cached frame up to 2 grid frames older than the requested one, answers grid frames 0 to 2 with the first frame, and after a backward request served from the cache it reads forward from a reader that is still far ahead, answering with an older cached frame" +seeds = "7 11 15 26 37-39 46 49-50 52-53 55 63 70-72 80 83 93-95 105-107 109 113 118 124 127 132-134 141-142 150 155 173 175 188 191 193 199" +reason = "NEW: the FFmpeg decoder answers a backward request whose frame is not cached with a cached frame up to 2 grid frames older (MAX_FRAME_LOOKBACK_TOLERANCE)" link = "" [[xfail]] @@ -176,18 +141,11 @@ seeds = "16 26 32 50 65 75 95 102 133 145" reason = "NEW: the FFmpeg decoder seeks to the exact request time, so after a reset it answers with the frame on screen at that time even when its rounded grid label is after the request; sequential playback shows the previous frame for the same request" link = "" -[[xfail]] -case = "decoder-model/ffmpeg/cfr/timeline/export" -class = "overread" -seeds = "12 42-43 64 133" -reason = "NEW: after a backward request served from the FFmpeg decoder's cache, the reader is left far ahead and the next request reads past the frame it needs" -link = "" - [[xfail]] case = "decoder-model/ffmpeg/cfr/timeline/export" class = "stale" -seeds = "12 35 42 43 64 133 135" -reason = "NEW: the FFmpeg decoder answers backward requests with a cached frame up to 2 grid frames older than the requested one, answers grid frames 0 to 2 with the first frame, and after a backward request served from the cache it reads forward from a reader that is still far ahead, answering with an older cached frame" +seeds = "35" +reason = "NEW: the FFmpeg decoder answers a backward request whose frame is not cached with a cached frame up to 2 grid frames older (MAX_FRAME_LOOKBACK_TOLERANCE)" link = "" [[xfail]] @@ -204,18 +162,11 @@ seeds = "0 5-7 15 22 23 25 29 38 41 45 53 56 61 62 66 72 73" reason = "NEW: the FFmpeg decoder seeks to the exact request time, so after a reset it answers with the frame on screen at that time even when its rounded grid label is after the request; sequential playback shows the previous frame for the same request" link = "" -[[xfail]] -case = "decoder-model/ffmpeg/cfr/transition/export" -class = "overread" -seeds = "4 5 19 22 23 26 29 52 60 73" -reason = "Found once each sequence keeps running after its first wrong answer (full failure sets, runs 38851482697 and 38851482675): the reader decodes past the sample after the answer, running ahead of the requests (seed index 4 request 108). FFmpeg decoder request handling, the mechanisms of this decoder's other entries (backward lookback tolerance, first-frame shortcut, reset decisions)" -link = "" - [[xfail]] case = "decoder-model/ffmpeg/cfr/transition/export" class = "stale" -seeds = "0-2 4 5 7 15 19 22-26 29 33-36 38 40 41 52 55 60 62 67 73-75 79" -reason = "NEW: the FFmpeg decoder answers backward requests with a cached frame up to 2 grid frames older than the requested one, answers grid frames 0 to 2 with the first frame, and after a backward request served from the cache it reads forward from a reader that is still far ahead, answering with an older cached frame" +seeds = "0-2 4-5 7 15 22-26 29 33-36 38 40-41 52 60 62 67 73-75 79" +reason = "NEW: the FFmpeg decoder answers a backward request whose frame is not cached with a cached frame up to 2 grid frames older (MAX_FRAME_LOOKBACK_TOLERANCE)" link = "" [[xfail]] @@ -232,18 +183,11 @@ seeds = "9 14 19-21 25 29 31 42 51 53 54 57 58 61 64-66" reason = "NEW: the FFmpeg decoder seeks to the exact request time, so after a reset it answers with the frame on screen at that time even when its rounded grid label is after the request; sequential playback shows the previous frame for the same request" link = "" -[[xfail]] -case = "decoder-model/ffmpeg/cfr/transition/playback" -class = "overread" -seeds = "6 25 27 39 50 57 70" -reason = "Found once each sequence keeps running after its first wrong answer (full failure sets, runs 38851482697 and 38851482675): the reader decodes past the sample after the answer, running ahead of the requests (seed index 6 request 48). FFmpeg decoder request handling, the mechanisms of this decoder's other entries (backward lookback tolerance, first-frame shortcut, reset decisions)" -link = "" - [[xfail]] case = "decoder-model/ffmpeg/cfr/transition/playback" class = "stale" seeds = "3 6 7 9 14 20 21 25 27 31 36 37 39 43-45 50 53 54 57 59 61 64-66 70 77" -reason = "NEW: the FFmpeg decoder answers backward requests with a cached frame up to 2 grid frames older than the requested one, answers grid frames 0 to 2 with the first frame, and after a backward request served from the cache it reads forward from a reader that is still far ahead, answering with an older cached frame" +reason = "NEW: the FFmpeg decoder answers a backward request whose frame is not cached with a cached frame up to 2 grid frames older (MAX_FRAME_LOOKBACK_TOLERANCE)" link = "" [[xfail]] @@ -256,22 +200,15 @@ link = "" [[xfail]] case = "decoder-model/ffmpeg/dense/random-seeks/playback" class = "grid-rounding" -seeds = "1 7 15 17 21 22 25 32 33 35 36 45 47 48 55 64 70 82 86 90 91 100 103 114 115 121 126 147" +seeds = "1 7 15 17 21-22 25 32-33 35-36 45 47-48 55 64 70 82 86 90-91 100 103 114-115 121 126 144 147" reason = "NEW: the FFmpeg decoder seeks to the exact request time, so after a reset it answers with the frame on screen at that time even when its rounded grid label is after the request; sequential playback shows the previous frame for the same request" link = "" -[[xfail]] -case = "decoder-model/ffmpeg/dense/random-seeks/playback" -class = "overread" -seeds = "0 4 14 15 17 20 22 25 27 29 31-33 38 43 50 54 56 57 61 65 67 71 84 86 91 92 97 99 102 114 116 123 126 128 130 135 137 140 144" -reason = "NEW: after a backward request served from the FFmpeg decoder's cache, the reader is left far ahead and the next request reads past the frame it needs" -link = "" - [[xfail]] case = "decoder-model/ffmpeg/dense/random-seeks/playback" class = "stale" -seeds = "0 4 14 15 17 20 22 25 27 29 31-33 38 39 43 50 54 56 57 61 63 65 67 71 84 86 91-93 97 99 102 114 116 123 126 128 130 135 137 140 144" -reason = "NEW: the FFmpeg decoder answers backward requests with a cached frame up to 2 grid frames older than the requested one, answers grid frames 0 to 2 with the first frame, and after a backward request served from the cache it reads forward from a reader that is still far ahead, answering with an older cached frame" +seeds = "39 57 61" +reason = "NEW: the FFmpeg decoder answers a backward request whose frame is not cached with a cached frame up to 2 grid frames older (MAX_FRAME_LOOKBACK_TOLERANCE)" link = "" [[xfail]] @@ -284,24 +221,10 @@ link = "" [[xfail]] case = "decoder-model/ffmpeg/vfr/keyframe-seeks/playback" class = "grid-rounding" -seeds = "1-5 8-11 13 15 16 18 21-25 27-32 34 36 37 40-44 46-48 50 51 53-66 68-76 79-97 99" +seeds = "1-5 9-11 13 16-18 21-25 27-32 34 36-37 40-44 46-48 50-51 53-56 58-66 68-74 76 79-93 95-97" reason = "NEW: the FFmpeg decoder seeks to the exact request time, so after a reset it answers with the frame on screen at that time even when its rounded grid label is after the request; sequential playback shows the previous frame for the same request" link = "" -[[xfail]] -case = "decoder-model/ffmpeg/vfr/keyframe-seeks/playback" -class = "overread" -seeds = "1-15 17-21 23-25 28-31 34 40-44 46-48 50-53 55 57-62 64 66 68-69 72-73 75-76 78-79 81 83-84 86-87 89 91 94 96-97 99" -reason = "NEW: after a backward request served from the FFmpeg decoder's cache, the reader is left far ahead and the next request reads past the frame it needs" -link = "" - -[[xfail]] -case = "decoder-model/ffmpeg/vfr/keyframe-seeks/playback" -class = "stale" -seeds = "1-4 7-15 17-21 23-25 28-31 34 40-44 46-48 51 52 55 57 59-62 64 66 68 69 72 73 75 76 78 79 81 83 84 86 87 89 91 93 94 96 99" -reason = "NEW: the FFmpeg decoder answers backward requests with a cached frame up to 2 grid frames older than the requested one, answers grid frames 0 to 2 with the first frame, and after a backward request served from the cache it reads forward from a reader that is still far ahead, answering with an older cached frame" -link = "" - [[xfail]] case = "decoder-model/ffmpeg/vfr/random-seeks/playback" class = "future" @@ -316,20 +239,6 @@ seeds = "3 8 10 12 16 20 34 46 52 58 59 64 77 78 86 95 117 137 144 160 168 185 1 reason = "NEW: the FFmpeg decoder seeks to the exact request time, so after a reset it answers with the frame on screen at that time even when its rounded grid label is after the request; sequential playback shows the previous frame for the same request" link = "" -[[xfail]] -case = "decoder-model/ffmpeg/vfr/random-seeks/playback" -class = "overread" -seeds = "3 12 18 20 29 53 55 67 79-80 84 93 120 148 167 177" -reason = "NEW: after a backward request served from the FFmpeg decoder's cache, the reader is left far ahead and the next request reads past the frame it needs" -link = "" - -[[xfail]] -case = "decoder-model/ffmpeg/vfr/random-seeks/playback" -class = "stale" -seeds = "3 12 18 20 29 53 55 67 79 80 84 93 120 148 167 177" -reason = "NEW: the FFmpeg decoder answers backward requests with a cached frame up to 2 grid frames older than the requested one, answers grid frames 0 to 2 with the first frame, and after a backward request served from the cache it reads forward from a reader that is still far ahead, answering with an older cached frame" -link = "" - [[xfail]] case = "decoder-model/ffmpeg/vfr/scrub/playback" class = "future" @@ -340,22 +249,15 @@ link = "" [[xfail]] case = "decoder-model/ffmpeg/vfr/scrub/playback" class = "grid-rounding" -seeds = "2 23 30 37 46 48 59 67 78 81 87 111 114" +seeds = "23 30 46 48 78 87 111 114 119" reason = "NEW: the FFmpeg decoder seeks to the exact request time, so after a reset it answers with the frame on screen at that time even when its rounded grid label is after the request; sequential playback shows the previous frame for the same request" link = "" -[[xfail]] -case = "decoder-model/ffmpeg/vfr/scrub/playback" -class = "overread" -seeds = "1-2 7 12 15 18-19 23 28 33-34 36-37 43 45-46 49 53-54 56 59 67 69-72 75 80-81 88 91 95 101 103 105 107 110-112 119" -reason = "NEW: after a backward request served from the FFmpeg decoder's cache, the reader is left far ahead and the next request reads past the frame it needs" -link = "" - [[xfail]] case = "decoder-model/ffmpeg/vfr/scrub/playback" class = "stale" -seeds = "0-2 7 8 12 15 17-19 21 23 33 34 36 37 43 45 46 49 53 54 56 59 66 67 69-72 75 80 81 86 88 91 95 101-103 105 107 108 110-112 119" -reason = "NEW: the FFmpeg decoder answers backward requests with a cached frame up to 2 grid frames older than the requested one, answers grid frames 0 to 2 with the first frame, and after a backward request served from the cache it reads forward from a reader that is still far ahead, answering with an older cached frame" +seeds = "0 7-8 12 17-18 21 23 46 59 66 75 86 103 108" +reason = "NEW: the FFmpeg decoder answers a backward request whose frame is not cached with a cached frame up to 2 grid frames older (MAX_FRAME_LOOKBACK_TOLERANCE)" link = "" [[xfail]] @@ -383,7 +285,7 @@ link = "" case = "decoder-model/ffmpeg/vfr/transition/playback" class = "future" seeds = "66 75 77" -reason = "Found once each sequence keeps running after its first wrong answer (full failure sets, runs 38851482697 and 38851482675): a later request is answered with a sample ahead of the right one (seed index 66 request 206). FFmpeg decoder request handling, the mechanisms of this decoder's other entries (backward lookback tolerance, first-frame shortcut, reset decisions)" +reason = "Found once each sequence keeps running after its first wrong answer (full failure sets, runs 38851482697 and 38851482675): a later request is answered with a sample ahead of the right one (seed index 66 request 206). FFmpeg decoder request handling, the mechanisms of this decoder's other entries (backward lookback tolerance, reset decisions)" link = "" [[xfail]] @@ -397,6 +299,6 @@ link = "" case = "decoder-model/ffmpeg/vfr/transition/playback" class = "stale" seeds = "4-6 8 10 12 13 18 19 21 28 31 33 38 39 41 45-47 49 50 52 55 56 58 59 61 66 68 71 75-77 79" -reason = "NEW: the FFmpeg decoder answers backward requests with a cached frame up to 2 grid frames older than the requested one, answers grid frames 0 to 2 with the first frame, and after a backward request served from the cache it reads forward from a reader that is still far ahead, answering with an older cached frame" +reason = "NEW: the FFmpeg decoder answers a backward request whose frame is not cached with a cached frame up to 2 grid frames older (MAX_FRAME_LOOKBACK_TOLERANCE)" link = "" diff --git a/crates/rendering/src/lib.rs b/crates/rendering/src/lib.rs index 641ddb275b4..8e95cd3b623 100644 --- a/crates/rendering/src/lib.rs +++ b/crates/rendering/src/lib.rs @@ -368,6 +368,67 @@ pub struct RecordingSegmentDecoders { screen: AsyncVideoDecoderHandle, camera: Option, pub segment_offset: f64, + transition_readers: Option>, +} + +/// A second set of readers for this clip, opened on first use, for the +/// outgoing side of a transition between two parts of the same clip. The +/// FFmpeg and Media Foundation readers each hold one position, so two sides +/// read through one reader take each other's frames or seek every frame. +/// The AVAssetReader pool opens readers per position itself and is shared. +struct TransitionSideDecoders { + screen: DecoderSource, + camera: Option, + force_ffmpeg: bool, + decoders: tokio::sync::OnceCell>, +} + +#[derive(Clone)] +struct DecoderSource { + path: PathBuf, + fps: u32, + offset: f64, +} + +async fn spawn_screen_decoder( + source: DecoderSource, + force_ffmpeg: bool, +) -> Result { + spawn_decoder( + "screen", + source.path, + source.fps, + source.offset, + force_ffmpeg, + ) + .await + .map(|decoder| decoder.with_max_fallback_distance(SCREEN_MAX_FALLBACK_DISTANCE)) + .map_err(|e| format!("Screen:{e}")) +} + +async fn spawn_camera_decoder( + source: DecoderSource, + force_ffmpeg: bool, +) -> Result { + spawn_decoder( + "camera", + source.path, + source.fps, + source.offset, + force_ffmpeg, + ) + .await + .map(|decoder| decoder.with_max_fallback_distance(CAMERA_MAX_FALLBACK_DISTANCE)) + .map_err(|e| format!("Camera:{e}")) +} + +fn serves_two_positions(decoder: &AsyncVideoDecoderHandle) -> bool { + #[cfg(target_os = "macos")] + if decoder.decoder_status().decoder_type == decoder::DecoderType::AVAssetReader { + return true; + } + let _ = decoder; + false } const SCREEN_MAX_FALLBACK_DISTANCE: u32 = 4; @@ -399,48 +460,98 @@ impl RecordingSegmentDecoders { camera_offset, } = segment_timing::segment_video_timing(meta, segment_i); - let screen_future = async { - spawn_decoder( - "screen", - display_path, - screen_fps, - screen_offset, - force_ffmpeg, - ) - .await - .map(|decoder| decoder.with_max_fallback_distance(SCREEN_MAX_FALLBACK_DISTANCE)) - .map_err(|e| format!("Screen:{e}")) + let screen_source = DecoderSource { + path: display_path, + fps: screen_fps, + offset: screen_offset, }; - - let camera_future = async { - let Some(camera_path) = camera_path else { - return Ok::, String>(None); - }; - let camera_fps = camera_fps.ok_or_else(|| "Camera metadata missing".to_string())?; - let camera = spawn_decoder( - "camera", - camera_path, - camera_fps, - camera_offset, - force_ffmpeg, - ) - .await - .map(|decoder| decoder.with_max_fallback_distance(CAMERA_MAX_FALLBACK_DISTANCE)) - .map_err(|e| format!("Camera:{e}"))?; - Ok(Some(camera)) + let camera_source = match camera_path { + Some(path) => Some(DecoderSource { + path, + fps: camera_fps.ok_or_else(|| "Camera metadata missing".to_string())?, + offset: camera_offset, + }), + None => None, }; // Decoders spawn their own threads and just signal readiness, so screen // and camera can always initialize concurrently. - let (screen, camera) = tokio::try_join!(screen_future, camera_future)?; + let camera = async { + match camera_source.clone() { + Some(source) => spawn_camera_decoder(source, force_ffmpeg).await.map(Some), + None => Ok(None), + } + }; + let (screen, camera) = tokio::try_join!( + spawn_screen_decoder(screen_source.clone(), force_ffmpeg), + camera + )?; Ok(Self { screen, camera, segment_offset: latest_start_time.unwrap_or(0.0), + transition_readers: Some(Arc::new(TransitionSideDecoders { + screen: screen_source, + camera: camera_source, + force_ffmpeg, + decoders: tokio::sync::OnceCell::new(), + })), }) } + /// The decoders for the outgoing side of a transition whose incoming + /// side is read from this clip too. Opens the second readers on first + /// use; falls back to these decoders if they cannot open. + pub async fn transition_side(&self) -> &RecordingSegmentDecoders { + let Some(side) = &self.transition_readers else { + return self; + }; + let screen_shared = serves_two_positions(&self.screen); + let camera_shared = self.camera.as_ref().is_none_or(serves_two_positions); + if screen_shared && camera_shared { + return self; + } + let decoders = side + .decoders + .get_or_init(|| async { + let screen = async { + if screen_shared { + Ok(self.screen.clone()) + } else { + spawn_screen_decoder(side.screen.clone(), side.force_ffmpeg).await + } + }; + let camera = async { + match (&self.camera, &side.camera) { + (Some(_), Some(source)) if !camera_shared => { + spawn_camera_decoder(source.clone(), side.force_ffmpeg) + .await + .map(Some) + } + (camera, _) => Ok(camera.clone()), + } + }; + match tokio::try_join!(screen, camera) { + Ok((screen, camera)) => Some(RecordingSegmentDecoders { + screen, + camera, + segment_offset: self.segment_offset, + transition_readers: None, + }), + Err(error) => { + tracing::warn!( + %error, + "Transition decoders failed to open; both sides share one reader" + ); + None + } + } + }) + .await; + decoders.as_ref().unwrap_or(self) + } + pub async fn get_frames( &self, segment_time: f32, @@ -832,10 +943,11 @@ pub async fn render_video_to_channel( let (segment_frames, outgoing_frames) = if let Some((outgoing, _, _)) = transition_mapping { let (incoming, outgoing) = tokio::join!( incoming_decode, - decode_timeline_source_frames( + decode_transition_outgoing_frames( project, &render_segments, outgoing, + segment_clip_index, needs_camera, current_frame_number, is_initial_frame, @@ -1438,10 +1550,11 @@ pub async fn render_video_to_channel_nv12( let (segment_frames, outgoing_frames) = if let Some((outgoing, _, _)) = transition_mapping { let (incoming, outgoing) = tokio::join!( incoming_decode, - decode_timeline_source_frames( + decode_transition_outgoing_frames( project, &render_segments, outgoing, + segment_clip_index, needs_camera, current_frame_number, is_initial_frame, @@ -1988,22 +2101,30 @@ struct TransitionExportContext<'a> { duration: f64, } -async fn decode_timeline_source_frames( +#[allow(clippy::too_many_arguments)] +async fn decode_transition_outgoing_frames( project: &ProjectConfiguration, render_segments: &[RenderSegment], source: TimelineSource<'_>, + incoming_clip_index: usize, needs_camera: bool, current_frame_number: u32, is_initial_frame: bool, fps: u32, ) -> Result, RenderingError> { - let render_segment = &render_segments[source.segment.recording_clip as usize]; + let clip_index = source.segment.recording_clip as usize; + let render_segment = &render_segments[clip_index]; + let decoders = if clip_index == incoming_clip_index { + render_segment.decoders.transition_side().await + } else { + &render_segment.decoders + }; let clip_config = project .clips .iter() .find(|clip| clip.index == source.segment.recording_clip); decode_segment_frames_with_retry( - &render_segment.decoders, + decoders, source.source_time, needs_camera && render_segment.render_display, render_segment.render_display, @@ -6252,6 +6373,7 @@ mod export_order_tests { screen, camera: None, segment_offset: 0.0, + transition_readers: None, }, render_display: true, }]; diff --git a/crates/verify/expectations.toml b/crates/verify/expectations.toml index f59ca5d42b9..97d0aa7f8e8 100644 --- a/crates/verify/expectations.toml +++ b/crates/verify/expectations.toml @@ -66,22 +66,25 @@ link = "" [[xfail]] case = "seek/seek-gop/storm30/ffmpeg" class = "stale" -bound = { screen-frames = 100, camera-frames = 100, delta = 100, frame = [1, 900] } -reason = "NEW: FFmpeg decoder seeks answer stale frames: the backward-seek lookback tolerance serves a cached frame 1 to 4 source frames old, and seeks near the start are answered by the first-frame shortcut with frame 0 (71 of 3000 full-tier seeks, 1 in the fast tier, up to 81 source frames stale). The count depends on the decoder's wall-clock paths (it varies between runs on macOS: 65 to 78 on the native fixture), so the bound is the mechanism's: at most 100 seeks (3.3%), never more stale than the 100 frames the shortcut and the lookback can reach" +flaky = true +bound = { screen-frames = 4, camera-frames = 4, delta = 2, frame = [1, 900] } +reason = "FFmpeg decoder backward-seek lookback tolerance (MAX_FRAME_LOOKBACK_TOLERANCE in crates/rendering/src/decoder/ffmpeg.rs): a backward seek whose frame is not cached is answered with a cached frame up to 2 source frames older. The fast tier passes; the full tier's 3000 seeks hit it once (macOS, 1 frame stale). Bound: a few seeks, never more than the 2 frames the tolerance reaches" link = "" [[xfail]] case = "seek/seek-native/storm30/ffmpeg" class = "stale" -bound = { screen-frames = 100, camera-frames = 100, delta = 100, frame = [1, 900] } -reason = "NEW: same FFmpeg lookback tolerance and first-frame shortcut as seek/seek-gop/storm30/ffmpeg on the native fixture: 62 to 78 of 3000 full-tier seeks (9 in the fast tier), up to 83 source frames stale; bounded like seek/seek-gop/storm30/ffmpeg" +flaky = true +bound = { screen-frames = 4, camera-frames = 4, delta = 2, frame = [1, 900] } +reason = "Same FFmpeg lookback tolerance as seek/seek-gop/storm30/ffmpeg on the native fixture: the fast tier passes, the full tier hits it once (macOS, 1 frame stale)" link = "" [[xfail]] case = "seek/vfr-holds/storm60/ffmpeg" class = "stale" -bound = { screen-frames = 38, camera-frames = 67, delta = 76, frame = [1, 1800] } -reason = "NEW: same FFmpeg lookback tolerance and first-frame shortcut over long VFR holds: 38 screen and 67 camera seeks of the full storm (7 and 11 in the fast tier), up to 76 source frames stale" +flaky = true +bound = { screen-frames = 4, camera-frames = 4, delta = 2, frame = [1, 1800] } +reason = "Same FFmpeg lookback tolerance as seek/seek-gop/storm30/ffmpeg over long VFR holds: the fast tier passes, the full tier hits it on 2 camera seeks (macOS, 2 frames stale)" link = "" # Decode deadline under machine starvation. These apply only to a case the @@ -186,14 +189,6 @@ bound = { screen-frames = 1, delta = 1, frame = [149, 149] } reason = "During a cross-fade both clips read the same AVAssetReader decoder at two positions; a reset for one position drops the other position's last vended frame from the cache, so at 60 fps the incoming side of output frame 149 (2.483 s) shows source frame 115 instead of 116. Not the IOSurface source lifetime bug: it stays with that fix (sol-avf proof run 38762654200). Same frame in export and in offline playback. Fires in almost every run but depends on the decoder's cache state, which its 250 ms idle timeout (IDLE_READER_TIMEOUT) clears: a 300 ms pause in the request stream at 2.0, 2.42 or 2.48 s makes offline playback correct (measured on Apple silicon), and the export passed once on macos-15 (push run 38763177849). Bound: that one screen frame, one source frame stale" link = "https://github.com/CapSoftware/Cap/pull/2638" -[[xfail]] -case = "audio/audio-mix-*-a2/long-crossfade/mp4-*" -class = "stale" -platforms = ["linux", "windows"] -bound = { screen-frames = 18, delta = 49, frame = [61, 115] } -reason = "FFmpeg decoder, same-clip cross-fade (cuts 0.3-4.1 s and 3.9-7.8 s of one recording, 1.8 s transition): both sides read one decoder at two positions, so blend frames take a side up to 49 source frames stale (frame 61: 80 for 129; 18 frames, output frames 61 to 110, 6 of them where the stale side is the weaker one, inside the 1.8 s transition at output frames 61 to 115). Same mechanism as editing/export/same-clip-transition/forward/ffmpeg; identical on Linux and Windows and for the 30 s full-tier fixture (same cuts)" -link = "" - [[xfail]] case = "seek/seek-native/keyframes60/ffmpeg" class = "stale" @@ -230,60 +225,12 @@ bound = { count = 1 } reason = "NEW (minor): loading a legacy (output-timed) project drops a caption that ends exactly at the timeline end: synchronize_captions maps each end through source_start + (end - start) / timescale, which rounds just below the timeline end after a clip transition (clips 41/15 s and 82/15 s, 0.2 s transition: 7.9999999 < 8.0), so the end does not map and the whole caption is discarded" link = "" -[[xfail]] -case = "editing/export/multi-segment/seed-7" -class = "stale" -bound = { screen-frames = 30, camera-frames = 14, delta = 29, frame = [129, 157] } -reason = "FFmpeg decoder (full tier): after the timeline played clip 0 from 1.68 s to its end and then transitions back into clip 0 at 0 s, the frames after the transition show source frame 0 instead of 20-29 (deterministic: 22 screen and 10 camera frames, output frames 135 to 157, with the dominant side of each blend checked). Same mechanism as the seek/*/storm*/ffmpeg entries: backward seeks near the start answer frame 0" -link = "" - -[[xfail]] -case = "editing/export/multi-segment/seed-7" -class = "unreadable" -bound = { screen-frames = 1, frame = [141, 141] } -reason = "FFmpeg decoder (full tier): one blend frame of the transition back into clip 0 mixes two stale frames and its marker is unreadable (same bug as the stale entry above)" -link = "" - -[[xfail]] -case = "editing/export/basic/seed-10" -class = "stale" -bound = { screen-frames = 3, camera-frames = 3, delta = 2, frame = [106, 109] } -reason = "NEW: FFmpeg decoder (full tier, Linux): seed 10 splits the clip at 0.34 s, plays the later part first and cross-fades back into the clip start, so the requests for source frames 1 and 2 right after the transition are answered with frame 0 (frames 108-109 on Linux, 107-109 with the macOS-encoded fixture). The first-frame shortcut in crates/rendering/src/decoder/ffmpeg.rs (requested_frame <= MAX_FRAME_LOOKBACK_TOLERANCE answered with first_ever_frame, no reset) serves frame 0 for any request up to frame 2 that is not cached; same mechanism as seek/seek-gop/home-then-play30/ffmpeg. Deterministic" -link = "" - -[[xfail]] -case = "editing/export/basic/seed-37" -class = "stale" -bound = { screen-frames = 1, camera-frames = 1, delta = 1, frame = [12, 12] } -reason = "NEW: FFmpeg decoder (full tier, Linux), the first-frame shortcut of editing/export/basic/seed-10: in a cross-fade back into the clip start the weaker side's request for source frame 1 is answered with frame 0 (output frame 12; found by the two-sided blend check, run 38851482675)" -link = "" - -[[xfail]] -case = "editing/export/same-clip-transition/forward/ffmpeg" -class = "stale" -bound = { screen-frames = 8, camera-frames = 8, delta = 55, frame = [37, 58] } -reason = "NEW: FFmpeg decoder, cross-fade between two parts of the same recording clip (0-2 s, then 3-5.5 s, 0.8 s transition): the export reads one decoder at two positions interleaved and every third blend frame takes the far side from the near position (frame 37: incoming should be source 90, shows 35; up to 55 source frames stale). 6 frames where the wrong side dominates; the two-sided blend check finds 2 more where it is the weaker side (weights 0.08 and 0.21, measured on Apple silicon). Deterministic; AVAssetReader passes this case (its 60 fps variant is the */basic/transition/*60*/avf xfail)" -link = "" - -[[xfail]] -case = "editing/export/basic/seed-5" -class = "stale" -bound = { screen-frames = 2, camera-frames = 2, delta = 2, frame = [52, 54] } -reason = "FFmpeg decoder, same-clip cross-fade (see editing/export/same-clip-transition/forward/ffmpeg): blend frame 53 one source frame stale" -link = "" - [[xfail]] case = "editing/export/basic/seed-23" -class = "stale" -bound = { screen-frames = 6, camera-frames = 6, delta = 70, frame = [7, 12] } -reason = "FFmpeg decoder (full tier), same-clip cross-fade (see editing/export/same-clip-transition/forward/ffmpeg): blend frames 7 to 11 up to 70 source frames stale" -link = "" - -[[xfail]] -case = "seek/seek-gop/home-then-play30/ffmpeg" -class = "stale" -bound = { screen-frames = 31, camera-frames = 31, delta = 89, frame = [60, 90] } -reason = "NEW: FFmpeg decoder freezes on the first frame after a jump to the start. Seek 6.1 s, seek 0, seek 2.0 s and play: 31 of 122 frames show source frame 0 (up to 89 frames stale, about 1 s frozen). A request at grid 0..2 is answered by the first-frame shortcut (ffmpeg.rs, requested_frame <= MAX_FRAME_LOOKBACK_TOLERANCE) without resetting the reader, which stays near 6 s, and last_sent_frame becomes frame 0; the next request is then not 'far forward' of frame 0 (< FRAME_CACHE_SIZE), so no reset happens, the reader vends frames past the request and the answer falls back to last_sent_frame (frame 0) until playback outruns FRAME_CACHE_SIZE. Main decoder on Linux and on Windows without a D3D11 video device; macOS AVAssetReader passes. Minimal: CAP_VERIFY_SEEK_SCRIPT='seek-gop@60:6.1,0.0167,2.0667' (third request shows frame 0 for 61)" +class = "future" +platforms = ["linux"] +bound = { camera-frames = 1, delta = 8, frame = [8, 8] } +reason = "NEW: Linux only (software renderer): in seed 23's 3 s same-clip cross-fade, output frame 8 shows incoming camera source frame 84 instead of 76. The FFmpeg decoder answers every camera request with the requested frame (traced: request 76 read 76, run 38997099068, identical on macOS), so a later camera frame reaches the renderer's shared camera layer, which draws the outgoing and incoming sides in turn. Before #2661 the shared decoder made output frames 7 to 12 up to 70 frames stale and hid it. Deterministic on Linux; macOS and Windows pass" link = "" [[xfail]]