Skip to content

feat(rendering): loop video and GIF backgrounds behind the recording - #3480

Open
AatmanAJ wants to merge 3 commits into
CapSoftware:mainfrom
AatmanAJ:feat/video-backgrounds
Open

AatmanAJ wants to merge 3 commits into
CapSoftware:mainfrom
AatmanAJ:feat/video-backgrounds

Conversation

@AatmanAJ

@AatmanAJ AatmanAJ commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Lets you pick a video or GIF as the background in the editor. It loops behind the recording, in the preview and in exports. Richie OK'd the idea on Discord, on the condition that it's performant on every platform, so this PR is built around that.

What changes

  • Rendering (cap-rendering): when an image background's file is a video (mp4, mov, m4v, webm, mkv) or a GIF, it's played and looped instead of showing only its first frame. There's no project-config change: it's still BackgroundSource::Image { path }, so older builds that open the project just show the first frame (GIF) or fall back to white (video), the same as any image they can't read.
  • GPUI editor: the Image pane's picker now also accepts mp4, mov, m4v and webm, and a video's thumbnail is its first frame. The screenshot editor still accepts images only.

How it stays cheap

layers/video_background.rs:

  • Decoding: each background video gets one decode thread using the existing cap_video_decode::FFmpegDecoder, with VideoToolbox on macOS, D3D11VA on Windows and software decoding on Linux. GIFs always decode in software.
  • Scaling: frames are scaled once to RGBA, never larger than the output needs: 640, 960, 1280 or 1920 px on the long edge, chosen by output size. Downscaling uses AREA, which is cheaper than bilinear and looks better. The scaler runs on up to 4 threads (a raw swscale context with threads set before init; software::scaling::Context::get can't do that). The output is pixel-identical to the single-threaded scaler, and a test checks it.
  • Only shown frames are scaled: the reader decodes one frame ahead and drops a frame without scaling it if the next one starts before anyone asks for it. That covers decoding forward from a keyframe after a seek, and clips with more frames than the output (a 60 fps clip in a 30 fps export).
  • Uploads: the GPU only gets a write_texture when the frame actually changes, so a 30 fps clip in a 60 fps export uploads every other frame. A paused preview uploads nothing.
  • Read-ahead: the thread reads 3 frames ahead between requests, so playback and export normally get a frame that's already decoded.
  • Looping:
    • The first frames of the clip stay decoded (up to 32 MB), so the loop restarts without a seek.
    • Short clips and most GIFs fit completely and never decode again after opening.
    • Any other jump of more than 1.5 s seeks; smaller jumps decode forward.
  • Failures: a file that won't open logs a warning and falls back to white, like a broken image does, and it isn't retried every frame. Background blur recomputes per frame, the same as for the animated gradient.

Benchmark

background-benchmark (existing example) renders 1920×1080 frames back to back, with no idle time for read-ahead, so these numbers are the worst case. Apple M5 Max, 300 frames after 20 warm-up frames, mean of 2 runs:

Background Playback RGBA mean / p95 Export NV12 mean / p95
Solid colour 1.78 / 1.94 ms 0.54 / 0.66 ms
PNG image 1.81 / 2.02 ms 0.60 / 0.70 ms
GIF 480×270 15 fps 1.86 / 2.12 ms 0.63 / 1.09 ms
H.264 1080p30 2.20 / 2.83 ms 1.30 / 1.63 ms
HEVC 2160p60 7.05 / 7.74 ms 7.04 / 7.59 ms

For a 4K60 source, the cost is the decode (~3.3 ms including the hardware frame transfer) plus the 4K → 1920 conversion (~3.8 ms with AREA; it was 5.4 ms with bilinear). Snapshots at output frames 45 and 330 show the clip's burned-in counter at 0.733 s (source frame 22) and 5.500 s (frame 165), so playback stays in sync.

Optimisation pass

Measured on the M5 Max with VideoToolbox, before and after the third commit, timing frame_at on its own:

Case Before After
4K60 HEVC, export pace (back to back) 7.51 ms 4.70 ms
4K60 HEVC behind a 30 fps export 14.97 ms 8.62 ms
4K60 HEVC, jump to a random time 612 ms 298 ms
1080p30 H.264, jump to a random time 214 ms 152 ms
Any clip at 60 fps playback pace 0.04 ms 0.04 ms

I also tried software decoding on macOS. It's about 3x faster back to back (4K export 1.6 ms), but it uses 8.5 cores to get there, against 1.1 for VideoToolbox. A decorative layer shouldn't take CPU from the recording's own decode and encode, especially on smaller machines, so hardware decoding stays. Jumping to a random time costs whatever it takes to decode forward from the previous keyframe, so clips with a short keyframe interval (-g 30) scrub faster.

Windows and Linux

Measured on GitHub's hosted x86 runners (4 vCPUs, AMD EPYC, no GPU) with a throwaway workflow on my fork (run). Neither runner has a GPU, so D3D11VA isn't available and both decode in software, which is the worst case.

Decode thread on its own (time for frame_at to return):

Clip Windows, 60 fps pace Linux, 60 fps pace Windows, back to back Linux, back to back
GIF 480×270 0.06 ms 0.07 ms 0.04 ms 0.03 ms
H.264 1080p30 0.05 ms 0.07 ms 1.97 ms 1.81 ms
HEVC 2160p60 0.05 ms 0.78 ms 11.8 ms 15.4 ms

At playback pace, read-ahead hides the decode completely, even for 4K HEVC in software. Back to back (export with nothing else to do) is the raw software decode-and-scale cost.

Full render, extra time per frame vs. a PNG background. Frames are drawn by WARP on Windows and lavapipe on Linux, so absolute times (~105 ms and ~35 ms per frame) aren't meaningful, only the difference:

Background Windows playback / export Linux playback / export
GIF 480×270 −0.3 / −0.5 ms −0.9 / −0.8 ms
H.264 1080p30 +2.7 / +2.3 ms +2.4 / +2.4 ms
HEVC 2160p60 +13.7 / +13.5 ms +13.7 / +13.8 ms

This run found a bug, fixed in the second commit. When a request landed exactly on the newest decoded frame, the reader seeked to the previous keyframe instead of decoding one more frame. On the Mac, read-ahead always got there first, but on these runners it happened on almost every frame of an export: 4K averaged 223 ms with 2.4 s spikes, and now averages 15 ms with a 19 ms worst case.

Testing

  • New tests in video_background.rs:
    • A small GIF plays and loops from memory.
    • A longer clip streams, seeks backwards and forwards, and loops with a 5-frame head cache.
    • The poster frame is the scaled first frame.
    • A missing file fails to open.
    • Extension and size-step helpers.
    • Playing forward without read-ahead never seeks (regression test for the fix below).
  • New GPUI test: videos get a poster, and GIFs still decode as images.
  • cargo test -p cap-rendering: all pass (424 lib tests).
  • cargo +1.95.0 test --bin cap-gpui: 1522 passed, 0 failed.
  • Clippy: nothing new in the touched files.

Not done yet:

  • Hand test in the GPUI editor on macOS (M5 Max) with an MP4 background (960×540): it previews, and a 3444×2160 60 fps export finished with the background moving (the edge of the frame differs at 1 s, 4 s and 8 s). The log shows VideoToolbox decoding it.
  • Same hand test with a MOV and a GIF (960×540): both exported, per the log.
  • Hand test on Windows and Linux, and a benchmark on a Windows machine with a GPU (to cover D3D11VA). The software path is covered by the runner numbers above.
  • The legacy Tauri editor UI isn't changed. Its renderer plays videos too, but its picker still only offers images.

To reproduce the benchmark:

cargo build --release -p cap-rendering --example background-benchmark
target/release/examples/background-benchmark --backgrounds cases.json --frames 300 --repetitions 2

where cases.json is [{"name":"video","source":{"type":"image","path":"/abs/path/clip.mp4"}}].

RetriggerView in GreptileConfidence Score: 2/5

Fix incorrect frames after scrubbing, shortened GIF loops, and sideways portrait videos before merging.

Findings

  1. P1 Scrubbing shows an earlier frame ▶
  2. P1 GIFs restart too early ▶
  3. P1 Portrait videos appear sideways ▶
  4. P2 Videos look squeezed ▶
  5. P2 Comments repeat the code ▶
Fix with agent prompt
### Issue 1
crates/rendering/src/layers/video_background.rs:497-503
`prefetch` drops frames based on the previous request interval, but `covering_frame` treats the remaining frames as continuous coverage. For a streaming 10 fps clip, requests spaced 0.5 seconds apart can leave cached frames at 1.5 and 2.0 seconds. A later scrub to 1.7 seconds returns the 1.5-second frame without decoding again.

Keep each cached frame's actual end time and decode again when a request lands in a skipped gap. Add a test that changes request spacing after read-ahead.

### Issue 2
crates/rendering/src/layers/video_background.rs:615-620
`reached_end` replaces the file's duration with the last frame's start time plus the previous frame interval. That interval does not include the last frame's own delay. A GIF with frames starting at 0, 0.1 and 0.2 seconds, with the last frame held for two seconds, loops after 0.3 seconds instead of 2.2 seconds.

Preserve a valid file duration or use the last frame's actual duration before falling back to the previous interval.

### Issue 3
crates/rendering/src/layers/video_background.rs:632-645
The new video path scales decoded pixels without applying the file's display rotation. Portrait phone videos commonly store landscape pixels with a 90-degree rotation tag, so these files appear sideways in both the background and its poster.

Read and apply the display rotation before choosing the displayed dimensions and crop.

### Issue 4
crates/rendering/src/layers/video_background.rs:625-629
`output_size` preserves the stored pixel dimensions but ignores the video's sample aspect ratio. Non-square-pixel videos, such as 720×576 footage meant to display at 16:9, are stretched into the wrong shape before the background crop is applied.

Include the sample aspect ratio when choosing the output dimensions.

### Issue 5
crates/rendering/src/layers/video_background.rs:531
The added `decode_next` comment only repeats what the function does. The repository guide requires comments to explain non-obvious context and explicitly bans comments that narrate the code. Remove this comment and the equivalent poster description at lines 54–55 before merging. Keep the comments that explain seek behavior and scaling trade-offs.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

Adds looping video and GIF backgrounds to the shared renderer and video selection to the GPUI editor.

  • Video and GIF backgrounds loop behind recordings in previews and exports.
  • The editor's background picker accepts videos alongside images and GIFs.

Reviews (1) · Last reviewed commit: "perf(rendering): scale video backgrounds..." · Reviewed by Greptile

An image background whose file is a video (mp4, mov, m4v, webm, mkv) or a
GIF now plays and loops instead of showing its first frame. Each one is
decoded on its own thread with FFmpeg (VideoToolbox on macOS, D3D11VA on
Windows, software elsewhere), scaled once to RGBA no larger than the output
needs (max 1920px), and read ahead between frames. The start of the clip
stays decoded so a loop restarts without a seek, and short clips and most
GIFs stay entirely in memory.

The GPUI editor's image pane now also accepts videos and shows their first
frame as the thumbnail.
@vercel

vercel Bot commented Oct 27, 2026

Copy link
Copy Markdown
Contributor

@AatmanAJ is attempting to deploy a commit to the Cap Software Inc Team on Vercel.

A member of the Team first needs to authorize it.

@AatmanAJ
AatmanAJ marked this pull request as ready for review October 28, 2026 15:04
Comment on lines +497 to +503
let interval = self.request_interval;
let next = self.next_shown(|frame_time| match interval {
Some(interval) if frame_time > anchor => {
anchor + ((frame_time - anchor) / interval - TIME_EPSILON).ceil() * interval
}
_ => frame_time.max(anchor),
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Scrubbing shows an earlier frame

prefetch drops frames based on the previous request interval, but covering_frame treats the remaining frames as continuous coverage. For a streaming 10 fps clip, requests spaced 0.5 seconds apart can leave cached frames at 1.5 and 2.0 seconds. A later scrub to 1.7 seconds returns the 1.5-second frame without decoding again.

Keep each cached frame's actual end time and decode again when a request lands in a skipped gap. Add a test that changes request spacing after read-ahead.

Knowledge Base Used: Roll Back render-farm padding and opening-frame replay

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/rendering/src/layers/video_background.rs
Line: 497-503

Comment:
**Scrubbing shows an earlier frame**

`prefetch` drops frames based on the previous request interval, but `covering_frame` treats the remaining frames as continuous coverage. For a streaming 10 fps clip, requests spaced 0.5 seconds apart can leave cached frames at 1.5 and 2.0 seconds. A later scrub to 1.7 seconds returns the 1.5-second frame without decoding again.

Keep each cached frame's actual end time and decode again when a request lands in a skipped gap. Add a test that changes request spacing after read-ahead.

**Knowledge Base Used:** [Roll Back render-farm padding and opening-frame replay](https://app.greptile.com/cap/-/custom-context/knowledge-base/capsoftware/cap/-/reverts/rollback_2393-20260930-zero-padded-exports-and-held-opening-frames-9228121.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment on lines +615 to +620
fn reached_end<T>(&mut self) -> Option<T> {
if let Some(last) = self.last_time {
let end = last + self.frame_interval;
if end > 0.0 {
self.duration = end;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 GIFs restart too early

reached_end replaces the file's duration with the last frame's start time plus the previous frame interval. That interval does not include the last frame's own delay. A GIF with frames starting at 0, 0.1 and 0.2 seconds, with the last frame held for two seconds, loops after 0.3 seconds instead of 2.2 seconds.

Preserve a valid file duration or use the last frame's actual duration before falling back to the previous interval.

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/rendering/src/layers/video_background.rs
Line: 615-620

Comment:
**GIFs restart too early**

`reached_end` replaces the file's duration with the last frame's start time plus the previous frame interval. That interval does not include the last frame's own delay. A GIF with frames starting at 0, 0.1 and 0.2 seconds, with the last frame held for two seconds, loops after 0.3 seconds instead of 2.2 seconds.

Preserve a valid file duration or use the last frame's actual duration before falling back to the previous interval.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment on lines +632 to +645
fn scale(&mut self, decoded: &frame::Video) -> Option<(Vec<u8>, u32, u32)> {
let input = (decoded.format(), decoded.width(), decoded.height());
if input.1 == 0 || input.2 == 0 {
return None;
}

if self
.scaler
.as_ref()
.is_none_or(|scaler| scaler.input != input)
{
let size = self.output_size(input.1, input.2);
let bt709 = decoded.color_space() == ffmpeg::util::color::Space::BT709;
self.scaler = Some(Scaler::new(input, size, bt709)?);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Portrait videos appear sideways

The new video path scales decoded pixels without applying the file's display rotation. Portrait phone videos commonly store landscape pixels with a 90-degree rotation tag, so these files appear sideways in both the background and its poster.

Read and apply the display rotation before choosing the displayed dimensions and crop.

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/rendering/src/layers/video_background.rs
Line: 632-645

Comment:
**Portrait videos appear sideways**

The new video path scales decoded pixels without applying the file's display rotation. Portrait phone videos commonly store landscape pixels with a 90-degree rotation tag, so these files appear sideways in both the background and its poster.

Read and apply the display rotation before choosing the displayed dimensions and crop.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment on lines +625 to +629
fn output_size(&self, width: u32, height: u32) -> (u32, u32) {
let longest = width.max(height).max(1);
let scale = (f64::from(self.max_dimension) / f64::from(longest)).min(1.0);
let even = |value: u32| ((f64::from(value) * scale).round() as u32).max(2) & !1;
(even(width), even(height))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Videos look squeezed

output_size preserves the stored pixel dimensions but ignores the video's sample aspect ratio. Non-square-pixel videos, such as 720×576 footage meant to display at 16:9, are stretched into the wrong shape before the background crop is applied.

Include the sample aspect ratio when choosing the output dimensions.

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/rendering/src/layers/video_background.rs
Line: 625-629

Comment:
**Videos look squeezed**

`output_size` preserves the stored pixel dimensions but ignores the video's sample aspect ratio. Non-square-pixel videos, such as 720×576 footage meant to display at 16:9, are stretched into the wrong shape before the background crop is applied.

Include the sample aspect ratio when choosing the output dimensions.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

}
}

/// The next frame, decoded and scaled.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Comments repeat the code

The added decode_next comment only repeats what the function does. The repository guide requires comments to explain non-obvious context and explicitly bans comments that narrate the code. Remove this comment and the equivalent poster description at lines 54–55 before merging. Keep the comments that explain seek behavior and scaling trade-offs.

Context Used: CLAUDE.md (source)

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/rendering/src/layers/video_background.rs
Line: 531

Comment:
**Comments repeat the code**

The added `decode_next` comment only repeats what the function does. The repository guide requires comments to explain non-obvious context and explicitly bans comments that narrate the code. Remove this comment and the equivalent poster description at lines 54–55 before merging. Keep the comments that explain seek behavior and scaling trade-offs.

**Context Used:** CLAUDE.md ([source](https://github.com/capsoftware/cap/blob/main/CLAUDE.md))

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant