Skip to content

Fix A/V sync bugs and add sync test infrastructure#1977

Open
richiemcilroy wants to merge 13 commits into
mainfrom
av-sync-hardening
Open

Fix A/V sync bugs and add sync test infrastructure#1977
richiemcilroy wants to merge 13 commits into
mainfrom
av-sync-hardening

Conversation

@richiemcilroy

@richiemcilroy richiemcilroy commented Jul 3, 2026

Copy link
Copy Markdown
Member

Fixes eight audio/video sync bugs found through end-to-end testing, and adds the infrastructure to keep sync verified continuously.

Fixes

  • Segmented video encoder stamped frames frame_count / fps, discarding capture timestamps — every dropped/undelivered frame compressed the video timeline against audio, desyncing screen-only studio recordings by hundreds of ms with progressive drift
  • Recorder persisted the default timeline as frame_count / fps, truncating VFR recordings; now uses the real muxed timestamp span
  • Wall-clock-confirmed forward jumps (static screens, stream restarts) were collapsed by the anomaly tracker, permanently losing gaps that occur before the drift anchor exists
  • Audio timeline counted source-rate samples against the 48kHz master clock — non-48kHz mics (44.1kHz interfaces, 8/16kHz Bluetooth headsets) recorded at the wrong speed with bogus silence insertion
  • Audio encoder input pts were stamped in output-timebase units while the resampler rescaled them as input-rate units; only 48k→48k was coincidentally correct (Opus and AAC)
  • BufferedResampler panicked with an integer overflow on non-integer rate ratios (44.1↔48kHz)
  • 5.1/6-channel microphones failed Opus encoder init and couldn't start a recording; now downmixed to stereo
  • Decoders dropped frame requests inside VFR pts gaps, starving the renderer into a runaway state that aborted exports ("Failed to decode video frames"); requests now always answer with the nearest frame (at-or-before preferred)

Every finalized recording now cross-checks its display container duration against the muxed timestamp span and logs a sync-invariant violation on mismatch.

Test infrastructure

  • cap selftest av-sync [--mic]: ~30s end-to-end sync check on any machine — renders a flash+beep pattern, records through the real capture stack, exports, and measures offset/drift in both (with --mic, also verifies the physical input path acoustically). Beep detection uses a 1kHz bandpass with edge-triggered hysteresis, so it works while music plays on the machine
  • crates/recording/tests/sync_matrix.rs: synthetic device matrix — 21 curated cases (15–120fps, jitter/drops/gaps, 8–96kHz, 1/2/6 channels, both muxers) plus seed-reproducible randomized cases: random fps/resolution/content (including per-frame noise for worst-case encoder load), random sample rates (8–192kHz), 1–8 channels, real-world buffer sizes (3–90ms), source clock drift, and random gap placement — with video+audio legs running concurrently like a real recording. Real frames and samples go through the real encoders and containers and are decoded back for verification. PRs run 6 random cases; the nightly schedule runs 40 (seed printed and stored in the report for exact reproduction via CAP_SYNC_MATRIX_SEED). This matrix found five of the eight bugs above on its first run
  • .github/workflows/sync-tests.yml: runs the matrix and timestamp property tests on macOS/Windows/Ubuntu with a per-case job-summary table and JSON artifacts

Verified: full test suites pass, all matrix cases pass on macOS, Windows and Ubuntu CI, and live selftest runs measure −2 to +18 ms offset with ~0 drift on macOS (recording, microphone, and export legs).

Greptile Summary

This PR improves A/V sync handling and adds continuous sync checks. The main changes are:

  • A new cap selftest av-sync command and measurement pipeline.
  • Cross-platform sync matrix workflow and synthetic recording tests.
  • Timestamp fixes for segmented video, audio sources, resampling, and recording metadata.
  • Decoder fallback changes for VFR timestamp gaps.

Confidence Score: 4/5

The sync fixes need follow-up before merging because changed runtime paths can hang or return wrong media frames.

  • Selftest can hang when the runtime thread cannot be started.
  • Non-48 kHz audio can still drift because fractional master-clock samples are discarded per buffer.
  • Decoder fallbacks can return frames outside the configured tolerance, producing stale or wrong video output.

apps/cli/src/main.rs, crates/recording/src/output_pipeline/core.rs, crates/rendering/src/decoder/avassetreader.rs, crates/rendering/src/decoder/ffmpeg.rs

Important Files Changed

Filename Overview
apps/cli/src/main.rs Adds the selftest command and main-thread pattern runner integration; the spawn-failure path can hang.
apps/cli/src/selftest/mod.rs Adds selftest recording, export, analysis, and report evaluation.
apps/cli/src/selftest/pattern.rs Adds the flash and beep pattern runner used by the selftest.
apps/cli/src/selftest/measure.rs Adds video flash detection, audio beep detection, and sync statistics.
crates/recording/src/output_pipeline/core.rs Updates audio and video timestamp handling; non-48 kHz clock advancement can accumulate truncation drift.
crates/enc-ffmpeg/src/audio/base.rs Stamps audio frames in input-rate units before resampling.
crates/enc-ffmpeg/src/audio/buffered_resampler.rs Handles rounded resampler overlaps and exposes input resampler configuration.
crates/enc-ffmpeg/src/audio/opus.rs Downmixes multichannel Opus input to stereo.
crates/enc-ffmpeg/src/mux/segmented_stream.rs Preserves capture-derived timestamps for segmented video encoding.
crates/rendering/src/decoder/avassetreader.rs Changes AVAssetReader fallback behavior; forward fallback distance is no longer enforced.
crates/rendering/src/decoder/ffmpeg.rs Changes FFmpeg decoder fallback behavior; stale previous frames can be reused without distance checks.
.github/workflows/sync-tests.yml Adds a cross-platform scheduled and pull-request sync test workflow.

Comments Outside Diff (1)

  1. crates/rendering/src/decoder/ffmpeg.rs, line 826-835 (link)

    P1 Stale Last Frame Reused

    This treats any previous last_sent_frame with number <= requested_frame as a valid VFR hold, regardless of how far it is from the request. After a long seek or decoder reset, a frame from seconds earlier can satisfy the new request and silently freeze the preview or export at stale content.

    Context Used: AGENTS.md (source)

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: crates/rendering/src/decoder/ffmpeg.rs
    Line: 826-835
    
    Comment:
    **Stale Last Frame Reused**
    
    This treats any previous `last_sent_frame` with `number <= requested_frame` as a valid VFR hold, regardless of how far it is from the request. After a long seek or decoder reset, a frame from seconds earlier can satisfy the new request and silently freeze the preview or export at stale content.
    
    **Context Used:** AGENTS.md ([source](https://app.greptile.com/cap/github/capsoftware/cap/-/custom-context?memory=27801409-c24c-4476-9c6c-180f1ef0a7f2))
    
    How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
Fix the following 4 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 4
apps/cli/src/main.rs:468-469
**Spawn Failure Hangs Selftest**

When `cap selftest` installs the pattern runner and the runtime thread fails to spawn, the main thread enters `serve_main_thread(rx)` before the spawn error is handled. The sender remains installed, so `rx.recv()` never closes and the CLI hangs instead of reporting the spawn failure.

### Issue 2 of 4
crates/recording/src/output_pipeline/core.rs:672-673
**Fractional Clock Drift Accumulates**

For non-48 kHz sources, this converts each buffer to master-clock samples with integer truncation and drops the fractional remainder. A 44.1 kHz device with 1024-sample buffers loses part of a 48 kHz clock sample every buffer, so the master clock can drift from the source timeline and the gap tracker can still insert bogus silence over longer recordings.

### Issue 3 of 4
crates/rendering/src/decoder/avassetreader.rs:928-936
**Forward Fallback Ignores Distance**

The `fallback_distance` check only logs when the nearest frame is after the request and too far away, but the frame is still sent unconditionally. After a seek or sparse decode, an initial request with a 2-frame tolerance can receive a future cached frame far outside that tolerance, producing a visible frame from the wrong timestamp instead of retrying or failing.

### Issue 4 of 4
crates/rendering/src/decoder/ffmpeg.rs:826-835
**Stale Last Frame Reused**

This treats any previous `last_sent_frame` with `number <= requested_frame` as a valid VFR hold, regardless of how far it is from the request. After a long seek or decoder reset, a frame from seconds earlier can satisfy the new request and silently freeze the preview or export at stale content.

Reviews (1): Last reviewed commit: "Add randomized combined A/V cases and en..." | Re-trigger Greptile

Greptile also left 3 inline comments on this PR.

Context used:

  • Context used - CLAUDE.md (source)
  • Context used - AGENTS.md (source)

Follow-up hardening (second verification pass)

An adversarial re-review of this branch confirmed several defects in the fixes themselves, all now resolved:

Product fixes

  • Pause excision (instant mode): the confirmed-forward-jump passthrough treated a pause that began before the ~2s drift anchor as a real capture gap, permanently desyncing A/V by ~100ms. Accumulated pause time is now excised from the video content timeline before anomaly tracking, matching the audio path and the pause-adjusted wall clock. New pause-resume case in the sync matrix guards this end-to-end.
  • Decoder VFR holds (both decoders): pts holes are now tracked explicitly from direct decoder evidence (consecutive vends whose frame numbers jump — immutable facts about the file), and requests inside a recorded hole are served the hole's start frame without advancing the decoder; the served hold content persists across request batches. Previously, long static gaps (>90 frames) evicted the hold frame and served post-gap future content (macOS), or the relaxations were unreachable dead code (Windows/Linux). Vended frames below the fallback floor are kept as hold candidates instead of discarded, so seeks into a gap serve the true hold. Hole detection is evidence-gated specifically so disjoint cache islands left by scrubbing can never be mistaken for holes (which would freeze playback on stale frames).
  • Sync-span invariant: now one-sided — only a container shorter than the muxed span is a violation. AVFoundation legitimately extends the final frame through trailing static-screen holds, which previously produced spurious error logs on healthy camera recordings.
  • CLI main thread: pattern-server shutdown is now a drop guard, so a runtime build failure or a panic in the command can no longer hang the process.

Selftest hardening

  • Flash/beep schedules (live pattern + synthetic fixture) now carry deterministic ±300ms jitter, so an A/V shift of exactly one period can no longer alias to a zero measured offset.
  • WARN verdicts now also gate on event spread (mad_ms); export-leg WARNs contribute a reason; --duration floor raised to 14s so the minimum event count is always reachable.
  • Master-clock advancement converts source-rate samples cumulatively (per-buffer truncation accumulated drift for non-integer ratios).

New coverage: cap selftest playback

  • Headless editor-playback sync harness: opens a flash+beep .cap, drives the real editor machinery (EditorInstance, real decoders, real frame scheduling, real audio pipeline) with taps at both presentation boundaries — the renderer frame callback and a headless audio sink that pulls blocks on a device-like schedule through the exact production source pipeline.
  • Generates its fixture through the real recording pipeline (channel sources → real encoders/muxers → real project metadata), including a mid-recording VFR gap, so it runs on CI without capture hardware; also exports the fixture and verifies export sync, giving the decoder gap-hold paths CI coverage on all three platforms (AVAssetReader on macOS, FFmpeg decoder on Windows/Linux).
  • Verified locally: playback within one frame of the raw recording (delta ≈ −30ms inside the modeled asymmetric window), no drift; export matches the raw recording within 1ms.
  • Wired into sync-tests.yml alongside cap-rendering unit tests and a permissions: contents: read block.
  • Deterministic regression tests for the resampler pts-overlap clamp.

Comment thread .github/workflows/sync-tests.yml Fixed
@socket-security

socket-security Bot commented Jul 3, 2026

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addedcargo/​winit@​0.30.138210096100100

View full report

Comment thread .github/workflows/sync-tests.yml Fixed
@richiemcilroy richiemcilroy marked this pull request as ready for review July 3, 2026 16:09

@superagent-security superagent-security Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Superagent found 2 security concern(s).

- macos-latest
- windows-2022
- ubuntu-24.04
runs-on: ${{ matrix.runner }}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Workflow does not declare permissions, leaving GITHUB_TOKEN scope to repo defaults

Workflow has no permissions: block at top or job level.

Add permissions: {} at the workflow top and grant least-privilege per job.

AI prompt
Check if this security scanner issue is valid. If so, understand the root cause and fix it. If appropriate, update or add tests. Keep the change focused and preserve intended behavior.

<file name=".github/workflows/sync-tests.yml">
<violation number="1" location=".github/workflows/sync-tests.yml:42">
<priority>P1</priority>
<title>Workflow does not declare `permissions`, leaving `GITHUB_TOKEN` scope to repo defaults</title>
<evidence>The workflow has no `permissions:` block at the top level or job level. The default GITHUB_TOKEN permissions depend on repo and org settings; older repos may default to write-all, giving this test workflow broad access it does not need.</evidence>
<recommendation>Add `permissions: {}` at the workflow top level, then grant each job only the permissions it needs. For example, the `sync-tests` job needs `contents: read` for checkout and `actions: write` for artifact upload.</recommendation>
</violation>
</file>

runs-on: ${{ matrix.runner }}
steps:
- name: Checkout
uses: actions/checkout@v4

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Actions are pinned to mutable tags instead of commit SHAs

uses: lines reference mutable tags instead of immutable commit SHAs.

Pin all actions to full 40-character SHAs with version comments.

AI prompt
Check if this security scanner issue is valid. If so, understand the root cause and fix it. If appropriate, update or add tests. Keep the change focused and preserve intended behavior.

<file name=".github/workflows/sync-tests.yml">
<violation number="1" location=".github/workflows/sync-tests.yml:45">
<priority>P1</priority>
<title>Actions are pinned to mutable tags instead of commit SHAs</title>
<evidence>The workflow references several third-party and first-party actions by mutable tags: `uses: actions/checkout@v4`, `uses: dtolnay/rust-toolchain@1.88.0`, `uses: actions/setup-node@v4`, and `uses: actions/upload-artifact@v4`. Tags can be force-pushed by an attacker who compromises the action repository, silently injecting malicious code into this workflow.</evidence>
<recommendation>Pin every action to the full 40-character commit SHA of the intended version and append a version comment for readability. For example: `uses: actions/checkout@<sha> # v4`.</recommendation>
</violation>
</file>

@superagent-security superagent-security Bot added the pr:flagged PR flagged for review by security analysis. label Jul 3, 2026
Comment thread apps/cli/src/main.rs
Comment on lines 468 to +469

if let Some(rx) = pattern_rx {

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 Spawn Failure Hangs Selftest

When cap selftest installs the pattern runner and the runtime thread fails to spawn, the main thread enters serve_main_thread(rx) before the spawn error is handled. The sender remains installed, so rx.recv() never closes and the CLI hangs instead of reporting the spawn failure.

Context Used: AGENTS.md (source)

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/cli/src/main.rs
Line: 468-469

Comment:
**Spawn Failure Hangs Selftest**

When `cap selftest` installs the pattern runner and the runtime thread fails to spawn, the main thread enters `serve_main_thread(rx)` before the spawn error is handled. The sender remains installed, so `rx.recv()` never closes and the CLI hangs instead of reporting the spawn failure.

**Context Used:** AGENTS.md ([source](https://app.greptile.com/cap/github/capsoftware/cap/-/custom-context?memory=27801409-c24c-4476-9c6c-180f1ef0a7f2))

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +672 to +673
} else {
(samples as u128 * clock.sample_rate() as u128 / u128::from(self.sample_rate.max(1)))

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 Fractional Clock Drift Accumulates

For non-48 kHz sources, this converts each buffer to master-clock samples with integer truncation and drops the fractional remainder. A 44.1 kHz device with 1024-sample buffers loses part of a 48 kHz clock sample every buffer, so the master clock can drift from the source timeline and the gap tracker can still insert bogus silence over longer recordings.

Context Used: AGENTS.md (source)

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/recording/src/output_pipeline/core.rs
Line: 672-673

Comment:
**Fractional Clock Drift Accumulates**

For non-48 kHz sources, this converts each buffer to master-clock samples with integer truncation and drops the fractional remainder. A 44.1 kHz device with 1024-sample buffers loses part of a 48 kHz clock sample every buffer, so the master clock can drift from the source timeline and the gap tracker can still insert bogus silence over longer recordings.

**Context Used:** AGENTS.md ([source](https://app.greptile.com/cap/github/capsoftware/cap/-/custom-context?memory=27801409-c24c-4476-9c6c-180f1ef0a7f2))

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +928 to +936
&& req.frame.abs_diff(frame_num) > fallback_distance
{
let _ = req.sender.send(last.to_decoded_frame());
} else if allow_relaxed_fallback
&& let Some(ref first) = *first_ever_frame.borrow()
{
let _ = req.sender.send(first.to_decoded_frame());
} else {
unfulfilled_count += 1;
tracing::debug!(
req_frame = req.frame,
nearest_frame = frame_num,
"serving forward frame across pts gap"
);
}
let _ = req.sender.send(cached.data().to_decoded_frame());

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 Forward Fallback Ignores Distance

The fallback_distance check only logs when the nearest frame is after the request and too far away, but the frame is still sent unconditionally. After a seek or sparse decode, an initial request with a 2-frame tolerance can receive a future cached frame far outside that tolerance, producing a visible frame from the wrong timestamp instead of retrying or failing.

Context Used: AGENTS.md (source)

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/rendering/src/decoder/avassetreader.rs
Line: 928-936

Comment:
**Forward Fallback Ignores Distance**

The `fallback_distance` check only logs when the nearest frame is after the request and too far away, but the frame is still sent unconditionally. After a seek or sparse decode, an initial request with a 2-frame tolerance can receive a future cached frame far outside that tolerance, producing a visible frame from the wrong timestamp instead of retrying or failing.

**Context Used:** AGENTS.md ([source](https://app.greptile.com/cap/github/capsoftware/cap/-/custom-context?memory=27801409-c24c-4476-9c6c-180f1ef0a7f2))

How can I resolve this? If you propose a fix, please make it concise.

// what un-edited recordings use.
let display_media_duration = match s.pipeline.screen.video_timestamp_span {
Some((first, last)) if display_fps > 0 => {
(last - first).as_secs_f64() + 1.0 / f64::from(display_fps)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

last - first will panic on underflow if we ever see out-of-order timestamps (or a missing first). Might be worth making this saturating since it’s just being used for a duration estimate.

Suggested change
(last - first).as_secs_f64() + 1.0 / f64::from(display_fps)
last.saturating_sub(first).as_secs_f64() + 1.0 / f64::from(display_fps)

/// pipeline's timestamp span, the container duration from what the encoder
/// and muxer wrote. If they disagree by more than the tolerance, timestamps
/// were mangled between the pipeline and the file — the class of bug that
/// silently desyncs audio from video. Non-fatal: logs a structured warning

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Nit: the doc says this “logs a structured warning” but the code logs at error level.

Suggested change
/// silently desyncs audio from video. Non-fatal: logs a structured warning
/// silently desyncs audio from video. Non-fatal: logs a structured error

} else {
(samples as u128 * clock.sample_rate() as u128 / u128::from(self.sample_rate.max(1)))
as u64
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Minor: the integer division here will always floor, which introduces a small but systematic bias when converting between rates (eg 44.1k -> 48k). Rounding avoids that drift without changing the overall approach.

Suggested change
};
let denom = u128::from(self.sample_rate.max(1));
let numer = samples as u128 * clock.sample_rate() as u128;
((numer + denom / 2) / denom) as u64

- "crates/media-info/**"
- ".github/workflows/sync-tests.yml"

concurrency:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Worth adding an explicit permissions: block here. This workflow checks out code and uploads artifacts, so something like this keeps the token scoped tightly.

Suggested change
concurrency:
permissions:
contents: read
actions: write
concurrency:

Comment on lines +1042 to +1050
} else if let Some((&frame_num, cached)) = cache.range(req.frame..).next() {
if req.frame.abs_diff(frame_num) > fallback_distance {
tracing::debug!(
req_frame = req.frame,
nearest_frame = frame_num,
"serving forward frame across pts gap"
);
}
let _ = req.sender.send(cached.data().to_decoded_frame());

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This still sends a forward cached frame even when it’s way outside fallback_distance (it only logs). If allow_relaxed_fallback is true, it seems safer to prefer last_sent_frame/first_ever_frame over showing a future frame from the wrong timestamp.

Suggested change
} else if let Some((&frame_num, cached)) = cache.range(req.frame..).next() {
if req.frame.abs_diff(frame_num) > fallback_distance {
tracing::debug!(
req_frame = req.frame,
nearest_frame = frame_num,
"serving forward frame across pts gap"
);
}
let _ = req.sender.send(cached.data().to_decoded_frame());
} else if let Some((&frame_num, cached)) = cache.range(req.frame..).next() {
if req.frame.abs_diff(frame_num) > fallback_distance && allow_relaxed_fallback {
if let Some(ref last) = *last_sent_frame.borrow() {
let _ = req.sender.send(last.to_decoded_frame());
continue;
}
if let Some(ref first) = *first_ever_frame.borrow() {
let _ = req.sender.send(first.to_decoded_frame());
continue;
}
}
if req.frame.abs_diff(frame_num) > fallback_distance {
tracing::debug!(
req_frame = req.frame,
nearest_frame = frame_num,
"serving forward frame across pts gap"
);
}
let _ = req.sender.send(cached.data().to_decoded_frame());

Comment on lines +1042 to +1050
} else if let Some((&frame_num, cached)) = cache.range(req.frame..).next() {
if req.frame.abs_diff(frame_num) > fallback_distance {
tracing::debug!(
req_frame = req.frame,
nearest_frame = frame_num,
"serving forward frame across pts gap"
);
}
let _ = req.sender.send(cached.data().to_decoded_frame());

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Follow-up: the suggestion above was missing the closing } for this branch.

Suggested change
} else if let Some((&frame_num, cached)) = cache.range(req.frame..).next() {
if req.frame.abs_diff(frame_num) > fallback_distance {
tracing::debug!(
req_frame = req.frame,
nearest_frame = frame_num,
"serving forward frame across pts gap"
);
}
let _ = req.sender.send(cached.data().to_decoded_frame());
} else if let Some((&frame_num, cached)) = cache.range(req.frame..).next() {
if req.frame.abs_diff(frame_num) > fallback_distance && allow_relaxed_fallback {
if let Some(ref last) = *last_sent_frame.borrow() {
let _ = req.sender.send(last.to_decoded_frame());
continue;
}
if let Some(ref first) = *first_ever_frame.borrow() {
let _ = req.sender.send(first.to_decoded_frame());
continue;
}
}
if req.frame.abs_diff(frame_num) > fallback_distance {
tracing::debug!(
req_frame = req.frame,
nearest_frame = frame_num,
"serving forward frame across pts gap"
);
}
let _ = req.sender.send(cached.data().to_decoded_frame());
}

Comment on lines +1145 to +1149
let last_before = last_sent_frame
.borrow()
.as_ref()
.filter(|l| l.number <= requested_frame)
.cloned();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

last_sent_frame fallback looks like it can still satisfy a post-seek request with a very old frame (freeze-at-stale-content). If this is meant as “VFR hold”, it seems worth bounding it (except when you’ve positively identified a pts hole via gap_hold).

Suggested change
let last_before = last_sent_frame
.borrow()
.as_ref()
.filter(|l| l.number <= requested_frame)
.cloned();
let last_before = last_sent_frame
.borrow()
.as_ref()
.filter(|l| {
l.number <= requested_frame
&& requested_frame.saturating_sub(l.number)
<= MAX_FRAME_LOOKBACK_TOLERANCE
})
.cloned();

echo "## A/V sync matrix — ${{ matrix.runner }}"
echo ""
if [ -f "$REPORT" ]; then
PYTHONIOENCODING=utf-8 python3 - "$REPORT" << 'PYEOF'

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

On windows-2022 the Bash environment usually has python but not always python3. Might be worth making this pick the right binary per runner so the summary generation can’t flake.

Suggested change
PYTHONIOENCODING=utf-8 python3 - "$REPORT" << 'PYEOF'
PYTHON_BIN=python3
if [ "$RUNNER_OS" = "Windows" ]; then PYTHON_BIN=python; fi
PYTHONIOENCODING=utf-8 "$PYTHON_BIN" - "$REPORT" << 'PYEOF'

if [ "$RUNNER_OS" = "macOS" ]; then
export CAP_EDITOR_FORCE_FFMPEG_DECODER=1
fi
cargo run --locked -p cap -- --log-level info selftest playback --duration 30 --json

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Since the comment calls out “default 30 fps”, might be worth pinning it explicitly so this can’t change silently if the CLI default ever shifts.

Suggested change
cargo run --locked -p cap -- --log-level info selftest playback --duration 30 --json
cargo run --locked -p cap -- --log-level info selftest playback --duration 30 --fps 30 --json

contents: read
steps:
- name: Checkout
uses: actions/checkout@v4

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 actions/checkout leaves credentials persisted in the workspace

Checkout step persists GITHUB_TOKEN in git config by default.

Add persist-credentials: false to the checkout step.

AI prompt
Check if this security scanner issue is valid. If so, understand the root cause and fix it. If appropriate, update or add tests. Keep the change focused and preserve intended behavior.

<file name=".github/workflows/sync-tests.yml">
<violation number="1" location=".github/workflows/sync-tests.yml:50">
<priority>P2</priority>
<title>actions/checkout leaves credentials persisted in the workspace</title>
<evidence>The workflow uses `actions/checkout@v4` without setting `persist-credentials: false`. The default behavior keeps the GITHUB_TOKEN in the local git config, where it can be read by later steps or untrusted build scripts.</evidence>
<recommendation>Add `persist-credentials: false` to the checkout step, or explicitly set it to `true` only if the workflow needs to push back to the repository.</recommendation>
</violation>
</file>

/// thread from its serve loop.
pub fn shutdown_main_thread_runner() {
if let Some(slot) = PATTERN_TX.get() {
slot.lock().unwrap().take();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Minor robustness: lock().unwrap() here can panic if the mutex is poisoned, which would prevent the sender from being dropped and leave the main thread stuck in rx.recv().

Suggested change
slot.lock().unwrap().take();
slot.lock().unwrap_or_else(|e| e.into_inner()).take();

pub async fn request_pattern(spec: PatternSpec) -> Result<PatternReport, String> {
let tx = PATTERN_TX
.get()
.and_then(|slot| slot.lock().unwrap().clone())

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Same idea here: avoiding a poisoned-mutex panic makes the selftest failure mode cleaner.

Suggested change
.and_then(|slot| slot.lock().unwrap().clone())
.and_then(|slot| Some(slot.lock().unwrap_or_else(|e| e.into_inner()).clone()))

@cursor

cursor Bot commented Jul 4, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@superagent-security superagent-security Bot removed the pr:flagged PR flagged for review by security analysis. label Jul 4, 2026
max_abs = max_abs.max((p - s).abs());
let rel = ((p - pts[0]) - (s - sent[0])).abs();
max_rel = max_rel.max(rel);
if rel > rel_tolerance {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Minor: since rel_tolerance now varies with fps, it’d be helpful to include it (and case.fps) in the failure message so CI flakes are easier to diagnose.

Suggested change
if rel > rel_tolerance {
return Err(format!(
"frame {i}: relative pts error {rel:.3}s exceeds tolerance {rel_tolerance:.3}s at {fps}fps (pts {p:.3}s vs sent {s:.3}s)",
fps = case.fps
));

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