Skip to content

chore(release): release v1.7.0 into main#135

Closed
EtienneLescot wants to merge 16 commits into
mainfrom
release/v1.7.0-sync
Closed

chore(release): release v1.7.0 into main#135
EtienneLescot wants to merge 16 commits into
mainfrom
release/v1.7.0-sync

Conversation

@EtienneLescot

@EtienneLescot EtienneLescot commented Jul 19, 2026

Copy link
Copy Markdown
Collaborator

Sync main with the released snapshot (RC + cherry-picked bugfixes + version bump). Rebase-merged via PAT; bypass applies because EtienneLescot is a ruleset bypass actor.

Summary by CodeRabbit

  • New Features
    • Video exports now mix all available audio tracks, including system and microphone audio.
    • Text annotations start empty and preserve existing content when edited.
    • Timeline playhead positioning stays synchronized with video playback.
  • Bug Fixes
    • HUD resizing no longer jumps or repeatedly recalculates while dragging.
    • Automatic zoom focus now follows cursor movement throughout playback.
    • Improved capture reliability and compatibility for unusual video dimensions.
  • Chores
    • Updated the application version to 1.7.0.

github-actions Bot and others added 16 commits July 16, 2026 15:27
… span

Auto zoom regions (created by the magic-wand cursor-dwell suggestion pass)
were built with focusMode left undefined unless the separate, global
"Auto Focus All" toggle was on. That meant the pan/zoom used the static
dwell-centroid focus captured at suggestion time and never tracked the
cursor afterward, even though the cursor-follow interpolation logic
(zoomRegionUtils/cursorFollowUtils) already existed and just wasn't wired
up by default for these regions.

Auto-suggested regions now always get focusMode "auto" so they pan to
track the cursor telemetry across their whole span, independent of the
"Auto Focus All" toggle (which still only controls the default for
manually-drawn zoom regions).

Fixes #72

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The video-writer thread held the shared frame-state mutex across
IMFSinkWriter::WriteSample, which the main thread's stop-wait also
locks to check control.stopRequested. With the software H.264 encoder
fallback (preferSoftwareEncoder: true), WriteSample is slow enough
that the writer thread could keep re-acquiring the lock every frame
faster than the main thread could win it after a stop request,
starving the stop-wait indefinitely — explaining why no [stop-timing]
line ever printed.

Split MFEncoder::writeFrame/writeBgraFrame into a capture step (GPU
readback + IMFSample construction, still under the shared mutex since
it touches latestFrameTexture) and a submit step (WriteSample, only
under the encoder's own low-contention writerMutex_), and call submit
outside the shared mutex in main.cpp's writeVideoFrames.

Fixes #115.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Fixes #60: recording a single window (as opposed to a full display) on
Windows produced a solid black video for the entire clip.

Root cause: GraphicsCaptureItem::Size() for a window capture item reports the
window's real client-area pixel dimensions verbatim, which are frequently odd
(arbitrary resize, DPI rounding) — unlike monitor/display capture items,
which are always even. H.264 encoding (and the RGB32->NV12 conversion
feeding it) requires even width/height. The frame pool and captureWidth()/
captureHeight() (which configures the encoder's input media type) both used
the raw, possibly-odd item size, so an odd-dimensioned window capture fed the
Media Foundation H.264 encoder a size it can't encode correctly, producing
black output.

Fix: round window capture dimensions up to the nearest even value once, in
WgcSession::createCaptureItem(HWND), and use that same rounded size (not the
raw item size) for both the Direct3D11CaptureFramePool buffer and
captureWidth()/captureHeight(), so every consumer of the session agrees on
one even-dimensioned size. Monitor/display capture is untouched (always even
in practice).

Verified: built wgc-capture.exe via CMake/Ninja (VS 18 Insiders x64), opened
a real Notepad window resized to an odd 789x595 client area, and drove the
helper directly against it (sourceType:"window", real HWND).
- Original (pre-fix) binary: 5.4KB output, ffmpeg blackdetect flags the
  entire clip as black (black_duration ~= full clip length).
- Fixed binary: 36.5KB output (consistent with real, compressible frame
  content vs. a solid-color clip), ffmpeg blackdetect reports zero black
  segments across the same clip.
- Sanity-checked normal display/monitor capture is unaffected (unchanged
  code path).
…ks playback

The timeline playhead read its position from `currentTime` React state, set
from VideoEditor (a ~3300-line component) on every rAF tick of video playback
and on every `seeking` event while dragging. Each tick forced a full re-render
of VideoEditor + VideoPlayback + TimelineEditor before the playhead's DOM
moved, so the visual position lagged behind actual video playback and felt
sluggish while scrubbing during playback.

- PlaybackCursor now reads the video element's currentTime directly via a
  `getPlaybackTimeMs` getter, in its own requestAnimationFrame loop and its
  own local `useState`. Only this small component re-renders per frame,
  independent of how long the ancestor tree's render takes.
- Coalesce the `onTimeUpdate` React state commit (still used by everything
  else that reads `currentTime`) to at most once per animation frame via a
  new `createRafCoalescer` helper, so a burst of `seeking` events from a fast
  timeline drag doesn't force one state commit per event.

Fixes #111
Per CodeRabbit review on #120: PlaybackCursor wrote to
getPlaybackTimeMsRef.current directly in the render body instead of in an
effect. Writing to a ref during render is a React anti-pattern — it can leave
the ref holding a stale value from a discarded render pass under concurrent
rendering. Moved the assignment into a useEffect keyed on getPlaybackTimeMs,
matching the recommended pattern for "latest callback" refs.

Verified: tsc --noEmit clean, biome clean, rafCoalescer.test.ts and
zoomRegionUtils.test.ts pass (7/7).
Follow-up to #119 (Fixes #115). CodeRabbit flagged, on the now-superseded
#121, that the video-writer thread's per-frame cv.wait() has no timeout: if a
notification were ever missed, the thread would block indefinitely, hanging
videoWriterThread.join() and the whole stop sequence — exactly the failure
mode this release is eliminating. #119 already fixed the actual root cause
(the blocking WriteSample call no longer runs under this mutex, so the main
thread's stop-wait is never starved), but the wait itself was still
unbounded. Switch it to wait_for(100ms) so the loop always re-checks
stopRequested/encodeFailed even in that circumstance, instead of relying
solely on a notification reaching an already-waiting thread.

Verified: rebuilt wgc-capture.exe on top of current main (includes #119),
re-ran the #115 repro (screen-only, all extras disabled) and the
full-featured regression (system audio + cursor) - both stop in under 110ms
with a full [stop-timing] log and a valid MP4.
Native macOS recordings write system audio and the microphone as two
separate AAC tracks in the screen recording, and both are flagged
`default`. The exporter decoded audio through web-demuxer's bare "audio"
selector, which resolves to the single stream FFmpeg's
`av_find_best_stream` picks — the first (system-audio) track. When
nothing was playing, that track is silent, so the exported video had no
audible audio even though the mic was recorded fine. The browser
recorder already blends system + mic into one track; the native path
never did.

Decode every audio stream and mix them into one timeline before
encoding, mirroring the browser recorder:

- `mixPlanarSources` (pure, unit-tested) sums each decoded source,
  downmixed to the target channels and aligned at its source-time
  offset, clamped to [-1, 1].
- Per-stream decode via `readAVPacket(streamIndex)` targets each track
  by container index instead of the best-stream heuristic.
- The trim-only and offline (speed) export paths both mix multi-track
  sources; multi-track speed projects are routed to the offline path
  because the real-time <audio> capture can only play one track.
- The source-copy fast path is disabled for multi-track sources, since
  copying verbatim carries both tracks over and players fall back to the
  silent first one.

Single-track recordings keep the original fast path unchanged. Adds a
real-browser regression test (fixture: silent track + 440 Hz tone) that
asserts the exported audio carries the tone through both mixing paths.

Closes #108

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Preserve the signed startFrame so AAC preroll (negative timestamp) no
  longer collides with the timestamp-zero frame; mixPlanarSources already
  discards pre-zero frames.
- Keep the sole decodable stream instead of returning null, so a partial
  decode failure can't fall back to the demuxer's best stream and export
  silence.
- Log per-track sample rates when a cross-rate mix is skipped.
- Document the hard-clip trade-off in mixPlanarSources.
- Tests: pin the source-copy audioStreamCount>1 blocker, cover the signed
  startFrame case, and tighten the browser RMS thresholds (0.01 -> 0.05).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… cap

Two bugs found during the 1.7.0 RC end-to-end pass:

1. New text annotations were created with the literal string
   "Enter text..." baked into their actual content, instead of starting
   empty with a real placeholder. The properties panel's textarea already
   had a proper `placeholder` attribute wired up, but since the field's
   *value* was never actually empty, the placeholder never showed and
   clicking in to type appended after the baked-in text instead of
   replacing it. Fixed by defaulting new text annotations' content to ""
   (both on creation and when switching an existing annotation's type to
   "text"), matching the pattern already used for "image"/"figure"/"blur"
   types.

2. After Save Project -> Load Project, the editor's scrubber/seek range
   was capped at the last timeline element's end time instead of the true
   recording duration. Root cause: `applyLoadedProject` sets `duration`
   to an inferred placeholder value (max of all region endMs) as a
   provisional guess before the real video metadata loads, expecting
   `VideoPlayback`'s `syncResolvedDuration` to correct it once the video
   element reports its real duration. But `syncResolvedDuration` skips
   calling `onDurationChange` whenever the resolved value matches
   `lastResolvedDurationRef` -- a memoization guard against redundant
   updates that has no way to know `applyLoadedProject` just set `duration`
   to something else. If the real duration happened to match what the ref
   already remembered from before the reload (the common case, since it's
   the same video file), the correction never fired and the inferred
   placeholder stuck. Fixed by exposing a `resetDurationResolution()`
   method on `VideoPlaybackRef` that clears the memoization guard, called
   from `applyLoadedProject` right when it sets the placeholder duration --
   forcing the next metadata load to always re-sync regardless of what the
   guard last saw.

Verified via computer-use against a real dev build:
- New annotation's text field now shows a genuine empty value (real
  greyed placeholder, timeline label reads "Empty text"); typing replaces
  cleanly with no leftover baked-in text.
- Loading a saved project with a trim region correctly shows the full
  original duration (1:10) and scrubs all the way to the end, instead of
  being capped at the last element's end time (previously 0:47).

Full suite: 53 files / 423 tests pass. tsc --noEmit and biome check clean.
Two findings on the original fix:

1. Added regression tests. Extracted the "create empty text annotation"
   and "resolve content when converting to text" logic out of VideoEditor's
   inline handlers into createTextAnnotationRegion()/
   resolveTextAnnotationContent() in types.ts, so both paths are directly
   unit-testable without rendering the whole (3000+ line) VideoEditor
   component.

2. resetDurationResolution() only cleared the memoization guard and relied
   on a future `loadedmetadata` event to re-sync -- which never fires when
   reloading a project referencing the same video file, since the <video>
   element's src string doesn't change. Hardened it to also immediately
   attempt resolution if the video is already loaded. This alone wasn't
   enough, though: it was being called *before* the placeholder
   `setDuration(inferredDurationMs...)` in applyLoadedProject, so its
   correction was immediately clobbered by that same-tick placeholder
   assignment winning the state-update race. Moved the call to run after
   all the state setters, so it's the one that wins.

Verified via computer-use: loaded the same saved project three times in a
row from a fresh app launch (the exact scenario the ordering bug hid in --
same video file, so `loadedmetadata` never refires). All three loads now
correctly show 1:10, not just the first one.

Full suite: 54 files / 426 tests pass (3 new). tsc --noEmit and biome check
clean.
Fixes #100: dragging the HUD by its grip handle would drift away from the
cursor rather than tracking it 1:1.

Root cause: two independent IPC channels can both reposition the HUD
BrowserWindow. Pointer drags send incremental deltas via
hud-overlay-move-by; separately, a ResizeObserver watching the HUD's content
calls hud-overlay-set-size whenever the observed content size changes, which
recomputes the window's bounds from a bottom-centre anchor using the
window's *current* bounds at the time of the call. If a resize observation
fires mid-drag (even a spurious one from transient reflow), its anchor
recompute races the drag's own incremental repositioning, and the two
compound into a net offset — read by users as the HUD "drifting away".

Fix: freeze content-size measurement while a drag is in progress
(isDraggingHudRef), so hud-overlay-set-size cannot fire mid-drag. The
content size is re-measured once via measureHudSize() right after the drag
ends, so a real size change (e.g. from a toggle that was clicked mid-drag)
still gets picked up promptly.

Verified via computer-use against a real dev build: multiple multi-step
drags (7-9 incremental pointer moves each, matching how a real drag event
stream looks) all track the cursor to within a few px, both immediately on
release and after a 2s settle -- no drift, no post-drop jump.
…ssion

Per CodeRabbit review on #125: covers the fix's actual behavior end-to-end
through the real drag handlers rather than testing isDraggingHudRef directly.

Adds data-testid="hud-drag-handle" (the drag grip had no selector) and a test
that: fires a real pointerdown on the handle, triggers a ResizeObserver
callback mid-drag and asserts setHudOverlaySize is NOT called (the race this
PR fixes), then fires pointermove/pointerup and asserts a measurement DOES
happen once released -- so a real size change made mid-drag still gets
picked up promptly.

Full suite: 53 files / 424 tests pass. tsc --noEmit and biome check clean.
@coderabbitai

coderabbitai Bot commented Jul 19, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The PR updates native capture synchronization, HUD drag sizing, video editor playback and annotation behavior, multi-track audio export, capture dimension normalization, release metadata, and Vitest artifact handling.

Changes

Native capture pipeline

Layer / File(s) Summary
Separate sample capture and submission
electron/native/wgc-capture/src/main.cpp, electron/native/wgc-capture/src/mf_encoder.*
MFEncoder separates sample capture from sink-writer submission, while the encoding loop uses timed waits and handles capture or submission failures.
Normalize capture dimensions
electron/native/wgc-capture/src/wgc_session.cpp
Capture dimensions are clamped and rounded to even values for capture items and frame pools.

HUD drag measurement

Layer / File(s) Summary
Gate HUD measurement during drag
src/components/launch/LaunchWindow.tsx, src/components/launch/LaunchWindow.test.tsx
HUD measurement is skipped during pointer dragging and performed again after release, with regression coverage.

Video editor synchronization and annotations

Layer / File(s) Summary
Live playhead synchronization
src/components/video-editor/VideoEditor.tsx, src/components/video-editor/VideoPlayback.tsx, src/components/video-editor/timeline/TimelineEditor.tsx, src/components/video-editor/videoPlayback/*
Playback time updates are coalesced per animation frame, disposed during cleanup, and read directly by the timeline cursor.
Reset duration resolution
src/components/video-editor/VideoEditor.tsx, src/components/video-editor/VideoPlayback.tsx
Video duration resolution can be reset after a placeholder duration is applied.
Normalize annotations and auto-zoom regions
src/components/video-editor/VideoEditor.tsx, src/components/video-editor/types.*, src/components/video-editor/zoomRegionUtils.test.ts
Text annotation helpers enforce empty default content, and generated cursor-following zoom regions are explicitly automatic.

Multi-track audio export

Layer / File(s) Summary
Decode and mix audio streams
src/lib/exporter/audioEncoder.ts, src/lib/exporter/audioEncoder.test.ts, src/lib/exporter/audioMixExport.browser.test.ts
Multiple audio streams are decoded, aligned, mixed, and routed through trim and offline export paths.
Expose audio stream count and block copying
src/lib/exporter/streamingDecoder.ts, src/lib/exporter/videoExporter.*
Metadata reports audio stream counts, and multi-track sources bypass the source-copy fast path.

Release and test artifact metadata

Layer / File(s) Summary
Update release metadata
.gitignore, package.json
The application version is updated to 1.7.0 and Vitest attachment artifacts are ignored.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant VideoElement
  participant VideoPlayback
  participant TimelineEditor
  VideoElement->>VideoPlayback: emit time update
  VideoPlayback->>TimelineEditor: coalesced playback update
  TimelineEditor->>VideoElement: read getPlaybackTimeMs
  TimelineEditor-->>VideoElement: render live cursor position
Loading
sequenceDiagram
  participant SourceContainer
  participant AudioProcessor
  participant VideoExporter
  SourceContainer->>AudioProcessor: enumerate and decode audio streams
  AudioProcessor->>AudioProcessor: align and mix planar samples
  AudioProcessor->>VideoExporter: encode mixed audio timeline
  VideoExporter-->>SourceContainer: produce exported media
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description is too sparse and omits required template sections like type, release impact, desktop impact, screenshots, and testing. Add the missing template sections, including a summary, related issue, change type, release impact, desktop impact, screenshots/video, and testing details.
Docstring Coverage ⚠️ Warning Docstring coverage is 45.45% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately reflects the release sync and version bump.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch release/v1.7.0-sync

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@EtienneLescot

Copy link
Copy Markdown
Collaborator Author

Redundant with #134, which was already merged manually (rebase + push from a local clone, since GitHub's hosted rebase-merge kept failing on this sync PR). main already has the full v1.7.0 content — verified package.json version and several of the cherry-picked fixes are present. The git merge-base --is-ancestor check in promote.yml's "Merge release branch into main" step doesn't detect this because the manual rebase produced new commit SHAs, so the workflow re-ran and opened this duplicate. Merging this PR as-is would actually regress main by deleting docs/testing/rc-e2e-checklist.md (never existed on the frozen release branch, so the diff wants to remove it). Closing without merging.

@coderabbitai coderabbitai 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.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
electron/native/wgc-capture/src/main.cpp (1)

614-623: 🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

Pause handling still falls through after the 100ms wait timeout (electron/native/wgc-capture/src/main.cpp:616-623). wait_for(..., pred) can return false here, but the result is ignored, so the loop reaches captureVideoSample/submitVideoSample with the stale pre-pause texture while paused is true. That keeps encoding duplicate frames instead of skipping the pause interval. Use the return value to continue when the predicate isn’t met.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@electron/native/wgc-capture/src/main.cpp` around lines 614 - 623, Update the
wait handling in the capture loop around control.cv.wait_for so its boolean
result is checked; after handling stopRequested and encodeFailed, continue the
loop when the predicate was not satisfied, preventing captureVideoSample or
submitVideoSample from running during a pause or without a fresh texture.

Source: Coding guidelines

🧹 Nitpick comments (2)
electron/native/wgc-capture/src/mf_encoder.cpp (1)

606-630: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicate sample-timing logic between captureVideoSample and captureBgraSample.

The locked timestamp-advance block (lines 613-630 and 676-693) is identical between the two methods. Since this logic enforces monotonic sample timing (a subtle invariant), keeping it in one place reduces the risk of the two paths silently diverging in a future edit.

♻️ Proposed extraction
// mf_encoder.h (private section)
bool computeSampleTiming(int64_t timestampHns, int64_t& outSampleTime, int64_t& outSampleDuration);
// mf_encoder.cpp
bool MFEncoder::computeSampleTiming(int64_t timestampHns, int64_t& outSampleTime, int64_t& outSampleDuration) {
    std::scoped_lock writerLock(writerMutex_);
    if (!sinkWriter_ || finalized_) {
        return false;
    }
    outSampleDuration = 10'000'000LL / fps_;
    if (firstTimestampHns_ < 0) {
        firstTimestampHns_ = timestampHns;
    }
    outSampleTime = timestampHns - firstTimestampHns_;
    if (outSampleTime <= lastTimestampHns_) {
        outSampleTime = lastTimestampHns_ + outSampleDuration;
    }
    lastTimestampHns_ = outSampleTime;
    return true;
}

Then both captureVideoSample and captureBgraSample call if (!computeSampleTiming(timestampHns, sampleTime, sampleDuration)) return false; instead of repeating the locked block.

Also applies to: 670-693

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@electron/native/wgc-capture/src/mf_encoder.cpp` around lines 606 - 630,
Extract the duplicated locked timestamp advancement from captureVideoSample and
captureBgraSample into a private MFEncoder::computeSampleTiming helper that
validates sinkWriter_ and finalized_, calculates the duration, and preserves
monotonic sample times. Replace both inline blocks with calls to this helper,
returning false when timing computation fails.
src/components/launch/LaunchWindow.test.tsx (1)

463-479: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Restore stubbed HTMLElement.prototype pointer-capture methods in afterEach.

setPointerCapture/hasPointerCapture/releasePointerCapture are assigned directly onto HTMLElement.prototype (not via vi.stubGlobal/vi.spyOn), so vi.unstubAllGlobals() in the afterEach doesn't revert them. They stay attached to the prototype for the rest of the test file, masking jsdom's real (missing) Pointer Capture support for any later test.

♻️ Proposed fix
 	afterEach(() => {
 		cleanup();
 		vi.unstubAllGlobals();
+		delete (HTMLElement.prototype as Record<string, unknown>).setPointerCapture;
+		delete (HTMLElement.prototype as Record<string, unknown>).hasPointerCapture;
+		delete (HTMLElement.prototype as Record<string, unknown>).releasePointerCapture;
 	});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/launch/LaunchWindow.test.tsx` around lines 463 - 479, Restore
the original HTMLElement.prototype pointer-capture methods in the LaunchWindow
HUD drag test’s afterEach. Capture their pre-test values before the beforeEach
assignments, then restore setPointerCapture, hasPointerCapture, and
releasePointerCapture during cleanup; do not rely on vi.unstubAllGlobals() for
these direct prototype mutations.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/components/video-editor/timeline/TimelineEditor.tsx`:
- Around line 323-340: Update the useEffect containing tick to derive a boolean
indicating whether getPlaybackTimeMs is provided and include that flag in the
dependency array, so the requestAnimationFrame loop starts when the prop becomes
available and cleans up when it is removed.

In `@src/lib/exporter/audioEncoder.ts`:
- Around line 353-360: Update the EncodedAudioChunk construction in the
decoder.decode path to always set type to "key" instead of deriving it from
packet.keyframe. Preserve the existing timestamp, duration, and data handling.

---

Outside diff comments:
In `@electron/native/wgc-capture/src/main.cpp`:
- Around line 614-623: Update the wait handling in the capture loop around
control.cv.wait_for so its boolean result is checked; after handling
stopRequested and encodeFailed, continue the loop when the predicate was not
satisfied, preventing captureVideoSample or submitVideoSample from running
during a pause or without a fresh texture.

---

Nitpick comments:
In `@electron/native/wgc-capture/src/mf_encoder.cpp`:
- Around line 606-630: Extract the duplicated locked timestamp advancement from
captureVideoSample and captureBgraSample into a private
MFEncoder::computeSampleTiming helper that validates sinkWriter_ and finalized_,
calculates the duration, and preserves monotonic sample times. Replace both
inline blocks with calls to this helper, returning false when timing computation
fails.

In `@src/components/launch/LaunchWindow.test.tsx`:
- Around line 463-479: Restore the original HTMLElement.prototype
pointer-capture methods in the LaunchWindow HUD drag test’s afterEach. Capture
their pre-test values before the beforeEach assignments, then restore
setPointerCapture, hasPointerCapture, and releasePointerCapture during cleanup;
do not rely on vi.unstubAllGlobals() for these direct prototype mutations.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: aef15c64-e9d8-4cb9-98c0-43185c31228e

📥 Commits

Reviewing files that changed from the base of the PR and between 46bf3eb and 7b2b6e5.

⛔ Files ignored due to path filters (1)
  • tests/fixtures/sample-dual-audio.mp4 is excluded by !**/*.mp4
📒 Files selected for processing (23)
  • .gitignore
  • electron/native/wgc-capture/src/main.cpp
  • electron/native/wgc-capture/src/mf_encoder.cpp
  • electron/native/wgc-capture/src/mf_encoder.h
  • electron/native/wgc-capture/src/wgc_session.cpp
  • package.json
  • src/components/launch/LaunchWindow.test.tsx
  • src/components/launch/LaunchWindow.tsx
  • src/components/video-editor/VideoEditor.tsx
  • src/components/video-editor/VideoPlayback.tsx
  • src/components/video-editor/timeline/TimelineEditor.tsx
  • src/components/video-editor/types.test.ts
  • src/components/video-editor/types.ts
  • src/components/video-editor/videoPlayback/rafCoalescer.test.ts
  • src/components/video-editor/videoPlayback/rafCoalescer.ts
  • src/components/video-editor/videoPlayback/videoEventHandlers.ts
  • src/components/video-editor/videoPlayback/zoomRegionUtils.test.ts
  • src/lib/exporter/audioEncoder.test.ts
  • src/lib/exporter/audioEncoder.ts
  • src/lib/exporter/audioMixExport.browser.test.ts
  • src/lib/exporter/streamingDecoder.ts
  • src/lib/exporter/videoExporter.test.ts
  • src/lib/exporter/videoExporter.ts

Comment on lines +323 to +340
useEffect(() => {
if (!getPlaybackTimeMsRef.current) {
return;
}

let rafId: number;
const tick = () => {
const getter = getPlaybackTimeMsRef.current;
if (getter) {
const latest = getter();
setLiveTimeMs((previous) => (previous === latest ? previous : latest));
}
rafId = requestAnimationFrame(tick);
};
rafId = requestAnimationFrame(tick);

return () => cancelAnimationFrame(rafId);
}, []);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Make the tick loop reactive to the presence of getPlaybackTimeMs.

Because the useEffect uses an empty dependency array, if getPlaybackTimeMs is initially omitted and later provided dynamically by a parent component, the loop will fail to start. Deriving a boolean flag and including it in the dependency array ensures the loop cleanly reacts to changes in the prop's presence.

🐛 Proposed fix
-	useEffect(() => {
-		if (!getPlaybackTimeMsRef.current) {
-			return;
-		}
+	const hasGetter = !!getPlaybackTimeMs;
+	useEffect(() => {
+		if (!hasGetter) {
+			return;
+		}
 
 		let rafId: number;
 		const tick = () => {
 			const getter = getPlaybackTimeMsRef.current;
 			if (getter) {
 				const latest = getter();
 				setLiveTimeMs((previous) => (previous === latest ? previous : latest));
 			}
 			rafId = requestAnimationFrame(tick);
 		};
 		rafId = requestAnimationFrame(tick);
 
 		return () => cancelAnimationFrame(rafId);
-	}, []);
+	}, [hasGetter]);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
useEffect(() => {
if (!getPlaybackTimeMsRef.current) {
return;
}
let rafId: number;
const tick = () => {
const getter = getPlaybackTimeMsRef.current;
if (getter) {
const latest = getter();
setLiveTimeMs((previous) => (previous === latest ? previous : latest));
}
rafId = requestAnimationFrame(tick);
};
rafId = requestAnimationFrame(tick);
return () => cancelAnimationFrame(rafId);
}, []);
const hasGetter = !!getPlaybackTimeMs;
useEffect(() => {
if (!hasGetter) {
return;
}
let rafId: number;
const tick = () => {
const getter = getPlaybackTimeMsRef.current;
if (getter) {
const latest = getter();
setLiveTimeMs((previous) => (previous === latest ? previous : latest));
}
rafId = requestAnimationFrame(tick);
};
rafId = requestAnimationFrame(tick);
return () => cancelAnimationFrame(rafId);
}, [hasGetter]);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/video-editor/timeline/TimelineEditor.tsx` around lines 323 -
340, Update the useEffect containing tick to derive a boolean indicating whether
getPlaybackTimeMs is provided and include that flag in the dependency array, so
the requestAnimationFrame loop starts when the prop becomes available and cleans
up when it is removed.

Comment on lines +353 to +360
decoder.decode(
new EncodedAudioChunk({
type: packet.keyframe === 1 ? "key" : "delta",
timestamp: Math.round(packet.timestamp * 1_000_000),
duration: Math.round(packet.duration * 1_000_000),
data: packet.data,
}),
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

web-demuxer 4.0.0 readAVPacket keyframe flag AAC audio packets

💡 Result:

In web-demuxer version 4.0.0, the keyframe information for media packets is provided via a WebAVPacket object, which includes a keyframe property [1]. Based on the library's implementation in C++, this keyframe property is derived directly from the underlying FFmpeg AVPacket flags [1]. Specifically, the code calculates this value using: web_packet.keyframe = packet->flags & AV_PKT_FLAG_KEY; [1] For AAC audio packets, the keyframe flag is typically not set (as they are generally considered independent or not requiring keyframe signaling in the same way video frames do), unless the specific container format or encoder flags explicitly mark them as such. Since the library maps the AVPacket flags directly to the keyframe property, it will reflect whatever status is reported by the FFmpeg demuxer for that packet [1]. It is important to note that as of version 4.0.0, the API has undergone breaking changes where methods such as readVideoPacket and readAudioPacket have been replaced by readMediaPacket [2]. Furthermore, internal packet reading has been refactored to a non-ASYNCIFY variant to address stability issues on platforms like Safari [2][3].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the file and surrounding symbols first.
ast-grep outline src/lib/exporter/audioEncoder.ts --view expanded || true

# Locate `readAVPacket` and `keyframe` usage in the repo.
rg -n --hidden --glob '!**/node_modules/**' --glob '!**/dist/**' \
  'readAVPacket|keyframe|EncodedAudioChunk|AudioDecoder|decodeMixedAudioPlanes|demuxer\.read\("audio"\)' \
  src . || true

Repository: getopenscreen/openscreen

Length of output: 15184


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Read the relevant section of the file with line numbers.
file="src/lib/exporter/audioEncoder.ts"
wc -l "$file"
sed -n '300,420p' "$file" | cat -n

# Search for helper behavior around the mixed-audio decode path.
rg -n --hidden --glob '!**/node_modules/**' --glob '!**/dist/**' \
  'decodeMixedAudioPlanes|AudioDecoder|EncodedAudioChunk|configure\(|decode\(' src/lib/exporter src || true

Repository: getopenscreen/openscreen

Length of output: 13727


🏁 Script executed:

#!/bin/bash
set -euo pipefail

file="src/lib/exporter/audioEncoder.ts"
wc -l "$file"
sed -n '300,420p' "$file" | cat -n

echo '--- SEARCH ---'
rg -n --hidden --glob '!**/node_modules/**' --glob '!**/dist/**' \
  'readAVPacket|keyframe|EncodedAudioChunk|AudioDecoder|decodeMixedAudioPlanes|demuxer\.read\("audio"\)' \
  src . || true

Repository: getopenscreen/openscreen

Length of output: 18141


🏁 Script executed:

#!/bin/bash
set -euo pipefail

file="src/lib/exporter/audioEncoder.ts"
wc -l "$file"
sed -n '320,390p' "$file" | cat -n

echo '--- SEARCH ---'
rg -n --hidden --glob '!**/node_modules/**' --glob '!**/dist/**' \
  'readAVPacket|keyframe|EncodedAudioChunk|AudioDecoder|decodeMixedAudioPlanes|demuxer\.read\("audio"\)' \
  src . || true

Repository: getopenscreen/openscreen

Length of output: 15819


🌐 Web query:

WebCodecs AudioDecoder first EncodedAudioChunk must be key audio delta chunk decode() throws

💡 Result:

Yes, the WebCodecs specification mandates that the first EncodedAudioChunk passed to an AudioDecoder.decode method must be a key frame [1][2]. When an AudioDecoder is initialized or reset, it sets an internal state, often referred to as [[key chunk required]], to true [1][2]. According to the specification, the following requirements apply to the decode method: 1. If the [[key chunk required]] state is true, the decoder checks if the provided EncodedAudioChunk has a type of "key" [1][2]. 2. If the chunk type is not "key", the decode method will throw a DataError [1][2]. 3. Furthermore, implementations are encouraged to inspect the chunk's internal data to verify it is truly a key chunk, throwing a DataError if a mismatch is detected [1][2]. 4. Once a valid key frame is successfully processed, the [[key chunk required]] state is set to false, allowing subsequent chunks to be processed regardless of their type (as they are typically assumed to be independently decodable in many audio contexts) [1][2]. While many audio formats allow chunks to be treated as independently decodable [3], the WebCodecs API enforces this formal check to ensure the decoder state is properly initialized [1][2].

Citations:


🌐 Web query:

WebCodecs AudioDecoder first chunk after configure type key delta spec audio

💡 Result:

According to the WebCodecs specification, when an AudioDecoder is configured via the configure method, an internal flag known as [[key chunk required]] is set to true [1][2]. This flag indicates that the very first chunk passed to the decode method must be a key chunk (type "key") [1][3]. If the decoder receives a chunk while this flag is true, it performs a check: 1. If the chunk's type is not "key", the decoder will throw a DataError [1][4]. 2. If the chunk's type is "key", the decoder will proceed to process it and then set the [[key chunk required]] flag to false, allowing subsequent chunks to be either "key" or "delta" frames [1][3]. Implementations are also expected to inspect the internal data (payload) of the chunk to verify it is indeed a key chunk, and will throw a DataError if a mismatch is detected [4]. While most audio codecs primarily use key frames, this mechanism ensures that the decoder begins with a valid, independently decodable starting point [5][6].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail
pwd
ls -1

Repository: getopenscreen/openscreen

Length of output: 622


🏁 Script executed:

#!/bin/bash
set -euo pipefail

file="src/lib/exporter/audioEncoder.ts"

echo '--- decodeMixedAudioPlanes ---'
sed -n '420,560p' "$file" | cat -n

echo '--- single-stream / mixed callers ---'
sed -n '620,760p' "$file" | cat -n

echo '--- export path ---'
sed -n '1000,1295p' "$file" | cat -n

Repository: getopenscreen/openscreen

Length of output: 25412


🏁 Script executed:

#!/bin/bash
set -euo pipefail

file="src/lib/exporter/audioEncoder.ts"

echo '--- process() / error handling ---'
sed -n '560,700p' "$file" | cat -n

echo '--- surrounding higher-level caller ---'
sed -n '980,1085p' "$file" | cat -n

Repository: getopenscreen/openscreen

Length of output: 10874


Force audio chunks to start as "key" AAC packets often don’t carry a keyframe flag, and AudioDecoder.decode() requires the first chunk after configure() to be "key". Using packet.keyframe here can make multi-track exports fail on the first packet; set type: "key" for this path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/exporter/audioEncoder.ts` around lines 353 - 360, Update the
EncodedAudioChunk construction in the decoder.decode path to always set type to
"key" instead of deriving it from packet.keyframe. Preserve the existing
timestamp, duration, and data handling.

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