Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 31 additions & 35 deletions src/components/video-editor/VideoPlayback.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ import {
mapCursorToCanvasNormalized,
mapSmoothedCursorToCanvasNormalized,
} from "@/lib/extensions/cursorCoordinates";
import { selectCursorClickForEmission } from "@/lib/extensions/cursorClickSelection";
import {
clearCursorEffects,
executeExtensionCursorEffects,
Expand Down Expand Up @@ -2286,43 +2287,38 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(

// Emit cursor:click events for extensions
if (isPlayingRef.current && telemetry.length > 0) {
for (let i = telemetry.length - 1; i >= 0; i--) {
const p = telemetry[i];
if (p.timeMs > timeMs) continue;
if (p.timeMs < timeMs - 100) break;
if (
p.interactionType &&
p.interactionType !== "move" &&
p.timeMs !== lastEmittedClickTimeMsRef.current
) {
const extensionCursor = mapCursorToCanvasNormalized(
{
cx: p.cx,
cy: p.cy,
interactionType: p.interactionType,
},
{
maskRect: baseMaskRef.current,
canvasWidth: extensionCanvasWidth,
canvasHeight: extensionCanvasHeight,
},
const clickPoint = selectCursorClickForEmission(
telemetry,
timeMs,
lastEmittedClickTimeMsRef.current,
);
if (clickPoint) {
const extensionCursor = mapCursorToCanvasNormalized(
{
cx: clickPoint.cx,
cy: clickPoint.cy,
interactionType: clickPoint.interactionType,
},
{
maskRect: baseMaskRef.current,
canvasWidth: extensionCanvasWidth,
canvasHeight: extensionCanvasHeight,
},
);
lastEmittedClickTimeMsRef.current = clickPoint.timeMs;
extensionHost.emitEvent({
type: "cursor:click",
timeMs: clickPoint.timeMs,
data: extensionCursor,
});
if (extensionCursor) {
notifyCursorInteraction(
clickPoint.timeMs,
extensionCursor.cx,
extensionCursor.cy,
clickPoint.interactionType,
);
lastEmittedClickTimeMsRef.current = p.timeMs;
extensionHost.emitEvent({
type: "cursor:click",
timeMs: p.timeMs,
data: extensionCursor,
});
if (extensionCursor) {
notifyCursorInteraction(
p.timeMs,
extensionCursor.cx,
extensionCursor.cy,
p.interactionType,
);
}
}
break;
}
}
}
Expand Down
85 changes: 85 additions & 0 deletions src/lib/exporter/audioEncoder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,4 +134,89 @@ describe("AudioProcessor offline render preparation", () => {
expect(loadAudioFileDemuxer).not.toHaveBeenCalled();
expect(renderAndMuxOfflineAudio).toHaveBeenCalled();
});

it("reuses decoded buffers for repeated overlay audio paths", async () => {
const processor = new AudioProcessor() as unknown as OfflineRenderTestHarness;
const mainBuffer = { duration: 10, numberOfChannels: 2 } as AudioBuffer;
const clickBuffer = { duration: 0.2, numberOfChannels: 1 } as AudioBuffer;
const decodeAudioFromUrl = vi
.spyOn(processor, "decodeAudioFromUrl")
.mockImplementation(async (url: string) => {
if (url === "file:///tmp/recording.mp4") {
return mainBuffer;
}
if (url === "file:///tmp/click.wav") {
return clickBuffer;
}
return null;
});
vi.spyOn(processor, "getMediaDurationSec").mockResolvedValue(10);

await processor.prepareOfflineRender(
"file:///tmp/recording.mp4",
[],
[],
[
{
id: "click-a",
startMs: 1_000,
endMs: 1_100,
audioPath: "file:///tmp/click.wav",
volume: 1,
},
{
id: "click-b",
startMs: 2_000,
endMs: 2_100,
audioPath: "file:///tmp/click.wav",
volume: 0.8,
},
] as never[],
["/tmp/recording.mp4"],
);

const clickDecodeCalls = decodeAudioFromUrl.mock.calls.filter(
([url]) => url === "file:///tmp/click.wav",
).length;
expect(clickDecodeCalls).toBe(1);
});

it("caps cached overlay buffers to avoid unbounded growth", async () => {
const processor = new AudioProcessor() as unknown as OfflineRenderTestHarness;
const mainBuffer = { duration: 10, numberOfChannels: 2 } as AudioBuffer;
const clickBuffer = { duration: 0.2, numberOfChannels: 1 } as AudioBuffer;
const decodeAudioFromUrl = vi
.spyOn(processor, "decodeAudioFromUrl")
.mockImplementation(async (url: string) => {
if (url === "file:///tmp/recording.mp4") {
return mainBuffer;
}
if (url.startsWith("file:///tmp/click-")) {
return clickBuffer;
}
return null;
});
vi.spyOn(processor, "getMediaDurationSec").mockResolvedValue(10);

const audioRegions = Array.from({ length: 102 }, (_, index) => ({
id: `click-${index}`,
startMs: 1_000 + index * 10,
endMs: 1_100 + index * 10,
audioPath: `file:///tmp/click-${index < 101 ? index : 100}.wav`,
volume: 1,
}));

await processor.prepareOfflineRender(
"file:///tmp/recording.mp4",
[],
[],
audioRegions as never[],
["/tmp/recording.mp4"],
);

const cappedPathDecodeCalls = decodeAudioFromUrl.mock.calls.filter(
([url]) => url === "file:///tmp/click-100.wav",
).length;
expect(cappedPathDecodeCalls).toBe(2);
});
});
10 changes: 9 additions & 1 deletion src/lib/exporter/audioEncoder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const MP4_AUDIO_CODEC = "mp4a.40.2";
const OFFLINE_AUDIO_SAMPLE_RATE = 48_000;
const OFFLINE_ENCODE_CHUNK_FRAMES = 1024;
const OFFLINE_CHUNK_DURATION_SEC = 30;
const MAX_CACHED_AUDIO_REGION_PATHS = 100;

function resolveSourceTrackGain(
sourceAudioTrackSettings: SourceAudioTrackSettings | undefined,
Expand Down Expand Up @@ -725,9 +726,16 @@ export class AudioProcessor {

// Decode audio region overlay files
const regionEntries: Array<{ buffer: AudioBuffer; region: AudioRegion }> = [];
const regionBufferCache = new Map<string, AudioBuffer>();
for (const region of audioRegions) {
if (this.cancelled) throw new Error("Export cancelled");
const buffer = await this.decodeAudioFromUrl(region.audioPath);
let buffer: AudioBuffer | null | undefined = regionBufferCache.get(region.audioPath);
if (!buffer) {
buffer = await this.decodeAudioFromUrl(region.audioPath);
if (buffer && regionBufferCache.size < MAX_CACHED_AUDIO_REGION_PATHS) {
regionBufferCache.set(region.audioPath, buffer);
}
}
if (buffer) regionEntries.push({ buffer, region });
}

Expand Down
57 changes: 57 additions & 0 deletions src/lib/exporter/frameRenderer.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_WEBCAM_OVERLAY } from "../../components/video-editor/types";
import { extensionHost } from "@/lib/extensions";

const {
cancelForwardFrameSourceMock,
Expand Down Expand Up @@ -535,3 +536,59 @@ describe("FrameRenderer webcam export path", () => {
expect(renderer.backgroundVideoElement).toBeTruthy();
});
});

describe("FrameRenderer cursor click emission parity", () => {
it("emits a click behind a newer move in the frame window", () => {
const renderer = Object.create(FrameRenderer.prototype) as FrameRenderer & {
config: {
cursorTelemetry: Array<{
timeMs: number;
cx: number;
cy: number;
interactionType?: string;
}>;
width: number;
height: number;
};
layoutCache: {
maskRect: {
x: number;
y: number;
width: number;
height: number;
sourceCrop: { x: number; y: number; width: number; height: number };
};
};
lastEmittedClickTimeMs: number;
emitCursorInteractions(timeMs: number): void;
};
renderer.config = {
cursorTelemetry: [
{ timeMs: 1_000, cx: 100, cy: 100, interactionType: "click" },
{ timeMs: 1_020, cx: 120, cy: 120, interactionType: "move" },
],
width: 1920,
height: 1080,
};
renderer.layoutCache = {
maskRect: {
x: 0,
y: 0,
width: 1920,
height: 1080,
sourceCrop: { x: 0, y: 0, width: 1, height: 1 },
},
};
renderer.lastEmittedClickTimeMs = -1;

const emitSpy = vi.spyOn(extensionHost, "emitEvent");
renderer.emitCursorInteractions(1_020);
expect(emitSpy).toHaveBeenCalledTimes(1);
expect(emitSpy).toHaveBeenCalledWith(
expect.objectContaining({
type: "cursor:click",
timeMs: 1_000,
}),
);
});
});
60 changes: 32 additions & 28 deletions src/lib/exporter/frameRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ import {
mapCursorToCanvasNormalized,
mapSmoothedCursorToCanvasNormalized,
} from "@/lib/extensions/cursorCoordinates";
import { selectCursorClickForEmission } from "@/lib/extensions/cursorClickSelection";
import {
executeExtensionCursorEffects,
executeExtensionRenderHooks,
Expand Down Expand Up @@ -1588,10 +1589,10 @@ export class FrameRenderer {

this.compositeCtx.save();
applyCanvasSceneTransform(this.compositeCtx, temporalSnapshot.sceneTransform);
this.emitCursorInteractions(temporalSnapshot.cursorTimeMs);
executeExtensionRenderHooks("post-video", this.compositeCtx, hookParams);
executeExtensionRenderHooks("post-zoom", this.compositeCtx, hookParams);
executeExtensionRenderHooks("post-cursor", this.compositeCtx, hookParams);
this.emitCursorInteractions(temporalSnapshot.cursorTimeMs);
executeExtensionCursorEffects(
this.compositeCtx,
temporalSnapshot.timeMs,
Expand Down Expand Up @@ -1784,12 +1785,10 @@ export class FrameRenderer {
x: this.animationState.x,
y: this.animationState.y,
});
this.emitCursorInteractions(cursorTimeMs);
executeExtensionRenderHooks("post-video", this.compositeCtx, hookParams);
executeExtensionRenderHooks("post-zoom", this.compositeCtx, hookParams);
executeExtensionRenderHooks("post-cursor", this.compositeCtx, hookParams);

// Cursor click effects
this.emitCursorInteractions(cursorTimeMs);
executeExtensionCursorEffects(
this.compositeCtx,
timeMs,
Expand Down Expand Up @@ -1850,31 +1849,36 @@ export class FrameRenderer {
const telemetry = this.config.cursorTelemetry;
if (!telemetry || telemetry.length === 0) return;

// Find click events near this time
for (const point of telemetry) {
if (point.timeMs > timeMs) break;
if (point.timeMs < timeMs - 100) continue;
if (!point.interactionType || point.interactionType === "move") continue;
if (point.timeMs === this.lastEmittedClickTimeMs) continue;
const point = selectCursorClickForEmission(
telemetry,
timeMs,
this.lastEmittedClickTimeMs,
);
if (!point) return;

const mappedCursor = mapCursorToCanvasNormalized(
{ cx: point.cx, cy: point.cy, interactionType: point.interactionType },
{
maskRect: this.layoutCache?.maskRect,
canvasWidth: this.config.width,
canvasHeight: this.config.height,
},
);
if (!mappedCursor) continue;

this.lastEmittedClickTimeMs = point.timeMs;
notifyCursorInteraction(
point.timeMs,
mappedCursor.cx,
mappedCursor.cy,
point.interactionType,
);
}
const mappedCursor = mapCursorToCanvasNormalized(
{ cx: point.cx, cy: point.cy, interactionType: point.interactionType },
{
maskRect: this.layoutCache?.maskRect,
canvasWidth: this.config.width,
canvasHeight: this.config.height,
},
);

if (!mappedCursor) return;
this.lastEmittedClickTimeMs = point.timeMs;
extensionHost.emitEvent({
type: "cursor:click",
timeMs: point.timeMs,
data: mappedCursor,
});

notifyCursorInteraction(
point.timeMs,
mappedCursor.cx,
mappedCursor.cy,
point.interactionType,
);
}

private updateLayout(): void {
Expand Down
Loading