Repository navigation
Add fps_conversion option for even-cadence frame-rate resampling - #24
RikHeijdens wants to merge 2 commits into
Conversation
`fps` resampling keeps the source frame nearest each output timestamp, so non-integer ratios space frames unevenly: 30 -> 24 fps keeps 4 of every 5 frames and motion steps go 1x, 1x, 1x, 2x. VideoStreamRequest gains `fps_conversion`: - "nearest" (default): unchanged FFmpeg `fps` behaviour. - "blend": FFmpeg `framerate`, cross-fades the two neighbouring frames. Even cadence at about nearest's cost; moving edges show double images. - "motion": bilateral block-matching motion estimation with overlapped block motion compensation (the model of FFmpeg's `minterpolate`), implemented in src/decoder/motion.rs. Motion is estimated only for source pairs that bracket an output frame. About 1.25x nearest's decode time, against ~12x for `minterpolate`, with equal or better fidelity. Both interpolating modes run after the resize and after HDR tone mapping, keep nearest's frame count and timestamps (so planned buffer sizes and A/V sync are unchanged), and fall back to nearest when the source rate is an integer multiple of the target (48 -> 24, 23.976 -> 24). The video buffersrc now declares the stream frame rate.
| // SAFETY: an allocated frame holds `rows` rows of `stride` bytes | ||
| // per plane; the last row only needs `width` of them. | ||
| let data = | ||
| unsafe { std::slice::from_raw_parts(frame.data[i], stride * (rows - 1) + width) }; |
There was a problem hiding this comment.
Rust safety bypass can lead to security risks in unsafe operations - medium severity
This code did not enforce Rust safety bounds checking, ownership validation, or type safety guarantees in when handling critical operations within an unsafe block. Attackers may exploit this weakness to execute arbitrary code, trigger denial-of-service via panics, or manipulate memory out of bounds, such as read/write violations, by abusing unchecked arithmetic, transmutation, or direct pointer assignments.
Show fix
Remediation: Replace unsafe operations with Rust's safe alternatives, validate inputs rigorously, and rely on Rust's borrow checker to enforce memory safety wherever possible.
Reply @AikidoSec ignore: [REASON] to ignore this issue.
More info
There was a problem hiding this comment.
@AikidoSec ignore: We've double-checked the safety of this operation. The unsafe here is required to read the planes of an FFmpeg AVFrame as Rust slices, and the slice bounds are validated immediately before it.
Since the scan, 8b473ec has made both functions validate for themselves instead of relying on the caller. plane_strides checks that:
- the frame's width, height and pixel format match the layout;
- the layout only admits 8-bit, fully planar, 3-component YUV, so each plane is a separate array of 1-byte samples;
- every plane pointer is non-null, with a non-negative stride of at least its width.
FFmpeg allocates plane buffers for at least the frame's full height (av_frame_get_buffer and the decoder and filter pools pad it to 32 rows). So each slice of stride * (rows - 1) + width bytes stays inside the buffer.
The slices borrow the AVFrame, which keeps the buffer referenced for their lifetime. frame_planes_mut also requires av_frame_is_writable, so the mutable slices can't alias another frame's buffer, and the three planes don't overlap each other.
We also ran the full test suite under AddressSanitizer (nightly, -Zsanitizer=address -Zbuild-std) with no reports. That covered 4:2:0, 4:2:2, 4:4:4, odd and sub-block frame sizes, and real 1080p/2K decodes. A deliberate overrun from the same frames was caught, which confirms the instrumentation was active.
There was a problem hiding this comment.
✅ Based on your feedback, we ignored this issue because of the following reason:
We've double-checked the safety of this operation. The
unsafehere is required to read the planes of an FFmpegAVFrameas Rust slices, and the slice bounds are validated immediately before it.Since the scan,
8b473echas made both functions validate for themselves instead of relying on the caller.plane_strideschecks that:
- the frame's width, height and pixel format match the layout;
- the layout only admits 8-bit, fully planar, 3-component YUV, so each plane is a separate array of 1-byte samples;
- every plane pointer is non-null, with a non-negative stride of at least its width.
FFmpeg allocates plane buffers for at least the frame's full height (
av_frame_get_bufferand the decoder and filter pools pad it to 32 rows). So each slice ofstride * (rows - 1) + widthbytes stays inside the buffer.The slices borrow the
AVFrame, which keeps the buffer referenced for their lifetime.frame_planes_mutalso requiresav_frame_is_writable, so the mutable slices can't alias another frame's buffer, and the three planes don't overlap each other.We also ran the full test suite under AddressSanitizer (nightly,
-Zsanitizer=address -Zbuild-std) with no reports. That covered 4:2:0, 4:2:2, 4:4:4, odd and sub-block frame sizes, and real 1080p/2K decodes. A deliberate overrun from the same frames was caught, which confirms the instrumentation was active.
| // SAFETY: the frame was just allocated by `av_frame_get_buffer` with | ||
| // this layout, so every plane holds `rows` rows of `stride >= width` | ||
| // bytes and nothing else references its buffers. | ||
| let data = unsafe { std::slice::from_raw_parts_mut(data[i], stride * (rows - 1) + width) }; |
There was a problem hiding this comment.
Rust safety bypass can lead to security risks in unsafe operations - medium severity
This code did not enforce Rust safety bounds checking, ownership validation, or type safety guarantees in when handling critical operations within an unsafe block. Attackers may exploit this weakness to execute arbitrary code, trigger denial-of-service via panics, or manipulate memory out of bounds, such as read/write violations, by abusing unchecked arithmetic, transmutation, or direct pointer assignments.
Show fix
Remediation: Replace unsafe operations with Rust's safe alternatives, validate inputs rigorously, and rely on Rust's borrow checker to enforce memory safety wherever possible.
Reply @AikidoSec ignore: [REASON] to ignore this issue.
More info
There was a problem hiding this comment.
@AikidoSec ignore: We've double-checked the safety of this operation. The unsafe here is required to read the planes of an FFmpeg AVFrame as Rust slices, and the slice bounds are validated immediately before it.
Since the scan, 8b473ec has made both functions validate for themselves instead of relying on the caller. plane_strides checks that:
- the frame's width, height and pixel format match the layout;
- the layout only admits 8-bit, fully planar, 3-component YUV, so each plane is a separate array of 1-byte samples;
- every plane pointer is non-null, with a non-negative stride of at least its width.
FFmpeg allocates plane buffers for at least the frame's full height (av_frame_get_buffer and the decoder and filter pools pad it to 32 rows). So each slice of stride * (rows - 1) + width bytes stays inside the buffer.
The slices borrow the AVFrame, which keeps the buffer referenced for their lifetime. frame_planes_mut also requires av_frame_is_writable, so the mutable slices can't alias another frame's buffer, and the three planes don't overlap each other.
We also ran the full test suite under AddressSanitizer (nightly, -Zsanitizer=address -Zbuild-std) with no reports. That covered 4:2:0, 4:2:2, 4:4:4, odd and sub-block frame sizes, and real 1080p/2K decodes. A deliberate overrun from the same frames was caught, which confirms the instrumentation was active.
There was a problem hiding this comment.
✅ Based on your feedback, we ignored this issue because of the following reason:
We've double-checked the safety of this operation. The
unsafehere is required to read the planes of an FFmpegAVFrameas Rust slices, and the slice bounds are validated immediately before it.Since the scan,
8b473echas made both functions validate for themselves instead of relying on the caller.plane_strideschecks that:
- the frame's width, height and pixel format match the layout;
- the layout only admits 8-bit, fully planar, 3-component YUV, so each plane is a separate array of 1-byte samples;
- every plane pointer is non-null, with a non-negative stride of at least its width.
FFmpeg allocates plane buffers for at least the frame's full height (
av_frame_get_bufferand the decoder and filter pools pad it to 32 rows). So each slice ofstride * (rows - 1) + widthbytes stays inside the buffer.The slices borrow the
AVFrame, which keeps the buffer referenced for their lifetime.frame_planes_mutalso requiresav_frame_is_writable, so the mutable slices can't alias another frame's buffer, and the three planes don't overlap each other.We also ran the full test suite under AddressSanitizer (nightly,
-Zsanitizer=address -Zbuild-std) with no reports. That covered 4:2:0, 4:2:2, 4:4:4, odd and sub-block frame sizes, and real 1080p/2K decodes. A deliberate overrun from the same frames was caught, which confirms the instrumentation was active.
| let data = | ||
| unsafe { std::slice::from_raw_parts(frame.data[i], stride * (rows - 1) + width) }; |
There was a problem hiding this comment.
Improper unsafe usage in safe functions may lead to unsoundness issues. - low severity
If unsafe blocks are used carelessly in safe functions (fn), attackers could exploit undefined behavior (e.g., via dangling pointers or unaligned reads) to leak data, crash the program, or achieve arbitrary code execution.
Show fix
Remediation: Avoid unsafe blocks unless absolutely necessary. If unsafe operations are required, encapsulate them in well-documented unsafe fn functions with clear safety invariants, and minimize exposure to unsoundness code.
Reply @AikidoSec ignore: [REASON] to ignore this issue.
More info
There was a problem hiding this comment.
@AikidoSec ignore: We've double-checked the safety of this operation. The unsafe here is required to read the planes of an FFmpeg AVFrame as Rust slices, and the slice bounds are validated immediately before it.
Since the scan, 8b473ec has made both functions validate for themselves instead of relying on the caller. plane_strides checks that:
- the frame's width, height and pixel format match the layout;
- the layout only admits 8-bit, fully planar, 3-component YUV, so each plane is a separate array of 1-byte samples;
- every plane pointer is non-null, with a non-negative stride of at least its width.
FFmpeg allocates plane buffers for at least the frame's full height (av_frame_get_buffer and the decoder and filter pools pad it to 32 rows). So each slice of stride * (rows - 1) + width bytes stays inside the buffer.
The slices borrow the AVFrame, which keeps the buffer referenced for their lifetime. frame_planes_mut also requires av_frame_is_writable, so the mutable slices can't alias another frame's buffer, and the three planes don't overlap each other.
We also ran the full test suite under AddressSanitizer (nightly, -Zsanitizer=address -Zbuild-std) with no reports. That covered 4:2:0, 4:2:2, 4:4:4, odd and sub-block frame sizes, and real 1080p/2K decodes. A deliberate overrun from the same frames was caught, which confirms the instrumentation was active.
There was a problem hiding this comment.
✅ Based on your feedback, we ignored this issue because of the following reason:
We've double-checked the safety of this operation. The
unsafehere is required to read the planes of an FFmpegAVFrameas Rust slices, and the slice bounds are validated immediately before it.Since the scan,
8b473echas made both functions validate for themselves instead of relying on the caller.plane_strideschecks that:
- the frame's width, height and pixel format match the layout;
- the layout only admits 8-bit, fully planar, 3-component YUV, so each plane is a separate array of 1-byte samples;
- every plane pointer is non-null, with a non-negative stride of at least its width.
FFmpeg allocates plane buffers for at least the frame's full height (
av_frame_get_bufferand the decoder and filter pools pad it to 32 rows). So each slice ofstride * (rows - 1) + widthbytes stays inside the buffer.The slices borrow the
AVFrame, which keeps the buffer referenced for their lifetime.frame_planes_mutalso requiresav_frame_is_writable, so the mutable slices can't alias another frame's buffer, and the three planes don't overlap each other.We also ran the full test suite under AddressSanitizer (nightly,
-Zsanitizer=address -Zbuild-std) with no reports. That covered 4:2:0, 4:2:2, 4:4:4, odd and sub-block frame sizes, and real 1080p/2K decodes. A deliberate overrun from the same frames was caught, which confirms the instrumentation was active.
| // SAFETY: the frame was just allocated by `av_frame_get_buffer` with | ||
| // this layout, so every plane holds `rows` rows of `stride >= width` | ||
| // bytes and nothing else references its buffers. | ||
| let data = unsafe { std::slice::from_raw_parts_mut(data[i], stride * (rows - 1) + width) }; |
There was a problem hiding this comment.
Improper unsafe usage in safe functions may lead to unsoundness issues. - low severity
If unsafe blocks are used carelessly in safe functions (fn), attackers could exploit undefined behavior (e.g., via dangling pointers or unaligned reads) to leak data, crash the program, or achieve arbitrary code execution.
Show fix
Remediation: Avoid unsafe blocks unless absolutely necessary. If unsafe operations are required, encapsulate them in well-documented unsafe fn functions with clear safety invariants, and minimize exposure to unsoundness code.
Reply @AikidoSec ignore: [REASON] to ignore this issue.
More info
There was a problem hiding this comment.
@AikidoSec ignore: We've double-checked the safety of this operation. The unsafe here is required to read the planes of an FFmpeg AVFrame as Rust slices, and the slice bounds are validated immediately before it.
Since the scan, 8b473ec has made both functions validate for themselves instead of relying on the caller. plane_strides checks that:
- the frame's width, height and pixel format match the layout;
- the layout only admits 8-bit, fully planar, 3-component YUV, so each plane is a separate array of 1-byte samples;
- every plane pointer is non-null, with a non-negative stride of at least its width.
FFmpeg allocates plane buffers for at least the frame's full height (av_frame_get_buffer and the decoder and filter pools pad it to 32 rows). So each slice of stride * (rows - 1) + width bytes stays inside the buffer.
The slices borrow the AVFrame, which keeps the buffer referenced for their lifetime. frame_planes_mut also requires av_frame_is_writable, so the mutable slices can't alias another frame's buffer, and the three planes don't overlap each other.
We also ran the full test suite under AddressSanitizer (nightly, -Zsanitizer=address -Zbuild-std) with no reports. That covered 4:2:0, 4:2:2, 4:4:4, odd and sub-block frame sizes, and real 1080p/2K decodes. A deliberate overrun from the same frames was caught, which confirms the instrumentation was active.
There was a problem hiding this comment.
✅ Based on your feedback, we ignored this issue because of the following reason:
We've double-checked the safety of this operation. The
unsafehere is required to read the planes of an FFmpegAVFrameas Rust slices, and the slice bounds are validated immediately before it.Since the scan,
8b473echas made both functions validate for themselves instead of relying on the caller.plane_strideschecks that:
- the frame's width, height and pixel format match the layout;
- the layout only admits 8-bit, fully planar, 3-component YUV, so each plane is a separate array of 1-byte samples;
- every plane pointer is non-null, with a non-negative stride of at least its width.
FFmpeg allocates plane buffers for at least the frame's full height (
av_frame_get_bufferand the decoder and filter pools pad it to 32 rows). So each slice ofstride * (rows - 1) + widthbytes stays inside the buffer.The slices borrow the
AVFrame, which keeps the buffer referenced for their lifetime.frame_planes_mutalso requiresav_frame_is_writable, so the mutable slices can't alias another frame's buffer, and the three planes don't overlap each other.We also ran the full test suite under AddressSanitizer (nightly,
-Zsanitizer=address -Zbuild-std) with no reports. That covered 4:2:0, 4:2:2, 4:4:4, odd and sub-block frame sizes, and real 1080p/2K decodes. A deliberate overrun from the same frames was caught, which confirms the instrumentation was active.
`frame_planes` and `frame_planes_mut` turn FFmpeg plane pointers into Rust slices. Their bounds relied on callers having checked the frame's layout (`Resampler::push`) or on the frame being freshly allocated. Both now check for themselves, in `plane_strides`: the frame's size and pixel format must match the layout (8-bit, fully planar, 3-component YUV), every plane must be non-null with a non-negative stride of at least its width, and `frame_planes_mut` additionally requires the frame to be writable so the mutable slices cannot alias another frame's buffers. The SAFETY comments state the invariants each slice relies on. Adds tests that frames whose geometry, format or writability do not match are rejected, and motion decodes of odd-sized, 4:2:2 and sub-block frames.
Problem
fpsresampling uses FFmpeg'sfpsfilter, which keeps the source frame nearest each output timestamp. For non-integer ratios that spaces frames unevenly: 30 → 24 fps keeps 4 of every 5 frames, so per-frame motion goes 1×, 1×, 1×, 2× (and 1.5× steps for 50/60 → 24). Decoding always starts at the same phase, so the uneven step lands at the same position in every clip.Change
VideoStreamRequestgetsfps_conversion:"nearest"(default): today's behaviour, unchanged."blend": FFmpegframerate. Cross-fades the two neighbouring frames. Even spacing at about the cost ofnearest, but moving edges show double images."motion": motion-compensated interpolation insrc/decoder/motion.rs. It uses the same model as FFmpeg'sminterpolatedefaults: bilateral block matching on 16×16 blocks with EPZS predictors, then overlapped block motion compensation, and a frame-difference scene-cut check.minterpolatewas ~11–12× the decode time ofnearestand +150 MB per decode.Both interpolating modes:
nearest's frame count and timestamps, so planned buffer sizes, seek windows and A/V sync are unchanged;nearestwhen the source rate is an integer multiple of the target (48 → 24, and 23.976 → 24 within NTSC's 1000/1001 offset), where the output is bit-identical tonearest.The video buffer source now declares the stream frame rate. The README has a short example.
Measurements
Measured on 37 real 1080p/2K clips at 24–100 fps, decoded to 540p, one decode thread per process with six processes in parallel.
Decode time (24 fps sources excluded, since they fall back to
nearest):nearest(median)blendminterpolate(FFmpeg filter, for reference)motion(this PR)Frame spacing: the ratio of motion on transitions where
nearestskips an extra source frame to motion on normal transitions goes from 1.4–1.9 withnearestto ~1.0 with bothblendandmotion(1.0 = even).Reconstruction against held-out real frames. Each 50–100 fps clip was halved in frame rate and decoded back to the full rate, so every synthesized frame sits halfway between two inputs. Median over 14 clips:
nearestblendminterpolatemotion(this PR)Caveats for callers:
Testing
cargo test --no-default-features --releaseagainst FFmpeg 7.1: 152 passed, 3 ignored.cargo fmt --checkandcargo clippy --all-targetsare clean. New tests cover:nearest4,8,4,4 px;blend/motion5,5,5,5)