Skip to content

Fix click-sound export parity for Lightning and Legacy#580

Open
crbender wants to merge 4 commits into
webadderallorg:mainfrom
crbender:fix/click-sound-export-parity
Open

Fix click-sound export parity for Lightning and Legacy#580
crbender wants to merge 4 commits into
webadderallorg:mainfrom
crbender:fix/click-sound-export-parity

Conversation

@crbender
Copy link
Copy Markdown

@crbender crbender commented May 23, 2026

Description

This PR closes the gap where click sounds worked in the editor, but were missing or inconsistent in exports, especially on Lightning.

Main focus here is parity plus reliability. We now use the same click selection semantics in preview and both export paths, capture extension-triggered click audio during export, and force Lightning onto the audio path that can actually mix those captured events.

What changed

  1. Added export audio capture lifecycle in extensionHost (beginExportAudioCapture, drainExportAudioRegions, endExportAudioCapture) and wired playSound capture for export-time click events.
  2. Added shared click selector (cursorClickSelection.ts) and moved preview, legacy renderer, and modern renderer to the same selection behavior.
  3. Fixed selector behavior so fixed-frame export does not miss valid clicks when a newer move sample is in the same frame window.
  4. Tightened selector filtering to true click interactions only (click, double-click, right-click, middle-click) so one physical click does not retrigger from mouseup.
  5. Aligned click emission ordering in preview and exporters so cursor:click emits before hook/effect execution.
  6. Updated modern renderer compositing gate to include event listeners (hasEventListeners("cursor:click")) so event-only extensions still run in export.
  7. Updated Lightning audio planning so extension capture forces offline-render-fallback and is not dropped on no-source-audio recordings.
  8. Added decode-cache reuse for repeated overlay audio paths and a size cap (MAX_CACHED_AUDIO_REGION_PATHS) to avoid unbounded growth.
  9. Added focused tests for selector parity, Lightning audio planning behavior, frame-render emission behavior, and audio cache behavior.

Why this should fix the issue

  • Click sounds are now captured at export time as concrete audio regions.
  • Lightning and Legacy both route those regions into the mixed export audio path.
  • Event selection and timing are now consistent between what you hear in-app and what gets rendered.

Testing

  • Ran focused Vitest coverage for:
    • cursorClickSelection
    • frameRenderer / modernFrameRenderer cursor click parity tests
    • modernVideoExporter.nativeStaticLayout audio-plan tests
    • audioEncoder overlay-cache tests
  • Built mac app locally and validated test bundle output.

Related issues

Closes #572
Closes #573
Closes #574
Closes #575
Closes #576
Closes #577
Closes #578

Related follow-up: #579 (docs and author guidance)

Summary by CodeRabbit

  • New Features

    • Extensions can capture click audio during export; cursor-click events are emitted earlier in the render pipeline.
  • Performance Improvements

    • Reuses decoded audio buffers per source to reduce redundant work and limit memory use.
  • Bug Fixes

    • Prevents duplicate or mis-timed click emissions so a single physical click yields one click sound.
  • Tests

    • Added tests covering click selection/emission parity and captured-audio export behaviors.

Unifies preview/export cursor click selection, captures extension audio events during export, and routes Lightning audio planning to offline mix when click-sound capture is active. Also adds guardrails and regression tests for selector behavior, export parity, and overlay decode caching.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 23, 2026

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 5cac3892-d41a-4b46-a6c9-8fda3b194170

📥 Commits

Reviewing files that changed from the base of the PR and between af1ba99 and 3882cb6.

📒 Files selected for processing (8)
  • src/components/video-editor/VideoPlayback.tsx
  • src/lib/exporter/audioEncoder.test.ts
  • src/lib/exporter/audioEncoder.ts
  • src/lib/exporter/frameRenderer.ts
  • src/lib/exporter/modernFrameRenderer.test.ts
  • src/lib/exporter/modernFrameRenderer.ts
  • src/lib/exporter/modernVideoExporter.nativeStaticLayout.test.ts
  • src/lib/exporter/modernVideoExporter.ts
🚧 Files skipped from review as they are similar to previous changes (6)
  • src/lib/exporter/modernVideoExporter.nativeStaticLayout.test.ts
  • src/lib/exporter/audioEncoder.ts
  • src/lib/exporter/audioEncoder.test.ts
  • src/lib/exporter/frameRenderer.ts
  • src/lib/exporter/modernVideoExporter.ts
  • src/lib/exporter/modernFrameRenderer.ts

📝 Walkthrough

Walkthrough

Adds export-time capture of extension-triggered audio (cursor click sounds), a selector to pick a single click telemetry point for emission, refactors renderers to emit cursor:click earlier using the selector, caches decoded overlay audio, and merges captured audio regions into exporter audio processing and native planning.

Changes

Extension Audio Export Parity for Click Sounds

Layer / File(s) Summary
Cursor Click Selection Helper
src/lib/extensions/cursorClickSelection.ts, src/lib/extensions/cursorClickSelection.test.ts
Typed click telemetry and selectCursorClickForEmission select at most one eligible click within 100ms, excluding already-emitted times and non-click interactions; tests cover timing, dedupe, window, and mouseup filtering.
Extension Host Export Audio Capture Lifecycle
src/lib/extensions/extensionHost.ts
ExtensionHost gains export-capture state and APIs (hasEventListeners, beginExportAudioCapture, endExportAudioCapture, drainExportAudioRegions), sets per-handler event context during emitEvent, and records playSound behavior is adapted to capture events during export.
Audio Region Cache Optimization
src/lib/exporter/audioEncoder.ts, src/lib/exporter/audioEncoder.test.ts
Add MAX_CACHED_AUDIO_REGION_PATHS and a bounded per-path cache reused during offline preparation; tests verify reuse and cache capacity behavior.
Cursor Click Event Emission Refactor
src/components/video-editor/VideoPlayback.tsx, src/lib/exporter/frameRenderer.ts, src/lib/exporter/modernFrameRenderer.ts, plus related tests
Renderers/components now use selectCursorClickForEmission to pick a single click to emit, map it to canvas coords, call extensionHost.emitEvent("cursor:click", ...), update last-emitted time, and call notifyCursorInteraction. emitCursorInteractions runs before post-video/post-zoom/post-cursor hooks. Tests assert emission parity.
Export-time Extension Audio Integration
src/lib/exporter/videoExporter.ts, src/lib/exporter/modernVideoExporter.ts, src/lib/exporter/modernVideoExporter.nativeStaticLayout.test.ts
Exporters detect cursor:click listeners, wrap decode/render with beginExportAudioCapture/endExportAudioCapture, drain captured AudioRegions, merge captured regions with configured audio regions (getAudioRegionsForExport()), and feed merged regions into audio processing and native planning; native/streaming decisions updated when capture is active and tests validate native static-layout behavior.

Sequence Diagram

sequenceDiagram
  participant Component as Component/Renderer
  participant ExtHost as ExtensionHost
  participant Selector as Click Selector
  participant Exporter as Exporter
  participant Processor as AudioProcessor
  
  Exporter->>ExtHost: hasEventListeners("cursor:click")?
  ExtHost-->>Exporter: true/false
  
  rect rgba(0, 128, 255, 0.5)
  note right of Exporter: Export capture lifecycle
  Exporter->>ExtHost: beginExportAudioCapture()
  loop Frame decode & render
    Exporter->>Component: render(timeMs)
    Component->>Selector: selectCursorClickForEmission(telemetry, timeMs, lastEmitted)
    Selector-->>Component: CursorClickTelemetryPoint | null
    alt Click selected
      Component->>ExtHost: emitEvent("cursor:click", cursorData)
      activate ExtHost
      ExtHost->>ExtHost: set currentEventContext = timeMs
      ExtHost->>ExtHost: invoke handlers
      ExtHost->>ExtHost: clear context
      deactivate ExtHost
    end
  end
  Exporter->>ExtHost: endExportAudioCapture()
  Exporter->>ExtHost: drainExportAudioRegions(durationMs)
  ExtHost-->>Exporter: AudioRegion[]
  end
  
  Exporter->>Exporter: getAudioRegionsForExport() = merge configured + captured
  Exporter->>Processor: process(mergedAudioRegions)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

"I hopped and cached every little click,
Picked the timely tap and mixed it in quick,
From preview to export the sounds now play,
A rabbit’s small cheer for parity today! 🐇🎶"

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The PR title clearly and concisely describes the main change: fixing click-sound export parity for two export paths (Lightning and Legacy), which aligns with the primary objective of the changeset.
Description check ✅ Passed The PR description provides comprehensive coverage of changes, motivation, testing approach, and related issues, fulfilling the template requirements with clear structure and detail.
Linked Issues check ✅ Passed All code changes directly address the seven linked issues (#572-#578) by implementing export audio capture, click-selector parity, event emission ordering, compositing gates, audio planning adjustments, cache optimization, and comprehensive test coverage.
Out of Scope Changes check ✅ Passed All file modifications are scoped to fulfilling the linked issue objectives; no unrelated refactoring, style changes, or feature creep are present in the changeset.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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 and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

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

⚠️ Outside diff range comments (2)
src/lib/exporter/modernFrameRenderer.ts (1)

3276-3284: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Include post-video and post-webcam in the compositing gate.

compositeExtensions() still runs both of those hook phases, but shouldCompositeExtensionFrame() cannot be activated by them. An extension that only registers post-video or post-webcam will never composite in Modern export.

Proposed fix
 	private shouldCompositeExtensionFrame(): boolean {
 		return (
 			extensionHost.hasCursorEffects() ||
+			extensionHost.hasRenderHooks("post-video") ||
 			extensionHost.hasRenderHooks("post-zoom") ||
 			extensionHost.hasRenderHooks("post-cursor") ||
+			extensionHost.hasRenderHooks("post-webcam") ||
 			extensionHost.hasRenderHooks("post-annotations") ||
 			extensionHost.hasRenderHooks("final") ||
 			extensionHost.hasEventListeners("cursor:click")
 		);
 	}
🤖 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/modernFrameRenderer.ts` around lines 3276 - 3284, The
compositing gate in shouldCompositeExtensionFrame() omits the "post-video" and
"post-webcam" render hook checks causing extensions that only implement those
hooks to never trigger compositing; update shouldCompositeExtensionFrame() to
also OR in extensionHost.hasRenderHooks("post-video") and
extensionHost.hasRenderHooks("post-webcam") so compositeExtensions() can run for
those phases (keep the existing checks for hasCursorEffects, post-zoom,
post-cursor, post-annotations, final, and the cursor:click event listener).
src/lib/exporter/videoExporter.ts (1)

586-590: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don’t return audioMode: "none" when extension capture is enabled.

At Line 586, the early none branch ignores this.extensionAudioCaptureEnabled. For extension-only audio cases, this can misclassify the export as audio-less and skip the intended audio-handling path.

Suggested fix
 		if (
 			!videoInfo.hasAudio &&
 			sourceAudioFallbackPaths.length === 0 &&
-			audioRegions.length === 0
+			audioRegions.length === 0 &&
+			!this.extensionAudioCaptureEnabled
 		) {
 			return { audioMode: "none" };
 		}
🤖 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/videoExporter.ts` around lines 586 - 590, The early-return
branch that sets audioMode to "none" uses the condition on videoInfo.hasAudio,
sourceAudioFallbackPaths, and audioRegions but omits
this.extensionAudioCaptureEnabled; update the conditional in the block (the
check around videoInfo.hasAudio && sourceAudioFallbackPaths.length === 0 &&
audioRegions.length === 0) to also require that
this.extensionAudioCaptureEnabled is false (i.e., only classify as "none" when
extension audio capture is not enabled), so extension-only audio cases fall
through to the audio-handling path.
🤖 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/lib/extensions/extensionHost.ts`:
- Line 965: The no-op disposer currently returns an empty block `() => {}` which
trips the lint rule; replace it with a concise expression-bodied no-op such as
`() => void 0` (i.e., change the returned noop disposer from `() => {}` to `()
=> void 0`) so the disposer (the noop returned in extensionHost.ts) no longer
contains an empty block statement.

---

Outside diff comments:
In `@src/lib/exporter/modernFrameRenderer.ts`:
- Around line 3276-3284: The compositing gate in shouldCompositeExtensionFrame()
omits the "post-video" and "post-webcam" render hook checks causing extensions
that only implement those hooks to never trigger compositing; update
shouldCompositeExtensionFrame() to also OR in
extensionHost.hasRenderHooks("post-video") and
extensionHost.hasRenderHooks("post-webcam") so compositeExtensions() can run for
those phases (keep the existing checks for hasCursorEffects, post-zoom,
post-cursor, post-annotations, final, and the cursor:click event listener).

In `@src/lib/exporter/videoExporter.ts`:
- Around line 586-590: The early-return branch that sets audioMode to "none"
uses the condition on videoInfo.hasAudio, sourceAudioFallbackPaths, and
audioRegions but omits this.extensionAudioCaptureEnabled; update the conditional
in the block (the check around videoInfo.hasAudio &&
sourceAudioFallbackPaths.length === 0 && audioRegions.length === 0) to also
require that this.extensionAudioCaptureEnabled is false (i.e., only classify as
"none" when extension audio capture is not enabled), so extension-only audio
cases fall through to the audio-handling path.
🪄 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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: e1416d8d-e81b-46ed-b361-2b4fa83acdc5

📥 Commits

Reviewing files that changed from the base of the PR and between 48b48dc and d558539.

📒 Files selected for processing (13)
  • src/components/video-editor/VideoPlayback.tsx
  • src/lib/exporter/audioEncoder.test.ts
  • src/lib/exporter/audioEncoder.ts
  • src/lib/exporter/frameRenderer.test.ts
  • src/lib/exporter/frameRenderer.ts
  • src/lib/exporter/modernFrameRenderer.test.ts
  • src/lib/exporter/modernFrameRenderer.ts
  • src/lib/exporter/modernVideoExporter.nativeStaticLayout.test.ts
  • src/lib/exporter/modernVideoExporter.ts
  • src/lib/exporter/videoExporter.ts
  • src/lib/extensions/cursorClickSelection.test.ts
  • src/lib/extensions/cursorClickSelection.ts
  • src/lib/extensions/extensionHost.ts

Comment thread src/lib/extensions/extensionHost.ts Outdated
- replace empty no-op block in extensionHost disposer\n- include post-video and post-webcam hooks in modern compositing gate\n- prevent legacy audioPlan from returning none when extension capture is enabled

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
src/lib/exporter/modernFrameRenderer.test.ts (1)

757-772: ⚡ Quick win

Add a matching post-webcam compositing test.

This new case pins the post-video branch, but the sibling post-webcam branch added in src/lib/exporter/modernFrameRenderer.ts is still uncovered. A second assertion here would keep the full gate change from partially regressing.

🤖 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/modernFrameRenderer.test.ts` around lines 757 - 772, Test
only covers the "post-video" branch of compositing; add a parallel assertion
that pins the "post-webcam" branch by mocking extensionHost.hasRenderHooks to
return true when phase === "post-webcam" and asserting
shouldCompositeExtensionFrame() is true. Modify the test block around
createRenderer() / renderer.shouldCompositeExtensionFrame() to include a second
mock (or a separate it() case) that mirrors the existing setup but uses
hasRenderHooks.mockImplementation((phase: string) => phase === "post-webcam"),
and ensure you restore that spy along with the others (hasCursorEffectsSpy,
hasRenderHooksSpy, hasEventListenersSpy) so the test suite remains isolated.
🤖 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/lib/exporter/videoExporter.ts`:
- Around line 589-590: The current guard uses extensionAudioCaptureEnabled
(derived from extensionHost.hasEventListeners("cursor:click")) to decide the
no-audio fast path, which is wrong; change the logic in videoExporter.ts so that
the "none" audio path is determined solely from drained/captured audio regions
(audioRegions after draining) or an explicit "captured audio exists" flag
populated when playSound() events actually produce audio, and remove
extensionAudioCaptureEnabled from this guard. Specifically, stop using
extensionAudioCaptureEnabled (and the hasEventListeners call) as proof of
captured audio and instead check audioRegions.length (or a boolean set when
audio frames/regions are appended during drain) to choose the audio plan.

---

Nitpick comments:
In `@src/lib/exporter/modernFrameRenderer.test.ts`:
- Around line 757-772: Test only covers the "post-video" branch of compositing;
add a parallel assertion that pins the "post-webcam" branch by mocking
extensionHost.hasRenderHooks to return true when phase === "post-webcam" and
asserting shouldCompositeExtensionFrame() is true. Modify the test block around
createRenderer() / renderer.shouldCompositeExtensionFrame() to include a second
mock (or a separate it() case) that mirrors the existing setup but uses
hasRenderHooks.mockImplementation((phase: string) => phase === "post-webcam"),
and ensure you restore that spy along with the others (hasCursorEffectsSpy,
hasRenderHooksSpy, hasEventListenersSpy) so the test suite remains isolated.
🪄 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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: bb8faf89-e469-4076-998d-c8c2452d58c5

📥 Commits

Reviewing files that changed from the base of the PR and between d558539 and f17706d.

📒 Files selected for processing (4)
  • src/lib/exporter/modernFrameRenderer.test.ts
  • src/lib/exporter/modernFrameRenderer.ts
  • src/lib/exporter/videoExporter.ts
  • src/lib/extensions/extensionHost.ts

Comment thread src/lib/exporter/videoExporter.ts Outdated
Carl Bender and others added 2 commits May 23, 2026 18:06
- use captured regions only for legacy audioPlan none-fast-path\n- add post-webcam compositing gate test in modernFrameRenderer

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Resolve audioEncoder conflicts by preserving both the offline mix soft limiter and capped overlay decode cache, with merged test coverage for both behaviors.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@crbender
Copy link
Copy Markdown
Author

crbender commented Jun 2, 2026

Resolved the merge conflicts by keeping both changes in :\n- preserved capped overlay decode caching\n- preserved offline soft-limiter logic\n- merged test coverage for both paths\n\nPushed in commit ; PR should now be conflict-free.

@crbender
Copy link
Copy Markdown
Author

crbender commented Jun 2, 2026

Conflict resolution update: merged both sides of the audioEncoder conflicts and kept all intended behavior.

  • Preserved capped overlay decode caching (MAX_CACHED_AUDIO_REGION_PATHS)
  • Preserved offline mix soft-limiter logic (softLimitOfflineMixPeaksInPlace)
  • Preserved test coverage for both cache and limiter paths

Pushed in commit 3882cb6. PR is now mergeable (no conflict state).

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