Skip to content

perf(rendering): reuse styled project snapshots across frames (352–2,135× faster warm CPU preparation) - #3112

Merged
richiemcilroy merged 2 commits into
CapSoftware:mainfrom
kartikkabadi:perf/reuse-active-style-snapshots
Oct 24, 2026
Merged

richiemcilroy merged 2 commits into
CapSoftware:mainfrom
kartikkabadi:perf/reuse-active-style-snapshots

Conversation

@kartikkabadi

@kartikkabadi kartikkabadi commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Styled frames currently clone and later drop the full ProjectConfiguration, including unrelated caption and keyboard data, on every call to ProjectUniforms::new. Reuse one resolved snapshot while the source Arc and winning background/camera/cursor style segments remain the same.

frame -> select active styles
      -> no overrides: share source Arc
      -> same source + selected styles: share cached snapshot
      -> otherwise: resolve once and replace cached snapshot

A Weak source reference prevents allocation identity reuse and makes Arc::make_mut edits invalidate the entry. Frame snapshots keep their own references. Full cloning and destruction stay outside the mutex; concurrent misses reconcile before publishing. Time-dependent uniforms still update every frame. The cache retains one resolved snapshot, including across later unstyled calls.

The diff contains only configuration/cache logic, renderer integration, and focused regression tests in three existing files. Benchmark programs and investigation logs are provided below as review evidence rather than permanent repository files.

This completes the active-style case left by #2483, which shares the base project between frames, and #2957, which selects style groups in one pass. Both still clone the project when an override applies. #2620 keeps the editor's source Arc stable; this change reuses the resolved styled result. A fresh audit of all 145 open PRs, 500 recently updated merged PRs, and the patches for 55 related candidates found no equivalent published change.

Evidence

Before, every styled request cloned the project. After, matching warmed requests reuse the same snapshot, with zero allocations in the measured cache-hit path.

Independent local baseline/cached comparison against e258c87007d9eb6b2ff2b4f73cd645ec2bdb9fa7:

Scope Caption data Before After Median paired speedup
ProjectUniforms construction/release 10 min 139.438 µs/frame 0.392 µs/frame 352×
ProjectUniforms construction/release 60 min 872.760 µs/frame 0.391 µs/frame 2,135×
Metal render plus GPU readback, captions/screen/camera enabled 10 min 1.666 ms/frame 1.499 ms/frame 1.12×
Metal render plus GPU readback, captions/screen/camera enabled 60 min 2.427 ms/frame 1.548 ms/frame 1.49×

Five fresh process pairs alternated baseline/cached order. Times are medians of per-process medians; speedups are medians of paired ratios. All five native-render pairs improved. A separate macOS sampling profile identifies project/String/Vec copying and allocation under ProjectUniforms -> style_at in the baseline.

In a separate allocation fixture, uncached styled resolution made 6,031 allocations/query for 10-minute data and 36,061 for 60-minute data. Warm hits made zero. Cold cached resolution made 6,032/36,062 allocations. This fixture has a different caption density and keyboard segments; it is not the uniform fixture.

Output checks:

  • 110 captured baseline/cached native images matched byte for byte. Native preview/export buffers also matched.
  • All 40 full decode/render/H.264 exports completed at 640×360 with the expected frame counts. Twenty before/after pairs matched every decoded frame: 20,100 corresponding frames total.
  • An independent pre-change oracle passed 151,808 queries over 256 seeded projects, including boundaries, overlapping priorities, reverse seeks, edits and retained snapshots. Its source is below.
  • Existing renderer style and pixel tests cover the actual integration. Focused permanent tests cover reuse, source/frame edits, eviction, boundaries and concurrent callers.

Scope and tradeoffs

The large multiplier measures warmed ProjectUniforms construction/release, not application FPS or overall export speed. That fixture loads captions but leaves caption rendering disabled and supplies no decoded video/camera frames. The separate native probe enables captions and supplies screen/camera buffers, rendering and reading back actual Metal output at 640×360. It excludes decode/encode.

The complete export probes use seven-second and one-minute source clips carrying 10/60 minutes worth of caption records. They are not actual 10/60-minute videos. Export timings were inconclusive under variable background load. Median paired throughput ratios were 0.82×/0.99× for seven-second clips and 0.94×/1.05× for one-minute clips. No full-export speedup or regression is established.

Cold misses retain the full clone cost. Frequent random style changes can lose the benefit. In one seven-sample process, the 60-minute random-seek fixture was about 18% slower with caching and still made about 35,357 allocations/query. This needs a quiet-machine measurement before claiming a general seek regression. Unstyled frames do not lock or allocate. Callers that create a fresh source Arc every frame cannot expect warmed hits.

Host: Apple M1 Pro, 16 GB, macOS 27.0.1, Metal. Root benchmark builds used pinned Rust 1.88.0, opt-level=s, thin LTO, one codegen unit, and release debug info disabled. The standalone shipping GPUI workspace defaults to optimization level 3, so these are not timings of the complete desktop binary. Desktop GUI interaction and fresh Windows/Linux execution were not tested.

Checks

The full local regression run passed 951 tests on upstream 12ee63a24d1de2eb63d6cd68c75011a993f6726e. The final branch is based on f510acfa1846552a490bfcc3267910218c606364, which adds an unrelated screenshot optimization and web test; the project/renderer/editor/export code and this patch are unchanged between those bases. Formatting, the CI-equivalent workspace lint command and the desktop compile check were rerun on the final base. Rust commands use pinned 1.88.0 unless noted.

  • Passed: cargo +1.88.0 fmt --all -- --check.
  • Passed: cargo +1.88.0 check --locked -p cap-project -p cap-rendering --all-targets --all-features.
  • Passed: cargo +1.88.0 test --locked -p cap-project --lib -- --test-threads=1.
  • Passed: cargo +1.88.0 test --locked -p cap-rendering -- --test-threads=1.
  • Passed: cargo +1.88.0 test --locked -p cap-rendering --lib -- --ignored --skip benchmark --skip dump_chrome_pngs --skip camera_setup_keeps_frame_resources_out_of_first_uploads --nocapture --test-threads=1.
  • Passed: cargo +1.88.0 test --locked -p cap-editor --lib -- --test-threads=1.
  • Passed: cargo +1.88.0 test --locked -p cap-export --lib -- --test-threads=1.
  • Passed: cargo +1.88.0 test --locked -p cap-utils -p cap-cli-install --lib.
  • Passed: cargo +1.88.0 clippy --workspace --exclude cap-desktop --all-features --locked -- -D warnings.
  • Passed: cargo +1.88.0 test --locked --release -p cap-project --test independent_style_cache -- --test-threads=1.
  • Passed: cargo +1.96.1 check --locked --manifest-path apps/desktop-gpui/Cargo.toml. This is a compile check; GPUI CI uses Rust 1.95.0.

The independent oracle is a temporary integration test provided below and is not part of the repository diff. The normal project/renderer/editor/export suites and native GPU cases were run; unrelated ignored benchmarks and fixture-dependent audio/decoder tests were not enabled.

The native run excludes layers::camera::tests::camera_setup_keeps_frame_resources_out_of_first_uploads, an already ignored test proven to fail identically on the untouched baseline at layers/camera.rs:846. On macOS, RendererLayers::prepare_for_video_dimensions deliberately defers allocation for IOSurface conversion, while the test expects eager camera texture allocation. Style resolution is not reached. This pre-existing code and assertion are unchanged. The normal renderer suite passes.

Merge Danger

Door: two-way. Reverting restores per-frame configuration copying; no schema, stored project data or dependency changes.

Blast radius: rendering. Source identity or style selection errors could retain stale settings across preview/export frames. Regression tests exercise edits, style boundaries, snapshot ownership and concurrent sources. Memory retention is one cached resolved snapshot plus references held by in-flight frames. Miss-heavy work still clones the project and can pay cache overhead.

Measurement procedure and per-process results

Both variants used identical probe source, package selection, native dependencies, target settings and inputs. The uniform example performs warmup and seven samples per case; the native example performs warmup and three samples per case. Each was launched in five alternating fresh-process pairs, with no validation builds running during timing. The host still had unrelated background load.

The source programs below are temporary local examples. Copy the uniform/native/export programs to their named example paths on both the base and patched checkout. The allocation program references the new cache and runs on the patched checkout, comparing its uncached resolver and cached resolver in one binary. The independent oracle is a temporary project integration test. They need not be checked in.

Use the repository's normal native dependency setup first. Build the same package/feature selection on each side:

CARGO_INCREMENTAL=0 CARGO_PROFILE_RELEASE_DEBUG=0 cargo +1.88.0 build --locked --release \
  -p cap-rendering -p cap-export \
  --example styled-uniforms-benchmark \
  --example independent-native-probe \
  --example independent-style-export

Preserve the binaries from each checkout separately before rebuilding the other. Place relocated binaries under a directory whose ../Frameworks/Spacedrive.framework points to the installed native framework, or retain the original target-directory layout. Set ORT_DYLIB_PATH to the repository's installed ONNX runtime library when needed. Run with identical environment and alternate the launch order five times. For each native probe pair, pass different output directories and compare every generated .rgba file directly.

The allocation example uses 512 7 arguments. Its ns_per_query excludes cache setup and final destruction; add (cache_setup_ns + cache_drop_ns) / queries for an inclusive comparison. Allocation counts are separate untimed passes. warm-sequence primes its first query only; subsequent style changes remain measured misses.

Per-process values used for the local summaries follow. Units are ns/frame for uniform/native render and ms/export for export cases.

[
  {
    "scope": "uniforms",
    "case": "0m active=False",
    "before_runs": [
      557.8121583333333,
      404.778475,
      398.202775,
      393.37604166666665,
      394.0864583333333
    ],
    "after_runs": [
      393.9048583333333,
      355.41979166666664,
      355.50173333333333,
      357.72569166666665,
      356.00695
    ],
    "paired_speedup_median": 1.1201148620747465
  },
  {
    "scope": "uniforms",
    "case": "0m active=True",
    "before_runs": [
      1804.2514475743349,
      1505.485606060606,
      1705.1972811671087,
      1465.732946635731,
      1483.8227688787185
    ],
    "after_runs": [
      396.89548333333335,
      405.5809,
      389.977775,
      389.24965,
      393.7767333333333
    ],
    "paired_speedup_median": 3.7681829404142517
  },
  {
    "scope": "uniforms",
    "case": "10m active=False",
    "before_runs": [
      440.490625,
      394.8788166666667,
      426.94549166666667,
      391.9993083333333,
      409.14549166666666
    ],
    "after_runs": [
      356.12708333333336,
      360.82013333333333,
      355.059375,
      353.009375,
      355.18645833333335
    ],
    "paired_speedup_median": 1.1519174846544802
  },
  {
    "scope": "uniforms",
    "case": "10m active=True",
    "before_runs": [
      140838.19583333333,
      139437.84583333333,
      157010.59166666667,
      136625.175,
      138390.27708333332
    ],
    "after_runs": [
      391.90625,
      523.7854166666667,
      387.58576666666664,
      388.15555,
      393.38298333333336
    ],
    "paired_speedup_median": 351.9856279267422
  },
  {
    "scope": "uniforms",
    "case": "60m active=False",
    "before_runs": [
      394.246525,
      409.5493,
      401.89479166666666,
      392.9684,
      392.371525
    ],
    "after_runs": [
      356.1413166666667,
      408.72743333333335,
      352.5864583333333,
      355.509725,
      414.39861666666667
    ],
    "paired_speedup_median": 1.1053661049637953
  },
  {
    "scope": "uniforms",
    "case": "60m active=True",
    "before_runs": [
      872759.725,
      874996.1833333333,
      1162243.4083333334,
      812397.575,
      820807.9833333333
    ],
    "after_runs": [
      390.89166666666665,
      409.8170166666667,
      387.81875,
      389.71110833333336,
      445.1295166666667
    ],
    "paired_speedup_median": 2135.089924889649
  },
  {
    "scope": "native-render",
    "case": "10m",
    "before_runs": [
      1777479.1666666667,
      1616854.1666666667,
      1689203.125,
      1611585.0833333333,
      1665555.5833333333
    ],
    "after_runs": [
      1499479.1666666667,
      1487437.5,
      1500428.8333333333,
      1437321.1666666667,
      1548704.875
    ],
    "paired_speedup_median": 1.121242155690789
  },
  {
    "scope": "native-render",
    "case": "60m",
    "before_runs": [
      2426748.2916666665,
      2447534.75,
      2305314.2083333335,
      2472010.4166666665,
      2380328.125
    ],
    "after_runs": [
      1453064.25,
      1902649.2916666667,
      1547784.7083333333,
      1465533.0,
      1702954.875
    ],
    "paired_speedup_median": 1.4894282104749013
  },
  {
    "scope": "export",
    "case": "10m export_ms",
    "before_runs": [
      949.26225,
      626.035084,
      700.183042,
      985.064125,
      603.90175
    ],
    "after_runs": [
      567.4071670000001,
      761.502167,
      1237.8901250000001,
      608.519083,
      1704.8805
    ],
    "paired_speedup_median": 0.8221054530498795
  },
  {
    "scope": "export",
    "case": "60m export_ms",
    "before_runs": [
      631.917041,
      912.5153750000001,
      577.249459,
      1028.923541,
      950.662833
    ],
    "after_runs": [
      636.2462089999999,
      1113.382166,
      601.345125,
      941.0375829999999,
      767.800917
    ],
    "paired_speedup_median": 0.9931957661377596
  },
  {
    "scope": "export",
    "case": "60s clip 10m export_ms",
    "before_runs": [
      5325.474375,
      3455.3021670000003,
      15635.257875000001,
      2487.368917,
      2690.102583
    ],
    "after_runs": [
      3542.4995,
      3960.4449160000004,
      3370.2108329999996,
      3340.155792,
      2867.348625
    ],
    "paired_speedup_median": 0.9381846907437005
  },
  {
    "scope": "export",
    "case": "60s clip 60m export_ms",
    "before_runs": [
      5510.017666,
      9736.093291,
      15778.977333,
      2478.722416,
      2633.77925
    ],
    "after_runs": [
      3061.388167,
      10440.471208,
      5550.754041,
      2467.545333,
      2514.443458
    ],
    "paired_speedup_median": 1.0474601214914254
  }
]
CPU uniform benchmark

Temporary file: crates/rendering/examples/styled-uniforms-benchmark.rs

use anyhow::{Result, ensure};
use cap_project::{
    BackgroundConfiguration, Camera, CursorConfiguration, CursorEvents, ProjectConfiguration,
    RecordingMeta, StyleOverrides, StyleSegment, XY,
};
use cap_rendering::{
    DecodedSegmentFrames, ProjectUniforms, RenderOptions, RenderVideoConstants,
    ZoomTransformTimeline,
};
use std::{hint::black_box, sync::Arc, time::Instant};

const FPS: u32 = 60;
const FRAME_CYCLE: usize = 120;
const WARMUP_FRAMES: usize = FRAME_CYCLE * 2;
const SAMPLES: usize = 7;
const SCREEN: XY<u32> = XY { x: 1920, y: 1080 };

fn fixture(caption_minutes: u32) -> Result<(Arc<ProjectConfiguration>, f64, usize)> {
    let caption_count = caption_minutes as usize * 20;
    let duration = f64::from(caption_minutes * 60).max(10.0);
    let words = [
        "First",
        "open",
        "the",
        "project",
        "and",
        "select",
        "this",
        "recording.",
    ];
    let captions: Vec<_> = (0..caption_count)
        .map(|index| {
            let start = index as f64 * 3.0;
            serde_json::json!({
                "id": format!("caption-{index}"),
                "start": start,
                "end": start + 2.8,
                "text": words.join(" "),
                "words": words.iter().enumerate().map(|(word, text)| {
                    serde_json::json!({
                        "text": text,
                        "start": start + word as f64 * 0.35,
                        "end": start + word as f64 * 0.35 + 0.3,
                    })
                }).collect::<Vec<_>>(),
            })
        })
        .collect();
    let mut project: ProjectConfiguration = serde_json::from_value(serde_json::json!({
        "timeline": {
            "segments": [{"recordingClip": 0, "start": 0.0, "end": duration, "timescale": 1.0}],
            "zoomSegments": [],
            "captionSegments": captions,
        },
        "captions": {"segments": captions, "settings": {}},
    }))?;
    let mut cursor = CursorConfiguration::default();
    cursor.size = 120;
    let overrides = [
        StyleOverrides {
            background: Some(BackgroundConfiguration {
                padding: 6.0,
                ..Default::default()
            }),
            ..Default::default()
        },
        StyleOverrides {
            background: Some(BackgroundConfiguration {
                padding: 18.0,
                ..Default::default()
            }),
            ..Default::default()
        },
        StyleOverrides {
            camera: Some(Camera {
                size: 32.0,
                ..Default::default()
            }),
            ..Default::default()
        },
        StyleOverrides {
            cursor: Some(cursor),
            ..Default::default()
        },
    ];
    project.timeline.as_mut().unwrap().style_segments = overrides
        .into_iter()
        .enumerate()
        .map(|(index, overrides)| StyleSegment {
            start: 1.0,
            end: 5.0,
            track: index as u32,
            overrides,
            ..Default::default()
        })
        .collect();
    Ok((Arc::new(project), duration, caption_count))
}

fn measure(prepare: &impl Fn(u32) -> ProjectUniforms, first_frame: u32, iterations: usize) -> u128 {
    let start = Instant::now();
    for iteration in 0..iterations {
        let frame_number = first_frame + (iteration % FRAME_CYCLE) as u32;
        let uniforms = prepare(black_box(frame_number));
        black_box(&uniforms);
        drop(uniforms);
    }
    start.elapsed().as_nanos()
}

#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<()> {
    let fixed_iterations = std::env::var("CAP_STYLE_UNIFORMS_ITERATIONS")
        .ok()
        .map(|value| value.parse::<usize>())
        .transpose()?;
    ensure!(fixed_iterations.is_none_or(|iterations| iterations >= FRAME_CYCLE));
    let metadata: RecordingMeta = serde_json::from_value(serde_json::json!({
        "pretty_name": "Styled uniforms benchmark",
        "display": {"path": "synthetic.mp4", "fps": FPS},
        "camera": null,
        "audio": null,
        "cursor": null,
    }))?;
    let studio = metadata.studio_meta().unwrap().clone();
    let constants = RenderVideoConstants::new_with_options(
        RenderOptions {
            screen_size: SCREEN,
            camera_size: None,
            preserve_screen_alpha: false,
        },
        metadata,
        studio,
    )
    .await?;
    println!(
        "{}",
        serde_json::json!({
            "kind": "metadata",
            "benchmark": "styled-uniforms",
            "scope": "Full CPU ProjectUniforms construction and destruction; excludes decoding, GPU submission, encoding, and GPUI scheduling",
            "adapter": constants.adapter_name(),
            "software_adapter": constants.is_software_adapter,
            "synthetic_input": "Frame dimensions and timestamps; no camera frame or cursor events",
            "fps": FPS,
            "output_size": [SCREEN.x, SCREEN.y],
            "samples": SAMPLES,
            "warmup_frames": WARMUP_FRAMES,
            "fixed_iterations": fixed_iterations,
        })
    );
    let events = CursorEvents::default();
    for caption_minutes in [0, 10, 60] {
        let (project, duration, caption_count) = fixture(caption_minutes)?;
        let mut zoom = ZoomTransformTimeline::from_project(&project, &events, duration, SCREEN);
        zoom.ensure_precomputed_until(8.0);
        let prepare = |frame_number: u32| {
            let time = frame_number as f32 / FPS as f32;
            let frames = DecodedSegmentFrames {
                screen_size: SCREEN,
                screen_frame: None,
                camera_frame: None,
                segment_time: time,
                recording_time: time,
                segment_has_camera: false,
            };
            ProjectUniforms::new(
                black_box(&constants),
                black_box(&project),
                frame_number,
                black_box(FPS),
                black_box(SCREEN),
                black_box(&events),
                black_box(&frames),
                black_box(duration),
                black_box(&zoom),
            )
        };
        for style_active in [false, true] {
            let first_frame = if style_active { FPS } else { FPS * 6 };
            for frame_number in [first_frame, first_frame + FRAME_CYCLE as u32 - 1] {
                let uniforms = prepare(frame_number);
                let expected = project.style_at(f64::from(frame_number) / f64::from(FPS));
                ensure!(
                    serde_json::to_value(uniforms.project.as_ref())?
                        == serde_json::to_value(expected.as_ref())?
                );
                ensure!(uniforms.frame_number == frame_number && uniforms.frame_rate == FPS);
            }
            let warmup_ns = measure(&prepare, first_frame, WARMUP_FRAMES);
            let ns_per_frame = warmup_ns as f64 / WARMUP_FRAMES as f64;
            let iterations = fixed_iterations.unwrap_or_else(|| {
                let target = (75_000_000.0 / ns_per_frame).clamp(FRAME_CYCLE as f64, 120_000.0);
                (target as usize / FRAME_CYCLE).max(1) * FRAME_CYCLE
            });
            let mut samples = Vec::with_capacity(SAMPLES);
            for sample in 0..SAMPLES {
                let elapsed_ns = measure(&prepare, first_frame, iterations);
                let ns_per_frame = elapsed_ns as f64 / iterations as f64;
                samples.push(ns_per_frame);
                println!(
                    "{}",
                    serde_json::json!({
                        "kind": "sample",
                        "caption_minutes": caption_minutes,
                        "caption_segments": caption_count,
                        "style_segments": 4,
                        "style_active": style_active,
                        "sample": sample + 1,
                        "iterations": iterations,
                        "elapsed_ns": elapsed_ns,
                        "ns_per_frame": ns_per_frame,
                    })
                );
            }
            samples.sort_by(f64::total_cmp);
            println!(
                "{}",
                serde_json::json!({
                    "kind": "summary",
                    "caption_minutes": caption_minutes,
                    "caption_segments": caption_count,
                    "style_active": style_active,
                    "iterations_per_sample": iterations,
                    "median_ns_per_frame": samples[SAMPLES / 2],
                    "min_ns_per_frame": samples[0],
                    "max_ns_per_frame": samples[SAMPLES - 1],
                })
            );
        }
    }
    Ok(())
}
Allocation, cold, boundary and random-seek benchmark

Temporary file: crates/project/examples/style-cache-benchmark.rs

use cap_project::{
    BackgroundConfiguration, Camera, CaptionSegment, CaptionSettings, CaptionWord, CaptionsData,
    CursorConfiguration, KeyPressDisplay, KeyboardData, KeyboardSettings, KeyboardTrackSegment,
    ProjectConfiguration, ProjectStyleCache, StyleOverrides, StyleSegment, TimelineConfiguration,
    derive_caption_track_segments,
};
use std::{
    alloc::{GlobalAlloc, Layout, System},
    borrow::Cow,
    error::Error,
    hint::black_box,
    sync::{
        Arc,
        atomic::{AtomicBool, AtomicU64, Ordering},
    },
    time::Instant,
};

struct CountingAllocator;

static COUNT_ALLOCATIONS: AtomicBool = AtomicBool::new(false);
static ALLOCATIONS: AtomicU64 = AtomicU64::new(0);
static ALLOCATED_BYTES: AtomicU64 = AtomicU64::new(0);

fn count_allocation(pointer: *mut u8, bytes: usize) {
    if !pointer.is_null() && COUNT_ALLOCATIONS.load(Ordering::Relaxed) {
        ALLOCATIONS.fetch_add(1, Ordering::Relaxed);
        ALLOCATED_BYTES.fetch_add(bytes as u64, Ordering::Relaxed);
    }
}

unsafe impl GlobalAlloc for CountingAllocator {
    unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
        let pointer = unsafe { System.alloc(layout) };
        count_allocation(pointer, layout.size());
        pointer
    }

    unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 {
        let pointer = unsafe { System.alloc_zeroed(layout) };
        count_allocation(pointer, layout.size());
        pointer
    }

    unsafe fn dealloc(&self, pointer: *mut u8, layout: Layout) {
        unsafe { System.dealloc(pointer, layout) };
    }

    unsafe fn realloc(&self, pointer: *mut u8, layout: Layout, new_size: usize) -> *mut u8 {
        let pointer = unsafe { System.realloc(pointer, layout, new_size) };
        count_allocation(pointer, new_size);
        pointer
    }
}

#[global_allocator]
static ALLOCATOR: CountingAllocator = CountingAllocator;

fn fixture(seconds: usize, styles: usize) -> ProjectConfiguration {
    let captions: Vec<_> = (0..seconds / 2)
        .map(|index| {
            let start = (index * 2) as f32;
            CaptionSegment {
                id: format!("caption-{index}"),
                start,
                end: start + 2.0,
                text: "Here is the next step.".into(),
                words: ["Here", "is", "the", "next", "step."]
                    .into_iter()
                    .enumerate()
                    .map(|(word, text)| CaptionWord {
                        text: text.into(),
                        start: start + word as f32 * 0.4,
                        end: start + word as f32 * 0.4 + 0.35,
                    })
                    .collect(),
            }
        })
        .collect();
    let mut timeline: TimelineConfiguration = serde_json::from_value(serde_json::json!({
        "segments": [{"start": 0.0, "end": seconds, "timescale": 1.0}],
        "zoomSegments": []
    }))
    .unwrap();
    timeline.caption_segments =
        derive_caption_track_segments(&captions, &timeline, &[seconds as f64]);
    timeline.keyboard_segments = (0..seconds / 3)
        .map(|index| KeyboardTrackSegment {
            id: format!("shortcut-{index}"),
            start: (index * 3) as f64,
            end: (index * 3) as f64 + 0.8,
            display_text: "Ctrl+Shift+K".into(),
            keys: ["Ctrl", "Shift", "K"]
                .into_iter()
                .map(|key| KeyPressDisplay {
                    key: key.into(),
                    time_offset: 0.0,
                })
                .collect(),
            fade_duration_override: None,
            position_override: None,
            color_override: None,
            background_color_override: None,
            font_size_override: None,
            uppercase_override: None,
        })
        .collect();
    timeline.style_segments = (0..styles)
        .map(|index| {
            let mut cursor = CursorConfiguration::default();
            cursor.size = 80 + (index % 5) as u32 * 5;
            StyleSegment {
                start: seconds as f64 * index as f64 / styles as f64,
                end: seconds as f64 * (index + 1) as f64 / styles as f64,
                track: 0,
                name: format!("style-{index}"),
                overrides: StyleOverrides {
                    background: Some(BackgroundConfiguration {
                        padding: 8.0 + (index % 8) as f64,
                        ..Default::default()
                    }),
                    camera: Some(Camera {
                        size: 25.0 + (index % 5) as f32,
                        ..Default::default()
                    }),
                    cursor: Some(cursor),
                    ..Default::default()
                },
                ..Default::default()
            }
        })
        .collect();
    assert_eq!(timeline.caption_segments.len(), seconds / 2);
    ProjectConfiguration {
        captions: Some(CaptionsData {
            source_timed: true,
            segments: captions,
            settings: CaptionSettings {
                enabled: true,
                ..Default::default()
            },
        }),
        keyboard: Some(KeyboardData {
            settings: KeyboardSettings {
                enabled: true,
                ..Default::default()
            },
        }),
        timeline: Some(timeline),
        ..Default::default()
    }
}

#[derive(Clone, Copy)]
enum QueryPattern {
    NoStyles,
    SteadyPlayback,
    BoundaryPlayback,
    TimelineSweep,
    RandomSeeks,
}

impl QueryPattern {
    fn name(self) -> &'static str {
        match self {
            Self::NoStyles => "no-styles",
            Self::SteadyPlayback => "steady-playback",
            Self::BoundaryPlayback => "boundary-playback",
            Self::TimelineSweep => "timeline-sweep",
            Self::RandomSeeks => "random-seeks",
        }
    }

    fn times(self, seconds: usize, styles: usize, queries: usize) -> Vec<f64> {
        let mut seed = 0x728d_6f49_a3b1_c5e7_u64;
        (0..queries)
            .map(|index| match self {
                Self::NoStyles | Self::SteadyPlayback => {
                    let span = seconds as f64 / styles.max(1) as f64;
                    let frames = (span * 0.8 * 30.0).floor().max(1.0) as usize;
                    span * 0.1 + (index % frames) as f64 / 30.0
                }
                Self::BoundaryPlayback => {
                    let boundary = seconds as f64 / styles.max(1) as f64;
                    (boundary + (index as f64 - queries as f64 / 2.0) / 30.0)
                        .clamp(0.0, seconds as f64 - 1.0 / 30.0)
                }
                Self::TimelineSweep => (index as f64 + 0.5) * seconds as f64 / queries as f64,
                Self::RandomSeeks => {
                    seed ^= seed << 13;
                    seed ^= seed >> 7;
                    seed ^= seed << 17;
                    (seed >> 11) as f64 / (1_u64 << 53) as f64 * seconds as f64
                }
            })
            .collect()
    }
}

fn baseline(project: &Arc<ProjectConfiguration>, time: f64) -> Arc<ProjectConfiguration> {
    match project.style_at(time) {
        Cow::Borrowed(_) => Arc::clone(project),
        Cow::Owned(styled) => Arc::new(styled),
    }
}

fn run_queries(
    project: &Arc<ProjectConfiguration>,
    times: &[f64],
    resolve: &mut impl FnMut(&Arc<ProjectConfiguration>, f64) -> Arc<ProjectConfiguration>,
) {
    for &time in times {
        let resolved = resolve(black_box(project), black_box(time));
        black_box(&resolved);
    }
}

fn elapsed_ns(
    project: &Arc<ProjectConfiguration>,
    times: &[f64],
    resolve: &mut impl FnMut(&Arc<ProjectConfiguration>, f64) -> Arc<ProjectConfiguration>,
) -> f64 {
    let started = Instant::now();
    run_queries(project, times, resolve);
    started.elapsed().as_secs_f64() * 1e9 / times.len() as f64
}

fn allocation_counts(
    project: &Arc<ProjectConfiguration>,
    times: &[f64],
    resolve: &mut impl FnMut(&Arc<ProjectConfiguration>, f64) -> Arc<ProjectConfiguration>,
) -> (u64, u64) {
    ALLOCATIONS.store(0, Ordering::Relaxed);
    ALLOCATED_BYTES.store(0, Ordering::Relaxed);
    COUNT_ALLOCATIONS.store(true, Ordering::Relaxed);
    run_queries(project, times, resolve);
    COUNT_ALLOCATIONS.store(false, Ordering::Relaxed);
    (
        ALLOCATIONS.load(Ordering::Relaxed),
        ALLOCATED_BYTES.load(Ordering::Relaxed),
    )
}

#[derive(Default)]
struct Timing {
    ns_per_query: f64,
    cache_setup_ns: f64,
    cache_drop_ns: f64,
}

fn cached_timing(project: &Arc<ProjectConfiguration>, times: &[f64], warm: bool) -> Timing {
    let setup = Instant::now();
    let cache = ProjectStyleCache::default();
    let cache_setup_ns = setup.elapsed().as_secs_f64() * 1e9;
    let mut resolve = |project: &Arc<ProjectConfiguration>, time| cache.resolve(project, time);
    if warm {
        run_queries(project, &times[..1], &mut resolve);
    }
    let ns_per_query = elapsed_ns(project, times, &mut resolve);
    let retirement = Instant::now();
    drop(cache);
    Timing {
        ns_per_query,
        cache_setup_ns,
        cache_drop_ns: retirement.elapsed().as_secs_f64() * 1e9,
    }
}

fn cached_allocation_counts(
    project: &Arc<ProjectConfiguration>,
    times: &[f64],
    warm: bool,
) -> (u64, u64) {
    let cache = ProjectStyleCache::default();
    let mut resolve = |project: &Arc<ProjectConfiguration>, time| cache.resolve(project, time);
    if warm {
        run_queries(project, &times[..1], &mut resolve);
    }
    allocation_counts(project, times, &mut resolve)
}

fn verify_equivalence(
    project: &Arc<ProjectConfiguration>,
    times: &[f64],
) -> Result<(), Box<dyn Error>> {
    let cache = ProjectStyleCache::default();
    for &time in times {
        let expected = baseline(project, time);
        let actual = cache.resolve(project, time);
        assert!(
            serde_json::to_vec(expected.as_ref())? == serde_json::to_vec(actual.as_ref())?,
            "cached configuration differs from the baseline at {time} seconds"
        );
    }
    Ok(())
}

fn main() -> Result<(), Box<dyn Error>> {
    let mut arguments = std::env::args().skip(1);
    let queries: usize = arguments.next().unwrap_or_else(|| "512".into()).parse()?;
    let samples: usize = arguments.next().unwrap_or_else(|| "7".into()).parse()?;
    if queries == 0 || samples == 0 || arguments.next().is_some() {
        return Err(
            "usage: style-cache-benchmark [positive-query-count] [positive-samples]".into(),
        );
    }
    eprintln!(
        "Fixtures: 2.5 spoken words/second; source and projected captions; one 3-key shortcut/3 seconds."
    );
    eprintln!(
        "Times include configuration resolution and frame-reference release; allocations are counted in separate untimed passes."
    );
    eprintln!(
        "Cold-first uses an empty cache. Warm-sequence primes only its first query; later style changes remain measured misses. Cache construction and final destruction are excluded from ns_per_query and reported separately."
    );
    println!(
        "seconds,source_captions,source_words,projected_captions,keyboard_segments,styles,pattern,phase,variant,sample,queries,ns_per_query,allocations,allocated_bytes,cache_setup_ns,cache_drop_ns"
    );
    for (seconds, style_count) in [(600, 10), (3_600, 40)] {
        for pattern in [
            QueryPattern::NoStyles,
            QueryPattern::SteadyPlayback,
            QueryPattern::BoundaryPlayback,
            QueryPattern::TimelineSweep,
            QueryPattern::RandomSeeks,
        ] {
            let styles = if matches!(pattern, QueryPattern::NoStyles) {
                0
            } else {
                style_count
            };
            let project = Arc::new(fixture(seconds, styles));
            let times = pattern.times(seconds, styles, queries);
            verify_equivalence(&project, &times)?;
            run_queries(&project, &times, &mut baseline);
            for (phase, times, warm) in [
                ("cold-first", &times[..1], false),
                ("warm-sequence", times.as_slice(), true),
            ] {
                let counts = [
                    allocation_counts(&project, times, &mut baseline),
                    cached_allocation_counts(&project, times, warm),
                ];
                for sample in 0..samples {
                    for cached in if sample % 2 == 0 {
                        [false, true]
                    } else {
                        [true, false]
                    } {
                        let (variant, timing) = if cached {
                            ("cached", cached_timing(&project, times, warm))
                        } else {
                            (
                                "baseline",
                                Timing {
                                    ns_per_query: elapsed_ns(&project, times, &mut baseline),
                                    ..Default::default()
                                },
                            )
                        };
                        let (allocations, allocated_bytes) = counts[usize::from(cached)];
                        println!(
                            "{seconds},{},{},{},{},{styles},{},{phase},{variant},{sample},{},{:.3},{allocations},{allocated_bytes},{:.3},{:.3}",
                            seconds / 2,
                            seconds / 2 * 5,
                            project.timeline.as_ref().unwrap().caption_segments.len(),
                            project.timeline.as_ref().unwrap().keyboard_segments.len(),
                            pattern.name(),
                            times.len(),
                            timing.ns_per_query,
                            timing.cache_setup_ns,
                            timing.cache_drop_ns,
                        );
                    }
                }
            }
        }
    }
    Ok(())
}
Independent original-implementation differential oracle

Temporary file: crates/project/tests/independent_style_cache.rs

use cap_project::*;
use std::{borrow::Cow, sync::Arc};

fn original_style_at(project: &ProjectConfiguration, time: f64) -> Cow<'_, ProjectConfiguration> {
    let Some(timeline) = &project.timeline else {
        return Cow::Borrowed(project);
    };
    let mut selected: [Option<&StyleSegment>; 3] = [None; 3];
    for segment in &timeline.style_segments {
        if !segment.is_active_at(time) {
            continue;
        }
        for (current, present) in selected.iter_mut().zip([
            segment.overrides.background.is_some(),
            segment.overrides.camera.is_some(),
            segment.overrides.cursor.is_some(),
        ]) {
            if present
                && current.is_none_or(|previous| {
                    segment.track > previous.track
                        || (segment.track == previous.track && segment.start >= previous.start)
                })
            {
                *current = Some(segment);
            }
        }
    }
    let [background, camera, cursor] = selected;
    let mut configuration = Cow::Borrowed(project);
    if let Some(background) = background.and_then(|segment| segment.overrides.background.as_ref()) {
        configuration.to_mut().background = background.clone();
    }
    if let Some(camera) = camera.and_then(|segment| segment.overrides.camera.as_ref()) {
        configuration.to_mut().camera = camera.clone();
    }
    if let Some(cursor) = cursor.and_then(|segment| segment.overrides.cursor.as_ref()) {
        configuration.to_mut().cursor = cursor.clone();
    }
    configuration
}

fn next(seed: &mut u64) -> u64 {
    *seed ^= *seed << 13;
    *seed ^= *seed >> 7;
    *seed ^= *seed << 17;
    *seed
}

fn check(source: &Arc<ProjectConfiguration>, cache: &ProjectStyleCache, time: f64) {
    let expected = original_style_at(source, time);
    let actual = cache.resolve(source, time);
    assert_eq!(
        serde_json::to_vec(actual.as_ref()).unwrap(),
        serde_json::to_vec(expected.as_ref()).unwrap(),
        "time={time}"
    );
    assert_eq!(
        Arc::ptr_eq(&actual, source),
        matches!(expected, Cow::Borrowed(_))
    );
}

#[test]
fn cache_matches_original_before_and_after_seeded_project_edits() {
    let cache = ProjectStyleCache::default();
    let mut seed = 0xac50_9f3b_615c_4821;
    for case in 0..256 {
        let mut source = Arc::new(ProjectConfiguration::default());
        let project = Arc::make_mut(&mut source);
        project.hidden_text_segments = vec![case];
        let mut timeline: TimelineConfiguration =
            serde_json::from_value(serde_json::json!({"segments": [], "zoomSegments": []}))
                .unwrap();
        for index in 0..48 {
            let bits = next(&mut seed);
            let start = (bits % 24) as f64 / 4.0;
            let mut segment = StyleSegment {
                start,
                end: start + ((bits >> 8) % 16) as f64 / 4.0,
                enabled: bits & 16 != 0,
                track: ((bits >> 16) % 4) as u32,
                overrides: StyleOverrides {
                    background: (bits & 1 != 0).then(|| BackgroundConfiguration {
                        padding: f64::from(index),
                        ..Default::default()
                    }),
                    camera: (bits & 2 != 0).then(|| Camera {
                        size: index as f32,
                        ..Default::default()
                    }),
                    cursor: (bits & 4 != 0).then(|| {
                        let mut cursor = CursorConfiguration::default();
                        cursor.size = index;
                        cursor
                    }),
                    camera_only_padding: Some(f64::from(index)),
                },
                ..Default::default()
            };
            match index % 19 {
                0 => segment.start = f64::NAN,
                1 => segment.end = f64::INFINITY,
                2 => segment.end = segment.start - 1.0,
                3 => segment.start = -0.0,
                _ => {}
            }
            timeline.style_segments.push(segment);
        }
        project.timeline = Some(timeline);
        let times: Vec<_> = source
            .timeline
            .as_ref()
            .unwrap()
            .style_segments
            .iter()
            .flat_map(|s| {
                [
                    s.start.next_down(),
                    s.start,
                    s.start.next_up(),
                    s.end.next_down(),
                    s.end,
                    s.end.next_up(),
                ]
            })
            .chain([f64::NAN, f64::INFINITY, f64::NEG_INFINITY, -0.0, 0.0])
            .collect();
        for &time in times.iter().chain(times.iter().rev()) {
            check(&source, &cache, time);
        }
        let held = cache.resolve(&source, 2.0);
        let held_value = serde_json::to_vec(held.as_ref()).unwrap();
        {
            let edited = Arc::make_mut(&mut source);
            edited.hidden_text_segments.push(99);
            let segments = &mut edited.timeline.as_mut().unwrap().style_segments;
            segments.reverse();
            segments.truncate(24);
            for segment in segments {
                segment.enabled = !segment.enabled;
                segment.start = 0.0;
                segment.end = 4.0;
            }
        }
        for time in [0.0, 1.0, 2.0, 3.999, 4.0, 5.0, 2.0] {
            check(&source, &cache, time);
        }
        assert_eq!(serde_json::to_vec(held.as_ref()).unwrap(), held_value);
    }
}

#[test]
fn padding_changes_within_one_cached_snapshot_remain_time_dependent() {
    let mut source: ProjectConfiguration = serde_json::from_value(serde_json::json!({
        "timeline": {"segments": [],"zoomSegments": [],"styleSegments": [
            {"start":0.0,"end":10.0,"overrides":{"background":{"padding":12.0}}},
            {"start":2.0,"end":3.0,"overrides":{"cameraOnlyPadding":27.0}}
        ]}
    }))
    .unwrap();
    source.hidden_text_segments.push(31);
    let source = Arc::new(source);
    let cache = ProjectStyleCache::default();
    let before = cache.resolve(&source, 1.0);
    let during = cache.resolve(&source, 2.5);
    let after = cache.resolve(&source, 3.0);
    assert!(Arc::ptr_eq(&before, &during) && Arc::ptr_eq(&during, &after));
    assert_eq!(before.camera_only_padding_at(1.0), 0.0);
    assert_eq!(during.camera_only_padding_at(2.5), 27.0);
    assert_eq!(after.camera_only_padding_at(3.0), 0.0);
    for time in [1.0, 2.0, 2.5, 3.0, 10.0] {
        check(&source, &cache, time);
    }
}
Native Metal rendering and pixel comparison probe

Temporary file: crates/rendering/examples/independent-native-probe.rs

use anyhow::{Result, ensure};
use cap_project::{
    BackgroundConfiguration, Camera, CursorConfiguration, CursorEvents, ProjectConfiguration,
    RecordingMeta, StyleOverrides, StyleSegment, XY,
};
use cap_rendering::{
    DecodedSegmentFrames, FrameRenderer, ProjectUniforms, RenderOptions, RenderVideoConstants,
    RendererLayers, ZoomTransformTimeline, decoder::DecodedFrame,
};
use std::{sync::Arc, time::Instant};

const FPS: u32 = 60;
const SAMPLES: usize = 3;
const SCREEN: XY<u32> = XY { x: 640, y: 360 };

fn fixture(caption_minutes: u32) -> Result<(Arc<ProjectConfiguration>, f64, usize)> {
    let caption_count = caption_minutes as usize * 20;
    let duration = f64::from(caption_minutes * 60).max(10.0);
    let words = [
        "First",
        "open",
        "the",
        "project",
        "and",
        "select",
        "this",
        "recording.",
    ];
    let captions: Vec<_> = (0..caption_count)
        .map(|index| {
            let start = index as f64 * 3.0;
            serde_json::json!({
                "id": format!("caption-{index}"),
                "start": start,
                "end": start + 2.8,
                "text": words.join(" "),
                "words": words.iter().enumerate().map(|(word, text)| {
                    serde_json::json!({
                        "text": text,
                        "start": start + word as f64 * 0.35,
                        "end": start + word as f64 * 0.35 + 0.3,
                    })
                }).collect::<Vec<_>>(),
            })
        })
        .collect();
    let mut project: ProjectConfiguration = serde_json::from_value(serde_json::json!({
        "timeline": {
            "segments": [{"recordingClip": 0, "start": 0.0, "end": duration, "timescale": 1.0}],
            "zoomSegments": [],
            "captionSegments": captions,
        },
        "captions": {"segments": captions, "settings": {}},
    }))?;
    let mut cursor = CursorConfiguration::default();
    cursor.size = 120;
    let overrides = [
        StyleOverrides {
            background: Some(BackgroundConfiguration {
                padding: 6.0,
                ..Default::default()
            }),
            ..Default::default()
        },
        StyleOverrides {
            background: Some(BackgroundConfiguration {
                padding: 18.0,
                ..Default::default()
            }),
            ..Default::default()
        },
        StyleOverrides {
            camera: Some(Camera {
                size: 32.0,
                ..Default::default()
            }),
            ..Default::default()
        },
        StyleOverrides {
            cursor: Some(cursor),
            ..Default::default()
        },
    ];
    project.timeline.as_mut().unwrap().style_segments = overrides
        .into_iter()
        .enumerate()
        .map(|(index, overrides)| StyleSegment {
            start: 1.0,
            end: 5.0,
            track: index as u32,
            overrides,
            ..Default::default()
        })
        .collect();
    Ok((Arc::new(project), duration, caption_count))
}

fn packed(frame: &cap_rendering::RenderedFrame) -> Vec<u8> {
    frame
        .data
        .chunks_exact(frame.padded_bytes_per_row as usize)
        .flat_map(|row| row[..frame.width as usize * 4].iter().copied())
        .collect()
}

#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<()> {
    let out = std::path::PathBuf::from(std::env::args().nth(1).expect("output directory"));
    std::fs::create_dir_all(&out)?;
    let metadata: RecordingMeta = serde_json::from_value(serde_json::json!({
        "pretty_name":"Independent native render probe",
        "display":{"path":"synthetic.mp4","fps":FPS},
        "camera":{"path":"camera.mp4","fps":FPS},"audio":null,"cursor":null
    }))?;
    let studio = metadata.studio_meta().unwrap().clone();
    let constants = RenderVideoConstants::new_with_options(
        RenderOptions {
            screen_size: SCREEN,
            camera_size: Some(XY::new(80, 60)),
            preserve_screen_alpha: false,
        },
        metadata,
        studio,
    )
    .await?;
    println!(
        "{}",
        serde_json::json!({"kind":"metadata","adapter":constants.adapter_name(),"software":constants.is_software_adapter,"size":[SCREEN.x,SCREEN.y],"scope":"CPU uniforms plus actual GPU render and readback, with captions enabled; no decode or encode"})
    );
    let cursor = CursorEvents::default();
    let frames = DecodedSegmentFrames {
        screen_size: SCREEN,
        screen_frame: Some(DecodedFrame::new(
            (0..SCREEN.x * SCREEN.y)
                .flat_map(|i| [(i % 251) as u8, ((i / SCREEN.x) % 251) as u8, 90, 255])
                .collect(),
            SCREEN.x,
            SCREEN.y,
        )),
        camera_frame: Some(DecodedFrame::new(
            [30, 160, 220, 255].repeat(80 * 60),
            80,
            60,
        )),
        segment_time: 0.0,
        recording_time: 0.0,
        segment_has_camera: true,
    };
    for minutes in [10, 60] {
        let (mut project, duration, _) = fixture(minutes)?;
        Arc::make_mut(&mut project)
            .captions
            .as_mut()
            .unwrap()
            .settings
            .enabled = true;
        let mut zoom = ZoomTransformTimeline::from_project(&project, &cursor, duration, SCREEN);
        zoom.ensure_precomputed_until(8.0);
        let mut renderer = FrameRenderer::new(&constants);
        let mut layers = RendererLayers::new(&constants.device, &constants.queue);
        for number in [0, 59, 60, 61, 119, 239, 299, 300, 301, 179, 60, 100] {
            let mut frame = frames.clone();
            frame.segment_time = number as f32 / FPS as f32;
            frame.recording_time = frame.segment_time;
            let uniforms = ProjectUniforms::new(
                &constants, &project, number, FPS, SCREEN, &cursor, &frame, duration, &zoom,
            );
            let preview = renderer
                .render_immediate(frame.clone(), uniforms.clone(), &cursor, true, &mut layers)
                .await?;
            let export = match renderer
                .render(frame, uniforms, &cursor, true, &mut layers)
                .await?
            {
                Some(frame) => frame,
                None => renderer.flush_pipeline().await.expect("export frame")?,
            };
            ensure!(preview.frame_number == number && export.frame_number == number);
            ensure!(
                packed(&preview) == packed(&export),
                "preview/export mismatch at {number}"
            );
            std::fs::write(
                out.join(format!("{minutes}-{number}.rgba")),
                packed(&preview),
            )?;
            println!(
                "{}",
                serde_json::json!({"kind":"pixels","minutes":minutes,"frame":number,"width":preview.width,"height":preview.height,"preview_export_equal":true})
            );
        }
        for number in 60..84 {
            let uniforms = ProjectUniforms::new(
                &constants, &project, number, FPS, SCREEN, &cursor, &frames, duration, &zoom,
            );
            std::hint::black_box(
                renderer
                    .render_immediate(frames.clone(), uniforms, &cursor, true, &mut layers)
                    .await?,
            );
        }
        for sample in 0..SAMPLES {
            let start = Instant::now();
            let mut bytes = 0;
            for number in 60..84 {
                let uniforms = ProjectUniforms::new(
                    &constants, &project, number, FPS, SCREEN, &cursor, &frames, duration, &zoom,
                );
                let rendered = renderer
                    .render_immediate(frames.clone(), uniforms, &cursor, true, &mut layers)
                    .await?;
                ensure!(rendered.frame_number == number);
                bytes += rendered.data.len();
                std::hint::black_box(rendered);
            }
            println!(
                "{}",
                serde_json::json!({"kind":"timing","minutes":minutes,"sample":sample,"frames":24,"bytes":bytes,"ns_per_frame":start.elapsed().as_nanos() as f64/24.0})
            );
        }
    }
    Ok(())
}
Complete MP4 export probe

Temporary file: crates/export/examples/independent-style-export.rs

use cap_export::{
    ExporterBase,
    mp4::{ExportCompression, Mp4ExportSettings},
};
use cap_project::{ProjectConfiguration, XY};
use std::{
    path::PathBuf,
    sync::{
        Arc,
        atomic::{AtomicU32, Ordering},
    },
    time::Instant,
};

#[tokio::main]
async fn main() -> Result<(), String> {
    let arguments: Vec<_> = std::env::args().skip(1).collect();
    if arguments.len() != 3 {
        return Err("usage: independent-style-export project.cap config.json output.mp4".into());
    }
    let config: ProjectConfiguration =
        serde_json::from_slice(&std::fs::read(&arguments[1]).map_err(|e| e.to_string())?)
            .map_err(|e| e.to_string())?;
    let captions = config.captions.as_ref().map_or(0, |c| c.segments.len());
    let started = Instant::now();
    let base = ExporterBase::builder(PathBuf::from(&arguments[0]))
        .with_config(config)
        .with_output_path(PathBuf::from(&arguments[2]))
        .build()
        .await
        .map_err(|e| e.to_string())?;
    let expected_frames = base.total_frames(30);
    let build_ms = started.elapsed().as_secs_f64() * 1000.0;
    let settings = Mp4ExportSettings {
        fps: 30,
        resolution_base: XY::new(640, 360),
        compression: ExportCompression::Maximum,
        custom_bpp: None,
        force_ffmpeg_decoder: false,
        optimize_filesize: false,
    };
    let frames = Arc::new(AtomicU32::new(0));
    let observed = Arc::clone(&frames);
    let export_started = Instant::now();
    let output = settings
        .export(base, move |_| {
            observed.fetch_add(1, Ordering::Relaxed);
            true
        })
        .await
        .map_err(|e| e.to_string())?;
    let export_ms = export_started.elapsed().as_secs_f64() * 1000.0;
    if frames.load(Ordering::Relaxed) == 0 {
        return Err("Export did not render frames".into());
    }
    println!(
        "{}",
        serde_json::json!({"kind":"export","captions":captions,"build_ms":build_ms,"export_ms":export_ms,"total_ms":started.elapsed().as_secs_f64()*1000.0,"expected_frames":expected_frames,"progress_callbacks":frames.load(Ordering::Relaxed),"output":output})
    );
    Ok(())
}
Export fixtures and decoded-frame checks

Generate 640×360, 30 fps test-source MP4 input for each duration. The seven-second configuration uses styles from 1 to 5 seconds; the one-minute configuration uses styles from 1 to 59 seconds. Enable captions, include both source and projected caption records, and use the same input and configuration on both variants. Captions have 200/1,200 segments with eight words each, spaced three seconds apart. This matches the uniform fixture's caption density, with caption rendering explicitly enabled for export.

The following temporary Python script generates either measured configuration structure and a synthetic source clip. Its generated 10/60-minute caption configurations were compared structurally with all four measured configuration files. Run python3 export-fixture.py input.cap 7 or use duration 60. Use input.cap/config-10.json and input.cap/config-60.json with the export probe. FFmpeg source encoding can vary by local version; within each comparison both renderer variants must use the exact same generated input.

from pathlib import Path
import json,subprocess,sys

def config(minutes,duration):
    words='First open the project and select this recording.'.split()
    captions=[]
    for i in range(minutes*20):
        start=i*3.0
        captions.append(dict(id=f'caption-{i}',start=start,end=start+2.8,text=' '.join(words),words=[dict(text=text,start=start+j*0.35,end=start+j*0.35+0.3) for j,text in enumerate(words)]))
    return dict(audio=dict(mute=True),camera=dict(hide=True),cursor=dict(hide=True),background=dict(source=dict(type='color',value=[30,30,46]),padding=0.0,shadow=0.0),timeline=dict(segments=[dict(recordingClip=0,start=0.0,end=float(duration),timescale=1.0)],zoomSegments=[],captionSegments=captions,styleSegments=[dict(start=1.0,end=float(duration-2 if duration==7 else duration-1),overrides=dict(background=dict(source=dict(type='color',value=[180,30,40]),padding=12.0,shadow=0.0)))]),captions=dict(segments=captions,settings=dict(enabled=True)))

def main():
    root=Path(sys.argv[1]);duration=int(sys.argv[2]);assert duration in [7,60]
    root.mkdir(parents=True,exist_ok=False);(root/'content').mkdir();(root/'screenshots').mkdir()
    subprocess.run(['ffmpeg','-v','error','-f','lavfi','-i','testsrc2=size=640x360:rate=30','-t',str(duration),'-c:v','libx264','-preset','ultrafast','-crf','20','-pix_fmt','yuv420p',str(root/'content/display.mp4')],check=True)
    subprocess.run(['ffmpeg','-v','error','-i',str(root/'content/display.mp4'),'-frames:v','1',str(root/'screenshots/display.jpg')],check=True)
    (root/'recording-meta.json').write_text(json.dumps(dict(pretty_name='Independent style-cache validation',display=dict(path='content/display.mp4',fps=30),camera=None,audio=None,cursor=None)))
    for minutes in [10,60]:(root/f'config-{minutes}.json').write_text(json.dumps(config(minutes,duration)))
    (root/'project-config.json').write_text(json.dumps(config(60,duration)))

if __name__=='__main__':main()

Fixture recording metadata:

{"pretty_name":"Independent style-cache validation","display":{"path":"content/display.mp4","fps":30},"camera":null,"audio":null,"cursor":null}

Use the export probe as:

independent-style-export-base input.cap config.json before.mp4
independent-style-export-cached input.cap config.json after.mp4
ffprobe -v error -count_frames -show_entries stream=codec_type,nb_read_frames,width,height,duration -of json before.mp4
ffprobe -v error -count_frames -show_entries stream=codec_type,nb_read_frames,width,height,duration -of json after.mp4
ffmpeg -v error -threads 2 -i before.mp4 -map 0:v:0 -f framemd5 before.framemd5
ffmpeg -v error -threads 2 -i after.mp4 -map 0:v:0 -f framemd5 after.framemd5

Require exactly 210 or 1,800 decoded frames, depending on duration, and compare every non-comment framemd5 record, including timestamps. All twenty local before/after pairs matched exactly. The MP4 containers themselves were not used for equality.

RetriggerView in GreptileConfidence Score: 5/5

The PR appears safe to merge; no actionable issues were found.

Summary

Reuses a resolved styled project while the source Arc and winning background, camera, and cursor segments stay unchanged.

  • Styled rendering frames reuse matching project snapshots.

Reviews (1) · Last reviewed commit: "perf(rendering): cache resolved styles b..." · Reviewed by Greptile

@vercel

vercel Bot commented Oct 24, 2026

Copy link
Copy Markdown
Contributor

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

A member of the Team first needs to authorize it.

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.

2 participants