Skip to content

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

Open
crbender wants to merge 3 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 3 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 video export; click events are emitted to extensions earlier and more reliably during rendering.
  • Performance Improvements

    • Decoded audio buffers are reused per file to reduce redundant decoding and cap memory use.
  • Bug Fixes

    • Avoids duplicate/mis-timed click emissions so a single physical click produces one click sound.
  • Tests

    • Added tests for click-selection/emission parity and captured-audio export behavior.

Review Change Stack

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

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: 7e3ecb81-6833-4a58-bb68-40f503f7331a

📥 Commits

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

📒 Files selected for processing (2)
  • src/lib/exporter/modernFrameRenderer.test.ts
  • src/lib/exporter/videoExporter.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

🐰 I peeked at telemetry and found a click,
I cached the sounds and picked just one quick pick,
Events now travel from preview to final,
Merged and mixed—export audio feels vital! 🎵

🚥 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 title 'Fix click-sound export parity for Lightning and Legacy' clearly summarizes the main objective of the changeset: addressing export parity for click sounds across renderer implementations.
Description check ✅ Passed The PR description is comprehensive and well-structured, covering description, motivation, type of change, related issues, testing, and checklist items as specified in the template.
Linked Issues check ✅ Passed All code changes directly implement requirements from linked issues #572-#578: export audio capture lifecycle [#573], click selection parity [#574], audio path integration [#576], guardrails [#577], and test coverage [#578] are addressed.
Out of Scope Changes check ✅ Passed All changes are scoped to the stated objectives: cursor click selection, export audio capture, renderer parity, and audio processing. No unrelated refactors or feature creep detected.

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

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

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint skipped: no ESLint configuration detected in root package.json. To enable, add eslint to devDependencies.


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
- 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment