Merge remote-tracking branch 'upstream/main' into codex/feature/camera-crop

# Conflicts:
#	src/components/video-editor/VideoEditor.tsx
This commit is contained in:
wizardAEI
2026-05-06 11:17:15 +08:00
48 changed files with 3466 additions and 1455 deletions
+1
View File
@@ -9,6 +9,7 @@ Version 3, 19 November 2007
source code** (including all edits) p**ublicly available** under this
same AGPLv3 license.
- You CANNOT use the "Recordly" name or branding for your own project.
- If you use Recordly's code or create code derived from Recordly you must attribute Recordly in the user-facing UI and the repo.
Copyright (C) 2026 webadderall
+2 -2
View File
@@ -1,7 +1,7 @@
Language: EN | [简中](README.zh-CN.md)
<p align="center">
<img src="https://i.postimg.cc/tRnL8gHp/Frame-5.png" width="220" alt="Recordly logo">
<img width="220" alt="Recordly logo" src="https://github.com/user-attachments/assets/082bb4b0-5fc5-4e9f-abda-55611fd6aded" />
</p>
<p align="center">
@@ -55,7 +55,7 @@ Add webcam footage as an overlay bubble, position it with presets or custom coor
Use drag-and-drop timeline tools for zooms, trims, speed regions, annotations, extra audio regions, and crop-aware edits. Save and reopen work as `.recordly` project files.
<p>
<img src="./feature3.png" width="450" alt="Recordly timeline editor screenshot">
<img width="450" alt="timeline editor" src="https://github.com/user-attachments/assets/3692bd8f-7b8d-4a93-b696-d17c828487ea" />
</p>
## Extensions & Marketplace
+32 -10
View File
@@ -151,12 +151,12 @@ interface Window {
message?: string;
error?: string;
}>;
pauseCursorCapture: (boundaryMs?: number) => Promise<{
pauseCursorCapture: () => Promise<{
success: boolean;
message?: string;
error?: string;
}>;
resumeCursorCapture: (boundaryMs?: number) => Promise<{
resumeCursorCapture: () => Promise<{
success: boolean;
message?: string;
error?: string;
@@ -246,7 +246,7 @@ interface Window {
},
) => Promise<{
success: boolean;
data?: Uint8Array;
tempPath?: string;
error?: string;
metrics?: RendererFfmpegAudioMuxMetrics;
}>;
@@ -313,6 +313,15 @@ interface Window {
message?: string;
error?: string;
}>;
setCursorTelemetry: (
videoPath: string | undefined,
samples: CursorTelemetryPoint[],
) => Promise<{
success: boolean;
samples: CursorTelemetryPoint[];
message?: string;
error?: string;
}>;
getSystemCursorAssets: () => Promise<{
success: boolean;
cursors: Record<string, SystemCursorAsset>;
@@ -410,16 +419,28 @@ interface Window {
}>;
setCurrentVideoPath: (
path: string,
options?: { preserveProjectPath?: boolean },
options?: {
preserveProjectPath?: boolean;
hideOverlayCursorByDefault?: boolean;
},
) => Promise<{ success: boolean; webcamPath: string | null }>;
setCurrentRecordingSession: (session: {
videoPath: string;
webcamPath?: string | null;
timeOffsetMs?: number;
}, options?: { preserveProjectPath?: boolean }) => Promise<{ success: boolean }>;
setCurrentRecordingSession: (
session: {
videoPath: string;
webcamPath?: string | null;
timeOffsetMs?: number;
hideOverlayCursorByDefault?: boolean;
},
options?: { preserveProjectPath?: boolean },
) => Promise<{ success: boolean }>;
getCurrentRecordingSession: () => Promise<{
success: boolean;
session?: { videoPath: string; webcamPath?: string | null; timeOffsetMs?: number };
session?: {
videoPath: string;
webcamPath?: string | null;
timeOffsetMs?: number;
hideOverlayCursorByDefault?: boolean;
};
}>;
getCurrentVideoPath: () => Promise<{ success: boolean; path?: string }>;
clearCurrentVideoPath: () => Promise<{ success: boolean }>;
@@ -529,6 +550,7 @@ interface Window {
onMenuSaveProject: (callback: () => void) => () => void;
onMenuSaveProjectAs: (callback: () => void) => () => void;
getPlatform: () => Promise<string>;
getLinuxWindowSystem: () => Promise<"wayland" | "x11" | null>;
revealInFolder: (
filePath: string,
) => Promise<{ success: boolean; error?: string; message?: string }>;
+54
View File
@@ -1,4 +1,17 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { CURSOR_TELEMETRY_VERSION } from "../constants";
const { writeFile, rm } = vi.hoisted(() => ({
writeFile: vi.fn(),
rm: vi.fn(),
}));
vi.mock("node:fs/promises", () => ({
default: {
writeFile,
rm,
},
}));
vi.mock("electron", () => ({
app: {
@@ -18,14 +31,18 @@ vi.mock("../utils", () => ({
import {
getCursorCaptureElapsedMs,
normalizeCursorTelemetrySamples,
pauseCursorCapture,
resetCursorCaptureClock,
resumeCursorCapture,
writeCursorTelemetry,
} from "./telemetry";
import { setCursorCaptureStartTimeMs } from "../state";
describe("cursor telemetry pause clock", () => {
beforeEach(() => {
writeFile.mockReset();
rm.mockReset();
setCursorCaptureStartTimeMs(1_000);
resetCursorCaptureClock();
});
@@ -48,4 +65,41 @@ describe("cursor telemetry pause clock", () => {
expect(getCursorCaptureElapsedMs(1_900)).toBe(550);
});
it("normalizes cursor telemetry samples before persisting them", async () => {
const samples = normalizeCursorTelemetrySamples([
{ timeMs: 30, cx: 2, cy: -1, interactionType: "click", cursorType: "pointer" },
{ timeMs: -10, cx: Number.NaN, cy: 0.2, interactionType: "drag", cursorType: "ibeam" },
{ timeMs: 10, cx: 0.25, cy: 0.75, interactionType: "move", cursorType: "text" },
]);
expect(samples).toEqual([
{ timeMs: 0, cx: 0.5, cy: 0.2, interactionType: undefined, cursorType: undefined },
{ timeMs: 10, cx: 0.25, cy: 0.75, interactionType: "move", cursorType: "text" },
{ timeMs: 30, cx: 1, cy: 0, interactionType: "click", cursorType: "pointer" },
]);
await writeCursorTelemetry("/tmp/recording.mp4", samples);
expect(writeFile).toHaveBeenCalledWith(
"/tmp/recording.cursor.json",
JSON.stringify(
{
version: CURSOR_TELEMETRY_VERSION,
samples,
},
null,
2,
),
"utf-8",
);
expect(rm).not.toHaveBeenCalled();
});
it("removes the sidecar when saving an empty cursor telemetry payload", async () => {
await writeCursorTelemetry("/tmp/recording.mp4", []);
expect(rm).toHaveBeenCalledWith("/tmp/recording.cursor.json", { force: true });
expect(writeFile).not.toHaveBeenCalled();
});
});
+75 -2
View File
@@ -28,6 +28,79 @@ export function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
export function normalizeCursorTelemetrySamples(rawSamples: unknown): CursorTelemetryPoint[] {
const samples = Array.isArray(rawSamples)
? rawSamples
: Array.isArray((rawSamples as { samples?: unknown[] } | null | undefined)?.samples)
? ((rawSamples as { samples: unknown[] }).samples ?? [])
: [];
const boundedSamples = samples.slice(0, MAX_CURSOR_SAMPLES);
return boundedSamples
.filter((sample: unknown) => Boolean(sample && typeof sample === "object"))
.map((sample: unknown) => {
const point = sample as Partial<CursorTelemetryPoint>;
return {
timeMs:
typeof point.timeMs === "number" && Number.isFinite(point.timeMs)
? Math.max(0, point.timeMs)
: 0,
cx:
typeof point.cx === "number" && Number.isFinite(point.cx)
? clamp(point.cx, 0, 1)
: 0.5,
cy:
typeof point.cy === "number" && Number.isFinite(point.cy)
? clamp(point.cy, 0, 1)
: 0.5,
interactionType:
point.interactionType === "click" ||
point.interactionType === "double-click" ||
point.interactionType === "right-click" ||
point.interactionType === "middle-click" ||
point.interactionType === "move" ||
point.interactionType === "mouseup"
? point.interactionType
: undefined,
cursorType:
point.cursorType === "arrow" ||
point.cursorType === "text" ||
point.cursorType === "pointer" ||
point.cursorType === "crosshair" ||
point.cursorType === "open-hand" ||
point.cursorType === "closed-hand" ||
point.cursorType === "resize-ew" ||
point.cursorType === "resize-ns" ||
point.cursorType === "not-allowed"
? point.cursorType
: undefined,
};
})
.sort((a, b) => a.timeMs - b.timeMs);
}
export async function writeCursorTelemetry(videoPath: string, samples: unknown) {
const telemetryPath = getTelemetryPathForVideo(videoPath);
const normalizedSamples = normalizeCursorTelemetrySamples(samples);
if (normalizedSamples.length === 0) {
await fs.rm(telemetryPath, { force: true });
return normalizedSamples;
}
await fs.writeFile(
telemetryPath,
JSON.stringify(
{ version: CURSOR_TELEMETRY_VERSION, samples: normalizedSamples },
null,
2,
),
"utf-8",
);
return normalizedSamples;
}
export function stopCursorCapture() {
if (cursorCaptureInterval) {
clearTimeout(cursorCaptureInterval);
@@ -168,9 +241,9 @@ export function pushCursorSample(
}
}
export function sampleCursorPoint(sampledAtMs = Date.now()) {
export function sampleCursorPoint() {
const point = getNormalizedCursorPoint();
pushCursorSample(point.cx, point.cy, getCursorCaptureElapsedMs(sampledAtMs), "move");
pushCursorSample(point.cx, point.cy, getCursorCaptureElapsedMs(), "move");
}
export async function persistPendingCursorTelemetry(videoPath: string) {
+69
View File
@@ -0,0 +1,69 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("electron", () => ({
app: {
getPath: vi.fn(() => "/tmp"),
},
}));
vi.mock("../ffmpeg/binary", () => ({
getFfmpegBinaryPath: vi.fn(() => "/usr/bin/ffmpeg"),
}));
vi.mock("../state", () => ({
cachedNativeVideoEncoder: null,
setCachedNativeVideoEncoder: vi.fn(),
}));
const fsMocks = vi.hoisted(() => ({
writeFile: vi.fn(async () => undefined),
readFile: vi.fn(),
stat: vi.fn(async () => ({ size: 5_000_000_000 })),
unlink: vi.fn(async () => undefined),
}));
vi.mock("node:fs/promises", () => ({
default: fsMocks,
...fsMocks,
}));
const execFileMock = vi.hoisted(() =>
vi.fn((_cmd: string, _args: string[], _opts: unknown, cb: (err: Error | null) => void) => {
cb(null);
return { stdout: "", stderr: "" } as unknown;
}),
);
vi.mock("node:child_process", () => ({
execFile: execFileMock,
spawn: vi.fn(),
}));
import { muxExportedVideoAudioBuffer } from "./native-video";
describe("muxExportedVideoAudioBuffer", () => {
it("returns the muxed output path without reading the muxed file into memory", async () => {
const videoData = new ArrayBuffer(64);
const result = await muxExportedVideoAudioBuffer(videoData, { audioMode: "none" });
// Path-based contract: caller (IPC handler) registers ownership and
// hands the path to the renderer's finalize-exported-video flow.
expect(typeof result.outputPath).toBe("string");
expect(result.outputPath.length).toBeGreaterThan(0);
// The 2 GiB bug was a fs.readFile of the muxed output. The fix relies on
// stat-only metric collection — readFile must stay unused.
expect(fsMocks.readFile).not.toHaveBeenCalled();
// We still record byte size so export metrics survive the change.
expect(result.metrics.muxedVideoBytes).toBe(5_000_000_000);
});
it("preserves the input temp path when audioMode='none' (no re-mux)", async () => {
const videoData = new ArrayBuffer(32);
const result = await muxExportedVideoAudioBuffer(videoData, { audioMode: "none" });
// muxNativeVideoExportAudio short-circuits when audioMode === "none" and
// returns the input path unchanged. We surface that so the renderer can
// finalize the same temp file the buffer was written to.
expect(result.outputPath).toMatch(/recordly-export-video-/);
});
});
+28 -14
View File
@@ -492,6 +492,8 @@ export async function muxExportedVideoAudioBuffer(
`recordly-export-video-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.mp4`,
);
const metrics: NativeVideoAudioMuxMetrics = {};
let succeeded = false;
let outputPath = tempVideoPath;
try {
const tempVideoWriteStartedAt = getNowMs();
@@ -500,23 +502,35 @@ export async function muxExportedVideoAudioBuffer(
metrics.tempVideoBytes = videoData.byteLength;
const finalized = await muxNativeVideoExportAudio(tempVideoPath, options);
Object.assign(metrics, finalized.metrics);
const muxedVideoReadStartedAt = getNowMs();
const muxedData = await fs.readFile(finalized.outputPath);
metrics.muxedVideoReadMs = getNowMs() - muxedVideoReadStartedAt;
metrics.muxedVideoBytes = muxedData.byteLength;
outputPath = finalized.outputPath;
// Record byte size via stat instead of reading the whole file into a
// Buffer — fs.readFile throws ERR_FS_FILE_TOO_LARGE on >2 GiB outputs.
try {
const stat = await fs.stat(outputPath);
metrics.muxedVideoBytes = stat.size;
} catch {
// Stat failures are non-fatal; size is purely metric data.
}
succeeded = true;
return {
data: new Uint8Array(muxedData),
outputPath,
metrics,
};
} finally {
await Promise.allSettled([
removeTemporaryExportFile(tempVideoPath),
removeTemporaryExportFile(
path.join(
path.dirname(tempVideoPath),
`${path.basename(tempVideoPath, path.extname(tempVideoPath))}-final.mp4`,
),
),
]);
// Always remove the unmuxed intermediate when the muxer wrote a separate
// file. Only remove the muxed output on failure — on success the caller
// owns it and is responsible for moving/deleting it.
const cleanupTargets: string[] = [];
if (outputPath !== tempVideoPath) {
cleanupTargets.push(tempVideoPath);
}
if (!succeeded) {
cleanupTargets.push(outputPath);
}
if (cleanupTargets.length > 0) {
await Promise.allSettled(
cleanupTargets.map((target) => removeTemporaryExportFile(target)),
);
}
}
}
+6 -1
View File
@@ -382,9 +382,14 @@ export function registerExportHandlers() {
async (_, videoData: ArrayBuffer, options?: NativeVideoExportFinishOptions) => {
try {
const result = await muxExportedVideoAudioBuffer(videoData, options ?? {});
// Register the muxed output so finalize-exported-video / discard-
// exported-temp accept it. Returning a temp path (instead of the
// muxed bytes) keeps us off Node's >2 GiB fs.readFile cap and
// avoids a redundant copy through the renderer.
registerOwnedExportPath(result.outputPath);
return {
success: true,
data: result.data,
tempPath: result.outputPath,
metrics: result.metrics,
};
} catch (error) {
+18 -6
View File
@@ -45,6 +45,10 @@ function normalizeRecordingTimeOffsetMs(value: unknown): number {
return typeof value === "number" && Number.isFinite(value) ? Math.round(value) : 0;
}
function normalizeBoolean(value: unknown, fallback = false): boolean {
return typeof value === "boolean" ? value : fallback;
}
/**
* Produces a filesystem-safe project base name without the project extension.
*/
@@ -527,7 +531,7 @@ export function registerProjectHandlers() {
return { success: false, error: String(error), message: 'Failed to open projects folder.' }
}
})
ipcMain.handle('set-current-video-path', async (_, path: string, options?: { preserveProjectPath?: boolean }) => {
ipcMain.handle('set-current-video-path', async (_, path: string, options?: { preserveProjectPath?: boolean; hideOverlayCursorByDefault?: boolean }) => {
setCurrentVideoPath(normalizeVideoSourcePath(path) ?? path)
approveUserPath(currentVideoPath)
const resolvedSession = await resolveRecordingSession(currentVideoPath)
@@ -537,29 +541,37 @@ export function registerProjectHandlers() {
timeOffsetMs: 0,
}
setCurrentRecordingSession(resolvedSession)
const nextSession = {
...resolvedSession,
hideOverlayCursorByDefault:
normalizeBoolean(options?.hideOverlayCursorByDefault) ||
normalizeBoolean(resolvedSession.hideOverlayCursorByDefault),
}
setCurrentRecordingSession(nextSession)
await replaceApprovedSessionLocalReadPaths([
resolvedSession.videoPath,
resolvedSession.webcamPath,
])
if (resolvedSession.webcamPath) {
await persistRecordingSessionManifest(resolvedSession)
if (nextSession.webcamPath) {
await persistRecordingSessionManifest(nextSession)
}
if (!options?.preserveProjectPath) {
setCurrentProjectPath(null)
}
return { success: true, webcamPath: resolvedSession.webcamPath ?? null }
return { success: true, webcamPath: nextSession.webcamPath ?? null }
})
ipcMain.handle('set-current-recording-session', async (_, session: { videoPath: string; webcamPath?: string | null; timeOffsetMs?: number }, options?: { preserveProjectPath?: boolean }) => {
ipcMain.handle('set-current-recording-session', async (_, session: { videoPath: string; webcamPath?: string | null; timeOffsetMs?: number; hideOverlayCursorByDefault?: boolean }, options?: { preserveProjectPath?: boolean }) => {
const normalizedVideoPath = normalizeVideoSourcePath(session.videoPath) ?? session.videoPath
setCurrentVideoPath(normalizedVideoPath)
setCurrentRecordingSession({
videoPath: normalizedVideoPath,
webcamPath: normalizeVideoSourcePath(session.webcamPath ?? null),
timeOffsetMs: normalizeRecordingTimeOffsetMs(session.timeOffsetMs),
hideOverlayCursorByDefault: normalizeBoolean(session.hideOverlayCursorByDefault),
});
await replaceApprovedSessionLocalReadPaths([
currentRecordingSession!.videoPath,
+37 -62
View File
@@ -18,7 +18,7 @@ import { startWindowBoundsCapture, stopWindowBoundsCapture } from "../cursor/bou
import { startInteractionCapture, stopInteractionCapture } from "../cursor/interaction";
import { startNativeCursorMonitor, stopNativeCursorMonitor } from "../cursor/monitor";
import {
clamp,
normalizeCursorTelemetrySamples,
pauseCursorCapture,
resumeCursorCapture,
resetCursorCaptureClock,
@@ -26,6 +26,7 @@ import {
snapshotCursorTelemetryForPersistence,
startCursorSampling,
stopCursorCapture,
writeCursorTelemetry,
} from "../cursor/telemetry";
import { getFfmpegBinaryPath } from "../ffmpeg/binary";
import {
@@ -1312,23 +1313,15 @@ export function registerRecordingHandlers(
}
});
ipcMain.handle("pause-cursor-capture", (_event, boundaryMs?: number) => {
const timestamp =
typeof boundaryMs === "number" && Number.isFinite(boundaryMs)
? boundaryMs
: Date.now();
sampleCursorPoint(timestamp);
pauseCursorCapture(timestamp);
ipcMain.handle("pause-cursor-capture", () => {
sampleCursorPoint();
pauseCursorCapture(Date.now());
return { success: true };
});
ipcMain.handle("resume-cursor-capture", (_event, boundaryMs?: number) => {
const timestamp =
typeof boundaryMs === "number" && Number.isFinite(boundaryMs)
? boundaryMs
: Date.now();
resumeCursorCapture(timestamp);
sampleCursorPoint(timestamp);
ipcMain.handle("resume-cursor-capture", () => {
resumeCursorCapture(Date.now());
sampleCursorPoint();
return { success: true };
});
@@ -1342,53 +1335,7 @@ export function registerRecordingHandlers(
try {
const content = await fs.readFile(telemetryPath, "utf-8");
const parsed = JSON.parse(content);
const rawSamples = Array.isArray(parsed)
? parsed
: Array.isArray(parsed?.samples)
? parsed.samples
: [];
const samples: CursorTelemetryPoint[] = rawSamples
.filter((sample: unknown) => Boolean(sample && typeof sample === "object"))
.map((sample: unknown) => {
const point = sample as Partial<CursorTelemetryPoint>;
return {
timeMs:
typeof point.timeMs === "number" && Number.isFinite(point.timeMs)
? Math.max(0, point.timeMs)
: 0,
cx:
typeof point.cx === "number" && Number.isFinite(point.cx)
? clamp(point.cx, 0, 1)
: 0.5,
cy:
typeof point.cy === "number" && Number.isFinite(point.cy)
? clamp(point.cy, 0, 1)
: 0.5,
interactionType:
point.interactionType === "click" ||
point.interactionType === "double-click" ||
point.interactionType === "right-click" ||
point.interactionType === "middle-click" ||
point.interactionType === "move" ||
point.interactionType === "mouseup"
? point.interactionType
: undefined,
cursorType:
point.cursorType === "arrow" ||
point.cursorType === "text" ||
point.cursorType === "pointer" ||
point.cursorType === "crosshair" ||
point.cursorType === "open-hand" ||
point.cursorType === "closed-hand" ||
point.cursorType === "resize-ew" ||
point.cursorType === "resize-ns" ||
point.cursorType === "not-allowed"
? point.cursorType
: undefined,
};
})
.sort((a: CursorTelemetryPoint, b: CursorTelemetryPoint) => a.timeMs - b.timeMs);
const samples = normalizeCursorTelemetrySamples(parsed);
return { success: true, samples };
} catch (error) {
@@ -1405,4 +1352,32 @@ export function registerRecordingHandlers(
};
}
});
ipcMain.handle(
"set-cursor-telemetry",
async (_, videoPath: string | undefined, samples: CursorTelemetryPoint[]) => {
const targetVideoPath = normalizeVideoSourcePath(videoPath ?? currentVideoPath);
if (!targetVideoPath) {
return {
success: false,
samples: [],
message: "No video path available for cursor telemetry",
error: "Missing video path",
};
}
try {
const normalizedSamples = await writeCursorTelemetry(targetVideoPath, samples);
return { success: true, samples: normalizedSamples };
} catch (error) {
console.error("Failed to save cursor telemetry:", error);
return {
success: false,
samples: [],
message: "Failed to save cursor telemetry",
error: String(error),
};
}
},
);
}
+1
View File
@@ -47,6 +47,7 @@ export type RecordingSessionData = {
videoPath: string;
webcamPath?: string | null;
timeOffsetMs?: number;
hideOverlayCursorByDefault?: boolean;
};
export type PauseSegment = {
+27 -11
View File
@@ -202,7 +202,7 @@ contextBridge.exposeInMainWorld("electronAPI", {
) => {
return ipcRenderer.invoke("mux-exported-video-audio", videoData, options) as Promise<{
success: boolean;
data?: Uint8Array;
tempPath?: string;
error?: string;
metrics?: NativeVideoAudioMuxMetrics;
}>;
@@ -293,11 +293,11 @@ contextBridge.exposeInMainWorld("electronAPI", {
resumeNativeScreenRecording: () => {
return ipcRenderer.invoke("resume-native-screen-recording");
},
pauseCursorCapture: (boundaryMs?: number) => {
return ipcRenderer.invoke("pause-cursor-capture", boundaryMs);
pauseCursorCapture: () => {
return ipcRenderer.invoke("pause-cursor-capture");
},
resumeCursorCapture: (boundaryMs?: number) => {
return ipcRenderer.invoke("resume-cursor-capture", boundaryMs);
resumeCursorCapture: () => {
return ipcRenderer.invoke("resume-cursor-capture");
},
startFfmpegRecording: (source: ProcessedDesktopSource) => {
return ipcRenderer.invoke("start-ffmpeg-recording", source);
@@ -327,6 +327,9 @@ contextBridge.exposeInMainWorld("electronAPI", {
getCursorTelemetry: (videoPath?: string) => {
return ipcRenderer.invoke("get-cursor-telemetry", videoPath);
},
setCursorTelemetry: (videoPath: string | undefined, samples: CursorTelemetryPoint[]) => {
return ipcRenderer.invoke("set-cursor-telemetry", videoPath, samples);
},
getSystemCursorAssets: () => {
return ipcRenderer.invoke("get-system-cursor-assets");
},
@@ -436,14 +439,24 @@ contextBridge.exposeInMainWorld("electronAPI", {
}) => {
return ipcRenderer.invoke("generate-auto-captions", options);
},
setCurrentVideoPath: (path: string, options?: { preserveProjectPath?: boolean }) => {
setCurrentVideoPath: (
path: string,
options?: {
preserveProjectPath?: boolean;
hideOverlayCursorByDefault?: boolean;
},
) => {
return ipcRenderer.invoke("set-current-video-path", path, options);
},
setCurrentRecordingSession: (session: {
videoPath: string;
webcamPath?: string | null;
timeOffsetMs?: number;
}, options?: { preserveProjectPath?: boolean }) => {
setCurrentRecordingSession: (
session: {
videoPath: string;
webcamPath?: string | null;
timeOffsetMs?: number;
hideOverlayCursorByDefault?: boolean;
},
options?: { preserveProjectPath?: boolean },
) => {
return ipcRenderer.invoke("set-current-recording-session", session, options);
},
getCurrentRecordingSession: () => {
@@ -603,6 +616,9 @@ contextBridge.exposeInMainWorld("electronAPI", {
getPlatform: () => {
return ipcRenderer.invoke("get-platform");
},
getLinuxWindowSystem: () => {
return ipcRenderer.invoke("get-linux-window-system");
},
revealInFolder: (filePath: string) => {
return ipcRenderer.invoke("reveal-in-folder", filePath);
},
+330 -135
View File
@@ -1,4 +1,11 @@
import { Palette, Trash as Trash2, UploadSimple as Upload, X } from "@phosphor-icons/react";
import {
CursorClick,
Palette,
PresentationChart,
Trash as Trash2,
UploadSimple as Upload,
X,
} from "@phosphor-icons/react";
import { AnimatePresence, LayoutGroup, motion } from "motion/react";
import { useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
@@ -19,6 +26,14 @@ import {
getRenderableVideoUrl,
getWallpaperThumbnailUrl,
} from "@/lib/assetPath";
import {
TEMPORAL_MOTION_BLUR_DEFAULT_SAMPLE_COUNT,
TEMPORAL_MOTION_BLUR_DEFAULT_SHUTTER_FRACTION,
TEMPORAL_MOTION_BLUR_MAX_SAMPLE_COUNT,
TEMPORAL_MOTION_BLUR_MAX_SHUTTER_FRACTION,
TEMPORAL_MOTION_BLUR_MIN_SAMPLE_COUNT,
TEMPORAL_MOTION_BLUR_MIN_SHUTTER_FRACTION,
} from "@/lib/exporter/temporalMotionBlur";
import type { ExtensionSettingField } from "@/lib/extensions";
import { extensionHost, type FrameInstance } from "@/lib/extensions";
import { cn } from "@/lib/utils";
@@ -34,6 +49,7 @@ import { useI18n, useScopedT } from "../../contexts/I18nContext";
import type { AppLocale } from "../../i18n/config";
import { SUPPORTED_LOCALES } from "../../i18n/config";
import { AnnotationSettingsPanel } from "./AnnotationSettingsPanel";
import { CURSOR_MOTION_PRESETS, type CursorMotionPresetId } from "./cursorMotionPresets";
import { loadEditorPreferences, saveEditorPreferences } from "./editorPreferences";
import { SliderControl } from "./SliderControl";
import { KeyboardShortcutsDialog } from "./TutorialHelp";
@@ -48,7 +64,6 @@ import type {
EditorEffectSection,
FigureData,
Padding,
PlaybackSpeed,
WebcamOverlaySettings,
WebcamPositionPreset,
ZoomDepth,
@@ -62,7 +77,6 @@ import {
DEFAULT_CURSOR_CLICK_BOUNCE_DURATION,
DEFAULT_CURSOR_MOTION_BLUR,
DEFAULT_CURSOR_SIZE,
DEFAULT_CURSOR_SMOOTHING,
DEFAULT_CURSOR_STYLE,
DEFAULT_CURSOR_SWAY,
DEFAULT_PADDING,
@@ -74,8 +88,8 @@ import {
DEFAULT_WEBCAM_REACT_TO_ZOOM,
DEFAULT_WEBCAM_SHADOW,
DEFAULT_WEBCAM_SIZE,
DEFAULT_ZOOM_MOTION_BLUR,
SPEED_OPTIONS,
DEFAULT_ZOOM_IN_DURATION_MS,
DEFAULT_ZOOM_OUT_DURATION_MS,
} from "./types";
import { fromCursorSwaySliderValue, toCursorSwaySliderValue } from "./videoPlayback/cursorSway";
import { isZeroPadding } from "./videoPlayback/layoutUtils";
@@ -379,6 +393,66 @@ function ExtensionSettingsSection({
);
}
const MOTION_PRESET_ORDER: CursorMotionPresetId[] = ["focused", "smooth"];
function MotionPresetCards({
title,
activePresetId,
onApply,
tSettings,
}: {
title: string;
activePresetId: CursorMotionPresetId | null;
onApply: (presetId: CursorMotionPresetId) => void;
tSettings: (key: string, fallback?: string) => string;
}) {
return (
<div className="flex flex-col gap-2">
<div className="text-[10px] text-muted-foreground">{title}</div>
<div className="grid grid-cols-2 gap-2">
{MOTION_PRESET_ORDER.map((presetId) => {
const Icon = presetId === "focused" ? CursorClick : PresentationChart;
const isActive = activePresetId === presetId;
return (
<button
key={presetId}
type="button"
onClick={() => onApply(presetId)}
className={cn(
"rounded-xl border px-3 py-3 text-left transition-all",
"border-foreground/10 bg-foreground/[0.03] hover:border-foreground/20 hover:bg-foreground/[0.06]",
isActive &&
"border-[#2563EB]/70 bg-[#2563EB]/12 shadow-[inset_0_0_0_1px_rgba(37,99,235,0.15)]",
)}
>
<div className="flex items-start gap-3">
<div
className={cn(
"mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border border-foreground/10 bg-black/10 text-muted-foreground",
isActive &&
"border-[#2563EB]/30 bg-[#2563EB]/10 text-[#75A6FF]",
)}
>
<Icon className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<div className="text-[12px] font-medium text-foreground">
{tSettings(`effects.motionPresets.${presetId}.label`)}
</div>
</div>
</div>
<div className="mt-2 text-[10px] leading-4 text-muted-foreground">
{tSettings(`effects.motionPresets.${presetId}.description`)}
</div>
</button>
);
})}
</div>
</div>
);
}
interface SettingsPanelProps {
panelMode?: "editor" | "background";
activeEffectSection?: EditorEffectSection;
@@ -390,8 +464,6 @@ interface SettingsPanelProps {
selectedZoomMode?: ZoomMode | null;
onZoomModeChange?: (mode: ZoomMode) => void;
onZoomDelete?: (id: string) => void;
selectedTrimId?: string | null;
onTrimDelete?: (id: string) => void;
selectedClipId?: string | null;
selectedClipSpeed?: number | null;
selectedClipMuted?: boolean | null;
@@ -406,8 +478,12 @@ interface SettingsPanelProps {
onShadowChange?: (intensity: number) => void;
backgroundBlur?: number;
onBackgroundBlurChange?: (amount: number) => void;
zoomMotionBlur?: number;
onZoomMotionBlurChange?: (amount: number) => void;
zoomTemporalMotionBlur?: number;
onZoomTemporalMotionBlurChange?: (amount: number) => void;
zoomMotionBlurSampleCount?: number | null;
onZoomMotionBlurSampleCountChange?: (count: number | null) => void;
zoomMotionBlurShutterFraction?: number | null;
onZoomMotionBlurShutterFractionChange?: (fraction: number | null) => void;
connectZooms?: boolean;
onConnectZoomsChange?: (enabled: boolean) => void;
autoApplyFreshRecordingAutoZooms?: boolean;
@@ -438,8 +514,12 @@ interface SettingsPanelProps {
onCursorSizeChange?: (size: number) => void;
cursorSmoothing?: number;
onCursorSmoothingChange?: (smoothing: number) => void;
zoomSmoothness?: number;
onZoomSmoothnessChange?: (smoothness: number) => void;
cursorSpringStiffnessMultiplier?: number;
onCursorSpringStiffnessMultiplierChange?: (multiplier: number) => void;
cursorSpringDampingMultiplier?: number;
onCursorSpringDampingMultiplierChange?: (multiplier: number) => void;
cursorSpringMassMultiplier?: number;
onCursorSpringMassMultiplierChange?: (multiplier: number) => void;
zoomClassicMode?: boolean;
onZoomClassicModeChange?: (enabled: boolean) => void;
cursorMotionBlur?: number;
@@ -490,10 +570,6 @@ interface SettingsPanelProps {
onClearAutoCaptions?: () => void;
onDownloadWhisperSmallModel?: () => void;
onDeleteWhisperSmallModel?: () => void;
selectedSpeedId?: string | null;
selectedSpeedValue?: PlaybackSpeed | null;
onSpeedChange?: (speed: PlaybackSpeed) => void;
onSpeedDelete?: (id: string) => void;
}
const ZOOM_DEPTH_OPTIONS: Array<{ depth: ZoomDepth; label: string }> = [
@@ -771,8 +847,6 @@ export function SettingsPanel({
selectedZoomMode,
onZoomModeChange,
onZoomDelete,
selectedTrimId,
onTrimDelete,
selectedClipId,
selectedClipSpeed,
selectedClipMuted,
@@ -787,12 +861,20 @@ export function SettingsPanel({
onShadowChange,
backgroundBlur = 0,
onBackgroundBlurChange,
zoomMotionBlur = 0,
onZoomMotionBlurChange,
zoomTemporalMotionBlur = 0,
onZoomTemporalMotionBlurChange,
zoomMotionBlurSampleCount = TEMPORAL_MOTION_BLUR_DEFAULT_SAMPLE_COUNT,
onZoomMotionBlurSampleCountChange,
zoomMotionBlurShutterFraction = TEMPORAL_MOTION_BLUR_DEFAULT_SHUTTER_FRACTION,
onZoomMotionBlurShutterFractionChange,
connectZooms = true,
onConnectZoomsChange,
autoApplyFreshRecordingAutoZooms = true,
onAutoApplyFreshRecordingAutoZoomsChange,
zoomInDurationMs = DEFAULT_ZOOM_IN_DURATION_MS,
onZoomInDurationMsChange,
zoomOutDurationMs = DEFAULT_ZOOM_OUT_DURATION_MS,
onZoomOutDurationMsChange,
showCursor = false,
onShowCursorChange,
loopCursor = false,
@@ -803,8 +885,12 @@ export function SettingsPanel({
onCursorSizeChange,
cursorSmoothing = 2,
onCursorSmoothingChange,
zoomSmoothness = 0.5,
onZoomSmoothnessChange,
cursorSpringStiffnessMultiplier = 1,
onCursorSpringStiffnessMultiplierChange,
cursorSpringDampingMultiplier = 1,
onCursorSpringDampingMultiplierChange,
cursorSpringMassMultiplier = 1,
onCursorSpringMassMultiplierChange,
zoomClassicMode = false,
onZoomClassicModeChange,
cursorMotionBlur = DEFAULT_CURSOR_MOTION_BLUR,
@@ -853,10 +939,6 @@ export function SettingsPanel({
onClearAutoCaptions,
onDownloadWhisperSmallModel,
onDeleteWhisperSmallModel,
selectedSpeedId,
selectedSpeedValue,
onSpeedChange,
onSpeedDelete,
}: SettingsPanelProps) {
const tSettings = useScopedT("settings");
const { locale, setLocale, t } = useI18n();
@@ -1060,6 +1142,7 @@ export function SettingsPanel({
() => ({ ...builtInCursorPreviewUrls, ...extensionCursorPreviewUrls }),
[builtInCursorPreviewUrls, extensionCursorPreviewUrls],
);
const showDevMotionControls = import.meta.env.DEV;
const cursorStyleOptions = useMemo<CursorStyleOption[]>(
() => [
...BUILTIN_CURSOR_STYLE_OPTIONS,
@@ -1338,12 +1421,6 @@ export function SettingsPanel({
</div>
);
const handleTrimDeleteClick = () => {
if (selectedTrimId && onTrimDelete) {
onTrimDelete(selectedTrimId);
}
};
const crop = cropRegion ?? {
x: 0,
y: 0,
@@ -1390,8 +1467,13 @@ export function SettingsPanel({
};
const resetZoomSection = () => {
onZoomSmoothnessChange?.(0.5);
onZoomMotionBlurChange?.(initialEditorPreferences.zoomMotionBlur);
onZoomTemporalMotionBlurChange?.(initialEditorPreferences.zoomTemporalMotionBlur);
onZoomMotionBlurSampleCountChange?.(initialEditorPreferences.zoomMotionBlurSampleCount);
onZoomMotionBlurShutterFractionChange?.(
initialEditorPreferences.zoomMotionBlurShutterFraction,
);
onZoomInDurationMsChange?.(initialEditorPreferences.zoomInDurationMs);
onZoomOutDurationMsChange?.(initialEditorPreferences.zoomOutDurationMs);
onZoomClassicModeChange?.(false);
};
@@ -1401,17 +1483,65 @@ export function SettingsPanel({
onCursorStyleChange?.(initialEditorPreferences.cursorStyle);
onCursorSizeChange?.(initialEditorPreferences.cursorSize);
onCursorSmoothingChange?.(initialEditorPreferences.cursorSmoothing);
onCursorSpringStiffnessMultiplierChange?.(
initialEditorPreferences.cursorSpringStiffnessMultiplier,
);
onCursorSpringDampingMultiplierChange?.(
initialEditorPreferences.cursorSpringDampingMultiplier,
);
onCursorSpringMassMultiplierChange?.(initialEditorPreferences.cursorSpringMassMultiplier);
onCursorMotionBlurChange?.(initialEditorPreferences.cursorMotionBlur);
onCursorClickBounceChange?.(initialEditorPreferences.cursorClickBounce);
onCursorClickBounceDurationChange?.(DEFAULT_CURSOR_CLICK_BOUNCE_DURATION);
onCursorSwayChange?.(initialEditorPreferences.cursorSway);
};
const activeMotionPresetId = useMemo(() => {
return (
MOTION_PRESET_ORDER.find((presetId) => {
const preset = CURSOR_MOTION_PRESETS[presetId];
return (
preset.zoomInDurationMs === zoomInDurationMs &&
preset.zoomOutDurationMs === zoomOutDurationMs &&
preset.cursorSize === cursorSize &&
preset.cursorSmoothing === cursorSmoothing &&
preset.cursorSpringStiffnessMultiplier === cursorSpringStiffnessMultiplier &&
preset.cursorSpringDampingMultiplier === cursorSpringDampingMultiplier &&
preset.cursorSpringMassMultiplier === cursorSpringMassMultiplier &&
preset.cursorMotionBlur === cursorMotionBlur &&
preset.cursorClickBounce === cursorClickBounce &&
preset.cursorClickBounceDuration === cursorClickBounceDuration
);
}) ?? null
);
}, [
cursorClickBounce,
cursorClickBounceDuration,
cursorMotionBlur,
cursorSize,
cursorSmoothing,
cursorSpringDampingMultiplier,
cursorSpringMassMultiplier,
cursorSpringStiffnessMultiplier,
zoomInDurationMs,
zoomOutDurationMs,
]);
const applyMotionPreset = (presetId: CursorMotionPresetId) => {
const preset = CURSOR_MOTION_PRESETS[presetId];
onZoomInDurationMsChange?.(preset.zoomInDurationMs);
onZoomOutDurationMsChange?.(preset.zoomOutDurationMs);
onCursorSizeChange?.(preset.cursorSize);
onCursorSmoothingChange?.(preset.cursorSmoothing);
onCursorSpringStiffnessMultiplierChange?.(preset.cursorSpringStiffnessMultiplier);
onCursorSpringDampingMultiplierChange?.(preset.cursorSpringDampingMultiplier);
onCursorSpringMassMultiplierChange?.(preset.cursorSpringMassMultiplier);
onCursorMotionBlurChange?.(preset.cursorMotionBlur);
onCursorClickBounceChange?.(preset.cursorClickBounce);
onCursorClickBounceDurationChange?.(preset.cursorClickBounceDuration);
};
const resetFrameSection = () => {
onShadowChange?.(initialEditorPreferences.shadowIntensity);
onBorderRadiusChange?.(initialEditorPreferences.borderRadius);
onPaddingChange?.(DEFAULT_PADDING);
onFrameChange?.(null);
onAspectRatioChange?.(initialEditorPreferences.aspectRatio);
removeBackgroundStateRef.current = null;
};
@@ -2444,6 +2574,15 @@ export function SettingsPanel({
</div>
</section>
<section className="flex flex-col gap-2">
<MotionPresetCards
title={tSettings("effects.motionPresetsTitle", "Motion Presets")}
activePresetId={activeMotionPresetId}
onApply={applyMotionPreset}
tSettings={tSettings}
/>
</section>
<section className="flex flex-col gap-2">
<SectionLabel>{t("editor.keyboardShortcuts.title")}</SectionLabel>
<KeyboardShortcutsDialog
@@ -2514,7 +2653,7 @@ export function SettingsPanel({
)
: tSettings(
"zoom.modeAutoDescription",
"Camera follows cursor automatically",
"Camera recenters when the cursor nears the edge of the zoomed view",
)}
</p>
</div>
@@ -2564,29 +2703,87 @@ export function SettingsPanel({
/>
</div>
{!zoomClassicMode && (
<SliderControl
label={tSettings("effects.zoomSmoothness", "Zoom Smoothness")}
value={zoomSmoothness}
defaultValue={0.5}
min={0}
max={1}
step={0.01}
onChange={(v) => onZoomSmoothnessChange?.(v)}
formatValue={(v) => (v <= 0 ? tSettings("effects.off") : v.toFixed(2))}
parseInput={(text) => parseFloat(text)}
/>
<div className="text-[10px] text-muted-foreground">
{tSettings(
"effects.motionPresetsZoomHint",
"Zoom motion presets are available in Settings.",
)}
</div>
)}
{showDevMotionControls ? (
<div className="space-y-1.5 rounded-lg border border-[#2563EB]/15 bg-[#2563EB]/5 px-3 py-3">
<div>
<div className="text-[11px] font-medium text-foreground">
{tSettings("effects.exportBlurDebug", "Export Blur Debug")}
</div>
<div className="mt-0.5 text-[10px] text-muted-foreground">
{tSettings(
"effects.exportBlurDebugHint",
"Development-only temporal blur tuning for export and preview parity checks.",
)}
</div>
</div>
<SliderControl
label={tSettings("effects.zoomTemporalMotionBlur", "Temporal blur")}
value={zoomTemporalMotionBlur}
defaultValue={initialEditorPreferences.zoomTemporalMotionBlur}
min={0}
max={2}
step={0.05}
onChange={(value) => onZoomTemporalMotionBlurChange?.(value)}
formatValue={(value) => `${value.toFixed(2)}×`}
parseInput={(text) => parseFloat(text.replace(/×$/, ""))}
/>
<SliderControl
label={tSettings("effects.zoomMotionBlurSampleCount", "Sample count")}
value={
zoomMotionBlurSampleCount ??
TEMPORAL_MOTION_BLUR_DEFAULT_SAMPLE_COUNT
}
defaultValue={
initialEditorPreferences.zoomMotionBlurSampleCount ??
TEMPORAL_MOTION_BLUR_DEFAULT_SAMPLE_COUNT
}
min={TEMPORAL_MOTION_BLUR_MIN_SAMPLE_COUNT}
max={TEMPORAL_MOTION_BLUR_MAX_SAMPLE_COUNT}
step={2}
onChange={(value) =>
onZoomMotionBlurSampleCountChange?.(Math.round(value))
}
formatValue={(value) => `${Math.round(value)} samples`}
parseInput={(text) => parseFloat(text.replace(/samples?$/i, "").trim())}
/>
<SliderControl
label={tSettings("effects.zoomMotionBlurShutterFraction", "Shutter")}
value={
zoomMotionBlurShutterFraction ??
TEMPORAL_MOTION_BLUR_DEFAULT_SHUTTER_FRACTION
}
defaultValue={
initialEditorPreferences.zoomMotionBlurShutterFraction ??
TEMPORAL_MOTION_BLUR_DEFAULT_SHUTTER_FRACTION
}
min={TEMPORAL_MOTION_BLUR_MIN_SHUTTER_FRACTION}
max={TEMPORAL_MOTION_BLUR_MAX_SHUTTER_FRACTION}
step={0.01}
onChange={(value) => onZoomMotionBlurShutterFractionChange?.(value)}
formatValue={(value) => `${Math.round(value * 100)}%`}
parseInput={(text) => parseFloat(text.replace(/%$/, "")) / 100}
/>
</div>
) : (
<div className="rounded-lg border border-foreground/10 bg-foreground/[0.03] px-3 py-2">
<div className="text-[10px] text-muted-foreground">
{tSettings(
"effects.exportBlurLocked",
"Export blur is fixed for this build.",
)}
</div>
<div className="mt-1 text-[12px] font-medium text-foreground">
{`${TEMPORAL_MOTION_BLUR_DEFAULT_SAMPLE_COUNT} samples · ${Math.round(TEMPORAL_MOTION_BLUR_DEFAULT_SHUTTER_FRACTION * 100)}% shutter`}
</div>
</div>
)}
<SliderControl
label={tSettings("effects.zoomMotionBlur")}
value={zoomMotionBlur}
defaultValue={DEFAULT_ZOOM_MOTION_BLUR}
min={0}
max={2}
step={0.05}
onChange={(v) => onZoomMotionBlurChange?.(v)}
formatValue={(v) => `${v.toFixed(2)}×`}
parseInput={(text) => parseFloat(text.replace(/×$/, ""))}
/>
{selectedZoomId && (
<Button
onClick={() => {
@@ -2777,19 +2974,6 @@ export function SettingsPanel({
formatValue={(v) => `${v.toFixed(2)}×`}
parseInput={(text) => parseFloat(text.replace(/×$/, ""))}
/>
<SliderControl
label={tSettings("effects.cursorSmoothing")}
value={cursorSmoothing}
defaultValue={DEFAULT_CURSOR_SMOOTHING}
min={0}
max={2}
step={0.01}
onChange={(v) => onCursorSmoothingChange?.(v)}
formatValue={(v) =>
v <= 0 ? tSettings("effects.off") : v.toFixed(2)
}
parseInput={(text) => parseFloat(text)}
/>
<SliderControl
label={tSettings("effects.cursorMotionBlur")}
value={cursorMotionBlur}
@@ -2843,6 +3027,78 @@ export function SettingsPanel({
return parseFloat(text.replace(/×$/, ""));
}}
/>
{showDevMotionControls ? (
<div className="space-y-1.5 rounded-lg border border-[#2563EB]/15 bg-[#2563EB]/5 px-3 py-3">
<div>
<div className="text-[11px] font-medium text-foreground">
{tSettings(
"effects.cursorDebugTuning",
"Cursor Debug Tuning",
)}
</div>
<div className="mt-0.5 text-[10px] text-muted-foreground">
{tSettings(
"effects.cursorDebugTuningHint",
"Development-only spring tuning controls.",
)}
</div>
</div>
<SliderControl
label={tSettings(
"effects.cursorSpringStiffnessMultiplier",
"Spring stiffness",
)}
value={cursorSpringStiffnessMultiplier}
defaultValue={
initialEditorPreferences.cursorSpringStiffnessMultiplier
}
min={0.25}
max={3}
step={0.01}
onChange={(value) =>
onCursorSpringStiffnessMultiplierChange?.(value)
}
formatValue={(value) => `${value.toFixed(2)}×`}
parseInput={(text) => parseFloat(text.replace(/×$/, ""))}
/>
<SliderControl
label={tSettings(
"effects.cursorSpringDampingMultiplier",
"Spring damping",
)}
value={cursorSpringDampingMultiplier}
defaultValue={
initialEditorPreferences.cursorSpringDampingMultiplier
}
min={0.25}
max={3}
step={0.01}
onChange={(value) =>
onCursorSpringDampingMultiplierChange?.(value)
}
formatValue={(value) => `${value.toFixed(2)}×`}
parseInput={(text) => parseFloat(text.replace(/×$/, ""))}
/>
<SliderControl
label={tSettings(
"effects.cursorSpringMassMultiplier",
"Spring mass",
)}
value={cursorSpringMassMultiplier}
defaultValue={
initialEditorPreferences.cursorSpringMassMultiplier
}
min={0.25}
max={3}
step={0.01}
onChange={(value) =>
onCursorSpringMassMultiplierChange?.(value)
}
formatValue={(value) => `${value.toFixed(2)}×`}
parseInput={(text) => parseFloat(text.replace(/×$/, ""))}
/>
</div>
) : null}
</div>
{renderExtensionPanelsForSections("cursor")}
</section>
@@ -3134,70 +3390,9 @@ export function SettingsPanel({
<div
className={cn(
"flex-shrink-0 border-t border-foreground/10 bg-editor-header p-4 pt-3",
!selectedTrimId && !selectedSpeedId && !selectedAudioId && "hidden",
!selectedAudioId && "hidden",
)}
>
{selectedTrimId && (
<div className="mb-4">
<Button
onClick={handleTrimDeleteClick}
variant="destructive"
size="sm"
className="mt-2 h-8 w-full gap-2 border border-red-500/20 bg-red-500/10 text-xs text-red-400 transition-all hover:border-red-500/30 hover:bg-red-500/20"
>
<Trash2 className="h-3 w-3" />
{tSettings("trim.deleteRegion")}
</Button>
</div>
)}
{selectedSpeedId && (
<div>
<div className="mb-3 flex items-center justify-between">
<span className="text-sm font-medium text-foreground">
{tSettings("speed.playbackSpeed")}
</span>
{selectedSpeedValue && (
<span className="rounded-full bg-[#d97706]/10 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider text-[#d97706]">
{SPEED_OPTIONS.find((o) => o.speed === selectedSpeedValue)
?.label ?? `${selectedSpeedValue}×`}
</span>
)}
</div>
<div className="grid grid-cols-7 gap-1.5">
{SPEED_OPTIONS.map((option) => {
const isActive = selectedSpeedValue === option.speed;
return (
<Button
key={option.speed}
type="button"
onClick={() => onSpeedChange?.(option.speed)}
className={cn(
"h-auto w-full rounded-lg border px-1 py-2 text-center shadow-sm transition-all duration-200 ease-out cursor-pointer",
isActive
? "border-[#d97706] bg-[#d97706] text-white"
: "border-foreground/5 bg-foreground/5 text-muted-foreground hover:bg-foreground/10 hover:border-foreground/10 hover:text-foreground",
)}
>
<span className="text-xs font-semibold">
{option.label}
</span>
</Button>
);
})}
</div>
<Button
onClick={() => selectedSpeedId && onSpeedDelete?.(selectedSpeedId)}
variant="destructive"
size="sm"
className="mt-2 h-8 w-full gap-2 border border-red-500/20 bg-red-500/10 text-xs text-red-400 transition-all hover:border-red-500/30 hover:bg-red-500/20"
>
<Trash2 className="h-3 w-3" />
{tSettings("speed.deleteRegion")}
</Button>
</div>
)}
{selectedAudioId && (
<div>
<div className="mb-3 flex items-center justify-between">
+94 -13
View File
@@ -1,3 +1,5 @@
import type { PointerEvent as ReactPointerEvent } from "react";
import { useCallback, useRef } from "react";
import { cn } from "@/lib/utils";
interface SliderControlProps {
@@ -13,6 +15,18 @@ interface SliderControlProps {
accentColor?: "purple" | "blue";
}
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
function quantizeToStep(value: number, min: number, step: number) {
if (!(step > 0)) {
return value;
}
return min + Math.round((value - min) / step) * step;
}
export function SliderControl({
label,
value,
@@ -25,16 +39,94 @@ export function SliderControl({
parseInput: _parseInput,
accentColor = "blue",
}: SliderControlProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const pct = Math.min(100, Math.max(0, ((value - min) / (max - min || 1)) * 100));
const dividerClass =
accentColor === "purple"
? "bg-foreground/95 shadow-[0_0_10px_rgba(139,92,246,0.28)]"
: "bg-foreground/95 shadow-[0_0_10px_rgba(37,99,235,0.28)]";
const setValueFromClientX = useCallback(
(clientX: number) => {
const root = rootRef.current;
if (!root) {
return;
}
const bounds = root.getBoundingClientRect();
if (!(bounds.width > 0)) {
return;
}
const normalized = clamp((clientX - bounds.left) / bounds.width, 0, 1);
const rawValue = min + normalized * (max - min);
const nextValue = clamp(quantizeToStep(rawValue, min, step), min, max);
onChange(Number(nextValue.toFixed(6)));
},
[max, min, onChange, step],
);
const handlePointerDown = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
event.preventDefault();
const pointerId = event.pointerId;
const target = event.currentTarget;
target.setPointerCapture(pointerId);
setValueFromClientX(event.clientX);
const handlePointerMove = (moveEvent: PointerEvent) => {
if (moveEvent.pointerId !== pointerId) {
return;
}
setValueFromClientX(moveEvent.clientX);
};
const finishPointer = (finishEvent: PointerEvent) => {
if (finishEvent.pointerId !== pointerId) {
return;
}
target.releasePointerCapture(pointerId);
target.removeEventListener("pointermove", handlePointerMove);
target.removeEventListener("pointerup", finishPointer);
target.removeEventListener("pointercancel", finishPointer);
};
target.addEventListener("pointermove", handlePointerMove);
target.addEventListener("pointerup", finishPointer);
target.addEventListener("pointercancel", finishPointer);
},
[setValueFromClientX],
);
return (
<div className="relative flex h-10 w-full select-none items-center overflow-hidden rounded-xl bg-editor-bg/80 px-1.5">
<div
ref={rootRef}
role="slider"
tabIndex={0}
aria-label={label}
aria-valuemin={min}
aria-valuemax={max}
aria-valuenow={value}
aria-valuetext={formatValue(value)}
onPointerDown={handlePointerDown}
onKeyDown={(event) => {
if (event.key === "ArrowLeft" || event.key === "ArrowDown") {
event.preventDefault();
onChange(clamp(quantizeToStep(value - step, min, step), min, max));
}
if (event.key === "ArrowRight" || event.key === "ArrowUp") {
event.preventDefault();
onChange(clamp(quantizeToStep(value + step, min, step), min, max));
}
}}
className="relative flex h-10 w-full select-none items-center overflow-hidden rounded-xl bg-editor-bg/80 px-1.5 outline-none focus-visible:ring-1 focus-visible:ring-[#2563EB]/40"
>
<div
className="absolute inset-y-[3px] left-[3px] right-auto rounded-[10px] bg-foreground/[0.08] shadow-[0_4px_10px_0_rgba(0,0,0,0.18)] transition-none"
className="pointer-events-none absolute inset-y-[3px] left-[3px] right-auto rounded-[10px] bg-foreground/[0.08] shadow-[0_4px_10px_0_rgba(0,0,0,0.18)] transition-none"
style={{
width: pct > 0 ? `max(calc(${pct}% - 6px), 2.1rem)` : 0,
}}
@@ -52,17 +144,6 @@ export function SliderControl({
<span className="pointer-events-none relative z-10 pr-3 text-[12px] font-medium tabular-nums text-foreground">
{formatValue(value)}
</span>
<input
type="range"
min={min}
max={max}
step={step}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
aria-label={label}
aria-valuetext={formatValue(value)}
className="absolute inset-0 h-full w-full cursor-ew-resize opacity-0"
/>
</div>
);
}
File diff suppressed because it is too large Load Diff
+141 -385
View File
@@ -1,13 +1,6 @@
import {
Application,
BlurFilter,
Container,
Graphics,
Sprite,
Texture,
VideoSource,
} from "pixi.js";
import { Application, Container, Graphics, Rectangle, Sprite, Texture, VideoSource } from "pixi.js";
import { MotionBlurFilter } from "pixi-filters/motion-blur";
import { ZoomBlurFilter } from "pixi-filters/zoom-blur";
import type React from "react";
import {
forwardRef,
@@ -18,7 +11,6 @@ import {
useRef,
useState,
} from "react";
import { useI18n } from "@/contexts/I18nContext";
import { getAssetPath, getRenderableAssetUrl, getRenderableVideoUrl } from "@/lib/assetPath";
import { clampMediaTimeToDuration, getMediaSyncPlaybackRate } from "@/lib/mediaTiming";
import {
@@ -26,7 +18,6 @@ import {
DEFAULT_WALLPAPER_RELATIVE_PATH,
isVideoWallpaperSource,
} from "@/lib/wallpapers";
import { type CaptionEditTarget, normalizeCaptionEditText } from "./captionEditing";
import { buildActiveCaptionLayout } from "./captionLayout";
import {
CAPTION_FONT_WEIGHT,
@@ -238,7 +229,6 @@ interface VideoPlaybackProps {
showShadow?: boolean;
shadowIntensity?: number;
backgroundBlur?: number;
zoomMotionBlur?: number;
connectZooms?: boolean;
zoomInDurationMs?: number;
zoomInOverlapMs?: number;
@@ -260,7 +250,6 @@ interface VideoPlaybackProps {
annotationRegions?: AnnotationRegion[];
autoCaptions?: CaptionCue[];
autoCaptionSettings?: AutoCaptionSettings;
onEditAutoCaption?: (target: CaptionEditTarget, text: string) => void;
selectedAnnotationId?: string | null;
onSelectAnnotation?: (id: string | null) => void;
onAnnotationPositionChange?: (id: string, position: { x: number; y: number }) => void;
@@ -270,6 +259,9 @@ interface VideoPlaybackProps {
cursorStyle?: CursorStyle;
cursorSize?: number;
cursorSmoothing?: number;
cursorSpringStiffnessMultiplier?: number;
cursorSpringDampingMultiplier?: number;
cursorSpringMassMultiplier?: number;
zoomSmoothness?: number;
zoomClassicMode?: boolean;
cursorMotionBlur?: number;
@@ -279,11 +271,6 @@ interface VideoPlaybackProps {
volume?: number;
}
type CaptionEditSession = {
target: CaptionEditTarget;
draft: string;
};
export interface VideoPlaybackRef {
video: HTMLVideoElement | null;
app: Application | null;
@@ -314,7 +301,6 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
showShadow,
shadowIntensity = 0,
backgroundBlur = 0,
zoomMotionBlur = 0,
connectZooms = true,
zoomInDurationMs = DEFAULT_ZOOM_IN_DURATION_MS,
zoomInOverlapMs = DEFAULT_ZOOM_IN_OVERLAP_MS,
@@ -336,7 +322,6 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
annotationRegions = [],
autoCaptions = [],
autoCaptionSettings,
onEditAutoCaption,
selectedAnnotationId,
onSelectAnnotation,
onAnnotationPositionChange,
@@ -346,6 +331,9 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
cursorStyle = "tahoe",
cursorSize = DEFAULT_CURSOR_SIZE,
cursorSmoothing = DEFAULT_CURSOR_SMOOTHING,
cursorSpringStiffnessMultiplier = 1,
cursorSpringDampingMultiplier = 1,
cursorSpringMassMultiplier = 1,
zoomSmoothness = 0.5,
zoomClassicMode = false,
cursorMotionBlur = DEFAULT_CURSOR_MOTION_BLUR,
@@ -356,14 +344,15 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
},
ref,
) => {
const { t } = useI18n();
const editCurrentCaptionLabel = t("settings.captions.editCurrent", "Edit current caption");
const videoRef = useRef<HTMLVideoElement | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const appRef = useRef<Application | null>(null);
const videoSpriteRef = useRef<Sprite | null>(null);
const videoEffectsContainerRef = useRef<Container | null>(null);
const videoContainerRef = useRef<Container | null>(null);
const cursorContainerRef = useRef<Container | null>(null);
const zoomBlurFilterRef = useRef<ZoomBlurFilter | null>(null);
const motionBlurFilterRef = useRef<MotionBlurFilter | null>(null);
const cameraContainerRef = useRef<Container | null>(null);
const timeUpdateAnimationRef = useRef<number | null>(null);
const [pixiReady, setPixiReady] = useState(false);
@@ -378,17 +367,10 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
height: number;
} | null>(null);
const captionBoxRef = useRef<HTMLDivElement | null>(null);
const captionEditInputRef = useRef<HTMLTextAreaElement | null>(null);
const captionEditSessionRef = useRef<CaptionEditSession | null>(null);
const [captionEditSession, setCaptionEditSession] = useState<CaptionEditSession | null>(
null,
);
const currentTimeRef = useRef(0);
const zoomRegionsRef = useRef<ZoomRegion[]>([]);
const selectedZoomIdRef = useRef<string | null>(null);
const animationStateRef = useRef<PlaybackAnimationState>(createPlaybackAnimationState());
const blurFilterRef = useRef<BlurFilter | null>(null);
const motionBlurFilterRef = useRef<MotionBlurFilter | null>(null);
const isDraggingFocusRef = useRef(false);
const stageSizeRef = useRef({ width: 0, height: 0 });
const videoSizeRef = useRef({ width: 0, height: 0 });
@@ -424,7 +406,6 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
const lastWebcamSyncTimeRef = useRef<number | null>(null);
const lastBackgroundSyncTimeRef = useRef<number | null>(null);
const bgVideoRef = useRef<HTMLVideoElement | null>(null);
const zoomMotionBlurRef = useRef(zoomMotionBlur);
const connectZoomsRef = useRef(connectZooms);
const zoomInDurationMsRef = useRef(zoomInDurationMs);
const zoomInOverlapMsRef = useRef(zoomInOverlapMs);
@@ -442,6 +423,9 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
const cursorSizeRef = useRef(cursorSize);
const cursorStyleRef = useRef(cursorStyle);
const cursorSmoothingRef = useRef(cursorSmoothing);
const cursorSpringStiffnessMultiplierRef = useRef(cursorSpringStiffnessMultiplier);
const cursorSpringDampingMultiplierRef = useRef(cursorSpringDampingMultiplier);
const cursorSpringMassMultiplierRef = useRef(cursorSpringMassMultiplier);
const cursorMotionBlurRef = useRef(cursorMotionBlur);
const cursorClickBounceRef = useRef(cursorClickBounce);
const cursorClickBounceDurationRef = useRef(cursorClickBounceDuration);
@@ -495,148 +479,6 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
measureText: (text) => measurementContext.measureText(text).width,
});
}, [autoCaptionSettings, autoCaptions, currentTime]);
const activeCaptionEditTarget = activeCaptionLayout?.editTarget ?? null;
const activeCaptionEditTargetId = activeCaptionEditTarget?.id ?? null;
const isCaptionEditing = captionEditSession !== null;
const captionEditDraft = captionEditSession?.draft ?? "";
const captionEditTargetId = captionEditSession?.target.id ?? null;
const captionEditTextMetrics = useMemo(() => {
if (!captionEditSession || !autoCaptionSettings || typeof document === "undefined") {
return null;
}
const overlayWidth = overlayRef.current?.clientWidth || 960;
const fontSize = getCaptionScaledFontSize(
autoCaptionSettings.fontSize,
overlayWidth,
autoCaptionSettings.maxWidth,
);
const maxTextWidthPx = getCaptionTextMaxWidth(
overlayWidth,
autoCaptionSettings.maxWidth,
fontSize,
);
const measurementCanvas = document.createElement("canvas");
const measurementContext = measurementCanvas.getContext("2d");
if (!measurementContext) {
return null;
}
measurementContext.font = `${CAPTION_FONT_WEIGHT} ${fontSize}px ${getDefaultCaptionFontFamily()}`;
const measuredWidth = Math.max(
...captionEditSession.draft
.split(/\r?\n/)
.map((line) => measurementContext.measureText(line || " ").width),
);
return {
fontSize,
maxTextWidthPx,
widthPx: Math.ceil(
Math.min(maxTextWidthPx, Math.max(fontSize * 2, measuredWidth + 2)),
),
};
}, [autoCaptionSettings, captionEditSession]);
const captionEditSizeKey = captionEditSession
? `${captionEditTextMetrics?.widthPx ?? 0}:${captionEditDraft}`
: "";
const beginCaptionEdit = useCallback(() => {
if (!activeCaptionLayout?.editTarget || !onEditAutoCaption) {
return;
}
videoRef.current?.pause();
onPlayStateChange(false);
const nextSession = {
target: activeCaptionLayout.editTarget,
draft: activeCaptionLayout.editTarget.text,
};
captionEditSessionRef.current = nextSession;
setCaptionEditSession(nextSession);
}, [activeCaptionLayout, onEditAutoCaption, onPlayStateChange]);
const commitCaptionEdit = useCallback(() => {
const session = captionEditSessionRef.current;
if (!session || !onEditAutoCaption) {
captionEditSessionRef.current = null;
setCaptionEditSession(null);
return;
}
const normalizedDraft = normalizeCaptionEditText(session.draft);
captionEditSessionRef.current = null;
if (!normalizedDraft) {
setCaptionEditSession(null);
return;
}
if (normalizedDraft !== normalizeCaptionEditText(session.target.text)) {
onEditAutoCaption(session.target, session.draft);
}
setCaptionEditSession(null);
}, [onEditAutoCaption]);
const cancelCaptionEdit = useCallback(() => {
captionEditSessionRef.current = null;
setCaptionEditSession(null);
}, []);
useEffect(() => {
if (!activeCaptionEditTarget) {
return;
}
setCaptionEditSession((session) => {
if (!session || session.target.id === activeCaptionEditTargetId) {
return session;
}
const nextSession = {
...session,
target: activeCaptionEditTarget,
};
captionEditSessionRef.current = nextSession;
return nextSession;
});
}, [activeCaptionEditTarget, activeCaptionEditTargetId]);
useEffect(() => {
if (!captionEditTargetId) {
return;
}
const frame = requestAnimationFrame(() => {
const input = captionEditInputRef.current;
if (!input) {
return;
}
input.focus();
const cursorPosition = input.value.length;
input.setSelectionRange(cursorPosition, cursorPosition);
});
return () => cancelAnimationFrame(frame);
}, [captionEditTargetId]);
useEffect(() => {
if (!captionEditSizeKey) {
return;
}
const frame = requestAnimationFrame(() => {
const input = captionEditInputRef.current;
if (!input) {
return;
}
input.style.height = "auto";
input.style.height = `${input.scrollHeight}px`;
});
return () => cancelAnimationFrame(frame);
}, [captionEditSizeKey]);
useEffect(() => {
const captionBox = captionBoxRef.current;
@@ -649,12 +491,6 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
}
const frame = requestAnimationFrame(() => {
if (isCaptionEditing) {
captionBox.dataset.editingCaption = captionEditSizeKey;
} else {
delete captionBox.dataset.editingCaption;
}
const width = captionBox.offsetWidth;
const height = captionBox.offsetHeight;
if (width <= 0 || height <= 0) {
@@ -679,7 +515,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
});
return () => cancelAnimationFrame(frame);
}, [activeCaptionLayout, autoCaptionSettings, captionEditSizeKey, isCaptionEditing]);
}, [activeCaptionLayout, autoCaptionSettings]);
const motionBlurStateRef = useRef<MotionBlurState>(createMotionBlurState());
const webcamEnabled = webcam?.enabled ?? false;
const webcamMargin = webcam?.margin ?? 24;
@@ -823,6 +659,28 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
[],
);
const syncPreviewMotionBlurQuality = useCallback(() => {
const app = appRef.current;
const videoEffectsContainer = videoEffectsContainerRef.current;
const zoomBlurFilter = zoomBlurFilterRef.current;
const motionBlurFilter = motionBlurFilterRef.current;
if (!app || !videoEffectsContainer || !zoomBlurFilter || !motionBlurFilter) {
return;
}
const filterResolution = Math.max(
1,
app.renderer.resolution || window.devicePixelRatio || 1,
);
const stageWidth = Math.max(1, stageSizeRef.current.width || app.screen.width);
const stageHeight = Math.max(1, stageSizeRef.current.height || app.screen.height);
zoomBlurFilter.resolution = filterResolution;
motionBlurFilter.resolution = filterResolution;
videoEffectsContainer.filterArea = new Rectangle(0, 0, stageWidth, stageHeight);
}, []);
const layoutVideoContent = useCallback(() => {
const container = containerRef.current;
const app = appRef.current;
@@ -880,6 +738,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
if (result) {
stageSizeRef.current = result.stageSize;
syncPreviewMotionBlurQuality();
videoSizeRef.current = result.videoSize;
baseScaleRef.current = result.baseScale;
baseOffsetRef.current = result.baseOffset;
@@ -963,6 +822,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
showShadow,
shadowIntensity,
applyWebcamBubbleLayout,
syncPreviewMotionBlurQuality,
]);
useEffect(() => {
@@ -1294,8 +1154,23 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
}, [speedRegions]);
useEffect(() => {
zoomMotionBlurRef.current = zoomMotionBlur;
}, [zoomMotionBlur]);
const videoEffectsContainer = videoEffectsContainerRef.current;
const zoomBlurFilter = zoomBlurFilterRef.current;
const motionBlurFilter = motionBlurFilterRef.current;
if (!videoEffectsContainer || !zoomBlurFilter || !motionBlurFilter) {
return;
}
videoEffectsContainer.filters = null;
motionBlurFilter.velocity = { x: 0, y: 0 };
motionBlurFilter.kernelSize = 5;
motionBlurFilter.offset = 0;
zoomBlurFilter.strength = 0;
zoomBlurFilter.innerRadius = 0;
zoomBlurFilter.radius = -1;
motionBlurStateRef.current = createMotionBlurState();
}, [pixiReady]);
useEffect(() => {
connectZoomsRef.current = connectZooms;
@@ -1363,6 +1238,18 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
cursorSmoothingRef.current = cursorSmoothing;
}, [cursorSmoothing]);
useEffect(() => {
cursorSpringStiffnessMultiplierRef.current = cursorSpringStiffnessMultiplier;
}, [cursorSpringStiffnessMultiplier]);
useEffect(() => {
cursorSpringDampingMultiplierRef.current = cursorSpringDampingMultiplier;
}, [cursorSpringDampingMultiplier]);
useEffect(() => {
cursorSpringMassMultiplierRef.current = cursorSpringMassMultiplier;
}, [cursorSpringMassMultiplier]);
useEffect(() => {
zoomSmoothnessRef.current = zoomSmoothness;
}, [zoomSmoothness]);
@@ -1421,10 +1308,6 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
cursorOverlayRef.current?.reset();
motionBlurStateRef.current = createMotionBlurState();
if (blurFilterRef.current) {
blurFilterRef.current.blur = 0;
}
requestAnimationFrame(() => {
const container = cameraContainerRef.current;
const videoStage = videoContainerRef.current;
@@ -1445,7 +1328,8 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
applyZoomTransform({
cameraContainer: container,
blurFilter: blurFilterRef.current,
zoomBlurFilter: zoomBlurFilterRef.current,
motionBlurFilter: motionBlurFilterRef.current,
stageSize: stageSizeRef.current,
baseMask: baseMaskRef.current,
zoomScale: 1,
@@ -1453,7 +1337,8 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
focusY: DEFAULT_FOCUS.cy,
motionIntensity: 0,
isPlaying: false,
motionBlurAmount: zoomMotionBlurRef.current,
motionBlurAmount: 0,
motionBlurState: motionBlurStateRef.current,
});
requestAnimationFrame(() => {
@@ -1642,10 +1527,19 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
cameraContainerRef.current = cameraContainer;
app.stage.addChild(cameraContainer);
// Match the export scene graph so zoom motion blur is applied to the
// same layer in preview and export.
const videoEffectsContainer = new Container();
videoEffectsContainerRef.current = videoEffectsContainer;
zoomBlurFilterRef.current = new ZoomBlurFilter({ strength: 0 });
motionBlurFilterRef.current = new MotionBlurFilter([0, 0], 5, 0);
cameraContainer.addChild(videoEffectsContainer);
syncPreviewMotionBlurQuality();
// Video container - holds the masked video sprite
const videoContainer = new Container();
videoContainerRef.current = videoContainer;
cameraContainer.addChild(videoContainer);
videoEffectsContainer.addChild(videoContainer);
// Device frame overlay container — sits above video but below cursor
const frameContainer = new Container();
@@ -1663,6 +1557,11 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
dotRadius: DEFAULT_CURSOR_CONFIG.dotRadius * cursorSizeRef.current,
style: cursorStyleRef.current,
smoothingFactor: cursorSmoothingRef.current,
springTuning: {
stiffnessMultiplier: cursorSpringStiffnessMultiplierRef.current,
dampingMultiplier: cursorSpringDampingMultiplierRef.current,
massMultiplier: cursorSpringMassMultiplierRef.current,
},
motionBlur: cursorMotionBlurRef.current,
clickBounce: cursorClickBounceRef.current,
clickBounceDuration: cursorClickBounceDurationRef.current,
@@ -1691,6 +1590,10 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
cursorOverlayRef.current.destroy();
cursorOverlayRef.current = null;
}
zoomBlurFilterRef.current?.destroy();
motionBlurFilterRef.current?.destroy();
zoomBlurFilterRef.current = null;
motionBlurFilterRef.current = null;
if (app && app.renderer) {
app.destroy(true, {
children: true,
@@ -1700,6 +1603,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
}
appRef.current = null;
cameraContainerRef.current = null;
videoEffectsContainerRef.current = null;
videoContainerRef.current = null;
frameContainerRef.current = null;
frameSpriteRef.current = null;
@@ -1731,10 +1635,12 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
const video = videoRef.current;
const app = appRef.current;
const videoEffectsContainer = videoEffectsContainerRef.current;
const videoContainer = videoContainerRef.current;
const cursorContainer = cursorContainerRef.current;
if (!video || !app || !videoContainer || !cursorContainer) return;
if (!video || !app || !videoEffectsContainer || !videoContainer || !cursorContainer)
return;
if (video.videoWidth === 0 || video.videoHeight === 0) return;
const source = VideoSource.from(video);
@@ -1760,19 +1666,6 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
animationStateRef.current = createPlaybackAnimationState();
const blurFilter = new BlurFilter();
blurFilter.quality = 3;
blurFilter.resolution = app.renderer.resolution;
blurFilter.blur = 0;
const motionBlurFilter = new MotionBlurFilter([0, 0], 5, 0);
// Don't attach filters by default — the filter pipeline forces the video
// through an intermediate RenderTexture at renderer resolution, downsampling
// the native video and destroying detail. Filters are attached conditionally
// in the ticker only when zoom motion blur is actually active.
videoContainer.filters = null;
blurFilterRef.current = blurFilter;
motionBlurFilterRef.current = motionBlurFilter;
layoutVideoContent();
video.pause();
@@ -1814,15 +1707,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
}
videoContainer.mask = null;
maskGraphicsRef.current = null;
if (blurFilterRef.current) {
videoContainer.filters = [];
blurFilterRef.current.destroy();
blurFilterRef.current = null;
}
if (motionBlurFilterRef.current) {
motionBlurFilterRef.current.destroy();
motionBlurFilterRef.current = null;
}
videoEffectsContainer.filters = null;
videoTexture.destroy(false);
videoSpriteRef.current = null;
@@ -1834,8 +1719,9 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
const app = appRef.current;
const videoSprite = videoSpriteRef.current;
const videoEffectsContainer = videoEffectsContainerRef.current;
const videoContainer = videoContainerRef.current;
if (!app || !videoSprite || !videoContainer) return;
if (!app || !videoSprite || !videoEffectsContainer || !videoContainer) return;
const applyTransform = (
transform: { scale: number; x: number; y: number },
@@ -1850,7 +1736,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
const appliedTransform = applyZoomTransform({
cameraContainer,
blurFilter: blurFilterRef.current,
zoomBlurFilter: zoomBlurFilterRef.current,
motionBlurFilter: motionBlurFilterRef.current,
stageSize: stageSizeRef.current,
baseMask: baseMaskRef.current,
@@ -1861,7 +1747,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
motionIntensity,
motionVector,
isPlaying: isPlayingRef.current,
motionBlurAmount: zoomMotionBlurRef.current,
motionBlurAmount: 0,
transformOverride: transform,
motionBlurState: motionBlurStateRef.current,
frameTimeMs: performance.now(),
@@ -1878,6 +1764,8 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
currentTimeRef.current,
{
connectZooms: connectZoomsRef.current,
zoomInDurationMs: zoomInDurationMsRef.current,
zoomOutDurationMs: zoomOutDurationMsRef.current,
},
);
@@ -2047,24 +1935,6 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
motionVector,
);
// Conditionally attach motion blur filter only when the camera is
// actually moving. When filters are attached, PixiJS routes the video
// through an intermediate RenderTexture at renderer resolution, which
// downsamples the native video and degrades preview quality.
// Hysteresis prevents flickering when motionIntensity oscillates near threshold.
const filtersActive =
Array.isArray(videoContainer.filters) && videoContainer.filters.length > 0;
const cameraIsMoving = filtersActive
? motionIntensity > 0.002
: motionIntensity > 0.008;
const needsFilters =
zoomMotionBlurRef.current > 0 && isPlayingRef.current && cameraIsMoving;
if (needsFilters && !filtersActive && motionBlurFilterRef.current) {
videoContainer.filters = [motionBlurFilterRef.current];
} else if (!needsFilters && filtersActive) {
videoContainer.filters = null;
}
applyWebcamBubbleLayout(animationStateRef.current.appliedScale || 1);
const timeMs = currentTimeRef.current;
@@ -2277,6 +2147,11 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
overlay.setDotRadius(DEFAULT_CURSOR_CONFIG.dotRadius * cursorSize);
overlay.setSmoothingFactor(cursorSmoothing);
overlay.setSpringTuning({
stiffnessMultiplier: cursorSpringStiffnessMultiplier,
dampingMultiplier: cursorSpringDampingMultiplier,
massMultiplier: cursorSpringMassMultiplier,
});
overlay.setMotionBlur(cursorMotionBlur);
overlay.setClickBounce(cursorClickBounce);
overlay.setClickBounceDuration(cursorClickBounceDuration);
@@ -2305,6 +2180,9 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
cursorStyle,
cursorSize,
cursorSmoothing,
cursorSpringStiffnessMultiplier,
cursorSpringDampingMultiplier,
cursorSpringMassMultiplier,
cursorMotionBlur,
cursorClickBounce,
cursorClickBounceDuration,
@@ -2636,34 +2514,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
}}
>
<div
role={
onEditAutoCaption && !captionEditSession
? "button"
: undefined
}
tabIndex={
onEditAutoCaption && !captionEditSession ? 0 : undefined
}
aria-label={
onEditAutoCaption && !captionEditSession
? editCurrentCaptionLabel
: undefined
}
ref={captionBoxRef}
onClick={() => {
if (!captionEditSession) {
beginCaptionEdit();
}
}}
onKeyDown={(event) => {
if (!onEditAutoCaption || captionEditSession) {
return;
}
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
beginCaptionEdit();
}
}}
style={{
backgroundColor: `rgba(0, 0, 0, ${autoCaptionSettings.backgroundOpacity})`,
fontFamily: getDefaultCaptionFontFamily(),
@@ -2701,137 +2552,42 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
),
)}px`,
boxSizing: "border-box",
cursor:
onEditAutoCaption && !captionEditSession
? "text"
: undefined,
pointerEvents: onEditAutoCaption ? "auto" : undefined,
}}
>
{captionEditSession ? (
<textarea
ref={captionEditInputRef}
value={captionEditSession.draft}
onChange={(event) => {
const draft = event.target.value;
setCaptionEditSession((session) => {
const nextSession = session
? {
...session,
draft,
}
: session;
captionEditSessionRef.current = nextSession;
return nextSession;
});
}}
onBlur={commitCaptionEdit}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => {
if (event.key === "Escape") {
event.preventDefault();
cancelCaptionEdit();
return;
}
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
event.currentTarget.blur();
}
}}
rows={Math.max(
1,
activeCaptionLayout.visibleLines.length,
)}
aria-label={editCurrentCaptionLabel}
{activeCaptionLayout.visibleLines.map((line) => (
<div
key={`${activeCaptionLayout.blockKey}-${line.startWordIndex}`}
style={{
display: "block",
width: `${
captionEditTextMetrics?.widthPx ??
Math.max(
48,
activeCaptionLayout.visibleLines.reduce(
(width, line) =>
Math.max(width, line.width),
0,
),
)
}px`,
maxWidth: `${
captionEditTextMetrics?.maxTextWidthPx ??
getCaptionTextMaxWidth(
overlayRef.current?.clientWidth || 960,
autoCaptionSettings.maxWidth,
getCaptionScaledFontSize(
autoCaptionSettings.fontSize,
overlayRef.current?.clientWidth ||
960,
autoCaptionSettings.maxWidth,
),
)
}px`,
minHeight: `${
Math.max(
1,
activeCaptionLayout.visibleLines.length,
) *
getCaptionScaledFontSize(
autoCaptionSettings.fontSize,
overlayRef.current?.clientWidth || 960,
autoCaptionSettings.maxWidth,
) *
CAPTION_LINE_HEIGHT
}px`,
resize: "none",
border: "0",
outline: "0",
padding: "0",
margin: "0",
overflow: "hidden",
background: "transparent",
color: autoCaptionSettings.textColor,
font: "inherit",
fontWeight: "inherit",
lineHeight: "inherit",
textAlign: "center",
display: "flex",
justifyContent: "center",
flexWrap: "nowrap",
whiteSpace: "nowrap",
}}
/>
) : (
activeCaptionLayout.visibleLines.map((line) => (
<div
key={`${activeCaptionLayout.blockKey}-${line.startWordIndex}`}
style={{
display: "flex",
justifyContent: "center",
flexWrap: "nowrap",
whiteSpace: "nowrap",
}}
>
{line.words.map((word) => {
const visualState =
getCaptionWordVisualState(
activeCaptionLayout.hasWordTimings,
word.state,
);
>
{line.words.map((word) => {
const visualState = getCaptionWordVisualState(
activeCaptionLayout.hasWordTimings,
word.state,
);
return (
<span
key={`${activeCaptionLayout.blockKey}-${word.index}`}
style={{
display: "inline-block",
whiteSpace: "pre",
color: visualState.isInactive
? autoCaptionSettings.inactiveTextColor
: autoCaptionSettings.textColor,
opacity: visualState.opacity,
}}
>
{`${word.leadingSpace ? " " : ""}${word.text}`}
</span>
);
})}
</div>
))
)}
return (
<span
key={`${activeCaptionLayout.blockKey}-${word.index}`}
style={{
display: "inline-block",
whiteSpace: "pre",
color: visualState.isInactive
? autoCaptionSettings.inactiveTextColor
: autoCaptionSettings.textColor,
opacity: visualState.opacity,
}}
>
{`${word.leadingSpace ? " " : ""}${word.text}`}
</span>
);
})}
</div>
))}
</div>
</div>
</div>
@@ -0,0 +1,51 @@
import { DEFAULT_ZOOM_IN_DURATION_MS, DEFAULT_ZOOM_OUT_DURATION_MS } from "./types";
export type CursorMotionPresetId = "focused" | "smooth";
export interface CursorMotionPreset {
id: CursorMotionPresetId;
label: string;
zoomSmoothness: number;
zoomInDurationMs: number;
zoomOutDurationMs: number;
cursorSize: number;
cursorSmoothing: number;
cursorSpringStiffnessMultiplier: number;
cursorSpringDampingMultiplier: number;
cursorSpringMassMultiplier: number;
cursorMotionBlur: number;
cursorClickBounce: number;
cursorClickBounceDuration: number;
}
const SHARED_CURSOR_PRESET_VALUES = {
cursorSize: 2.5,
cursorSmoothing: 0.67,
cursorSpringMassMultiplier: 1.29,
cursorMotionBlur: 0.4,
cursorClickBounce: 3.5,
cursorClickBounceDuration: 350,
} as const;
export const CURSOR_MOTION_PRESETS: Record<CursorMotionPresetId, CursorMotionPreset> = {
focused: {
id: "focused",
label: "Focused",
zoomSmoothness: 0.5,
zoomInDurationMs: 200,
zoomOutDurationMs: 200,
...SHARED_CURSOR_PRESET_VALUES,
cursorSpringStiffnessMultiplier: 1.35,
cursorSpringDampingMultiplier: 0.79,
},
smooth: {
id: "smooth",
label: "Smooth",
zoomSmoothness: 0.5,
zoomInDurationMs: DEFAULT_ZOOM_IN_DURATION_MS,
zoomOutDurationMs: DEFAULT_ZOOM_OUT_DURATION_MS,
...SHARED_CURSOR_PRESET_VALUES,
cursorSpringStiffnessMultiplier: 0.92,
cursorSpringDampingMultiplier: 1.36,
},
};
@@ -53,13 +53,27 @@ describe("editorPreferences", () => {
customAspectHeight: "",
customWallpapers: "not-an-array",
}),
).toEqual(DEFAULT_EDITOR_PREFERENCES);
).toMatchObject({
wallpaper: DEFAULT_EDITOR_PREFERENCES.wallpaper,
showCursor: DEFAULT_EDITOR_PREFERENCES.showCursor,
aspectRatio: DEFAULT_EDITOR_PREFERENCES.aspectRatio,
cursorStyle: DEFAULT_EDITOR_PREFERENCES.cursorStyle,
cursorSize: DEFAULT_EDITOR_PREFERENCES.cursorSize,
customAspectWidth: DEFAULT_EDITOR_PREFERENCES.customAspectWidth,
customAspectHeight: DEFAULT_EDITOR_PREFERENCES.customAspectHeight,
customWallpapers: DEFAULT_EDITOR_PREFERENCES.customWallpapers,
});
});
it("defaults MP4 exports to source quality", () => {
expect(DEFAULT_EDITOR_PREFERENCES.exportQuality).toBe("source");
});
it("defaults cursor preferences to macOS at 2.5x", () => {
expect(DEFAULT_EDITOR_PREFERENCES.cursorStyle).toBe("macos");
expect(DEFAULT_EDITOR_PREFERENCES.cursorSize).toBe(2.5);
});
it("loads stored editor control preferences", () => {
vi.stubGlobal(
"localStorage",
@@ -81,49 +95,18 @@ describe("editorPreferences", () => {
);
expect(loadEditorPreferences()).toEqual({
...DEFAULT_EDITOR_PREFERENCES,
wallpaper: "#123456",
shadowIntensity: DEFAULT_EDITOR_PREFERENCES.shadowIntensity,
backgroundBlur: 3.5,
zoomMotionBlur: DEFAULT_EDITOR_PREFERENCES.zoomMotionBlur,
connectZooms: DEFAULT_EDITOR_PREFERENCES.connectZooms,
zoomInDurationMs: DEFAULT_EDITOR_PREFERENCES.zoomInDurationMs,
zoomInOverlapMs: DEFAULT_EDITOR_PREFERENCES.zoomInOverlapMs,
zoomOutDurationMs: DEFAULT_EDITOR_PREFERENCES.zoomOutDurationMs,
connectedZoomGapMs: DEFAULT_EDITOR_PREFERENCES.connectedZoomGapMs,
connectedZoomDurationMs: DEFAULT_EDITOR_PREFERENCES.connectedZoomDurationMs,
zoomInEasing: DEFAULT_EDITOR_PREFERENCES.zoomInEasing,
zoomOutEasing: DEFAULT_EDITOR_PREFERENCES.zoomOutEasing,
connectedZoomEasing: DEFAULT_EDITOR_PREFERENCES.connectedZoomEasing,
showCursor: false,
loopCursor: DEFAULT_EDITOR_PREFERENCES.loopCursor,
cursorStyle: DEFAULT_EDITOR_PREFERENCES.cursorStyle,
cursorSize: DEFAULT_EDITOR_PREFERENCES.cursorSize,
cursorSmoothing: DEFAULT_EDITOR_PREFERENCES.cursorSmoothing,
cursorMotionBlur: DEFAULT_EDITOR_PREFERENCES.cursorMotionBlur,
cursorClickBounce: DEFAULT_EDITOR_PREFERENCES.cursorClickBounce,
cursorClickBounceDuration: DEFAULT_EDITOR_PREFERENCES.cursorClickBounceDuration,
cursorSway: DEFAULT_EDITOR_PREFERENCES.cursorSway,
borderRadius: DEFAULT_EDITOR_PREFERENCES.borderRadius,
padding: DEFAULT_EDITOR_PREFERENCES.padding,
frame: DEFAULT_EDITOR_PREFERENCES.frame,
aspectRatio: "native",
exportEncodingMode: DEFAULT_EDITOR_PREFERENCES.exportEncodingMode,
exportBackendPreference: DEFAULT_EDITOR_PREFERENCES.exportBackendPreference,
exportPipelineModel: DEFAULT_EDITOR_PREFERENCES.exportPipelineModel,
exportQuality: DEFAULT_EDITOR_PREFERENCES.exportQuality,
mp4FrameRate: DEFAULT_EDITOR_PREFERENCES.mp4FrameRate,
zoomInOverlapMs: 200,
exportFormat: "gif",
gifFrameRate: 30,
gifLoop: false,
gifSizePreset: DEFAULT_EDITOR_PREFERENCES.gifSizePreset,
webcam: DEFAULT_EDITOR_PREFERENCES.webcam,
customAspectWidth: "21",
customAspectHeight: "9",
customWallpapers: ["data:image/jpeg;base64,abc"],
autoApplyFreshRecordingAutoZooms:
DEFAULT_EDITOR_PREFERENCES.autoApplyFreshRecordingAutoZooms,
whisperExecutablePath: DEFAULT_EDITOR_PREFERENCES.whisperExecutablePath,
whisperModelPath: DEFAULT_EDITOR_PREFERENCES.whisperModelPath,
});
});
@@ -153,49 +136,11 @@ describe("editorPreferences", () => {
saveEditorPreferences({ customAspectWidth: "", customAspectHeight: "abc" });
expect(loadEditorPreferences()).toEqual({
...DEFAULT_EDITOR_PREFERENCES,
aspectRatio: "16:9",
wallpaper: DEFAULT_EDITOR_PREFERENCES.wallpaper,
shadowIntensity: DEFAULT_EDITOR_PREFERENCES.shadowIntensity,
backgroundBlur: DEFAULT_EDITOR_PREFERENCES.backgroundBlur,
zoomMotionBlur: DEFAULT_EDITOR_PREFERENCES.zoomMotionBlur,
connectZooms: DEFAULT_EDITOR_PREFERENCES.connectZooms,
zoomInDurationMs: DEFAULT_EDITOR_PREFERENCES.zoomInDurationMs,
zoomInOverlapMs: DEFAULT_EDITOR_PREFERENCES.zoomInOverlapMs,
zoomOutDurationMs: DEFAULT_EDITOR_PREFERENCES.zoomOutDurationMs,
connectedZoomGapMs: DEFAULT_EDITOR_PREFERENCES.connectedZoomGapMs,
connectedZoomDurationMs: DEFAULT_EDITOR_PREFERENCES.connectedZoomDurationMs,
zoomInEasing: DEFAULT_EDITOR_PREFERENCES.zoomInEasing,
zoomOutEasing: DEFAULT_EDITOR_PREFERENCES.zoomOutEasing,
connectedZoomEasing: DEFAULT_EDITOR_PREFERENCES.connectedZoomEasing,
showCursor: DEFAULT_EDITOR_PREFERENCES.showCursor,
loopCursor: DEFAULT_EDITOR_PREFERENCES.loopCursor,
cursorStyle: DEFAULT_EDITOR_PREFERENCES.cursorStyle,
cursorSize: DEFAULT_EDITOR_PREFERENCES.cursorSize,
cursorSmoothing: DEFAULT_EDITOR_PREFERENCES.cursorSmoothing,
cursorMotionBlur: DEFAULT_EDITOR_PREFERENCES.cursorMotionBlur,
cursorClickBounce: DEFAULT_EDITOR_PREFERENCES.cursorClickBounce,
cursorClickBounceDuration: DEFAULT_EDITOR_PREFERENCES.cursorClickBounceDuration,
cursorSway: DEFAULT_EDITOR_PREFERENCES.cursorSway,
borderRadius: DEFAULT_EDITOR_PREFERENCES.borderRadius,
padding: DEFAULT_EDITOR_PREFERENCES.padding,
frame: DEFAULT_EDITOR_PREFERENCES.frame,
exportEncodingMode: DEFAULT_EDITOR_PREFERENCES.exportEncodingMode,
exportBackendPreference: DEFAULT_EDITOR_PREFERENCES.exportBackendPreference,
exportPipelineModel: DEFAULT_EDITOR_PREFERENCES.exportPipelineModel,
exportQuality: DEFAULT_EDITOR_PREFERENCES.exportQuality,
mp4FrameRate: DEFAULT_EDITOR_PREFERENCES.mp4FrameRate,
exportFormat: DEFAULT_EDITOR_PREFERENCES.exportFormat,
gifFrameRate: DEFAULT_EDITOR_PREFERENCES.gifFrameRate,
gifLoop: DEFAULT_EDITOR_PREFERENCES.gifLoop,
gifSizePreset: DEFAULT_EDITOR_PREFERENCES.gifSizePreset,
webcam: DEFAULT_EDITOR_PREFERENCES.webcam,
zoomInOverlapMs: 200,
customAspectWidth: "21",
customAspectHeight: "9",
customWallpapers: DEFAULT_EDITOR_PREFERENCES.customWallpapers,
autoApplyFreshRecordingAutoZooms:
DEFAULT_EDITOR_PREFERENCES.autoApplyFreshRecordingAutoZooms,
whisperExecutablePath: DEFAULT_EDITOR_PREFERENCES.whisperExecutablePath,
whisperModelPath: DEFAULT_EDITOR_PREFERENCES.whisperModelPath,
});
});
@@ -263,19 +208,13 @@ describe("editorPreferences", () => {
});
expect(loadEditorPreferences()).toEqual({
...DEFAULT_EDITOR_PREFERENCES,
wallpaper: "linear-gradient(to right, #000000, #ffffff)",
shadowIntensity: 0.4,
backgroundBlur: 1.5,
zoomMotionBlur: 0.75,
connectZooms: false,
zoomInDurationMs: DEFAULT_EDITOR_PREFERENCES.zoomInDurationMs,
zoomInOverlapMs: DEFAULT_EDITOR_PREFERENCES.zoomInOverlapMs,
zoomOutDurationMs: DEFAULT_EDITOR_PREFERENCES.zoomOutDurationMs,
connectedZoomGapMs: DEFAULT_EDITOR_PREFERENCES.connectedZoomGapMs,
connectedZoomDurationMs: DEFAULT_EDITOR_PREFERENCES.connectedZoomDurationMs,
zoomInEasing: DEFAULT_EDITOR_PREFERENCES.zoomInEasing,
zoomOutEasing: DEFAULT_EDITOR_PREFERENCES.zoomOutEasing,
connectedZoomEasing: DEFAULT_EDITOR_PREFERENCES.connectedZoomEasing,
zoomInOverlapMs: 200,
showCursor: false,
loopCursor: true,
cursorStyle: "figma",
@@ -287,24 +226,16 @@ describe("editorPreferences", () => {
cursorSway: 1.5,
borderRadius: 18,
padding: { top: 30, right: 30, bottom: 30, left: 30, linked: true },
frame: DEFAULT_EDITOR_PREFERENCES.frame,
aspectRatio: "4:5",
exportEncodingMode: "quality",
exportBackendPreference: DEFAULT_EDITOR_PREFERENCES.exportBackendPreference,
exportPipelineModel: DEFAULT_EDITOR_PREFERENCES.exportPipelineModel,
exportQuality: "source",
mp4FrameRate: DEFAULT_EDITOR_PREFERENCES.mp4FrameRate,
exportFormat: "gif",
gifFrameRate: 20,
gifLoop: false,
gifSizePreset: "large",
webcam: DEFAULT_EDITOR_PREFERENCES.webcam,
customAspectWidth: "4",
customAspectHeight: "5",
customWallpapers: ["data:image/jpeg;base64,abc"],
autoApplyFreshRecordingAutoZooms: false,
whisperExecutablePath: DEFAULT_EDITOR_PREFERENCES.whisperExecutablePath,
whisperModelPath: DEFAULT_EDITOR_PREFERENCES.whisperModelPath,
});
});
@@ -12,6 +12,9 @@ type PersistedEditorControls = Pick<
| "shadowIntensity"
| "backgroundBlur"
| "zoomMotionBlur"
| "zoomTemporalMotionBlur"
| "zoomMotionBlurSampleCount"
| "zoomMotionBlurShutterFraction"
| "connectZooms"
| "zoomInDurationMs"
| "zoomInOverlapMs"
@@ -26,6 +29,9 @@ type PersistedEditorControls = Pick<
| "cursorStyle"
| "cursorSize"
| "cursorSmoothing"
| "cursorSpringStiffnessMultiplier"
| "cursorSpringDampingMultiplier"
| "cursorSpringMassMultiplier"
| "cursorMotionBlur"
| "cursorClickBounce"
| "cursorClickBounceDuration"
@@ -83,6 +89,9 @@ export const DEFAULT_EDITOR_PREFERENCES: EditorPreferences = {
shadowIntensity: DEFAULT_EDITOR_CONTROLS.shadowIntensity,
backgroundBlur: DEFAULT_EDITOR_CONTROLS.backgroundBlur,
zoomMotionBlur: DEFAULT_EDITOR_CONTROLS.zoomMotionBlur,
zoomTemporalMotionBlur: DEFAULT_EDITOR_CONTROLS.zoomTemporalMotionBlur,
zoomMotionBlurSampleCount: DEFAULT_EDITOR_CONTROLS.zoomMotionBlurSampleCount,
zoomMotionBlurShutterFraction: DEFAULT_EDITOR_CONTROLS.zoomMotionBlurShutterFraction,
connectZooms: DEFAULT_EDITOR_CONTROLS.connectZooms,
zoomInDurationMs: DEFAULT_EDITOR_CONTROLS.zoomInDurationMs,
zoomInOverlapMs: DEFAULT_EDITOR_CONTROLS.zoomInOverlapMs,
@@ -97,6 +106,9 @@ export const DEFAULT_EDITOR_PREFERENCES: EditorPreferences = {
cursorStyle: DEFAULT_EDITOR_CONTROLS.cursorStyle,
cursorSize: DEFAULT_EDITOR_CONTROLS.cursorSize,
cursorSmoothing: DEFAULT_EDITOR_CONTROLS.cursorSmoothing,
cursorSpringStiffnessMultiplier: DEFAULT_EDITOR_CONTROLS.cursorSpringStiffnessMultiplier,
cursorSpringDampingMultiplier: DEFAULT_EDITOR_CONTROLS.cursorSpringDampingMultiplier,
cursorSpringMassMultiplier: DEFAULT_EDITOR_CONTROLS.cursorSpringMassMultiplier,
cursorMotionBlur: DEFAULT_EDITOR_CONTROLS.cursorMotionBlur,
cursorClickBounce: DEFAULT_EDITOR_CONTROLS.cursorClickBounce,
cursorClickBounceDuration: DEFAULT_EDITOR_CONTROLS.cursorClickBounceDuration,
@@ -164,9 +176,7 @@ function normalizeNullablePath(value: unknown): string | null {
function normalizePresetAutoCaptionSettings(value: unknown): PresetAutoCaptionSettings {
return normalizeProjectEditor({
autoCaptionSettings:
value && typeof value === "object"
? (value as PresetAutoCaptionSettings)
: undefined,
value && typeof value === "object" ? (value as PresetAutoCaptionSettings) : undefined,
}).autoCaptionSettings;
}
@@ -181,7 +191,8 @@ function normalizeEditorPresetSnapshot(candidate: unknown): EditorPresetSnapshot
...normalizeEditorControls(normalizedPreferences, normalizedPreferences),
autoCaptionSettings: normalizePresetAutoCaptionSettings(raw.autoCaptionSettings),
whisperExecutablePath:
normalizeNullablePath(raw.whisperExecutablePath) ?? normalizedPreferences.whisperExecutablePath,
normalizeNullablePath(raw.whisperExecutablePath) ??
normalizedPreferences.whisperExecutablePath,
whisperModelPath:
normalizeNullablePath(raw.whisperModelPath) ?? normalizedPreferences.whisperModelPath,
};
@@ -217,7 +228,8 @@ function normalizeEditorPreset(candidate: unknown): EditorPreset | null {
}
const timestamp = new Date().toISOString();
const id = typeof raw.id === "string" && raw.id.trim().length > 0 ? raw.id : crypto.randomUUID();
const id =
typeof raw.id === "string" && raw.id.trim().length > 0 ? raw.id : crypto.randomUUID();
return {
id,
@@ -252,6 +264,11 @@ function normalizeEditorControls(
shadowIntensity: raw.shadowIntensity ?? fallback.shadowIntensity,
backgroundBlur: raw.backgroundBlur ?? fallback.backgroundBlur,
zoomMotionBlur: raw.zoomMotionBlur ?? fallback.zoomMotionBlur,
zoomTemporalMotionBlur: raw.zoomTemporalMotionBlur ?? fallback.zoomTemporalMotionBlur,
zoomMotionBlurSampleCount:
raw.zoomMotionBlurSampleCount ?? fallback.zoomMotionBlurSampleCount,
zoomMotionBlurShutterFraction:
raw.zoomMotionBlurShutterFraction ?? fallback.zoomMotionBlurShutterFraction,
connectZooms: raw.connectZooms ?? fallback.connectZooms,
zoomInDurationMs: raw.zoomInDurationMs ?? fallback.zoomInDurationMs,
zoomInOverlapMs: raw.zoomInOverlapMs ?? fallback.zoomInOverlapMs,
@@ -266,6 +283,12 @@ function normalizeEditorControls(
cursorStyle: raw.cursorStyle ?? fallback.cursorStyle,
cursorSize: raw.cursorSize ?? fallback.cursorSize,
cursorSmoothing: raw.cursorSmoothing ?? fallback.cursorSmoothing,
cursorSpringStiffnessMultiplier:
raw.cursorSpringStiffnessMultiplier ?? fallback.cursorSpringStiffnessMultiplier,
cursorSpringDampingMultiplier:
raw.cursorSpringDampingMultiplier ?? fallback.cursorSpringDampingMultiplier,
cursorSpringMassMultiplier:
raw.cursorSpringMassMultiplier ?? fallback.cursorSpringMassMultiplier,
cursorMotionBlur: raw.cursorMotionBlur ?? fallback.cursorMotionBlur,
cursorClickBounce: raw.cursorClickBounce ?? fallback.cursorClickBounce,
cursorClickBounceDuration:
@@ -303,6 +326,9 @@ function normalizeEditorControls(
shadowIntensity: normalized.shadowIntensity,
backgroundBlur: normalized.backgroundBlur,
zoomMotionBlur: normalized.zoomMotionBlur,
zoomTemporalMotionBlur: normalized.zoomTemporalMotionBlur,
zoomMotionBlurSampleCount: normalized.zoomMotionBlurSampleCount,
zoomMotionBlurShutterFraction: normalized.zoomMotionBlurShutterFraction,
connectZooms: normalized.connectZooms,
zoomInDurationMs: normalized.zoomInDurationMs,
zoomInOverlapMs: normalized.zoomInOverlapMs,
@@ -317,6 +343,9 @@ function normalizeEditorControls(
cursorStyle: normalized.cursorStyle,
cursorSize: normalized.cursorSize,
cursorSmoothing: normalized.cursorSmoothing,
cursorSpringStiffnessMultiplier: normalized.cursorSpringStiffnessMultiplier,
cursorSpringDampingMultiplier: normalized.cursorSpringDampingMultiplier,
cursorSpringMassMultiplier: normalized.cursorSpringMassMultiplier,
cursorMotionBlur: normalized.cursorMotionBlur,
cursorClickBounce: normalized.cursorClickBounce,
cursorClickBounceDuration: normalized.cursorClickBounceDuration,
@@ -427,6 +456,7 @@ export function saveEditorPresets(presets: EditorPreset[]): boolean {
globalThis.localStorage.setItem(EDITOR_PRESETS_STORAGE_KEY, JSON.stringify(normalized));
return true;
} catch {
// Ignore storage failures so editor controls still work.
return false;
}
}
@@ -9,8 +9,17 @@ import type {
GifSizePreset,
} from "@/lib/exporter";
import { isValidMp4FrameRate } from "@/lib/exporter";
import {
TEMPORAL_MOTION_BLUR_DEFAULT_SAMPLE_COUNT,
TEMPORAL_MOTION_BLUR_DEFAULT_SHUTTER_FRACTION,
TEMPORAL_MOTION_BLUR_MAX_SAMPLE_COUNT,
TEMPORAL_MOTION_BLUR_MAX_SHUTTER_FRACTION,
TEMPORAL_MOTION_BLUR_MIN_SAMPLE_COUNT,
TEMPORAL_MOTION_BLUR_MIN_SHUTTER_FRACTION,
} from "@/lib/exporter/temporalMotionBlur";
import { DEFAULT_WALLPAPER_PATH } from "@/lib/wallpapers";
import { ASPECT_RATIOS, type AspectRatio, isCustomAspectRatio } from "@/utils/aspectRatioUtils";
import { CURSOR_MOTION_PRESETS } from "./cursorMotionPresets";
import {
type AnnotationRegion,
type AudioRegion,
@@ -29,11 +38,6 @@ import {
DEFAULT_CONNECTED_ZOOM_EASING,
DEFAULT_CONNECTED_ZOOM_GAP_MS,
DEFAULT_CROP_REGION,
DEFAULT_CURSOR_CLICK_BOUNCE,
DEFAULT_CURSOR_CLICK_BOUNCE_DURATION,
DEFAULT_CURSOR_MOTION_BLUR,
DEFAULT_CURSOR_SIZE,
DEFAULT_CURSOR_SMOOTHING,
DEFAULT_CURSOR_STYLE,
DEFAULT_CURSOR_SWAY,
DEFAULT_FIGURE_DATA,
@@ -50,11 +54,10 @@ import {
DEFAULT_WEBCAM_SIZE,
DEFAULT_WEBCAM_TIME_OFFSET_MS,
DEFAULT_ZOOM_DEPTH,
DEFAULT_ZOOM_IN_DURATION_MS,
DEFAULT_ZOOM_IN_EASING,
DEFAULT_ZOOM_IN_OVERLAP_MS,
DEFAULT_ZOOM_MOTION_BLUR,
DEFAULT_ZOOM_OUT_DURATION_MS,
DEFAULT_ZOOM_SMOOTHNESS,
DEFAULT_ZOOM_OUT_EASING,
getDefaultCaptionFontFamily,
type Padding,
@@ -68,11 +71,16 @@ import { normalizeWebcamCropRegion } from "./webcamOverlay";
export const PROJECT_VERSION = 1;
const DEFAULT_MOTION_PRESET = CURSOR_MOTION_PRESETS.focused;
export interface ProjectEditorState {
wallpaper: string;
shadowIntensity: number;
backgroundBlur: number;
zoomMotionBlur: number;
zoomTemporalMotionBlur: number;
zoomMotionBlurSampleCount: number | null;
zoomMotionBlurShutterFraction: number | null;
connectZooms: boolean;
zoomInDurationMs: number;
zoomInOverlapMs: number;
@@ -87,6 +95,9 @@ export interface ProjectEditorState {
cursorStyle: CursorStyle;
cursorSize: number;
cursorSmoothing: number;
cursorSpringStiffnessMultiplier: number;
cursorSpringDampingMultiplier: number;
cursorSpringMassMultiplier: number;
zoomSmoothness: number;
zoomClassicMode: boolean;
cursorMotionBlur: number;
@@ -294,6 +305,27 @@ export function validateProjectData(candidate: unknown): candidate is EditorProj
}
export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): ProjectEditorState {
const normalizeTemporalBlurSampleCount = (value: unknown): number => {
if (!isFiniteNumber(value)) {
return TEMPORAL_MOTION_BLUR_DEFAULT_SAMPLE_COUNT;
}
const roundedValue = Math.round(value);
const clampedValue = clamp(
roundedValue,
TEMPORAL_MOTION_BLUR_MIN_SAMPLE_COUNT,
TEMPORAL_MOTION_BLUR_MAX_SAMPLE_COUNT,
);
if (clampedValue % 2 === 1) {
return clampedValue;
}
return clampedValue >= TEMPORAL_MOTION_BLUR_MAX_SAMPLE_COUNT
? clampedValue - 1
: clampedValue + 1;
};
const validAspectRatios = new Set<AspectRatio>(ASPECT_RATIOS);
const legacyMotionBlurEnabled = (editor as Partial<{ motionBlurEnabled: boolean }>)
.motionBlurEnabled;
@@ -305,6 +337,11 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
: legacyMotionBlurEnabled
? 0.35
: DEFAULT_ZOOM_MOTION_BLUR;
const normalizedZoomTemporalMotionBlur = isFiniteNumber(
(editor as Partial<ProjectEditorState>).zoomTemporalMotionBlur,
)
? clamp((editor as Partial<ProjectEditorState>).zoomTemporalMotionBlur as number, 0, 2)
: normalizedZoomMotionBlur;
const normalizedBackgroundBlur = isFiniteNumber(
(editor as Partial<ProjectEditorState>).backgroundBlur,
)
@@ -312,15 +349,27 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
: legacyShowBlur
? 2
: 0;
const normalizedZoomMotionBlurSampleCount = normalizeTemporalBlurSampleCount(
(editor as Partial<ProjectEditorState>).zoomMotionBlurSampleCount,
);
const normalizedZoomMotionBlurShutterFraction = isFiniteNumber(
(editor as Partial<ProjectEditorState>).zoomMotionBlurShutterFraction,
)
? clamp(
(editor as Partial<ProjectEditorState>).zoomMotionBlurShutterFraction as number,
TEMPORAL_MOTION_BLUR_MIN_SHUTTER_FRACTION,
TEMPORAL_MOTION_BLUR_MAX_SHUTTER_FRACTION,
)
: TEMPORAL_MOTION_BLUR_DEFAULT_SHUTTER_FRACTION;
const normalizedZoomInDurationMs = isFiniteNumber(editor.zoomInDurationMs)
? clamp(editor.zoomInDurationMs, 60, 4000)
: DEFAULT_ZOOM_IN_DURATION_MS;
: DEFAULT_MOTION_PRESET.zoomInDurationMs;
const normalizedZoomInOverlapMs = isFiniteNumber(editor.zoomInOverlapMs)
? clamp(editor.zoomInOverlapMs, 0, normalizedZoomInDurationMs)
: DEFAULT_ZOOM_IN_OVERLAP_MS;
const normalizedZoomOutDurationMs = isFiniteNumber(editor.zoomOutDurationMs)
? clamp(editor.zoomOutDurationMs, 60, 4000)
: DEFAULT_ZOOM_OUT_DURATION_MS;
: DEFAULT_MOTION_PRESET.zoomOutDurationMs;
const normalizedConnectedZoomGapMs = isFiniteNumber(editor.connectedZoomGapMs)
? clamp(editor.connectedZoomGapMs, 0, 5000)
: DEFAULT_CONNECTED_ZOOM_GAP_MS;
@@ -719,6 +768,9 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
shadowIntensity: typeof editor.shadowIntensity === "number" ? editor.shadowIntensity : 0.67,
backgroundBlur: normalizedBackgroundBlur,
zoomMotionBlur: normalizedZoomMotionBlur,
zoomTemporalMotionBlur: normalizedZoomTemporalMotionBlur,
zoomMotionBlurSampleCount: normalizedZoomMotionBlurSampleCount,
zoomMotionBlurShutterFraction: normalizedZoomMotionBlurShutterFraction,
connectZooms: typeof editor.connectZooms === "boolean" ? editor.connectZooms : true,
zoomInDurationMs: normalizedZoomInDurationMs,
zoomInOverlapMs: normalizedZoomInOverlapMs,
@@ -736,21 +788,28 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
cursorStyle: normalizedCursorStyle,
cursorSize: isFiniteNumber(editor.cursorSize)
? clamp(editor.cursorSize, 0.5, 10)
: DEFAULT_CURSOR_SIZE,
: DEFAULT_MOTION_PRESET.cursorSize,
cursorSmoothing: isFiniteNumber(editor.cursorSmoothing)
? clamp(editor.cursorSmoothing, 0, 2)
: DEFAULT_CURSOR_SMOOTHING,
zoomSmoothness: isFiniteNumber(editor.zoomSmoothness)
? clamp(editor.zoomSmoothness, 0, 1)
: 0.5,
: DEFAULT_MOTION_PRESET.cursorSmoothing,
cursorSpringStiffnessMultiplier: isFiniteNumber(editor.cursorSpringStiffnessMultiplier)
? clamp(editor.cursorSpringStiffnessMultiplier, 0.25, 3)
: DEFAULT_MOTION_PRESET.cursorSpringStiffnessMultiplier,
cursorSpringDampingMultiplier: isFiniteNumber(editor.cursorSpringDampingMultiplier)
? clamp(editor.cursorSpringDampingMultiplier, 0.25, 3)
: DEFAULT_MOTION_PRESET.cursorSpringDampingMultiplier,
cursorSpringMassMultiplier: isFiniteNumber(editor.cursorSpringMassMultiplier)
? clamp(editor.cursorSpringMassMultiplier, 0.25, 3)
: DEFAULT_MOTION_PRESET.cursorSpringMassMultiplier,
zoomSmoothness: DEFAULT_ZOOM_SMOOTHNESS,
zoomClassicMode:
typeof editor.zoomClassicMode === "boolean" ? editor.zoomClassicMode : false,
cursorMotionBlur: isFiniteNumber((editor as Partial<ProjectEditorState>).cursorMotionBlur)
? clamp((editor as Partial<ProjectEditorState>).cursorMotionBlur as number, 0, 2)
: DEFAULT_CURSOR_MOTION_BLUR,
: DEFAULT_MOTION_PRESET.cursorMotionBlur,
cursorClickBounce: isFiniteNumber((editor as Partial<ProjectEditorState>).cursorClickBounce)
? clamp((editor as Partial<ProjectEditorState>).cursorClickBounce as number, 0, 5)
: DEFAULT_CURSOR_CLICK_BOUNCE,
: DEFAULT_MOTION_PRESET.cursorClickBounce,
cursorClickBounceDuration: isFiniteNumber(
(editor as Partial<ProjectEditorState>).cursorClickBounceDuration,
)
@@ -759,7 +818,7 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
60,
500,
)
: DEFAULT_CURSOR_CLICK_BOUNCE_DURATION,
: DEFAULT_MOTION_PRESET.cursorClickBounceDuration,
cursorSway: isFiniteNumber((editor as Partial<ProjectEditorState>).cursorSway)
? clamp((editor as Partial<ProjectEditorState>).cursorSway as number, 0, 2)
: DEFAULT_CURSOR_SWAY,
@@ -1048,11 +1048,11 @@ const TimelineEditor = forwardRef<TimelineEditorHandle, TimelineEditorProps>(
selectedZoomId,
onSelectZoom,
trimRegions = [],
onTrimAdded,
onTrimAdded: _onTrimAdded,
onTrimSpanChange,
onTrimDelete,
selectedTrimId,
onSelectTrim,
onTrimDelete: _onTrimDelete,
selectedTrimId: _selectedTrimId,
onSelectTrim: _onSelectTrim,
clipRegions = [],
onClipSplit,
onClipSpanChange,
@@ -1066,11 +1066,11 @@ const TimelineEditor = forwardRef<TimelineEditorHandle, TimelineEditorProps>(
selectedAnnotationId,
onSelectAnnotation,
speedRegions = [],
onSpeedAdded,
onSpeedAdded: _onSpeedAdded,
onSpeedSpanChange,
onSpeedDelete,
selectedSpeedId,
onSelectSpeed,
onSpeedDelete: _onSpeedDelete,
selectedSpeedId: _selectedSpeedId,
onSelectSpeed: _onSelectSpeed,
audioRegions = [],
onAudioAdded,
onAudioSpanChange,
@@ -1212,13 +1212,6 @@ const TimelineEditor = forwardRef<TimelineEditorHandle, TimelineEditorProps>(
onSelectZoom(null);
}, [selectedZoomId, onZoomDelete, onSelectZoom]);
// Delete selected trim item
const deleteSelectedTrim = useCallback(() => {
if (!selectedTrimId || !onTrimDelete || !onSelectTrim) return;
onTrimDelete(selectedTrimId);
onSelectTrim(null);
}, [selectedTrimId, onTrimDelete, onSelectTrim]);
const deleteSelectedClip = useCallback(() => {
if (!selectedClipId || !onClipDelete || !onSelectClip) return;
onClipDelete(selectedClipId);
@@ -1231,12 +1224,6 @@ const TimelineEditor = forwardRef<TimelineEditorHandle, TimelineEditorProps>(
onSelectAnnotation(null);
}, [selectedAnnotationId, onAnnotationDelete, onSelectAnnotation]);
const deleteSelectedSpeed = useCallback(() => {
if (!selectedSpeedId || !onSpeedDelete || !onSelectSpeed) return;
onSpeedDelete(selectedSpeedId);
onSelectSpeed(null);
}, [selectedSpeedId, onSpeedDelete, onSelectSpeed]);
const deleteSelectedAudio = useCallback(() => {
if (!selectedAudioId || !onAudioDelete || !onSelectAudio) return;
onAudioDelete(selectedAudioId);
@@ -1245,42 +1232,32 @@ const TimelineEditor = forwardRef<TimelineEditorHandle, TimelineEditorProps>(
const clearSelectedBlocks = useCallback(() => {
onSelectZoom(null);
onSelectTrim?.(null);
onSelectClip?.(null);
onSelectAnnotation?.(null);
onSelectSpeed?.(null);
onSelectAudio?.(null);
setSelectAllBlocksActive(false);
}, [
onSelectAnnotation,
onSelectAudio,
onSelectClip,
onSelectSpeed,
onSelectTrim,
onSelectZoom,
]);
const hasAnyTimelineBlocks =
zoomRegions.length > 0 ||
trimRegions.length > 0 ||
clipRegions.length > 0 ||
annotationRegions.length > 0 ||
speedRegions.length > 0 ||
audioRegions.length > 0;
const deleteAllBlocks = useCallback(() => {
const zoomIds = zoomRegions.map((region) => region.id);
const trimIds = trimRegions.map((region) => region.id);
const clipIds = clipRegions.map((region) => region.id);
const annotationIds = annotationRegions.map((region) => region.id);
const speedIds = speedRegions.map((region) => region.id);
const audioIds = audioRegions.map((region) => region.id);
zoomIds.forEach((id) => onZoomDelete(id));
trimIds.forEach((id) => onTrimDelete?.(id));
clipIds.forEach((id) => onClipDelete?.(id));
annotationIds.forEach((id) => onAnnotationDelete?.(id));
speedIds.forEach((id) => onSpeedDelete?.(id));
audioIds.forEach((id) => onAudioDelete?.(id));
clearSelectedBlocks();
@@ -1293,11 +1270,7 @@ const TimelineEditor = forwardRef<TimelineEditorHandle, TimelineEditorProps>(
onAnnotationDelete,
onAudioDelete,
onClipDelete,
onSpeedDelete,
onTrimDelete,
onZoomDelete,
speedRegions,
trimRegions,
zoomRegions,
]);
@@ -1309,14 +1282,6 @@ const TimelineEditor = forwardRef<TimelineEditorHandle, TimelineEditorProps>(
[onSelectZoom],
);
const handleSelectTrim = useCallback(
(id: string | null) => {
setSelectAllBlocksActive(false);
onSelectTrim?.(id);
},
[onSelectTrim],
);
const handleSelectClip = useCallback(
(id: string | null) => {
setSelectAllBlocksActive(false);
@@ -1333,14 +1298,6 @@ const TimelineEditor = forwardRef<TimelineEditorHandle, TimelineEditorProps>(
[onSelectAnnotation],
);
const handleSelectSpeed = useCallback(
(id: string | null) => {
setSelectAllBlocksActive(false);
onSelectSpeed?.(id);
},
[onSelectSpeed],
);
const handleSelectAudio = useCallback(
(id: string | null) => {
setSelectAllBlocksActive(false);
@@ -1519,17 +1476,27 @@ const TimelineEditor = forwardRef<TimelineEditorHandle, TimelineEditorProps>(
}
const startPos = Math.max(0, Math.min(startMs, totalMs));
const activeClip =
clipRegions.length === 0
? { startMs: 0, endMs: totalMs }
: clipRegions.find((clip) => startPos >= clip.startMs && startPos < clip.endMs);
if (!activeClip) {
return false;
}
const sorted = [...zoomRegions].sort((a, b) => a.startMs - b.startMs);
const nextRegion = sorted.find((region) => region.startMs > startPos);
const gapToNext = nextRegion ? nextRegion.startMs - startPos : totalMs - startPos;
const gapToNextClipEdge = activeClip.endMs - startPos;
const gapToNextRegion = nextRegion ? nextRegion.startMs - startPos : gapToNextClipEdge;
const availableDuration = Math.min(gapToNextClipEdge, gapToNextRegion);
const isOverlapping = sorted.some(
(region) => startPos >= region.startMs && startPos < region.endMs,
);
return !isOverlapping && gapToNext >= defaultDuration;
return !isOverlapping && availableDuration >= defaultDuration;
},
[videoDuration, totalMs, zoomRegions, defaultRegionDurationMs],
[videoDuration, totalMs, zoomRegions, defaultRegionDurationMs, clipRegions],
);
const addZoomAtMs = useCallback(
@@ -1547,7 +1514,7 @@ const TimelineEditor = forwardRef<TimelineEditorHandle, TimelineEditorProps>(
if (!canPlaceZoomAtMs(startPos)) {
toast.error("Cannot place zoom here", {
description:
"Zoom already exists at this location or not enough space available.",
"Zoom already exists here or there is not enough room before the next zoom or clip end.",
});
return;
}
@@ -1649,46 +1616,6 @@ const TimelineEditor = forwardRef<TimelineEditorHandle, TimelineEditorProps>(
handleSuggestZooms();
}, [autoSuggestZoomsTrigger, handleSuggestZooms, onAutoSuggestZoomsConsumed]);
const handleAddTrim = useCallback(() => {
if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onTrimAdded) {
return;
}
const defaultDuration = Math.min(defaultRegionDurationMs, totalMs);
if (defaultDuration <= 0) {
return;
}
// Always place trim at playhead
const startPos = Math.max(0, Math.min(currentTimeMs, totalMs));
// Find the next trim region after the playhead
const sorted = [...trimRegions].sort((a, b) => a.startMs - b.startMs);
const nextRegion = sorted.find((region) => region.startMs > startPos);
const gapToNext = nextRegion ? nextRegion.startMs - startPos : totalMs - startPos;
// Check if playhead is inside any trim region
const isOverlapping = sorted.some(
(region) => startPos >= region.startMs && startPos < region.endMs,
);
if (isOverlapping || gapToNext <= 0) {
toast.error("Cannot place trim here", {
description:
"Trim already exists at this location or not enough space available.",
});
return;
}
const actualDuration = Math.min(defaultRegionDurationMs, gapToNext);
onTrimAdded({ start: startPos, end: startPos + actualDuration });
}, [
videoDuration,
totalMs,
currentTimeMs,
trimRegions,
onTrimAdded,
defaultRegionDurationMs,
]);
const handleSplitClip = useCallback(() => {
if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onClipSplit) {
return;
@@ -1696,46 +1623,6 @@ const TimelineEditor = forwardRef<TimelineEditorHandle, TimelineEditorProps>(
onClipSplit(currentTimeMs);
}, [videoDuration, totalMs, currentTimeMs, onClipSplit]);
const handleAddSpeed = useCallback(() => {
if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onSpeedAdded) {
return;
}
const defaultDuration = Math.min(defaultRegionDurationMs, totalMs);
if (defaultDuration <= 0) {
return;
}
// Always place speed region at playhead
const startPos = Math.max(0, Math.min(currentTimeMs, totalMs));
// Find the next speed region after the playhead
const sorted = [...speedRegions].sort((a, b) => a.startMs - b.startMs);
const nextRegion = sorted.find((region) => region.startMs > startPos);
const gapToNext = nextRegion ? nextRegion.startMs - startPos : totalMs - startPos;
// Check if playhead is inside any speed region
const isOverlapping = sorted.some(
(region) => startPos >= region.startMs && startPos < region.endMs,
);
if (isOverlapping || gapToNext <= 0) {
toast.error("Cannot place speed here", {
description:
"Speed region already exists at this location or not enough space available.",
});
return;
}
const actualDuration = Math.min(defaultRegionDurationMs, gapToNext);
onSpeedAdded({ start: startPos, end: startPos + actualDuration });
}, [
videoDuration,
totalMs,
currentTimeMs,
speedRegions,
onSpeedAdded,
defaultRegionDurationMs,
]);
const handleAddAudio = useCallback(
async (preferredTrackIndex?: number) => {
if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onAudioAdded) {
@@ -1918,18 +1805,12 @@ const TimelineEditor = forwardRef<TimelineEditorHandle, TimelineEditorProps>(
if (matchesShortcut(e, keyShortcuts.addZoom, isMac)) {
handleAddZoom();
}
if (matchesShortcut(e, keyShortcuts.addTrim, isMac)) {
handleAddTrim();
}
if (matchesShortcut(e, keyShortcuts.splitClip, isMac)) {
handleSplitClip();
}
if (matchesShortcut(e, keyShortcuts.addAnnotation, isMac)) {
handleAddAnnotation();
}
if (matchesShortcut(e, keyShortcuts.addSpeed, isMac)) {
handleAddSpeed();
}
// Tab: Cycle through overlapping annotations at current time
if (e.key === "Tab" && annotationRegions.length > 0) {
@@ -1970,14 +1851,10 @@ const TimelineEditor = forwardRef<TimelineEditorHandle, TimelineEditorProps>(
deleteSelectedKeyframe();
} else if (selectedZoomId) {
deleteSelectedZoom();
} else if (selectedTrimId) {
deleteSelectedTrim();
} else if (selectedClipId) {
deleteSelectedClip();
} else if (selectedAnnotationId) {
deleteSelectedAnnotation();
} else if (selectedSpeedId) {
deleteSelectedSpeed();
} else if (selectedAudioId) {
deleteSelectedAudio();
}
@@ -1988,24 +1865,18 @@ const TimelineEditor = forwardRef<TimelineEditorHandle, TimelineEditorProps>(
}, [
addKeyframe,
handleAddZoom,
handleAddTrim,
handleSplitClip,
handleAddAnnotation,
handleAddSpeed,
deleteAllBlocks,
deleteSelectedKeyframe,
deleteSelectedZoom,
deleteSelectedTrim,
deleteSelectedClip,
deleteSelectedAnnotation,
deleteSelectedSpeed,
deleteSelectedAudio,
selectedKeyframeId,
selectedZoomId,
selectedTrimId,
selectedClipId,
selectedAnnotationId,
selectedSpeedId,
selectedAudioId,
annotationRegions,
currentTimeMs,
@@ -2479,16 +2350,12 @@ const TimelineEditor = forwardRef<TimelineEditorHandle, TimelineEditorProps>(
onAddZoomAtMs={addZoomAtMs}
canPlaceZoomAtMs={canPlaceZoomAtMs}
onSelectZoom={handleSelectZoom}
onSelectTrim={handleSelectTrim}
onSelectClip={handleSelectClip}
onSelectAnnotation={handleSelectAnnotation}
onSelectSpeed={handleSelectSpeed}
onSelectAudio={handleSelectAudio}
selectedZoomId={selectedZoomId}
selectedTrimId={selectedTrimId}
selectedClipId={selectedClipId}
selectedAnnotationId={selectedAnnotationId}
selectedSpeedId={selectedSpeedId}
selectedAudioId={selectedAudioId}
selectAllBlocksActive={selectAllBlocksActive}
onClearBlockSelection={clearSelectedBlocks}
+53 -1
View File
@@ -1,6 +1,13 @@
import { describe, expect, it } from "vitest";
import { extendAutoFullTrackClip } from "./types";
import {
extendAutoFullTrackClip,
findClipAtTimelineTime,
mapSourceTimeToTimelineTime,
mapTimelineTimeToSourceTime,
trimsToClips,
} from "./types";
import { deriveNextId } from "./projectPersistence";
describe("extendAutoFullTrackClip", () => {
it("extends the default full-track clip when metadata duration grows", () => {
@@ -105,3 +112,48 @@ describe("extendAutoFullTrackClip", () => {
).toBeNull();
});
});
describe("clip timeline mapping", () => {
const clips = [
{ id: "clip-1", startMs: 0, endMs: 4_000, speed: 1 },
{ id: "clip-2", startMs: 6_000, endMs: 8_000, speed: 2 },
];
it("maps kept timeline time into source time", () => {
expect(mapTimelineTimeToSourceTime(1_500, clips)).toBe(1_500);
expect(mapTimelineTimeToSourceTime(7_000, clips)).toBe(8_000);
});
it("snaps timeline gaps to the nearest clip edge", () => {
expect(mapTimelineTimeToSourceTime(4_300, clips)).toBe(4_000);
expect(mapTimelineTimeToSourceTime(5_700, clips)).toBe(6_000);
});
it("maps kept source time back into timeline time", () => {
expect(mapSourceTimeToTimelineTime(1_500, clips)).toBe(1_500);
expect(mapSourceTimeToTimelineTime(8_000, clips)).toBe(7_000);
});
it("snaps removed source gaps to the nearest kept boundary", () => {
expect(mapSourceTimeToTimelineTime(4_200, clips)).toBe(4_000);
expect(mapSourceTimeToTimelineTime(5_900, clips)).toBe(6_000);
});
it("finds clips only inside visible kept spans", () => {
expect(findClipAtTimelineTime(500, clips)?.id).toBe("clip-1");
expect(findClipAtTimelineTime(5_000, clips)).toBeNull();
});
it("derives the next clip id after converting trim gaps into clip ids", () => {
const clipsFromTrims = trimsToClips(
[
{ id: "trim-gap-1", startMs: 1_000, endMs: 2_000 },
{ id: "trim-gap-2", startMs: 4_000, endMs: 5_000 },
],
6_000,
);
expect(clipsFromTrims.map((clip) => clip.id)).toEqual(["clip-1", "clip-2", "clip-3"]);
expect(deriveNextId("clip", clipsFromTrims.map((clip) => clip.id))).toBe(4);
});
});
+88 -1
View File
@@ -51,7 +51,7 @@ export interface CursorVisualSettings {
}
export type CursorStyle = "macos" | "tahoe" | "tahoe-inverted" | "dot" | "figma" | (string & {}); // extension-contributed cursor styles
export const DEFAULT_CURSOR_STYLE: CursorStyle = "tahoe";
export const DEFAULT_CURSOR_STYLE: CursorStyle = "macos";
export type EditorEffectSection =
| "scene"
@@ -101,6 +101,7 @@ export const DEFAULT_CURSOR_MOTION_BLUR = 0.4;
export const DEFAULT_CURSOR_CLICK_BOUNCE = 2.5;
export const DEFAULT_CURSOR_CLICK_BOUNCE_DURATION = 350;
export const DEFAULT_CURSOR_SWAY = 0.25;
export const DEFAULT_ZOOM_SMOOTHNESS = 0.5;
export const DEFAULT_ZOOM_MOTION_BLUR = 0.35;
export const DEFAULT_ZOOM_IN_DURATION_MS = 1522.575;
export const DEFAULT_ZOOM_IN_OVERLAP_MS = 500;
@@ -157,6 +158,92 @@ export function getClipSourceEndMs(clip: ClipRegion): number {
return Math.round(clip.startMs + displayDurationMs * speed);
}
export function sortClipRegions(clips: ClipRegion[]): ClipRegion[] {
return [...clips].sort((left, right) => left.startMs - right.startMs);
}
function getSafeClipSpeed(clip: ClipRegion) {
return Number.isFinite(clip.speed) && clip.speed > 0 ? clip.speed : 1;
}
function clampToNearestClipBoundary(
timeMs: number,
clips: ClipRegion[],
kind: "timeline" | "source",
) {
let nearestTimeMs = Math.round(timeMs);
let nearestDistance = Number.POSITIVE_INFINITY;
for (const clip of clips) {
const boundaries =
kind === "timeline"
? [clip.startMs, clip.endMs]
: [clip.startMs, getClipSourceEndMs(clip)];
for (const boundary of boundaries) {
const distance = Math.abs(timeMs - boundary);
if (distance < nearestDistance) {
nearestDistance = distance;
nearestTimeMs = Math.round(boundary);
}
}
}
return nearestTimeMs;
}
export function mapTimelineTimeToSourceTime(timeMs: number, clips: ClipRegion[]): number {
const roundedTimeMs = Math.round(timeMs);
const sortedClips = sortClipRegions(clips);
for (const clip of sortedClips) {
if (roundedTimeMs < clip.startMs || roundedTimeMs > clip.endMs) {
continue;
}
return Math.round(
clip.startMs + (roundedTimeMs - clip.startMs) * getSafeClipSpeed(clip),
);
}
if (sortedClips.length === 0) {
return roundedTimeMs;
}
return clampToNearestClipBoundary(roundedTimeMs, sortedClips, "timeline");
}
export function mapSourceTimeToTimelineTime(timeMs: number, clips: ClipRegion[]): number {
const roundedTimeMs = Math.round(timeMs);
const sortedClips = sortClipRegions(clips);
for (const clip of sortedClips) {
const sourceEndMs = getClipSourceEndMs(clip);
if (roundedTimeMs < clip.startMs || roundedTimeMs > sourceEndMs) {
continue;
}
return Math.round(
clip.startMs + (roundedTimeMs - clip.startMs) / getSafeClipSpeed(clip),
);
}
if (sortedClips.length === 0) {
return roundedTimeMs;
}
return clampToNearestClipBoundary(roundedTimeMs, sortedClips, "source");
}
export function findClipAtTimelineTime(timeMs: number, clips: ClipRegion[]): ClipRegion | null {
const roundedTimeMs = Math.round(timeMs);
return (
sortClipRegions(clips).find(
(clip) => roundedTimeMs >= clip.startMs && roundedTimeMs < clip.endMs,
) ?? null
);
}
export function extendAutoFullTrackClip(
clips: ClipRegion[],
autoClipId: string | null,
@@ -0,0 +1,110 @@
import { describe, expect, it } from "vitest";
import {
computeCursorFollowFocus,
createCursorFollowCameraState,
} from "./cursorFollowCamera";
describe("computeCursorFollowFocus", () => {
it("holds the camera while the cursor stays inside the safe zone", () => {
const state = createCursorFollowCameraState();
const cursorSamples = [
{ timeMs: 0, cx: 0.5, cy: 0.5, interactionType: "move" as const },
{ timeMs: 100, cx: 0.6, cy: 0.58, interactionType: "move" as const },
];
const initialFocus = computeCursorFollowFocus(
state,
cursorSamples,
0,
2,
1,
{ cx: 0.5, cy: 0.5 },
{ snapToEdgesRatio: 0.25 },
);
const heldFocus = computeCursorFollowFocus(
state,
cursorSamples,
100,
2,
1,
{ cx: 0.5, cy: 0.5 },
{ snapToEdgesRatio: 0.25 },
);
expect(initialFocus).toEqual({ cx: 0.5, cy: 0.5 });
expect(heldFocus).toEqual(initialFocus);
});
it("recenters the cursor after it leaves the safe zone", () => {
const state = createCursorFollowCameraState();
const cursorSamples = [
{ timeMs: 0, cx: 0.5, cy: 0.5, interactionType: "move" as const },
{ timeMs: 100, cx: 0.7, cy: 0.5, interactionType: "move" as const },
{ timeMs: 200, cx: 0.72, cy: 0.5, interactionType: "move" as const },
];
computeCursorFollowFocus(
state,
cursorSamples,
0,
2,
1,
{ cx: 0.5, cy: 0.5 },
{ snapToEdgesRatio: 0.25 },
);
const firstShift = computeCursorFollowFocus(
state,
cursorSamples,
100,
2,
1,
{ cx: 0.5, cy: 0.5 },
{ snapToEdgesRatio: 0.25 },
);
const secondShift = computeCursorFollowFocus(
state,
cursorSamples,
200,
2,
1,
{ cx: 0.5, cy: 0.5 },
{ snapToEdgesRatio: 0.25 },
);
expect(firstShift.cx).toBeCloseTo(0.7, 6);
expect(firstShift.cy).toBeCloseTo(0.5, 6);
expect(secondShift.cx).toBeCloseTo(0.7, 6);
expect(secondShift.cy).toBeCloseTo(0.5, 6);
});
it("clamps the camera when the cursor pushes past the stage edge", () => {
const state = createCursorFollowCameraState();
const cursorSamples = [
{ timeMs: 0, cx: 0.5, cy: 0.5, interactionType: "move" as const },
{ timeMs: 100, cx: 1, cy: 1, interactionType: "move" as const },
];
computeCursorFollowFocus(
state,
cursorSamples,
0,
2,
1,
{ cx: 0.5, cy: 0.5 },
{ snapToEdgesRatio: 0.25 },
);
const clampedFocus = computeCursorFollowFocus(
state,
cursorSamples,
100,
2,
1,
{ cx: 0.5, cy: 0.5 },
{ snapToEdgesRatio: 0.25 },
);
expect(clampedFocus).toEqual({ cx: 0.75, cy: 0.75 });
});
});
@@ -1,16 +1,13 @@
import type { CursorTelemetryPoint, ZoomFocus } from "../types";
import { interpolateCursorPosition } from "./cursorRenderer";
import { edgeSnapFocus } from "./focusUtils";
import { clampFocusToScale } from "./focusUtils";
/**
* Cursor-follow camera.
*
* Computes a TARGET focus point each frame (cursor → edge snap → focus),
* then the zoom transition animation layer smoothly interpolates toward it.
*
* Edge snap: With snapToEdgesRatio = 0.25, cursor positions within 25% of
* each edge pin the camera to that edge. The middle 50% maps linearly.
* This prevents the camera from panning beyond the viewport bounds.
* Keeps a persistent camera center while zoomed in. The camera only recenters
* after the cursor leaves an inner safe zone within the current zoomed view,
* shifting just enough to bring the cursor back inside that zone.
*/
/** Default snap ratio for manual zoom regions */
@@ -23,6 +20,10 @@ export interface CursorFollowCameraState {
initialized: boolean;
/** Time of last update in ms (video time, not wall clock) */
lastTimeMs: number;
/** Current camera focus while zoomed */
focusX: number;
/** Current camera focus while zoomed */
focusY: number;
/** Whether the camera was active (zoomed) on the previous frame */
wasZoomed: boolean;
/** Whether the zoom reached full strength (≈1) — used to detect zoom-out */
@@ -35,7 +36,7 @@ export interface CursorFollowCameraState {
export interface CursorFollowConfig {
/**
* snapToEdgesRatio — how much of the screen edge pins the camera.
* 0.25 for manual zooms, 0.5 for auto/system zooms.
* 0.25 for manual zooms, 0.25 for auto/system zooms.
*/
snapToEdgesRatio: number;
}
@@ -48,6 +49,8 @@ export function createCursorFollowCameraState(): CursorFollowCameraState {
return {
initialized: false,
lastTimeMs: 0,
focusX: 0.5,
focusY: 0.5,
wasZoomed: false,
reachedFullZoom: false,
frozenFocusX: 0.5,
@@ -58,19 +61,71 @@ export function createCursorFollowCameraState(): CursorFollowCameraState {
export function resetCursorFollowCamera(state: CursorFollowCameraState): void {
state.initialized = false;
state.lastTimeMs = 0;
state.focusX = 0.5;
state.focusY = 0.5;
state.wasZoomed = false;
state.reachedFullZoom = false;
state.frozenFocusX = 0.5;
state.frozenFocusY = 0.5;
}
function clampSafeZoneRatio(ratio: number) {
if (!Number.isFinite(ratio)) {
return SNAP_TO_EDGES_RATIO_AUTO;
}
return Math.max(0, Math.min(0.49, ratio));
}
function getVisibleHalfSpan(zoomScale: number) {
return 1 / (2 * Math.max(1, zoomScale));
}
function recenterFocusWhenCursorLeavesSafeZone(
currentFocus: ZoomFocus,
cursorFocus: ZoomFocus,
zoomScale: number,
safeZoneRatio: number,
): ZoomFocus {
const halfSpan = getVisibleHalfSpan(zoomScale);
const visibleSpan = halfSpan * 2;
const safeZoneInset = visibleSpan * clampSafeZoneRatio(safeZoneRatio);
const safeLeft = currentFocus.cx - halfSpan + safeZoneInset;
const safeRight = currentFocus.cx + halfSpan - safeZoneInset;
const safeTop = currentFocus.cy - halfSpan + safeZoneInset;
const safeBottom = currentFocus.cy + halfSpan - safeZoneInset;
let nextFocusX = currentFocus.cx;
let nextFocusY = currentFocus.cy;
if (cursorFocus.cx < safeLeft) {
nextFocusX = cursorFocus.cx;
} else if (cursorFocus.cx > safeRight) {
nextFocusX = cursorFocus.cx;
}
if (cursorFocus.cy < safeTop) {
nextFocusY = cursorFocus.cy;
} else if (cursorFocus.cy > safeBottom) {
nextFocusY = cursorFocus.cy;
}
return clampFocusToScale(
{
cx: nextFocusX,
cy: nextFocusY,
},
zoomScale,
);
}
/**
* Cursor follow: target focus computation.
*
* Computes the desired camera focus point based on cursor position and
* edge snap. The zoom transition layer handles smooth interpolation.
*
* Pipeline: cursor → edgeSnap(snapToEdgesRatio) → focus
* Computes the desired camera focus point based on a persistent camera center
* and an inner safe zone. The zoom transition layer handles smooth
* interpolation toward the returned focus.
*
* @returns The target focus point for this frame (normalized 0-1).
*/
@@ -83,11 +138,7 @@ export function computeCursorFollowFocus(
regionFocus: ZoomFocus,
config: CursorFollowConfig = DEFAULT_CURSOR_FOLLOW_CONFIG,
): ZoomFocus {
// If no cursor data available, fall back to static region focus
const cursorPos = interpolateCursorPosition(cursorSamples, timeMs);
if (!cursorPos) {
return regionFocus;
}
const clampedRegionFocus = clampFocusToScale(regionFocus, zoomScale);
// If not zoomed (strength ≈ 0), reset state and return region focus
if (zoomStrength < 0.01) {
@@ -96,7 +147,14 @@ export function computeCursorFollowFocus(
state.initialized = false;
state.reachedFullZoom = false;
}
return regionFocus;
return clampedRegionFocus;
}
const cursorPos = interpolateCursorPosition(cursorSamples, timeMs);
if (!cursorPos) {
return state.initialized
? { cx: state.focusX, cy: state.focusY }
: clampedRegionFocus;
}
// Track when zoom reaches full strength
@@ -109,24 +167,36 @@ export function computeCursorFollowFocus(
return { cx: state.frozenFocusX, cy: state.frozenFocusY };
}
// First frame of a zoom: mark initialized
if (!state.initialized || !state.wasZoomed) {
const timeWentBackwards = state.initialized && timeMs + 0.5 < state.lastTimeMs;
if (!state.initialized || !state.wasZoomed || timeWentBackwards) {
const initialFocus = recenterFocusWhenCursorLeavesSafeZone(
clampedRegionFocus,
{ cx: cursorPos.cx, cy: cursorPos.cy },
zoomScale,
config.snapToEdgesRatio,
);
state.lastTimeMs = timeMs;
state.initialized = true;
state.wasZoomed = true;
state.focusX = initialFocus.cx;
state.focusY = initialFocus.cy;
state.frozenFocusX = initialFocus.cx;
state.frozenFocusY = initialFocus.cy;
return initialFocus;
}
state.lastTimeMs = timeMs;
// Edge snap: maps cursor through clamped linear remap.
// Camera pins to edge when cursor is within snapToEdgesRatio of boundary.
const targetFocus = edgeSnapFocus(
const targetFocus = recenterFocusWhenCursorLeavesSafeZone(
{ cx: state.focusX, cy: state.focusY },
{ cx: cursorPos.cx, cy: cursorPos.cy },
zoomScale,
config.snapToEdgesRatio,
);
// Save for zoom-out freeze
state.focusX = targetFocus.cx;
state.focusY = targetFocus.cy;
state.frozenFocusX = targetFocus.cx;
state.frozenFocusY = targetFocus.cy;
@@ -16,6 +16,7 @@ import {
getCursorSpringConfig,
resetSpringState,
stepSpringValue,
type CursorSpringTuning,
} from "./motionSmoothing";
import { cursorSetAssets, getCursorStyleSizeMultiplier } from "./uploadedCursorAssets";
@@ -56,6 +57,8 @@ export interface CursorRenderConfig {
trailLength: number;
/** Smoothing factor for cursor interpolation (0–1, lower = smoother/slower) */
smoothingFactor: number;
/** Optional multipliers applied on top of the derived cursor spring config. */
springTuning: CursorSpringTuning;
/** Directional cursor motion blur amount. */
motionBlur: number;
/** Click bounce multiplier. */
@@ -74,6 +77,11 @@ export const DEFAULT_CURSOR_CONFIG: CursorRenderConfig = {
dotAlpha: 0.95,
trailLength: 0,
smoothingFactor: 0.18,
springTuning: {
stiffnessMultiplier: 1,
dampingMultiplier: 1,
massMultiplier: 1,
},
motionBlur: 0,
clickBounce: 1,
clickBounceDuration: DEFAULT_CURSOR_CLICK_BOUNCE_DURATION,
@@ -644,7 +652,10 @@ function getCursorViewportScale(viewport: CursorViewportRect) {
return Math.max(MIN_CURSOR_VIEWPORT_SCALE, viewport.width / REFERENCE_WIDTH);
}
function getCursorSwaySpringConfig(smoothingFactor: number) {
function getCursorSwaySpringConfig(
smoothingFactor: number,
springTuning: CursorSpringTuning,
) {
const baseConfig = getCursorSpringConfig(
Math.min(
2,
@@ -653,6 +664,7 @@ function getCursorSwaySpringConfig(smoothingFactor: number) {
smoothingFactor * CURSOR_SWAY_SMOOTHING_MULTIPLIER + CURSOR_SWAY_SMOOTHING_OFFSET,
),
),
springTuning,
);
return {
@@ -700,14 +712,16 @@ export class SmoothedCursorState {
public y = 0.5;
public trail: Array<{ x: number; y: number }> = [];
private smoothingFactor: number;
private springTuning: CursorSpringTuning;
private trailLength: number;
private initialized = false;
private lastTimeMs: number | null = null;
private xSpring = createSpringState(0.5);
private ySpring = createSpringState(0.5);
constructor(config: Pick<CursorRenderConfig, "smoothingFactor" | "trailLength">) {
constructor(config: Pick<CursorRenderConfig, "smoothingFactor" | "trailLength" | "springTuning">) {
this.smoothingFactor = config.smoothingFactor;
this.springTuning = config.springTuning;
this.trailLength = config.trailLength;
}
@@ -741,7 +755,7 @@ export class SmoothedCursorState {
this.lastTimeMs === null ? 1000 / 60 : Math.max(1, timeMs - this.lastTimeMs);
this.lastTimeMs = timeMs;
const springConfig = getCursorSpringConfig(this.smoothingFactor);
const springConfig = getCursorSpringConfig(this.smoothingFactor, this.springTuning);
this.x = stepSpringValue(this.xSpring, targetX, deltaMs, springConfig);
this.y = stepSpringValue(this.ySpring, targetY, deltaMs, springConfig);
}
@@ -750,6 +764,10 @@ export class SmoothedCursorState {
this.smoothingFactor = smoothingFactor;
}
setSpringTuning(springTuning: CursorSpringTuning): void {
this.springTuning = springTuning;
}
snapTo(targetX: number, targetY: number, timeMs: number): void {
this.x = targetX;
this.y = targetY;
@@ -791,7 +809,14 @@ export class PixiCursorOverlay {
private swaySpring = createSpringState(0);
constructor(config: Partial<CursorRenderConfig> = {}) {
this.config = { ...DEFAULT_CURSOR_CONFIG, ...config };
this.config = {
...DEFAULT_CURSOR_CONFIG,
...config,
springTuning: {
...DEFAULT_CURSOR_CONFIG.springTuning,
...config.springTuning,
},
};
this.state = new SmoothedCursorState(this.config);
this.container = new Container();
@@ -863,6 +888,14 @@ export class PixiCursorOverlay {
this.state.setSmoothingFactor(smoothingFactor);
}
setSpringTuning(springTuning: CursorSpringTuning) {
this.config.springTuning = {
...DEFAULT_CURSOR_CONFIG.springTuning,
...springTuning,
};
this.state.setSpringTuning(this.config.springTuning);
}
setMotionBlur(motionBlur: number) {
this.config.motionBlur = Math.max(0, motionBlur);
this.container.filters = this.config.motionBlur > 0 ? [this.cursorMotionBlurFilter] : null;
@@ -1095,7 +1128,7 @@ export class PixiCursorOverlay {
this.swaySpring,
targetRotation,
deltaMs,
getCursorSwaySpringConfig(this.config.smoothingFactor),
getCursorSwaySpringConfig(this.config.smoothingFactor, this.config.springTuning),
);
if (Math.abs(this.swayRotation) < 0.0001 && targetRotation === 0) {
@@ -1,4 +1,4 @@
// Friendly reminder: Recordly is licensed under AGPL-3.0, author @webadderall, repo-> https://github.com/webadderallorg/Recordly
// Friendly reminder: Recordly is licensed under AGPL-3.0, author @webadderall, repo-> https://github.com/webadderall/Recordly
// Please use this code with the right attribution.
export interface SpringState {
@@ -15,9 +15,37 @@ export interface SpringConfig {
restSpeed?: number;
}
export interface CursorSpringTuning {
stiffnessMultiplier?: number;
dampingMultiplier?: number;
massMultiplier?: number;
}
const CURSOR_SMOOTHING_MIN = 0;
const CURSOR_SMOOTHING_MAX = 2;
const CURSOR_SMOOTHING_LEGACY_MAX = 0.5;
const DEFAULT_CURSOR_STIFFNESS_BOOST = 1.12;
function clampSpringMultiplier(value: number | undefined) {
if (typeof value !== "number" || !Number.isFinite(value)) {
return 1;
}
const numericValue = value;
return Math.min(3, Math.max(0.25, numericValue));
}
function applyCursorSpringTuning(
config: SpringConfig,
tuning?: CursorSpringTuning,
): SpringConfig {
return {
...config,
stiffness: config.stiffness * clampSpringMultiplier(tuning?.stiffnessMultiplier),
damping: config.damping * clampSpringMultiplier(tuning?.dampingMultiplier),
mass: config.mass * clampSpringMultiplier(tuning?.massMultiplier),
};
}
export function createSpringState(initialValue = 0): SpringState {
return {
@@ -191,17 +219,20 @@ export function stepSpringValue(
return state.value;
}
export function getCursorSpringConfig(smoothingFactor: number): SpringConfig {
export function getCursorSpringConfig(
smoothingFactor: number,
tuning?: CursorSpringTuning,
): SpringConfig {
const clamped = Math.min(CURSOR_SMOOTHING_MAX, Math.max(CURSOR_SMOOTHING_MIN, smoothingFactor));
if (clamped <= 0) {
return {
return applyCursorSpringTuning({
stiffness: 1000,
damping: 100,
mass: 1,
restDelta: 0.0001,
restSpeed: 0.001,
};
}, tuning);
}
if (clamped <= CURSOR_SMOOTHING_LEGACY_MAX) {
@@ -214,13 +245,13 @@ export function getCursorSpringConfig(smoothingFactor: number): SpringConfig {
),
);
return {
stiffness: 760 - legacyNormalized * 420,
return applyCursorSpringTuning({
stiffness: (760 - legacyNormalized * 420) * DEFAULT_CURSOR_STIFFNESS_BOOST,
damping: 34 + legacyNormalized * 24,
mass: 0.55 + legacyNormalized * 0.45,
mass: 0.85 + legacyNormalized * 0.55,
restDelta: 0.0002,
restSpeed: 0.01,
};
}, tuning);
}
const extendedNormalized = Math.min(
@@ -232,13 +263,13 @@ export function getCursorSpringConfig(smoothingFactor: number): SpringConfig {
),
);
return {
stiffness: 340 - extendedNormalized * 180,
return applyCursorSpringTuning({
stiffness: (340 - extendedNormalized * 180) * DEFAULT_CURSOR_STIFFNESS_BOOST,
damping: 58 + extendedNormalized * 22,
mass: 1 + extendedNormalized * 0.35,
mass: 1.35 + extendedNormalized * 0.45,
restDelta: 0.0002,
restSpeed: 0.01,
};
}, tuning);
}
export function getZoomSpringConfig(smoothnessFactor = 0.5): SpringConfig {
@@ -151,4 +151,29 @@ describe("createVideoEventHandlers", () => {
handlers.dispose();
expect(cancelVideoFrameCallback).toHaveBeenCalledWith(23);
});
it("skips removed footage after a paused seek", () => {
const video = createMockVideo({
currentTime: 1.25,
paused: true,
});
const onTimeUpdate = vi.fn();
const handlers = createVideoEventHandlers({
video,
isSeekingRef: createMutableRef(true),
isPlayingRef: createMutableRef(false),
allowPlaybackRef: createMutableRef(true),
currentTimeRef: createMutableRef(0),
timeUpdateAnimationRef: createMutableRef<number | null>(null),
onPlayStateChange: vi.fn(),
onTimeUpdate,
trimRegionsRef: createMutableRef([{ id: "trim-1", startMs: 1000, endMs: 2000 }]),
speedRegionsRef: createMutableRef([]),
});
handlers.handleSeeked();
expect(video.currentTime).toBe(2);
expect(onTimeUpdate).toHaveBeenLastCalledWith(2);
});
});
@@ -167,8 +167,8 @@ export function createVideoEventHandlers(params: VideoEventHandlersParams) {
const currentTimeMs = video.currentTime * 1000;
const activeTrimRegion = findActiveTrimRegion(currentTimeMs);
// If we seeked into a trim region while playing, skip to the end
if (activeTrimRegion && isPlayingRef.current && !video.paused) {
// Never leave the preview parked on removed footage after a seek.
if (activeTrimRegion) {
skipPastTrimRegion(activeTrimRegion);
} else {
emitTime(video.currentTime);
@@ -208,17 +208,31 @@ describe("getCursorSpringConfig", () => {
const c = getCursorSpringConfig(0.25);
expect(c.stiffness).toBeLessThan(760);
expect(c.stiffness).toBeGreaterThan(300);
expect(c.mass).toBeGreaterThan(1);
});
it("returns config in extended range at 1.5", () => {
const c = getCursorSpringConfig(1.5);
expect(c.stiffness).toBeLessThan(340);
expect(c.mass).toBeGreaterThan(1);
expect(c.mass).toBeGreaterThan(1.6);
});
it("clamps at max smoothing", () => {
expect(getCursorSpringConfig(999)).toEqual(getCursorSpringConfig(2));
});
it("applies cursor spring tuning multipliers", () => {
const untuned = getCursorSpringConfig(0.5);
const tuned = getCursorSpringConfig(0.5, {
stiffnessMultiplier: 1.5,
dampingMultiplier: 0.75,
massMultiplier: 1.25,
});
expect(tuned.stiffness).toBeCloseTo(untuned.stiffness * 1.5, 6);
expect(tuned.damping).toBeCloseTo(untuned.damping * 0.75, 6);
expect(tuned.mass).toBeCloseTo(untuned.mass * 1.25, 6);
});
});
// ---------------------------------------------------------------------------
@@ -318,6 +332,17 @@ describe("computeRegionStrength", () => {
expect(s).toBeGreaterThan(0);
expect(s).toBeLessThan(1);
});
it("shifts zoom timing when custom durations are provided", () => {
const defaultStrength = computeRegionStrength(region, region.startMs);
const fasterStrength = computeRegionStrength(region, region.startMs, {
zoomInDurationMs: 300,
zoomOutDurationMs: 300,
});
expect(fasterStrength).not.toBe(defaultStrength);
expect(fasterStrength).toBeGreaterThan(defaultStrength);
});
});
// ---------------------------------------------------------------------------
@@ -15,6 +15,8 @@ const ZOOM_ANIMATION_LEAD_MS = 200;
type DominantRegionOptions = {
connectZooms?: boolean;
zoomInDurationMs?: number;
zoomOutDurationMs?: number;
};
type ConnectedRegionPair = {
@@ -40,10 +42,17 @@ function easeConnectedPan(value: number) {
return cubicBezier(0.1, 0.0, 0.2, 1.0, value);
}
export function computeRegionStrength(region: ZoomRegion, timeMs: number) {
export function computeRegionStrength(
region: ZoomRegion,
timeMs: number,
options: Pick<DominantRegionOptions, "zoomInDurationMs" | "zoomOutDurationMs"> = {},
) {
const zoomInDurationMs = Math.max(1, options.zoomInDurationMs ?? ZOOM_IN_TRANSITION_WINDOW_MS);
const zoomOutDurationMs = Math.max(1, options.zoomOutDurationMs ?? TRANSITION_WINDOW_MS);
const adjustedTimeMs = timeMs - ZOOM_ANIMATION_LEAD_MS;
const leadInStart = region.startMs + ZOOM_IN_OVERLAP_MS - ZOOM_IN_TRANSITION_WINDOW_MS;
let zoomOutStart = region.endMs - ZOOM_OUT_EARLY_START_MS;
let zoomInEnd = region.startMs + ZOOM_IN_OVERLAP_MS;
let zoomInEnd = leadInStart + zoomInDurationMs;
if (zoomInEnd > zoomOutStart) {
const midpoint = (zoomInEnd + zoomOutStart) / 2;
@@ -51,15 +60,14 @@ export function computeRegionStrength(region: ZoomRegion, timeMs: number) {
zoomOutStart = midpoint;
}
const leadInStart = zoomInEnd - ZOOM_IN_TRANSITION_WINDOW_MS;
const leadOutEnd = zoomOutStart + TRANSITION_WINDOW_MS;
const leadOutEnd = zoomOutStart + zoomOutDurationMs;
if (adjustedTimeMs < leadInStart || adjustedTimeMs > leadOutEnd) {
return 0;
}
if (adjustedTimeMs < zoomInEnd) {
const progress = (adjustedTimeMs - leadInStart) / ZOOM_IN_TRANSITION_WINDOW_MS;
const progress = (adjustedTimeMs - leadInStart) / zoomInDurationMs;
return easeOutZoom(progress);
}
@@ -67,7 +75,7 @@ export function computeRegionStrength(region: ZoomRegion, timeMs: number) {
return 1;
}
const progress = clamp01((adjustedTimeMs - zoomOutStart) / TRANSITION_WINDOW_MS);
const progress = clamp01((adjustedTimeMs - zoomOutStart) / zoomOutDurationMs);
return 1 - easeOutZoom(progress);
}
@@ -111,6 +119,7 @@ function getActiveRegion(
regions: ZoomRegion[],
timeMs: number,
connectedPairs: ConnectedRegionPair[],
options: DominantRegionOptions,
) {
const activeRegions = regions
.map((region) => {
@@ -134,7 +143,7 @@ function getActiveRegion(
}
}
return { region, strength: computeRegionStrength(region, timeMs) };
return { region, strength: computeRegionStrength(region, timeMs, options) };
})
.filter((entry) => entry.strength > 0)
.sort((left, right) => {
@@ -242,7 +251,7 @@ export function findDominantRegion(
}
}
const activeRegion = getActiveRegion(regions, timeMs, connectedPairs);
const activeRegion = getActiveRegion(regions, timeMs, connectedPairs, options);
return activeRegion
? { ...activeRegion, transition: null }
: { region: null, strength: 0, blendedScale: null, transition: null };
@@ -1,15 +1,26 @@
import { BlurFilter, Container } from "pixi.js";
import { Container } from "pixi.js";
import { MotionBlurFilter } from "pixi-filters/motion-blur";
import { ZoomBlurFilter } from "pixi-filters/zoom-blur";
const PEAK_VELOCITY_PPS = 2000;
const MAX_BLUR_PX = 8;
const VELOCITY_THRESHOLD_PPS = 15;
const PEAK_TRANSLATION_VELOCITY_PPS = 1800;
const PAN_VELOCITY_THRESHOLD_PPS = 24;
const PEAK_LOG_SCALE_VELOCITY_PER_SECOND = 1.45;
const LOG_SCALE_VELOCITY_THRESHOLD = 0.05;
const MAX_DIRECTIONAL_BLUR_PX = 7.5;
const MAX_ZOOM_BLUR_STRENGTH = 0.14;
const PAN_RESPONSE_PER_SECOND = 11;
const ZOOM_RESPONSE_PER_SECOND = 9;
export interface MotionBlurState {
lastFrameTimeMs: number;
prevCamX: number;
prevCamY: number;
prevCamScale: number;
smoothedPanVelocityX: number;
smoothedPanVelocityY: number;
smoothedLogScaleVelocity: number;
smoothedDirectionalBlur: number;
smoothedRadialBlur: number;
initialized: boolean;
}
@@ -19,13 +30,18 @@ export function createMotionBlurState(): MotionBlurState {
prevCamX: 0,
prevCamY: 0,
prevCamScale: 1,
smoothedPanVelocityX: 0,
smoothedPanVelocityY: 0,
smoothedLogScaleVelocity: 0,
smoothedDirectionalBlur: 0,
smoothedRadialBlur: 0,
initialized: false,
};
}
interface TransformParams {
cameraContainer: Container;
blurFilter: BlurFilter | null;
zoomBlurFilter?: ZoomBlurFilter | null;
motionBlurFilter?: MotionBlurFilter | null;
stageSize: { width: number; height: number };
baseMask: { x: number; y: number; width: number; height: number };
@@ -66,7 +82,7 @@ interface ZoomTransformGeometry {
}
function resetMotionEffects(
blurFilter: BlurFilter | null,
zoomBlurFilter?: ZoomBlurFilter | null,
motionBlurFilter?: MotionBlurFilter | null,
motionBlurState?: MotionBlurState,
) {
@@ -76,15 +92,98 @@ function resetMotionEffects(
motionBlurFilter.offset = 0;
}
if (blurFilter) {
blurFilter.blur = 0;
if (zoomBlurFilter) {
zoomBlurFilter.strength = 0;
zoomBlurFilter.innerRadius = 0;
zoomBlurFilter.radius = -1;
}
if (motionBlurState) {
motionBlurState.lastFrameTimeMs = 0;
motionBlurState.prevCamX = 0;
motionBlurState.prevCamY = 0;
motionBlurState.prevCamScale = 1;
motionBlurState.smoothedPanVelocityX = 0;
motionBlurState.smoothedPanVelocityY = 0;
motionBlurState.smoothedLogScaleVelocity = 0;
motionBlurState.smoothedDirectionalBlur = 0;
motionBlurState.smoothedRadialBlur = 0;
motionBlurState.initialized = false;
}
}
function mixTowards(current: number, target: number, blendFactor: number) {
return current + (target - current) * blendFactor;
}
function computeSmoothingBlend(deltaSeconds: number, responsePerSecond: number) {
return 1 - Math.exp(-Math.max(0, deltaSeconds) * responsePerSecond);
}
function remapMotionStrength(value: number, threshold: number, peak: number) {
if (!Number.isFinite(value) || value <= threshold) {
return 0;
}
const span = Math.max(0.0001, peak - threshold);
return Math.min(1, (value - threshold) / span);
}
function squareEase(value: number) {
return value * value;
}
function resolveDirectionalKernelSize(blurStrength: number) {
if (blurStrength >= 5.5) {
return 13;
}
if (blurStrength >= 3) {
return 11;
}
if (blurStrength >= 1.25) {
return 7;
}
return 5;
}
function computeZoomBlurGeometry({
stageSize,
baseMask,
targetZoomScale,
focusX,
focusY,
}: {
stageSize: { width: number; height: number };
baseMask: { x: number; y: number; width: number; height: number };
targetZoomScale: number;
focusX: number;
focusY: number;
}) {
const safeZoomScale = Math.max(1, targetZoomScale);
const visibleWidth = stageSize.width / safeZoomScale;
const visibleHeight = stageSize.height / safeZoomScale;
const visibleHalfDiagonal = Math.hypot(visibleWidth / 2, visibleHeight / 2);
const focusStagePxX = baseMask.x + focusX * baseMask.width;
const focusStagePxY = baseMask.y + focusY * baseMask.height;
const outerRadius = Math.max(
Math.hypot(focusStagePxX, focusStagePxY),
Math.hypot(stageSize.width - focusStagePxX, focusStagePxY),
Math.hypot(focusStagePxX, stageSize.height - focusStagePxY),
Math.hypot(stageSize.width - focusStagePxX, stageSize.height - focusStagePxY),
);
const radius = Math.max(outerRadius, 1);
return {
centerX: focusStagePxX,
centerY: focusStagePxY,
innerRadius: Math.max(0, Math.min(Math.max(18, Math.min(radius - 1, visibleHalfDiagonal)), radius - 1)),
radius,
};
}
export function computeZoomTransform({
stageSize,
baseMask,
@@ -148,7 +247,7 @@ export function computeFocusFromTransform({
export function applyZoomTransform({
cameraContainer,
blurFilter,
zoomBlurFilter,
motionBlurFilter,
stageSize,
baseMask,
@@ -172,7 +271,7 @@ export function applyZoomTransform({
) {
cameraContainer.scale.set(1);
cameraContainer.position.set(0, 0);
resetMotionEffects(blurFilter, motionBlurFilter, motionBlurState);
resetMotionEffects(zoomBlurFilter, motionBlurFilter, motionBlurState);
return { scale: 1, x: 0, y: 0 };
}
@@ -203,52 +302,115 @@ export function applyZoomTransform({
motionBlurFilter.velocity = { x: 0, y: 0 };
motionBlurFilter.kernelSize = 5;
motionBlurFilter.offset = 0;
if (blurFilter) blurFilter.blur = 0;
if (zoomBlurFilter) {
zoomBlurFilter.strength = 0;
}
} else {
const dtMs = Math.min(80, Math.max(1, now - motionBlurState.lastFrameTimeMs));
const dtSeconds = dtMs / 1000;
motionBlurState.lastFrameTimeMs = now;
// Camera displacement this frame (stage-px)
const dx = transform.x - motionBlurState.prevCamX;
const dy = transform.y - motionBlurState.prevCamY;
const dScale = transform.scale - motionBlurState.prevCamScale;
const previousScale = Math.max(0.0001, motionBlurState.prevCamScale);
const scaleRatio = Math.max(0.0001, transform.scale) / previousScale;
const logScaleVelocity = Math.log(scaleRatio) / dtSeconds;
motionBlurState.prevCamX = transform.x;
motionBlurState.prevCamY = transform.y;
motionBlurState.prevCamScale = transform.scale;
// Velocity in px/s (translation + scale-change contribution)
const velocityX = dx / dtSeconds;
const velocityY = dy / dtSeconds;
const scaleVelocity =
Math.abs(dScale / dtSeconds) * Math.max(stageSize.width, stageSize.height) * 0.5;
const speed = Math.sqrt(velocityX * velocityX + velocityY * velocityY) + scaleVelocity;
const smoothingBlend = computeSmoothingBlend(dtSeconds, PAN_RESPONSE_PER_SECOND);
const zoomSmoothingBlend = computeSmoothingBlend(dtSeconds, ZOOM_RESPONSE_PER_SECOND);
const rawVelocityX = dx / dtSeconds;
const rawVelocityY = dy / dtSeconds;
motionBlurState.smoothedPanVelocityX = mixTowards(
motionBlurState.smoothedPanVelocityX,
rawVelocityX,
smoothingBlend,
);
motionBlurState.smoothedPanVelocityY = mixTowards(
motionBlurState.smoothedPanVelocityY,
rawVelocityY,
smoothingBlend,
);
motionBlurState.smoothedLogScaleVelocity = mixTowards(
motionBlurState.smoothedLogScaleVelocity,
logScaleVelocity,
zoomSmoothingBlend,
);
const normalised = Math.min(1, speed / PEAK_VELOCITY_PPS);
const targetBlur =
speed < VELOCITY_THRESHOLD_PPS
? 0
: normalised * normalised * MAX_BLUR_PX * motionBlurAmount;
const panVelocityX = motionBlurState.smoothedPanVelocityX;
const panVelocityY = motionBlurState.smoothedPanVelocityY;
const panSpeed = Math.hypot(panVelocityX, panVelocityY);
const panStrength = squareEase(
remapMotionStrength(
panSpeed,
PAN_VELOCITY_THRESHOLD_PPS,
PEAK_TRANSLATION_VELOCITY_PPS,
),
);
const zoomStrength = squareEase(
remapMotionStrength(
Math.abs(motionBlurState.smoothedLogScaleVelocity),
LOG_SCALE_VELOCITY_THRESHOLD,
PEAK_LOG_SCALE_VELOCITY_PER_SECOND,
),
);
const dirMag = Math.sqrt(velocityX * velocityX + velocityY * velocityY) || 1;
const velocityScale = targetBlur * 1.2;
const targetDirectionalBlur =
panStrength *
(1 - Math.min(0.82, zoomStrength * 0.85)) *
MAX_DIRECTIONAL_BLUR_PX *
motionBlurAmount;
const targetRadialBlur = zoomStrength * MAX_ZOOM_BLUR_STRENGTH * motionBlurAmount;
motionBlurState.smoothedDirectionalBlur = mixTowards(
motionBlurState.smoothedDirectionalBlur,
targetDirectionalBlur,
smoothingBlend,
);
motionBlurState.smoothedRadialBlur = mixTowards(
motionBlurState.smoothedRadialBlur,
targetRadialBlur,
zoomSmoothingBlend,
);
const directionalBlur = motionBlurState.smoothedDirectionalBlur;
const radialBlur = motionBlurState.smoothedRadialBlur;
const dirMag = Math.hypot(panVelocityX, panVelocityY) || 1;
const velocityScale = directionalBlur * 1.1;
motionBlurFilter.velocity =
targetBlur > 0
directionalBlur > 0.15
? {
x: (velocityX / dirMag) * velocityScale,
y: (velocityY / dirMag) * velocityScale,
x: (panVelocityX / dirMag) * velocityScale,
y: (panVelocityY / dirMag) * velocityScale,
}
: { x: 0, y: 0 };
motionBlurFilter.kernelSize = targetBlur > 4 ? 11 : targetBlur > 1.5 ? 9 : 5;
motionBlurFilter.offset = targetBlur > 0.5 ? -0.2 : 0;
motionBlurFilter.kernelSize = resolveDirectionalKernelSize(directionalBlur);
motionBlurFilter.offset = directionalBlur > 0.45 ? -0.12 : 0;
if (blurFilter) {
blurFilter.blur = 0;
if (zoomBlurFilter) {
const zoomBlurGeometry = computeZoomBlurGeometry({
stageSize,
baseMask,
targetZoomScale: Math.max(zoomScale, transform.scale, 1),
focusX,
focusY,
});
zoomBlurFilter.center = {
x: zoomBlurGeometry.centerX,
y: zoomBlurGeometry.centerY,
};
zoomBlurFilter.innerRadius = zoomBlurGeometry.innerRadius;
zoomBlurFilter.radius = zoomBlurGeometry.radius;
zoomBlurFilter.strength =
radialBlur * (motionBlurState.smoothedLogScaleVelocity >= 0 ? 0.88 : 1);
}
}
} else {
resetMotionEffects(blurFilter, motionBlurFilter, motionBlurState);
resetMotionEffects(zoomBlurFilter, motionBlurFilter, motionBlurState);
}
return {
+65 -122
View File
@@ -154,6 +154,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
const micFallbackRecorder = useRef<MediaRecorder | null>(null);
const micFallbackChunks = useRef<Blob[]>([]);
const micFallbackStartDelayMs = useRef<number | null>(null);
const hideEditorOverlayCursorByDefault = useRef(false);
const notifyRecordingFinalizationFailure = useCallback(async (message: string) => {
setFinalizing(false);
@@ -397,21 +398,27 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
const finalizeRecordingSession = useCallback(
async (videoPath: string, webcamPath: string | null) => {
const shouldHideOverlayCursor = hideEditorOverlayCursorByDefault.current;
try {
if (webcamPath) {
await window.electronAPI.setCurrentRecordingSession({
videoPath,
webcamPath,
timeOffsetMs: webcamTimeOffsetMs.current,
hideOverlayCursorByDefault: shouldHideOverlayCursor,
});
} else {
await window.electronAPI.setCurrentVideoPath(videoPath);
await window.electronAPI.setCurrentVideoPath(videoPath, {
hideOverlayCursorByDefault: shouldHideOverlayCursor,
});
}
} catch (error) {
console.error("Failed to persist recording session metadata:", error);
try {
await window.electronAPI.setCurrentVideoPath(videoPath);
await window.electronAPI.setCurrentVideoPath(videoPath, {
hideOverlayCursorByDefault: shouldHideOverlayCursor,
});
} catch (fallbackError) {
console.error("Failed to persist fallback video path:", fallbackError);
}
@@ -908,6 +915,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
try {
const platform = await window.electronAPI.getPlatform();
hideEditorOverlayCursorByDefault.current = false;
const existingSource = await window.electronAPI.getSelectedSource();
const selectedSource =
existingSource ?? (platform === "linux" ? LINUX_PORTAL_SOURCE : null);
@@ -995,6 +1003,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
);
if (!nativeResult.success) {
if (useNativeWindowsCapture) {
hideEditorOverlayCursorByDefault.current = true;
console.warn(
"Native Windows capture failed, falling back to browser capture:",
nativeResult.error ?? nativeResult.message,
@@ -1094,6 +1103,9 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
}
}
hideEditorOverlayCursorByDefault.current = true;
const wantsAudioCapture = microphoneEnabled || systemAudioEnabled;
const browserCaptureSource = await resolveBrowserCaptureSource(selectedSource);
if (
@@ -1114,6 +1126,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
let videoTrack: MediaStreamTrack | undefined;
let systemAudioIncluded = false;
const mediaDevices = navigator.mediaDevices as DesktopCaptureMediaDevices;
const useLinuxPortal = selectedSource.id === "screen:linux-portal";
const browserScreenVideoConstraints = {
mandatory: {
chromeMediaSource: CHROME_MEDIA_SOURCE,
@@ -1127,12 +1140,10 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
cursor: "never" as const,
};
const acquireLinuxPortalStream = async (withAudio: boolean): Promise<MediaStream> => {
try {
return await mediaDevices.getDisplayMedia({
if (wantsAudioCapture) {
let screenMediaStream: MediaStream;
const acquireLinuxPortalStream = (withAudio: boolean) =>
mediaDevices.getDisplayMedia({
audio: withAudio,
video: {
displaySurface: "monitor",
@@ -1144,43 +1155,6 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
selfBrowserSurface: "exclude",
surfaceSwitching: "exclude",
});
}
catch (err) {
console.warn("Linux portal failed, falling back to desktop capture(no audio):", err);
if (withAudio) {
alert("System audio is not supported in fallback mode. Recording will continue without audio.");
}
const sources = await window.electronAPI.getSources({ types: ["screen"] });
if (!sources.length) {
throw new Error("No screen sources available");
}
const source = sources[0];
console.log("Using fallback source:", source);
return await navigator.mediaDevices.getUserMedia({
audio: false, //intentional
video: {
mandatory: {
chromeMediaSource: "desktop",
chromeMediaSourceId: source.id,
maxWidth: TARGET_WIDTH,
maxHeight: TARGET_HEIGHT,
maxFrameRate: TARGET_FRAME_RATE,
},
},
} as any);
}
};
let screenMediaStream: MediaStream;
const useLinuxPortal = selectedSource.id === "screen:linux-portal";
if (systemAudioEnabled) {
try {
@@ -1285,10 +1259,31 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
} else if (micAudioTrack) {
stream.current.addTrack(micAudioTrack);
}
} else {
const mediaStream = useLinuxPortal
? await mediaDevices.getDisplayMedia({
audio: false,
video: {
displaySurface: selectedSource.id?.startsWith("window:")
? "window"
: "monitor",
width: { ideal: TARGET_WIDTH, max: TARGET_WIDTH },
height: { ideal: TARGET_HEIGHT, max: TARGET_HEIGHT },
frameRate: { ideal: TARGET_FRAME_RATE, max: TARGET_FRAME_RATE },
cursor: "never",
},
selfBrowserSurface: "exclude",
surfaceSwitching: "exclude",
})
: await mediaDevices.getUserMedia({
audio: false,
video: browserScreenVideoConstraints,
});
stream.current = mediaStream;
videoTrack = mediaStream.getVideoTracks()[0];
}
if (!stream.current || !videoTrack) {
throw new Error("Media stream is not available.");
}
@@ -1441,32 +1436,13 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
webcamRecorder.current.pause();
}
const boundaryMs = Date.now();
try {
await window.electronAPI.pauseCursorCapture(boundaryMs);
} catch (error) {
console.warn("Failed to pause cursor capture:", error);
try {
const rollbackResult =
await window.electronAPI.resumeNativeScreenRecording();
if (!rollbackResult.success) {
console.warn(
"Failed to roll back native pause after cursor pause failure:",
rollbackResult.error ?? rollbackResult.message,
);
}
} catch (rollbackError) {
console.warn(
"Failed to roll back native pause after cursor pause failure:",
rollbackError,
);
}
if (webcamRecorder.current?.state === "paused") {
webcamRecorder.current.resume();
}
return;
}
markRecordingPaused(boundaryMs);
setPaused(true);
try {
await window.electronAPI.pauseCursorCapture();
} catch (error) {
console.warn("Failed to pause cursor capture:", error);
}
})();
return;
}
@@ -1475,22 +1451,15 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
if (webcamRecorder.current?.state === "recording") {
webcamRecorder.current.pause();
}
const boundaryMs = Date.now();
void (async () => {
try {
await window.electronAPI.pauseCursorCapture(boundaryMs);
} catch (error) {
console.warn("Failed to pause cursor capture:", error);
if (mediaRecorder.current?.state === "paused") {
mediaRecorder.current.resume();
}
if (webcamRecorder.current?.state === "paused") {
webcamRecorder.current.resume();
}
return;
}
const boundaryMs = Date.now();
markRecordingPaused(boundaryMs);
setPaused(true);
try {
await window.electronAPI.pauseCursorCapture();
} catch (error) {
console.warn("Failed to pause cursor capture:", error);
}
})();
}
}, [markRecordingPaused, paused, recording]);
@@ -1512,32 +1481,13 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
webcamRecorder.current.resume();
}
const boundaryMs = Date.now();
try {
await window.electronAPI.resumeCursorCapture(boundaryMs);
} catch (error) {
console.warn("Failed to resume cursor capture:", error);
try {
const rollbackResult =
await window.electronAPI.pauseNativeScreenRecording();
if (!rollbackResult.success) {
console.warn(
"Failed to roll back native resume after cursor resume failure:",
rollbackResult.error ?? rollbackResult.message,
);
}
} catch (rollbackError) {
console.warn(
"Failed to roll back native resume after cursor resume failure:",
rollbackError,
);
}
if (webcamRecorder.current?.state === "recording") {
webcamRecorder.current.pause();
}
return;
}
markRecordingResumed(boundaryMs);
setPaused(false);
try {
await window.electronAPI.resumeCursorCapture();
} catch (error) {
console.warn("Failed to resume cursor capture:", error);
}
})();
return;
}
@@ -1546,22 +1496,15 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
if (webcamRecorder.current?.state === "paused") {
webcamRecorder.current.resume();
}
const boundaryMs = Date.now();
void (async () => {
try {
await window.electronAPI.resumeCursorCapture(boundaryMs);
} catch (error) {
console.warn("Failed to resume cursor capture:", error);
if (mediaRecorder.current?.state === "recording") {
mediaRecorder.current.pause();
}
if (webcamRecorder.current?.state === "recording") {
webcamRecorder.current.pause();
}
return;
}
const boundaryMs = Date.now();
markRecordingResumed(boundaryMs);
setPaused(false);
try {
await window.electronAPI.resumeCursorCapture();
} catch (error) {
console.warn("Failed to resume cursor capture:", error);
}
})();
}
}, [markRecordingResumed, paused, recording]);
+24 -4
View File
@@ -6,7 +6,7 @@
"modeAuto": "Auto",
"modeManual": "Manual",
"modeManualDescription": "Set a fixed focus point for this zoom",
"modeAutoDescription": "Camera follows cursor automatically"
"modeAutoDescription": "Camera recenters when the cursor nears the edge of the zoomed view"
},
"trim": {
"deleteRegion": "Delete Trim Region"
@@ -42,10 +42,15 @@
},
"backgroundBlur": "Background Blur",
"zoomMotionBlur": "Zoom Motion Blur",
"temporalZoomMotionBlur": "Temporal Zoom Blur",
"temporalZoomMotionBlurDescription": "Control the shutter window and frame samples used by the newer zoom blur pass.",
"zoomMotionBlurSamples": "Blur Samples",
"zoomMotionBlurShutter": "Shutter",
"auto": "Auto",
"connectZooms": "Connect Zooms",
"connectZoomsDescription": "Smooth consecutive zoom regions into a continuous camera move.",
"autoApplyFreshRecordingZooms": "Auto-apply fresh recording zooms",
"autoApplyFreshRecordingZoomsDescription": "Suggest cursor-follow zooms automatically when you open a new recording.",
"autoApplyFreshRecordingZoomsDescription": "Suggest edge-recentering zooms automatically when you open a new recording.",
"zoomGeneralTitle": "General",
"zoomGeneralDescription": "Global motion settings for every zoom transition.",
"zoomInTitle": "Zoom In",
@@ -54,6 +59,20 @@
"zoomOutDescription": "Control how the camera exits a zoom region.",
"connectedZoomTitle": "Between Zooms",
"connectedZoomDescription": "Tune the glide between consecutive zoom regions when connection is enabled.",
"motionPresetsTitle": "Motion Presets",
"motionPresetsZoomHint": "Zoom motion presets are available in Settings.",
"animationPresets": "Animation Presets",
"cursorMotionPresets": "Cursor Motion Presets",
"motionPresets": {
"focused": {
"label": "Focused",
"description": "Snappier motion for demos, walkthroughs, and everyday recordings."
},
"smooth": {
"label": "Smooth",
"description": "Gentler motion for presentations, keynote-style videos, and polished reveals."
}
},
"zoomInDuration": "Zoom In Duration",
"zoomInOverlap": "Zoom In Overlap",
"zoomOutDuration": "Zoom Out Duration",
@@ -71,6 +90,9 @@
},
"cursorSize": "Cursor Size",
"cursorSmoothing": "Cursor Smoothing",
"cursorSpringStiffness": "Cursor Spring Stiffness",
"cursorSpringDamping": "Cursor Spring Damping",
"cursorSpringMass": "Cursor Spring Mass",
"off": "Off",
"cursorMotionBlur": "Cursor Motion Blur",
"cursorClickBounce": "Cursor Click Bounce",
@@ -122,8 +144,6 @@
"generateFull": "Generate Captions",
"regenerateFull": "Regenerate Captions",
"clearFull": "Clear Captions",
"editCurrent": "Edit current caption",
"editSaved": "Caption updated",
"fontSettings": "Font Settings",
"defaultFont": "Default",
"fontFamily": "Font",
+17 -2
View File
@@ -54,6 +54,20 @@
"zoomOutDescription": "控制镜头离开缩放区域的方式。",
"connectedZoomTitle": "缩放之间",
"connectedZoomDescription": "在启用连接缩放时,调整连续缩放区域之间的滑移方式。",
"motionPresetsTitle": "运动预设",
"motionPresetsZoomHint": "缩放运动预设已移到“设置”中。",
"animationPresets": "动画预设",
"cursorMotionPresets": "光标运动预设",
"motionPresets": {
"focused": {
"label": "专注",
"description": "更利落,适合演示、讲解和日常录屏。"
},
"smooth": {
"label": "平滑",
"description": "更柔和,适合展示、发布会风格视频和更从容的镜头。"
}
},
"zoomInDuration": "进入时长",
"zoomInOverlap": "进入重叠",
"zoomOutDuration": "退出时长",
@@ -71,6 +85,9 @@
},
"cursorSize": "光标大小",
"cursorSmoothing": "光标平滑",
"cursorSpringStiffness": "光标弹簧刚度",
"cursorSpringDamping": "光标弹簧阻尼",
"cursorSpringMass": "光标弹簧质量",
"off": "关",
"cursorMotionBlur": "光标运动模糊",
"cursorClickBounce": "光标点击弹跳",
@@ -122,8 +139,6 @@
"generateFull": "生成字幕",
"regenerateFull": "重新生成字幕",
"clearFull": "清除字幕",
"editCurrent": "编辑当前字幕",
"editSaved": "字幕已更新",
"fontSettings": "字体设置",
"defaultFont": "默认",
"fontFamily": "字体",
+17
View File
@@ -36,6 +36,8 @@ export class ForwardFrameSource {
private heldFrame: VideoFrame | null = null;
private heldFrameSec = 0;
private lastTargetTimeSec = 0;
private lastFrameIntervalSec = 0;
private resolvedDecodedDurationSec: number | null = null;
private firstFrameTimestampUs: number | null = null;
private frameTimelineOffsetUs = 0;
@@ -297,6 +299,10 @@ export class ForwardFrameSource {
while (!this.cancelled) {
const nextFrame = await this.getNextFrame();
if (!nextFrame) {
this.resolvedDecodedDurationSec = Math.max(
this.heldFrameSec,
this.heldFrameSec + Math.max(0, this.lastFrameIntervalSec),
);
return new VideoFrame(this.heldFrame, {
timestamp: this.heldFrame.timestamp,
});
@@ -320,6 +326,7 @@ export class ForwardFrameSource {
});
}
this.lastFrameIntervalSec = Math.max(0, nextFrameSec - this.heldFrameSec);
this.heldFrame.close();
this.heldFrame = nextFrame;
this.heldFrameSec = nextFrameSec;
@@ -328,6 +335,14 @@ export class ForwardFrameSource {
return null;
}
getResolvedDurationSec(): number | null {
return this.resolvedDecodedDurationSec;
}
hasReachedEndOfStream(): boolean {
return this.decodeDone && this.pendingFrames.length === 0;
}
cancel(): void {
this.cancelled = true;
if (this.frameResolve) {
@@ -397,6 +412,8 @@ export class ForwardFrameSource {
this.decodeDone = false;
this.decodeError = null;
this.lastTargetTimeSec = 0;
this.lastFrameIntervalSec = 0;
this.resolvedDecodedDurationSec = null;
this.firstFrameTimestampUs = null;
this.frameTimelineOffsetUs = 0;
}
+6 -1
View File
@@ -86,6 +86,7 @@ type MockContext = {
closePath: MockFunction;
clip: MockFunction;
drawImage: MockFunction;
fillRect: MockFunction;
save: MockFunction;
restore: MockFunction;
translate: MockFunction;
@@ -214,6 +215,7 @@ function createMockContext() {
closePath: vi.fn(),
clip: vi.fn(),
drawImage: vi.fn(),
fillRect: vi.fn(),
save: vi.fn(),
restore: vi.fn(),
translate: vi.fn(),
@@ -273,6 +275,9 @@ describe("FrameRenderer webcam export path", () => {
},
document: {
createElement: vi.fn((tag: string) => {
if (tag === "video") {
return new FakeVideoElement();
}
if (tag !== "canvas") {
throw new Error(`Unexpected element requested in test: ${tag}`);
}
@@ -443,7 +448,7 @@ describe("FrameRenderer webcam export path", () => {
expect(createdCanvases).toHaveLength(2);
});
it("prefers decoder-backed video wallpapers during export", async () => {
it("prefers decoder-backed sync for video wallpapers during export", async () => {
const renderer = new FrameRenderer({
width: 1920,
height: 1080,
+460 -26
View File
@@ -1,5 +1,6 @@
import { Application, BlurFilter, Container, Graphics, Sprite, Texture } from "pixi.js";
import { Application, Container, Graphics, Rectangle, Sprite, Texture } from "pixi.js";
import { MotionBlurFilter } from "pixi-filters/motion-blur";
import { ZoomBlurFilter } from "pixi-filters/zoom-blur";
import type {
AnnotationRegion,
AutoCaptionSettings,
@@ -65,12 +66,19 @@ import {
} from "@/lib/extensions/renderHooks";
import { applyCanvasSceneTransform } from "@/lib/extensions/sceneTransform";
import { drawSquircleOnCanvas, drawSquircleOnGraphics } from "@/lib/geometry/squircle";
import { clampMediaTimeToDuration } from "@/lib/mediaTiming";
import {
clampMediaTimeToDuration,
getEffectiveVideoStreamDurationSeconds,
} from "@/lib/mediaTiming";
import { isVideoWallpaperSource } from "@/lib/wallpapers";
import { renderAnnotations } from "./annotationRenderer";
import { renderCaptions } from "./captionRenderer";
import { ForwardFrameSource } from "./forwardFrameSource";
import { resolveMediaElementSource } from "./localMediaSource";
import {
buildTemporalSamplePlanUs,
getTemporalMotionBlurConfig,
} from "./temporalMotionBlur";
interface FrameRenderConfig {
width: number;
@@ -82,6 +90,9 @@ interface FrameRenderConfig {
shadowIntensity: number;
backgroundBlur: number;
zoomMotionBlur?: number;
zoomTemporalMotionBlur?: number;
zoomMotionBlurSampleCount?: number | null;
zoomMotionBlurShutterFraction?: number | null;
connectZooms?: boolean;
zoomInDurationMs?: number;
zoomInOverlapMs?: number;
@@ -109,6 +120,9 @@ interface FrameRenderConfig {
cursorStyle?: CursorStyle;
cursorSize?: number;
cursorSmoothing?: number;
cursorSpringStiffnessMultiplier?: number;
cursorSpringDampingMultiplier?: number;
cursorSpringMassMultiplier?: number;
zoomSmoothness?: number;
zoomClassicMode?: boolean;
cursorMotionBlur?: number;
@@ -149,6 +163,14 @@ interface LayoutCache {
};
}
interface RenderSnapshot {
timeMs: number;
cursorTimeMs: number;
smoothedCursor: ReturnType<typeof mapSmoothedCursorToCanvasNormalized>;
sceneTransform: { scale: number; x: number; y: number };
zoom: { scale: number; focusX: number; focusY: number; progress: number };
}
function createAnimationState(): AnimationState {
return {
scale: 1,
@@ -185,17 +207,22 @@ export class FrameRenderer {
private videoTextureSource: VideoTextureSource | null = null;
private backgroundSprite: HTMLCanvasElement | null = null;
private maskGraphics: Graphics | null = null;
private blurFilter: BlurFilter | null = null;
private zoomBlurFilter: ZoomBlurFilter | null = null;
private motionBlurFilter: MotionBlurFilter | null = null;
private shadowCanvas: HTMLCanvasElement | null = null;
private shadowCtx: CanvasRenderingContext2D | null = null;
private compositeCanvas: HTMLCanvasElement | null = null;
private compositeCtx: CanvasRenderingContext2D | null = null;
private temporalAccumulationCanvas: HTMLCanvasElement | null = null;
private temporalAccumulationCtx: CanvasRenderingContext2D | null = null;
private backgroundForwardFrameSource: ForwardFrameSource | null = null;
private backgroundForwardFrameSourceUrl: string | null = null;
private backgroundForwardFrameDurationSec: number | null = null;
private backgroundDecodedFrame: VideoFrame | null = null;
private backgroundVideoElement: HTMLVideoElement | null = null;
private backgroundCtx: CanvasRenderingContext2D | null = null;
private backgroundSeekPromise: Promise<void> | null = null;
private lastSyncedBackgroundLoopTimeSec: number | null = null;
private cleanupBackgroundSource: (() => void) | null = null;
private config: FrameRenderConfig;
private animationState: AnimationState;
@@ -294,6 +321,11 @@ export class FrameRenderer {
style: this.config.cursorStyle ?? "tahoe",
smoothingFactor:
this.config.cursorSmoothing ?? DEFAULT_CURSOR_CONFIG.smoothingFactor,
springTuning: {
stiffnessMultiplier: this.config.cursorSpringStiffnessMultiplier,
dampingMultiplier: this.config.cursorSpringDampingMultiplier,
massMultiplier: this.config.cursorSpringMassMultiplier,
},
motionBlur: this.config.cursorMotionBlur ?? 0,
clickBounce: this.config.cursorClickBounce ?? DEFAULT_CURSOR_CONFIG.clickBounce,
clickBounceDuration:
@@ -308,13 +340,19 @@ export class FrameRenderer {
await this.setupWebcamSource();
await this.setupFrame();
// Setup blur filter for video container
this.blurFilter = new BlurFilter();
this.blurFilter.quality = 5;
this.blurFilter.resolution = this.app.renderer.resolution;
this.blurFilter.blur = 0;
this.motionBlurFilter = new MotionBlurFilter([0, 0], 5, 0);
this.videoContainer.filters = [this.blurFilter, this.motionBlurFilter];
if ((this.config.zoomMotionBlur ?? 0) > 0) {
this.zoomBlurFilter = new ZoomBlurFilter({ strength: 0 });
this.motionBlurFilter = new MotionBlurFilter([0, 0], 5, 0);
this.videoContainer.filterArea = new Rectangle(
0,
0,
this.config.width,
this.config.height,
);
this.videoContainer.filters = [this.zoomBlurFilter, this.motionBlurFilter];
} else {
this.videoContainer.filters = null;
}
// Setup composite canvas for final output with shadows
this.compositeCanvas = document.createElement("canvas");
@@ -330,6 +368,19 @@ export class FrameRenderer {
throw new Error("Failed to get 2D context for composite canvas");
}
this.temporalAccumulationCanvas = document.createElement("canvas");
this.temporalAccumulationCanvas.width = this.config.width;
this.temporalAccumulationCanvas.height = this.config.height;
this.temporalAccumulationCtx = configureHighQuality2DContext(
this.temporalAccumulationCanvas.getContext("2d", {
willReadFrequently: true,
}),
);
if (!this.temporalAccumulationCtx) {
throw new Error("Failed to get 2D context for temporal accumulation canvas");
}
// Setup shadow canvas if needed
if (this.config.showShadow) {
this.shadowCanvas = document.createElement("canvas");
@@ -372,9 +423,12 @@ export class FrameRenderer {
this.backgroundForwardFrameSource?.cancel();
void this.backgroundForwardFrameSource?.destroy();
this.backgroundForwardFrameSource = null;
this.backgroundForwardFrameSourceUrl = null;
this.backgroundForwardFrameDurationSec = null;
this.closeBackgroundDecodedFrame();
this.cleanupBackgroundSource?.();
this.cleanupBackgroundSource = null;
this.lastSyncedBackgroundLoopTimeSec = null;
if (this.backgroundVideoElement) {
this.backgroundVideoElement.pause();
this.backgroundVideoElement.src = "";
@@ -393,8 +447,18 @@ export class FrameRenderer {
try {
const frameSource = new ForwardFrameSource();
await frameSource.initialize(videoSrc);
const metadata = await frameSource.initialize(videoSrc);
this.backgroundForwardFrameSource = frameSource;
this.backgroundForwardFrameSourceUrl = videoSrc;
this.backgroundForwardFrameDurationSec = getEffectiveVideoStreamDurationSeconds(
{
duration: metadata?.duration,
streamDuration: metadata?.streamDuration,
},
);
this.backgroundVideoElement = null;
this.backgroundSeekPromise = null;
this.lastSyncedBackgroundLoopTimeSec = null;
this.backgroundSprite = bgCanvas;
return;
} catch (error) {
@@ -440,6 +504,7 @@ export class FrameRenderer {
});
this.backgroundVideoElement = video;
this.lastSyncedBackgroundLoopTimeSec = null;
this.drawVideoFrameToBackground();
this.backgroundSprite = bgCanvas;
return;
@@ -611,14 +676,95 @@ export class FrameRenderer {
this.backgroundDecodedFrame = null;
}
private async restartBackgroundForwardFrameSource(): Promise<void> {
const sourceUrl = this.backgroundForwardFrameSourceUrl;
if (!sourceUrl) {
return;
}
const nextSource = new ForwardFrameSource();
const metadata = await nextSource.initialize(sourceUrl);
const previousSource = this.backgroundForwardFrameSource;
this.backgroundForwardFrameSource = nextSource;
const effectiveDuration = getEffectiveVideoStreamDurationSeconds({
duration: metadata?.duration,
streamDuration: metadata?.streamDuration,
});
this.backgroundForwardFrameDurationSec =
Number.isFinite(effectiveDuration) && effectiveDuration > 0 ? effectiveDuration : null;
this.lastSyncedBackgroundLoopTimeSec = null;
previousSource?.cancel();
void previousSource?.destroy();
}
private async syncBackgroundFrame(timeSeconds: number): Promise<void> {
if (this.backgroundForwardFrameSource) {
const decodedFrame = await this.backgroundForwardFrameSource.getFrameAtTime(
Math.max(0, timeSeconds),
);
const duration = this.backgroundForwardFrameDurationSec;
const shouldLoop = Number.isFinite(duration) && (duration ?? 0) > 0;
let normalizedTargetTime = shouldLoop
? ((timeSeconds % duration!) + duration!) % duration!
: Math.max(0, timeSeconds);
if (
shouldLoop &&
this.lastSyncedBackgroundLoopTimeSec !== null &&
normalizedTargetTime + 0.001 < this.lastSyncedBackgroundLoopTimeSec
) {
try {
await this.restartBackgroundForwardFrameSource();
} catch (error) {
console.warn(
"[FrameRenderer] Unable to restart looping video wallpaper decoder during export:",
error,
);
}
}
const decodedFrame =
await this.backgroundForwardFrameSource.getFrameAtTime(normalizedTargetTime);
const resolvedDecodedDuration =
this.backgroundForwardFrameSource.getResolvedDurationSec();
if (
shouldLoop &&
this.backgroundForwardFrameSource.hasReachedEndOfStream() &&
Number.isFinite(resolvedDecodedDuration) &&
(resolvedDecodedDuration ?? 0) > 0 &&
normalizedTargetTime > (resolvedDecodedDuration ?? 0) + 0.001
) {
this.backgroundForwardFrameDurationSec = resolvedDecodedDuration ?? null;
this.closeBackgroundDecodedFrame();
try {
await this.restartBackgroundForwardFrameSource();
normalizedTargetTime =
((timeSeconds % resolvedDecodedDuration!) + resolvedDecodedDuration!) %
resolvedDecodedDuration!;
const restartedFrame =
await this.backgroundForwardFrameSource.getFrameAtTime(
normalizedTargetTime,
);
this.backgroundDecodedFrame = restartedFrame;
if (restartedFrame) {
this.lastSyncedBackgroundLoopTimeSec = normalizedTargetTime;
this.drawBackgroundSourceToCanvas(
restartedFrame,
restartedFrame.displayWidth,
restartedFrame.displayHeight,
);
}
return;
} catch (error) {
console.warn(
"[FrameRenderer] Unable to wrap looping video wallpaper at decoded EOF during export:",
error,
);
}
}
this.closeBackgroundDecodedFrame();
this.backgroundDecodedFrame = decodedFrame;
if (decodedFrame) {
this.lastSyncedBackgroundLoopTimeSec = normalizedTargetTime;
this.drawBackgroundSourceToCanvas(
decodedFrame,
decodedFrame.displayWidth,
@@ -1093,6 +1239,8 @@ export class FrameRenderer {
videoFrame: VideoFrame,
timestamp: number,
cursorTimestamp = timestamp,
frameDurationUs?: number,
backgroundTimelineTimestamp = timestamp,
): Promise<void> {
if (!this.app || !this.videoContainer || !this.cameraContainer) {
throw new Error("Renderer not initialized");
@@ -1100,16 +1248,6 @@ export class FrameRenderer {
this.currentVideoTime = timestamp / 1000000;
if (this.webcamForwardFrameSource || this.webcamVideoElement) {
const targetTime = Math.max(0, this.currentVideoTime);
await this.syncWebcamFrame(targetTime);
}
// Sync video wallpaper frame
if (this.backgroundForwardFrameSource || this.backgroundVideoElement) {
await this.syncBackgroundFrame(this.currentVideoTime);
}
// Create or update video sprite from VideoFrame
if (!this.videoSprite) {
const texture = Texture.from(videoFrame as unknown as PixiTextureInput);
@@ -1136,6 +1274,140 @@ export class FrameRenderer {
return;
}
const temporalSnapshot =
typeof frameDurationUs === "number" && frameDurationUs > 0
? await this.renderTemporalMotionBlurFrame(
timestamp,
cursorTimestamp,
backgroundTimelineTimestamp,
frameDurationUs,
layoutCache,
)
: null;
if (temporalSnapshot) {
extensionHost.setSmoothedCursor(
temporalSnapshot.smoothedCursor
? {
timeMs: temporalSnapshot.timeMs,
cx: temporalSnapshot.smoothedCursor.cx,
cy: temporalSnapshot.smoothedCursor.cy,
trail: temporalSnapshot.smoothedCursor.trail,
}
: null,
);
this.drawFrame();
if (
this.config.annotationRegions &&
this.config.annotationRegions.length > 0 &&
this.compositeCtx
) {
const scaleX = this.config.width / BASE_PREVIEW_WIDTH;
const scaleY = this.config.height / BASE_PREVIEW_HEIGHT;
const scaleFactor = (scaleX + scaleY) / 2;
await renderAnnotations(
this.compositeCtx,
this.config.annotationRegions,
this.config.width,
this.config.height,
temporalSnapshot.timeMs,
scaleFactor,
);
}
if (
this.config.autoCaptions &&
this.config.autoCaptions.length > 0 &&
this.config.autoCaptionSettings &&
this.compositeCtx
) {
renderCaptions(
this.compositeCtx,
this.config.autoCaptions,
this.config.autoCaptionSettings,
this.config.width,
this.config.height,
temporalSnapshot.timeMs,
);
}
if (this.compositeCtx) {
const maskRect = this.layoutCache?.maskRect;
const hookParams = {
width: this.config.width,
height: this.config.height,
timeMs: temporalSnapshot.timeMs,
durationMs: 0,
cursor: temporalSnapshot.smoothedCursor
? {
cx: temporalSnapshot.smoothedCursor.cx,
cy: temporalSnapshot.smoothedCursor.cy,
interactionType: this.getCursorPosition(
temporalSnapshot.cursorTimeMs,
)?.interactionType,
}
: this.getCursorPosition(temporalSnapshot.cursorTimeMs),
smoothedCursor: temporalSnapshot.smoothedCursor,
videoLayout: maskRect
? {
maskRect: {
x: maskRect.x,
y: maskRect.y,
width: maskRect.width,
height: maskRect.height,
},
borderRadius: this.config.borderRadius ?? 0,
padding: this.config.padding ?? 0,
}
: undefined,
zoom: temporalSnapshot.zoom,
shadow: {
enabled: this.config.showShadow,
intensity: this.config.shadowIntensity,
},
sceneTransform: temporalSnapshot.sceneTransform,
};
this.compositeCtx.save();
applyCanvasSceneTransform(this.compositeCtx, temporalSnapshot.sceneTransform);
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,
this.config.width,
this.config.height,
{
zoom: hookParams.zoom,
sceneTransform: hookParams.sceneTransform,
videoLayout: hookParams.videoLayout,
},
);
this.compositeCtx.restore();
executeExtensionRenderHooks("post-webcam", this.compositeCtx, hookParams);
executeExtensionRenderHooks("post-annotations", this.compositeCtx, hookParams);
executeExtensionRenderHooks("final", this.compositeCtx, hookParams);
}
return;
}
if (this.webcamForwardFrameSource || this.webcamVideoElement) {
const targetTime = Math.max(0, this.currentVideoTime);
await this.syncWebcamFrame(targetTime);
}
// Sync video wallpaper frame
if (this.backgroundForwardFrameSource || this.backgroundVideoElement) {
await this.syncBackgroundFrame(Math.max(0, backgroundTimelineTimestamp / 1_000_000));
}
const timeMs = this.currentVideoTime * 1000;
const cursorTimeMs = cursorTimestamp / 1000;
@@ -1179,7 +1451,7 @@ export class FrameRenderer {
// Apply transform once with maximum motion intensity from all ticks
applyZoomTransform({
cameraContainer: this.cameraContainer,
blurFilter: this.blurFilter,
zoomBlurFilter: this.zoomBlurFilter,
motionBlurFilter: this.motionBlurFilter,
stageSize: layoutCache.stageSize,
baseMask: layoutCache.maskRect,
@@ -1462,6 +1734,8 @@ export class FrameRenderer {
timeMs,
{
connectZooms: this.config.connectZooms,
zoomInDurationMs: this.config.zoomInDurationMs,
zoomOutDurationMs: this.config.zoomOutDurationMs,
},
);
@@ -1602,6 +1876,159 @@ export class FrameRenderer {
);
}
private async renderSceneSample(
timestamp: number,
cursorTimestamp: number,
backgroundTimelineTimestamp: number,
layoutCache: LayoutCache,
useVelocityMotionBlur: boolean,
): Promise<RenderSnapshot> {
if (!this.app || !this.cameraContainer) {
throw new Error("Renderer not initialized");
}
this.currentVideoTime = timestamp / 1_000_000;
if (this.webcamForwardFrameSource || this.webcamVideoElement) {
await this.syncWebcamFrame(Math.max(0, this.currentVideoTime));
}
if (this.backgroundForwardFrameSource || this.backgroundVideoElement) {
await this.syncBackgroundFrame(Math.max(0, backgroundTimelineTimestamp / 1_000_000));
}
const timeMs = this.currentVideoTime * 1000;
const cursorTimeMs = cursorTimestamp / 1000;
if (this.cursorOverlay) {
this.cursorOverlay.update(
this.config.cursorTelemetry ?? [],
cursorTimeMs,
layoutCache.maskRect,
this.config.showCursor ?? true,
false,
);
}
const smoothedCursor = mapSmoothedCursorToCanvasNormalized(
this.cursorOverlay?.getSmoothedCursorSnapshot() ?? null,
{
maskRect: layoutCache.maskRect,
canvasWidth: this.config.width,
canvasHeight: this.config.height,
},
);
const motionIntensity = this.updateAnimationState(timeMs);
applyZoomTransform({
cameraContainer: this.cameraContainer,
zoomBlurFilter: this.zoomBlurFilter,
motionBlurFilter: this.motionBlurFilter,
stageSize: layoutCache.stageSize,
baseMask: layoutCache.maskRect,
zoomScale: this.animationState.scale,
zoomProgress: this.animationState.progress,
focusX: this.animationState.focusX,
focusY: this.animationState.focusY,
motionIntensity,
motionVector: this.lastMotionVector,
isPlaying: true,
motionBlurAmount: useVelocityMotionBlur ? (this.config.zoomMotionBlur ?? 0) : 0,
transformOverride: {
scale: this.animationState.appliedScale,
x: this.animationState.x,
y: this.animationState.y,
},
motionBlurState: this.motionBlurState,
frameTimeMs: timeMs,
});
this.app.renderer.render(this.app.stage);
this.compositeWithShadows();
return {
timeMs,
cursorTimeMs,
smoothedCursor,
sceneTransform: {
scale: this.animationState.appliedScale,
x: this.animationState.x,
y: this.animationState.y,
},
zoom: {
scale: this.animationState.scale,
focusX: this.animationState.focusX,
focusY: this.animationState.focusY,
progress: this.animationState.progress,
},
};
}
private async renderTemporalMotionBlurFrame(
timestamp: number,
cursorTimestamp: number,
backgroundTimelineTimestamp: number,
frameDurationUs: number,
layoutCache: LayoutCache,
): Promise<RenderSnapshot | null> {
if (
!this.compositeCanvas ||
!this.compositeCtx ||
!this.temporalAccumulationCtx
) {
return null;
}
const blurConfig = getTemporalMotionBlurConfig(
this.config.zoomTemporalMotionBlur ?? this.config.zoomMotionBlur,
{
sampleCount: this.config.zoomMotionBlurSampleCount,
shutterFraction: this.config.zoomMotionBlurShutterFraction,
},
);
if (!blurConfig) {
return null;
}
const samplePlan = buildTemporalSamplePlanUs(frameDurationUs, blurConfig);
this.temporalAccumulationCtx.clearRect(0, 0, this.config.width, this.config.height);
let centerSnapshot: RenderSnapshot | null = null;
let lastSnapshot: RenderSnapshot | null = null;
for (const { offsetUs: sampleOffsetUs, weight } of samplePlan) {
const sampleTimestamp = Math.max(0, timestamp + sampleOffsetUs);
const sampleCursorTimestamp = Math.max(0, cursorTimestamp + sampleOffsetUs);
const sampleBackgroundTimelineTimestamp = Math.max(
0,
backgroundTimelineTimestamp + sampleOffsetUs,
);
const snapshot = await this.renderSceneSample(
sampleTimestamp,
sampleCursorTimestamp,
sampleBackgroundTimelineTimestamp,
layoutCache,
false,
);
lastSnapshot = snapshot;
if (Math.abs(sampleOffsetUs) < 0.0001) {
centerSnapshot = snapshot;
}
this.temporalAccumulationCtx.save();
this.temporalAccumulationCtx.globalCompositeOperation = "lighter";
this.temporalAccumulationCtx.globalAlpha = weight;
this.temporalAccumulationCtx.drawImage(this.compositeCanvas, 0, 0);
this.temporalAccumulationCtx.restore();
}
this.compositeCtx.clearRect(0, 0, this.config.width, this.config.height);
this.compositeCtx.drawImage(this.temporalAccumulationCanvas!, 0, 0);
return centerSnapshot ?? lastSnapshot;
}
private compositeWithShadows(): void {
if (!this.compositeCanvas || !this.compositeCtx || !this.app) return;
@@ -1933,10 +2360,12 @@ export class FrameRenderer {
});
this.app = null;
}
this.zoomBlurFilter?.destroy();
this.motionBlurFilter?.destroy();
this.cameraContainer = null;
this.videoContainer = null;
this.maskGraphics = null;
this.blurFilter = null;
this.zoomBlurFilter = null;
this.motionBlurFilter = null;
if (this.cursorOverlay) {
this.cursorOverlay.destroy();
@@ -1946,11 +2375,16 @@ export class FrameRenderer {
this.shadowCtx = null;
this.compositeCanvas = null;
this.compositeCtx = null;
this.temporalAccumulationCanvas = null;
this.temporalAccumulationCtx = null;
this.backgroundCtx = null;
this.closeBackgroundDecodedFrame();
this.backgroundForwardFrameSource?.cancel();
void this.backgroundForwardFrameSource?.destroy();
this.backgroundForwardFrameSource = null;
this.backgroundForwardFrameSourceUrl = null;
this.backgroundForwardFrameDurationSec = null;
this.lastSyncedBackgroundLoopTimeSec = null;
if (this.backgroundVideoElement) {
this.backgroundVideoElement.pause();
this.backgroundVideoElement.src = "";
+15
View File
@@ -42,6 +42,9 @@ interface GifExporterConfig {
shadowIntensity: number;
backgroundBlur: number;
zoomMotionBlur?: number;
zoomTemporalMotionBlur?: number;
zoomMotionBlurSampleCount?: number | null;
zoomMotionBlurShutterFraction?: number | null;
connectZooms?: boolean;
zoomInDurationMs?: number;
zoomInOverlapMs?: number;
@@ -65,6 +68,9 @@ interface GifExporterConfig {
cursorStyle?: CursorStyle;
cursorSize?: number;
cursorSmoothing?: number;
cursorSpringStiffnessMultiplier?: number;
cursorSpringDampingMultiplier?: number;
cursorSpringMassMultiplier?: number;
zoomSmoothness?: number;
zoomClassicMode?: boolean;
cursorMotionBlur?: number;
@@ -158,6 +164,9 @@ export class GifExporter {
shadowIntensity: this.config.shadowIntensity,
backgroundBlur: this.config.backgroundBlur,
zoomMotionBlur: this.config.zoomMotionBlur,
zoomTemporalMotionBlur: this.config.zoomTemporalMotionBlur,
zoomMotionBlurSampleCount: this.config.zoomMotionBlurSampleCount,
zoomMotionBlurShutterFraction: this.config.zoomMotionBlurShutterFraction,
connectZooms: this.config.connectZooms,
zoomInDurationMs: this.config.zoomInDurationMs,
zoomInOverlapMs: this.config.zoomInOverlapMs,
@@ -185,6 +194,9 @@ export class GifExporter {
cursorStyle: this.config.cursorStyle,
cursorSize: this.config.cursorSize,
cursorSmoothing: this.config.cursorSmoothing,
cursorSpringStiffnessMultiplier: this.config.cursorSpringStiffnessMultiplier,
cursorSpringDampingMultiplier: this.config.cursorSpringDampingMultiplier,
cursorSpringMassMultiplier: this.config.cursorSpringMassMultiplier,
zoomSmoothness: this.config.zoomSmoothness,
zoomClassicMode: this.config.zoomClassicMode,
cursorMotionBlur: this.config.cursorMotionBlur,
@@ -232,6 +244,7 @@ export class GifExporter {
console.log("[GifExporter] Using streaming decode (web-demuxer + VideoDecoder)");
let frameIndex = 0;
const frameDurationUs = 1_000_000 / this.config.frameRate;
// Stream decode and process frames — no seeking!
await this.streamingDecoder.decodeAll(
@@ -250,6 +263,8 @@ export class GifExporter {
videoFrame,
sourceTimestampUs,
cursorTimestampUs,
frameDurationUs,
frameIndex * frameDurationUs,
);
videoFrame.close();
+23 -1
View File
@@ -103,6 +103,7 @@ function createMockContext() {
return {
clearRect: vi.fn(),
drawImage: vi.fn(),
fillRect: vi.fn(),
save: vi.fn(),
restore: vi.fn(),
getImageData: vi.fn(() => ({ data: new Uint8ClampedArray(0) })),
@@ -170,8 +171,29 @@ describe("ModernFrameRenderer blur export path", () => {
beforeEach(() => {
Object.assign(globalThis, {
window: globalThis,
HTMLMediaElement: {
HAVE_CURRENT_DATA: 2,
},
document: {
createElement: vi.fn((tag: string) => {
if (tag === "video") {
return {
duration: 5,
readyState: 2,
videoWidth: 1280,
videoHeight: 720,
muted: true,
loop: true,
playsInline: true,
preload: "auto",
src: "",
currentTime: 0,
load: vi.fn(),
pause: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
};
}
if (tag !== "canvas") {
throw new Error(`Unexpected element requested in test: ${tag}`);
}
@@ -197,7 +219,7 @@ describe("ModernFrameRenderer blur export path", () => {
expect(renderer.capturePixelsForNativeExport()).not.toBeNull();
});
it("prefers decoder-backed video wallpapers during export", async () => {
it("prefers decoder-backed sync for video wallpapers during export", async () => {
const renderer = new FrameRenderer({
width: 1920,
height: 1080,
+459 -28
View File
@@ -1,5 +1,6 @@
import { Application, BlurFilter, Container, Graphics, Sprite, Texture } from "pixi.js";
import { Application, BlurFilter, Container, Graphics, Rectangle, Sprite, Texture } from "pixi.js";
import { MotionBlurFilter } from "pixi-filters/motion-blur";
import { ZoomBlurFilter } from "pixi-filters/zoom-blur";
import { buildActiveCaptionLayout } from "@/components/video-editor/captionLayout";
import {
CAPTION_FONT_WEIGHT,
@@ -72,7 +73,10 @@ import {
} from "@/lib/extensions/renderHooks";
import { applyCanvasSceneTransform } from "@/lib/extensions/sceneTransform";
import { drawSquircleOnCanvas, drawSquircleOnGraphics } from "@/lib/geometry/squircle";
import { clampMediaTimeToDuration } from "@/lib/mediaTiming";
import {
clampMediaTimeToDuration,
getEffectiveVideoStreamDurationSeconds,
} from "@/lib/mediaTiming";
import { isVideoWallpaperSource } from "@/lib/wallpapers";
import {
type AnnotationRenderAssets,
@@ -87,6 +91,7 @@ import {
VIDEO_SHADOW_LAYER_PROFILES,
WEBCAM_SHADOW_LAYER_PROFILES,
} from "./shadowProfile";
import { buildTemporalSamplePlanUs, getTemporalMotionBlurConfig } from "./temporalMotionBlur";
import type { ExportRenderBackend } from "./types";
interface FrameRenderConfig {
@@ -99,6 +104,9 @@ interface FrameRenderConfig {
shadowIntensity: number;
backgroundBlur: number;
zoomMotionBlur?: number;
zoomTemporalMotionBlur?: number;
zoomMotionBlurSampleCount?: number | null;
zoomMotionBlurShutterFraction?: number | null;
connectZooms?: boolean;
zoomInDurationMs?: number;
zoomInOverlapMs?: number;
@@ -126,6 +134,9 @@ interface FrameRenderConfig {
cursorStyle?: CursorStyle;
cursorSize?: number;
cursorSmoothing?: number;
cursorSpringStiffnessMultiplier?: number;
cursorSpringDampingMultiplier?: number;
cursorSpringMassMultiplier?: number;
cursorMotionBlur?: number;
cursorClickBounce?: number;
cursorClickBounceDuration?: number;
@@ -219,6 +230,14 @@ interface CaptionRenderState {
centerY: number;
}
interface RenderSnapshot {
timeMs: number;
cursorTimeMs: number;
backgroundTimelineTimeMs: number;
sceneTransform: { scale: number; x: number; y: number };
zoom: { scale: number; focusX: number; focusY: number; progress: number };
}
function createAnimationState(): AnimationState {
return {
scale: 1,
@@ -325,17 +344,20 @@ export class FrameRenderer {
private backgroundTextureSource: MutableVideoTextureSource | null = null;
private videoMaskGraphics: Graphics | null = null;
private webcamMaskGraphics: Graphics | null = null;
private blurFilter: BlurFilter | null = null;
private zoomBlurFilter: ZoomBlurFilter | null = null;
private motionBlurFilter: MotionBlurFilter | null = null;
private backgroundBlurFilter: BlurFilter | null = null;
private annotationAssets: AnnotationRenderAssets | null = null;
private annotationScaleFactor = 1;
private annotationSprites: AnnotationSpriteEntry[] = [];
private backgroundForwardFrameSource: ForwardFrameSource | null = null;
private backgroundForwardFrameSourceUrl: string | null = null;
private backgroundForwardFrameDurationSec: number | null = null;
private backgroundDecodedFrame: VideoFrame | null = null;
private backgroundVideoElement: HTMLVideoElement | null = null;
private backgroundSeekPromise: Promise<void> | null = null;
private cleanupBackgroundSource: (() => void) | null = null;
private lastSyncedBackgroundLoopTimeSec: number | null = null;
private videoShadowLayers: ShadowLayer[] = [];
private webcamShadowLayers: ShadowLayer[] = [];
private webcamSprite: Sprite | null = null;
@@ -360,6 +382,7 @@ export class FrameRenderer {
private captionTextureSource: MutableVideoTextureSource | null = null;
private captionRenderKey: string | null = null;
private exportCompositeCanvas: ExportCompositeCanvasState | null = null;
private temporalCompositeCanvas: ExportCompositeCanvasState | null = null;
private outputCanvasOverride: HTMLCanvasElement | null = null;
private config: FrameRenderConfig;
private animationState: AnimationState;
@@ -404,8 +427,8 @@ export class FrameRenderer {
}
const activeFilters =
this.shouldUseZoomMotionBlur() && this.motionBlurFilter
? [this.motionBlurFilter]
this.shouldUseZoomMotionBlur() && this.zoomBlurFilter && this.motionBlurFilter
? [this.zoomBlurFilter, this.motionBlurFilter]
: null;
this.videoEffectsContainer.filters = activeFilters;
}
@@ -462,6 +485,12 @@ export class FrameRenderer {
this.cameraContainer.addChild(this.videoEffectsContainer);
this.cameraContainer.addChild(this.cursorContainer);
this.videoEffectsContainer.addChild(this.videoContainer);
this.videoEffectsContainer.filterArea = new Rectangle(
0,
0,
this.config.width,
this.config.height,
);
this.webcamShadowLayers = this.createShadowLayers(
this.webcamRootContainer,
@@ -488,6 +517,11 @@ export class FrameRenderer {
style: this.config.cursorStyle ?? "tahoe",
smoothingFactor:
this.config.cursorSmoothing ?? DEFAULT_CURSOR_CONFIG.smoothingFactor,
springTuning: {
stiffnessMultiplier: this.config.cursorSpringStiffnessMultiplier,
dampingMultiplier: this.config.cursorSpringDampingMultiplier,
massMultiplier: this.config.cursorSpringMassMultiplier,
},
motionBlur: this.config.cursorMotionBlur ?? 0,
clickBounce: this.config.cursorClickBounce ?? DEFAULT_CURSOR_CONFIG.clickBounce,
clickBounceDuration:
@@ -506,6 +540,11 @@ export class FrameRenderer {
await this.setupAnnotationLayer();
this.setupCaptionResources();
if (this.shouldUseZoomMotionBlur()) {
this.zoomBlurFilter = new ZoomBlurFilter({ strength: 0 });
this.motionBlurFilter = new MotionBlurFilter([0, 0], 5, 0);
}
this.compositeCanvas = document.createElement("canvas");
this.compositeCanvas.width = this.config.width;
this.compositeCanvas.height = this.config.height;
@@ -518,9 +557,6 @@ export class FrameRenderer {
throw new Error("Failed to get 2D context for composite canvas");
}
if (this.shouldUseZoomMotionBlur()) {
this.motionBlurFilter = new MotionBlurFilter([0, 0], 5, 0);
}
this.updateVideoEffectsFilterState();
console.log(`[FrameRenderer] Export renderer backend: ${this.rendererBackend}`);
@@ -745,7 +781,9 @@ export class FrameRenderer {
const canvas = document.createElement("canvas");
canvas.width = targetWidth;
canvas.height = targetHeight;
const context = configureHighQuality2DContext(canvas.getContext("2d"));
const context = configureHighQuality2DContext(
canvas.getContext("2d", { willReadFrequently: true }),
);
if (!context) {
return null;
}
@@ -828,9 +866,12 @@ export class FrameRenderer {
this.backgroundForwardFrameSource?.cancel();
void this.backgroundForwardFrameSource?.destroy();
this.backgroundForwardFrameSource = null;
this.backgroundForwardFrameSourceUrl = null;
this.backgroundForwardFrameDurationSec = null;
this.closeBackgroundDecodedFrame();
this.cleanupBackgroundSource?.();
this.cleanupBackgroundSource = null;
this.lastSyncedBackgroundLoopTimeSec = null;
if (this.backgroundVideoElement) {
this.backgroundVideoElement.pause();
@@ -863,10 +904,18 @@ export class FrameRenderer {
try {
const frameSource = new ForwardFrameSource();
await frameSource.initialize(videoSrc);
const metadata = await frameSource.initialize(videoSrc);
this.backgroundForwardFrameSource = frameSource;
this.backgroundForwardFrameSourceUrl = videoSrc;
this.backgroundForwardFrameDurationSec = getEffectiveVideoStreamDurationSeconds(
{
duration: metadata?.duration,
streamDuration: metadata?.streamDuration,
},
);
this.backgroundVideoElement = null;
this.backgroundSeekPromise = null;
this.lastSyncedBackgroundLoopTimeSec = null;
return;
} catch (error) {
console.warn(
@@ -911,6 +960,7 @@ export class FrameRenderer {
});
this.backgroundVideoElement = video;
this.lastSyncedBackgroundLoopTimeSec = null;
this.ensureBackgroundSprite(video, video.videoWidth, video.videoHeight);
return;
}
@@ -1060,8 +1110,41 @@ export class FrameRenderer {
return;
}
const resolvedSource =
typeof VideoFrame !== "undefined" && source instanceof VideoFrame
? this.stageVideoFrameForTexture(source, "background", sourceWidth, sourceHeight)
: typeof HTMLVideoElement !== "undefined" &&
source instanceof HTMLVideoElement &&
sourceWidth > 0 &&
sourceHeight > 0
? (() => {
const staging = this.ensureVideoFrameStagingCanvas(
"background",
sourceWidth,
sourceHeight,
);
if (!staging) {
return source;
}
staging.context.clearRect(
0,
0,
staging.canvas.width,
staging.canvas.height,
);
staging.context.drawImage(
source,
0,
0,
staging.canvas.width,
staging.canvas.height,
);
return staging.canvas;
})()
: source;
if (!this.backgroundSprite) {
const texture = this.createTextureFromSource(source);
const texture = this.createTextureFromSource(resolvedSource);
this.backgroundSprite = new Sprite(texture);
this.backgroundTextureSource = texture.source as unknown as MutableVideoTextureSource;
this.backgroundContainer.addChild(this.backgroundSprite);
@@ -1074,7 +1157,7 @@ export class FrameRenderer {
this.backgroundSprite.filters = [this.backgroundBlurFilter];
}
} else if (this.backgroundTextureSource) {
this.backgroundTextureSource.resource = source;
this.backgroundTextureSource.resource = resolvedSource;
this.backgroundTextureSource.update();
}
@@ -1117,6 +1200,29 @@ export class FrameRenderer {
this.backgroundDecodedFrame = null;
}
private async restartBackgroundForwardFrameSource(): Promise<void> {
const sourceUrl = this.backgroundForwardFrameSourceUrl;
if (!sourceUrl) {
return;
}
const nextSource = new ForwardFrameSource();
const metadata = await nextSource.initialize(sourceUrl);
const previousSource = this.backgroundForwardFrameSource;
this.backgroundForwardFrameSource = nextSource;
const effectiveDuration = getEffectiveVideoStreamDurationSeconds({
duration: metadata?.duration,
streamDuration: metadata?.streamDuration,
});
this.backgroundForwardFrameDurationSec =
Number.isFinite(effectiveDuration) && effectiveDuration > 0 ? effectiveDuration : null;
this.lastSyncedBackgroundLoopTimeSec = null;
previousSource?.cancel();
void previousSource?.destroy();
}
private calculateAnnotationScaleFactor(): number {
const previewWidth = this.config.previewWidth || 1920;
const previewHeight = this.config.previewHeight || 1080;
@@ -1163,6 +1269,35 @@ export class FrameRenderer {
return this.exportCompositeCanvas;
}
private ensureTemporalCompositeCanvas(): ExportCompositeCanvasState | null {
const targetWidth = Math.max(1, Math.ceil(this.config.width));
const targetHeight = Math.max(1, Math.ceil(this.config.height));
if (
this.temporalCompositeCanvas &&
this.temporalCompositeCanvas.canvas.width === targetWidth &&
this.temporalCompositeCanvas.canvas.height === targetHeight
) {
return this.temporalCompositeCanvas;
}
const canvas = document.createElement("canvas");
canvas.width = targetWidth;
canvas.height = targetHeight;
const context = configureHighQuality2DContext(canvas.getContext("2d"));
if (!context) {
return null;
}
this.temporalCompositeCanvas = {
canvas,
context,
};
return this.temporalCompositeCanvas;
}
private drawCaptionOverlay(context: CanvasRenderingContext2D): void {
if (
!this.captionContainer?.visible ||
@@ -1183,7 +1318,10 @@ export class FrameRenderer {
context.restore();
}
private async composeBlurAnnotationFrame(timeMs: number): Promise<void> {
private async composeBlurAnnotationFrame(
timeMs: number,
sourceCanvas?: CanvasImageSource,
): Promise<void> {
if (!this.app) {
this.outputCanvasOverride = null;
return;
@@ -1197,7 +1335,7 @@ export class FrameRenderer {
const { canvas, context } = compositeState;
context.clearRect(0, 0, canvas.width, canvas.height);
context.drawImage(this.app.canvas as HTMLCanvasElement, 0, 0);
context.drawImage(sourceCanvas ?? (this.app.canvas as HTMLCanvasElement), 0, 0);
await renderAnnotations(
context,
@@ -1469,12 +1607,77 @@ export class FrameRenderer {
private async syncBackgroundFrame(timeSeconds: number): Promise<void> {
if (this.backgroundForwardFrameSource) {
const decodedFrame = await this.backgroundForwardFrameSource.getFrameAtTime(
Math.max(0, timeSeconds),
);
const duration = this.backgroundForwardFrameDurationSec;
const shouldLoop = Number.isFinite(duration) && (duration ?? 0) > 0;
let normalizedTargetTime = shouldLoop
? ((timeSeconds % duration!) + duration!) % duration!
: Math.max(0, timeSeconds);
if (
shouldLoop &&
this.lastSyncedBackgroundLoopTimeSec !== null &&
normalizedTargetTime + 0.001 < this.lastSyncedBackgroundLoopTimeSec
) {
try {
await this.restartBackgroundForwardFrameSource();
} catch (error) {
console.warn(
"[FrameRenderer] Unable to restart looping video wallpaper decoder during export:",
error,
);
}
}
const decodedFrame =
await this.backgroundForwardFrameSource.getFrameAtTime(normalizedTargetTime);
const resolvedDecodedDuration =
this.backgroundForwardFrameSource.getResolvedDurationSec();
if (
shouldLoop &&
this.backgroundForwardFrameSource.hasReachedEndOfStream() &&
Number.isFinite(resolvedDecodedDuration) &&
(resolvedDecodedDuration ?? 0) > 0 &&
normalizedTargetTime > (resolvedDecodedDuration ?? 0) + 0.001
) {
this.backgroundForwardFrameDurationSec = resolvedDecodedDuration ?? null;
this.closeBackgroundDecodedFrame();
decodedFrame?.close();
try {
await this.restartBackgroundForwardFrameSource();
normalizedTargetTime =
((timeSeconds % resolvedDecodedDuration!) + resolvedDecodedDuration!) %
resolvedDecodedDuration!;
const restartedFrame =
await this.backgroundForwardFrameSource.getFrameAtTime(
normalizedTargetTime,
);
this.backgroundDecodedFrame = restartedFrame;
if (restartedFrame) {
this.lastSyncedBackgroundLoopTimeSec = normalizedTargetTime;
const resolvedBackgroundSource = this.stageVideoFrameForTexture(
restartedFrame,
"background",
restartedFrame.displayWidth,
restartedFrame.displayHeight,
);
this.ensureBackgroundSprite(
resolvedBackgroundSource,
restartedFrame.displayWidth,
restartedFrame.displayHeight,
);
}
return;
} catch (error) {
console.warn(
"[FrameRenderer] Unable to wrap looping video wallpaper at decoded EOF during export:",
error,
);
}
}
this.closeBackgroundDecodedFrame();
this.backgroundDecodedFrame = decodedFrame;
if (decodedFrame) {
this.lastSyncedBackgroundLoopTimeSec = normalizedTargetTime;
const resolvedBackgroundSource = this.stageVideoFrameForTexture(
decodedFrame,
"background",
@@ -2275,12 +2478,15 @@ export class FrameRenderer {
}
}
async renderFrame(
videoFrame: VideoFrame,
private async renderSceneSample(
timestamp: number,
cursorTimestamp = timestamp,
): Promise<void> {
if (!this.app || !this.videoContainer || !this.cameraContainer || !this.videoMaskGraphics) {
cursorTimestamp: number,
backgroundTimelineTimestamp: number,
layoutCache: LayoutCache,
useVelocityMotionBlur: boolean,
includeOverlayLayers = true,
): Promise<RenderSnapshot> {
if (!this.app || !this.cameraContainer || !this.videoMaskGraphics) {
throw new Error("Renderer not initialized");
}
@@ -2291,9 +2497,191 @@ export class FrameRenderer {
}
if (this.backgroundForwardFrameSource || this.backgroundVideoElement) {
await this.syncBackgroundFrame(this.currentVideoTime);
await this.syncBackgroundFrame(Math.max(0, backgroundTimelineTimestamp / 1_000_000));
}
const timeMs = this.currentVideoTime * 1000;
const cursorTimeMs = cursorTimestamp / 1000;
if (this.cursorOverlay) {
this.cursorOverlay.update(
this.config.cursorTelemetry ?? [],
cursorTimeMs,
layoutCache.maskRect,
this.config.showCursor ?? true,
false,
);
}
const motionIntensity = this.updateAnimationState(timeMs);
applyZoomTransform({
cameraContainer: this.cameraContainer,
zoomBlurFilter: this.zoomBlurFilter,
motionBlurFilter: this.motionBlurFilter,
stageSize: layoutCache.stageSize,
baseMask: layoutCache.maskRect,
zoomScale: this.animationState.scale,
zoomProgress: this.animationState.progress,
focusX: this.animationState.focusX,
focusY: this.animationState.focusY,
motionIntensity,
motionVector: this.lastMotionVector,
isPlaying: true,
motionBlurAmount: useVelocityMotionBlur ? (this.config.zoomMotionBlur ?? 0) : 0,
transformOverride: {
scale: this.animationState.appliedScale,
x: this.animationState.x,
y: this.animationState.y,
},
motionBlurState: this.motionBlurState,
frameTimeMs: timeMs,
});
if (includeOverlayLayers) {
this.updateAnnotationLayer(timeMs);
this.updateCaptionLayer(timeMs);
}
this.updateWebcamOverlay();
const annotationContainerVisible = this.annotationContainer?.visible ?? true;
const captionContainerVisible = this.captionContainer?.visible ?? true;
if (!includeOverlayLayers) {
if (this.annotationContainer) {
this.annotationContainer.visible = false;
}
if (this.captionContainer) {
this.captionContainer.visible = false;
}
}
this.app.render();
if (!includeOverlayLayers) {
if (this.annotationContainer) {
this.annotationContainer.visible = annotationContainerVisible;
}
if (this.captionContainer) {
this.captionContainer.visible = captionContainerVisible;
}
}
return {
timeMs,
cursorTimeMs,
backgroundTimelineTimeMs: backgroundTimelineTimestamp / 1000,
sceneTransform: {
scale: this.animationState.appliedScale,
x: this.animationState.x,
y: this.animationState.y,
},
zoom: {
scale: this.animationState.scale,
focusX: this.animationState.focusX,
focusY: this.animationState.focusY,
progress: this.animationState.progress,
},
};
}
private async renderTemporalMotionBlurFrame(
timestamp: number,
cursorTimestamp: number,
backgroundTimelineTimestamp: number,
frameDurationUs: number,
layoutCache: LayoutCache,
): Promise<RenderSnapshot | null> {
if (!this.app) {
return null;
}
const blurConfig = getTemporalMotionBlurConfig(
this.config.zoomTemporalMotionBlur ?? this.config.zoomMotionBlur,
{
sampleCount: this.config.zoomMotionBlurSampleCount,
shutterFraction: this.config.zoomMotionBlurShutterFraction,
},
);
if (!blurConfig) {
return null;
}
const compositeState = this.ensureTemporalCompositeCanvas();
if (!compositeState) {
return null;
}
const samplePlan = buildTemporalSamplePlanUs(frameDurationUs, blurConfig);
compositeState.context.clearRect(
0,
0,
compositeState.canvas.width,
compositeState.canvas.height,
);
let centerSnapshot: RenderSnapshot | null = null;
let lastSnapshot: RenderSnapshot | null = null;
for (const { offsetUs: sampleOffsetUs, weight } of samplePlan) {
const sampleTimestamp = Math.max(0, timestamp + sampleOffsetUs);
const sampleCursorTimestamp = Math.max(0, cursorTimestamp + sampleOffsetUs);
const sampleBackgroundTimelineTimestamp = Math.max(
0,
backgroundTimelineTimestamp + sampleOffsetUs,
);
const snapshot = await this.renderSceneSample(
sampleTimestamp,
sampleCursorTimestamp,
sampleBackgroundTimelineTimestamp,
layoutCache,
false,
false,
);
lastSnapshot = snapshot;
if (Math.abs(sampleOffsetUs) < 0.0001) {
centerSnapshot = snapshot;
}
compositeState.context.save();
compositeState.context.globalCompositeOperation = "lighter";
compositeState.context.globalAlpha = weight;
compositeState.context.drawImage(this.app.canvas as HTMLCanvasElement, 0, 0);
compositeState.context.restore();
}
const resolvedSnapshot = centerSnapshot ?? lastSnapshot;
if (!resolvedSnapshot) {
return null;
}
this.updateCaptionLayer(resolvedSnapshot.timeMs);
const hasOverlayCanvasWork =
(this.config.annotationRegions?.length ?? 0) > 0 ||
Boolean(this.captionCanvas && this.captionSprite?.visible);
if (hasOverlayCanvasWork) {
await this.composeBlurAnnotationFrame(resolvedSnapshot.timeMs, compositeState.canvas);
} else {
this.outputCanvasOverride = compositeState.canvas;
}
return resolvedSnapshot;
}
async renderFrame(
videoFrame: VideoFrame,
timestamp: number,
cursorTimestamp = timestamp,
frameDurationUs?: number,
backgroundTimelineTimestamp = timestamp,
): Promise<void> {
if (!this.app || !this.videoContainer || !this.cameraContainer || !this.videoMaskGraphics) {
throw new Error("Renderer not initialized");
}
this.currentVideoTime = timestamp / 1_000_000;
const resolvedVideoSource = this.stageVideoFrameForTexture(
videoFrame,
"scene",
@@ -2327,6 +2715,39 @@ export class FrameRenderer {
throw new Error("Renderer layout cache is unavailable");
}
const temporalSnapshot =
typeof frameDurationUs === "number" && frameDurationUs > 0
? await this.renderTemporalMotionBlurFrame(
timestamp,
cursorTimestamp,
backgroundTimelineTimestamp,
frameDurationUs,
layoutCache,
)
: null;
if (temporalSnapshot) {
const sourceCanvas =
this.outputCanvasOverride ?? this.ensureTemporalCompositeCanvas()?.canvas;
if (sourceCanvas && this.shouldCompositeExtensionFrame()) {
this.compositeExtensions(
temporalSnapshot.timeMs,
temporalSnapshot.cursorTimeMs,
sourceCanvas,
);
this.outputCanvasOverride = this.compositeCanvas;
}
return;
}
if (this.webcamForwardFrameSource || this.webcamVideoElement) {
await this.syncWebcamFrame(Math.max(0, this.currentVideoTime));
}
if (this.backgroundForwardFrameSource || this.backgroundVideoElement) {
await this.syncBackgroundFrame(Math.max(0, backgroundTimelineTimestamp / 1_000_000));
}
const timeMs = this.currentVideoTime * 1000;
const cursorTimeMs = cursorTimestamp / 1000;
@@ -2346,7 +2767,7 @@ export class FrameRenderer {
applyZoomTransform({
cameraContainer: this.cameraContainer,
blurFilter: this.blurFilter,
zoomBlurFilter: this.zoomBlurFilter,
motionBlurFilter: this.motionBlurFilter,
stageSize: layoutCache.stageSize,
baseMask: layoutCache.maskRect,
@@ -2410,7 +2831,11 @@ export class FrameRenderer {
);
}
private compositeExtensions(timeMs: number, cursorTimeMs: number): void {
private compositeExtensions(
timeMs: number,
cursorTimeMs: number,
sourceCanvas?: CanvasImageSource,
): void {
if (!this.app || !this.compositeCtx || !this.compositeCanvas) {
return;
}
@@ -2420,7 +2845,7 @@ export class FrameRenderer {
}
this.compositeCtx.clearRect(0, 0, this.config.width, this.config.height);
this.compositeCtx.drawImage(this.app.canvas as HTMLCanvasElement, 0, 0);
this.compositeCtx.drawImage(sourceCanvas ?? (this.app.canvas as HTMLCanvasElement), 0, 0);
const maskRect = this.layoutCache?.maskRect;
const smoothedCursor = mapSmoothedCursorToCanvasNormalized(
@@ -2721,6 +3146,8 @@ export class FrameRenderer {
timeMs,
{
connectZooms: this.config.connectZooms,
zoomInDurationMs: this.config.zoomInDurationMs,
zoomOutDurationMs: this.config.zoomOutDurationMs,
},
);
@@ -2945,7 +3372,7 @@ export class FrameRenderer {
if (this.backgroundSprite) {
this.backgroundSprite.filters = null;
}
this.blurFilter?.destroy();
this.zoomBlurFilter?.destroy();
this.motionBlurFilter?.destroy();
this.backgroundBlurFilter?.destroy();
@@ -2980,7 +3407,7 @@ export class FrameRenderer {
this.backgroundTextureSource = null;
this.videoMaskGraphics = null;
this.webcamMaskGraphics = null;
this.blurFilter = null;
this.zoomBlurFilter = null;
this.motionBlurFilter = null;
this.backgroundBlurFilter = null;
this.annotationAssets = null;
@@ -2994,6 +3421,9 @@ export class FrameRenderer {
this.backgroundForwardFrameSource?.cancel();
void this.backgroundForwardFrameSource?.destroy();
this.backgroundForwardFrameSource = null;
this.backgroundForwardFrameSourceUrl = null;
this.backgroundForwardFrameDurationSec = null;
this.lastSyncedBackgroundLoopTimeSec = null;
if (this.backgroundVideoElement) {
this.backgroundVideoElement.pause();
this.backgroundVideoElement.src = "";
@@ -3030,6 +3460,7 @@ export class FrameRenderer {
this.captionTextureSource = null;
this.captionRenderKey = null;
this.exportCompositeCanvas = null;
this.temporalCompositeCanvas = null;
this.outputCanvasOverride = null;
this.annotationScaleFactor = 1;
+30 -5
View File
@@ -59,6 +59,9 @@ interface VideoExporterConfig extends ExportConfig {
shadowIntensity: number;
backgroundBlur: number;
zoomMotionBlur?: number;
zoomTemporalMotionBlur?: number;
zoomMotionBlurSampleCount?: number | null;
zoomMotionBlurShutterFraction?: number | null;
connectZooms?: boolean;
zoomInDurationMs?: number;
zoomInOverlapMs?: number;
@@ -82,6 +85,9 @@ interface VideoExporterConfig extends ExportConfig {
cursorStyle?: CursorStyle;
cursorSize?: number;
cursorSmoothing?: number;
cursorSpringStiffnessMultiplier?: number;
cursorSpringDampingMultiplier?: number;
cursorSpringMassMultiplier?: number;
cursorMotionBlur?: number;
cursorClickBounce?: number;
cursorClickBounceDuration?: number;
@@ -146,6 +152,7 @@ export class ModernVideoExporter {
private encoderName: string | null = null;
private backpressureProfile: ExportBackpressureProfile | null = null;
private nativeExportSessionId: string | null = null;
private nativePendingWrite: Promise<void> = Promise.resolve();
private nativeWritePromises = new Set<Promise<void>>();
private nativeWriteError: Error | null = null;
private maxNativeWriteInFlight = 1;
@@ -302,6 +309,9 @@ export class ModernVideoExporter {
shadowIntensity: this.config.shadowIntensity,
backgroundBlur: this.config.backgroundBlur,
zoomMotionBlur: this.config.zoomMotionBlur,
zoomTemporalMotionBlur: this.config.zoomTemporalMotionBlur,
zoomMotionBlurSampleCount: this.config.zoomMotionBlurSampleCount,
zoomMotionBlurShutterFraction: this.config.zoomMotionBlurShutterFraction,
connectZooms: this.config.connectZooms,
zoomInDurationMs: this.config.zoomInDurationMs,
zoomInOverlapMs: this.config.zoomInOverlapMs,
@@ -329,6 +339,9 @@ export class ModernVideoExporter {
cursorStyle: this.config.cursorStyle,
cursorSize: this.config.cursorSize,
cursorSmoothing: this.config.cursorSmoothing,
cursorSpringStiffnessMultiplier: this.config.cursorSpringStiffnessMultiplier,
cursorSpringDampingMultiplier: this.config.cursorSpringDampingMultiplier,
cursorSpringMassMultiplier: this.config.cursorSpringMassMultiplier,
cursorMotionBlur: this.config.cursorMotionBlur,
cursorClickBounce: this.config.cursorClickBounce,
cursorClickBounceDuration: this.config.cursorClickBounceDuration,
@@ -382,6 +395,8 @@ export class ModernVideoExporter {
videoFrame,
sourceTimestampUs,
cursorTimestampUs,
frameDuration,
timestamp,
);
this.renderFrameTimeMs += this.getNowMs() - renderStartedAt;
videoFrame.close();
@@ -924,6 +939,7 @@ export class ModernVideoExporter {
this.lastNativeExportError = null;
this.encodeBackend = "ffmpeg";
this.encoderName = "h264-stream-copy";
this.nativePendingWrite = Promise.resolve();
const sessionId = result.sessionId;
const encoder = new VideoEncoder({
@@ -934,8 +950,13 @@ export class ModernVideoExporter {
const buffer = new ArrayBuffer(chunk.byteLength);
chunk.copyTo(buffer);
const writePromise = window.electronAPI
.nativeVideoExportWriteFrame(sessionId, new Uint8Array(buffer))
const writePromise = this.nativePendingWrite
.then(() =>
window.electronAPI.nativeVideoExportWriteFrame(
sessionId,
new Uint8Array(buffer),
),
)
.then((writeResult) => {
if (!writeResult.success && !this.cancelled) {
throw new Error(
@@ -957,6 +978,7 @@ export class ModernVideoExporter {
}
throw error;
});
this.nativePendingWrite = writePromise;
this.trackNativeWritePromise(writePromise);
},
@@ -1107,6 +1129,7 @@ export class ModernVideoExporter {
this.finalizationStageMs.ffmpegAudioMuxBreakdown = result.metrics;
}
this.nativeExportSessionId = null;
this.nativePendingWrite = Promise.resolve();
if (!result.success) {
return {
@@ -1244,17 +1267,18 @@ export class ModernVideoExporter {
this.finalizationStageMs.ffmpegAudioMuxBreakdown = result.metrics;
}
if (!result.success || !result.data) {
if (!result.success || !result.tempPath) {
return {
success: false,
error: result.error || "Failed to mux exported audio with FFmpeg",
};
}
const videoBytes = result.data.slice();
// Returning a temp path (instead of buffering the muxed bytes back into
// the renderer) is what keeps >2 GiB exports off Node's fs.readFile cap.
return {
success: true,
blob: new Blob([videoBytes.buffer], { type: "video/mp4" }),
tempFilePath: result.tempPath,
};
}
@@ -1798,6 +1822,7 @@ export class ModernVideoExporter {
this.lastProgressSampleTimeMs = 0;
this.lastProgressSampleFrame = 0;
this.nativeWritePromises = new Set();
this.nativePendingWrite = Promise.resolve();
this.nativeWriteError = null;
this.maxNativeWriteInFlight = 1;
this.videoDescription = undefined;
@@ -0,0 +1,79 @@
import { describe, expect, it } from "vitest";
import {
buildTemporalSampleOffsetsUs,
buildTemporalSamplePlanUs,
getTemporalMotionBlurConfig,
} from "./temporalMotionBlur";
describe("temporalMotionBlur", () => {
it("disables temporal blur for zero strength", () => {
expect(getTemporalMotionBlurConfig(0)).toBeNull();
expect(getTemporalMotionBlurConfig(undefined)).toBeNull();
});
it("uses an odd number of centered samples", () => {
const config = getTemporalMotionBlurConfig(1);
expect(config).not.toBeNull();
expect(config?.sampleCount).toBe(5);
expect(config?.sampleCount % 2).toBe(1);
expect(config?.shutterFraction).toBeGreaterThan(0.24);
});
it("caps the automatic sample budget to keep exports responsive", () => {
const config = getTemporalMotionBlurConfig(2);
expect(config).not.toBeNull();
expect(config?.sampleCount).toBe(5);
});
it("builds symmetric shutter offsets around the frame center", () => {
const offsets = buildTemporalSampleOffsetsUs(33_333.333, {
sampleCount: 5,
shutterFraction: 0.9,
});
expect(offsets).toHaveLength(5);
expect(offsets[2]).toBeCloseTo(0, 6);
expect(offsets[0]).toBeCloseTo(-offsets[4], 6);
expect(offsets[1]).toBeCloseTo(-offsets[3], 6);
});
it("accepts explicit shutter and odd sample overrides", () => {
const config = getTemporalMotionBlurConfig(0.35, {
sampleCount: 60,
shutterFraction: 3,
});
expect(config).not.toBeNull();
expect(config?.sampleCount).toBe(61);
expect(config?.shutterFraction).toBeCloseTo(3, 6);
});
it("allows experimental multi-frame shutter windows", () => {
const offsets = buildTemporalSampleOffsetsUs(33_333.333, {
sampleCount: 61,
shutterFraction: 3,
});
expect(offsets).toHaveLength(61);
expect(offsets[30]).toBeCloseTo(0, 6);
expect(offsets[0]).toBeCloseTo(-50_000, 0);
expect(offsets[60]).toBeCloseTo(50_000, 0);
});
it("builds normalized sample weights with a center bias", () => {
const config = getTemporalMotionBlurConfig(1.2);
expect(config).not.toBeNull();
const plan = buildTemporalSamplePlanUs(33_333.333, config!);
const totalWeight = plan.reduce((sum, sample) => sum + sample.weight, 0);
const centerSample = plan[Math.floor(plan.length / 2)];
const edgeSample = plan[0];
expect(totalWeight).toBeCloseTo(1, 6);
expect(centerSample?.weight ?? 0).toBeGreaterThan(edgeSample?.weight ?? 0);
expect(plan.map((sample) => sample.offsetUs)).toContain(0);
});
});
+134
View File
@@ -0,0 +1,134 @@
interface TemporalMotionBlurConfig {
sampleCount: number;
shutterFraction: number;
weightCurvePower: number;
}
interface TemporalMotionBlurOverrides {
sampleCount?: number | null;
shutterFraction?: number | null;
}
interface TemporalMotionBlurSample {
offsetUs: number;
weight: number;
}
const MIN_BLUR_AMOUNT = 0.001;
export const TEMPORAL_MOTION_BLUR_MIN_SHUTTER_FRACTION = 0.18;
export const TEMPORAL_MOTION_BLUR_MAX_SHUTTER_FRACTION = 3;
export const TEMPORAL_MOTION_BLUR_MIN_SAMPLE_COUNT = 3;
export const TEMPORAL_MOTION_BLUR_MAX_SAMPLE_COUNT = 61;
export const TEMPORAL_MOTION_BLUR_DEFAULT_SAMPLE_COUNT = 13;
export const TEMPORAL_MOTION_BLUR_DEFAULT_SHUTTER_FRACTION = 0.94;
const TEMPORAL_MOTION_BLUR_AUTO_MIN_SHUTTER_FRACTION = 0.24;
const TEMPORAL_MOTION_BLUR_AUTO_MAX_SHUTTER_FRACTION = 0.62;
const TEMPORAL_MOTION_BLUR_AUTO_MAX_SAMPLE_COUNT = 5;
const TEMPORAL_MOTION_BLUR_WEIGHT_FLOOR = 0.22;
const MAX_BLUR_AMOUNT = 2;
function normalizeTemporalMotionBlurSampleCount(value: number | null | undefined): number | null {
if (!Number.isFinite(value)) {
return null;
}
const roundedValue = Math.round(value ?? 0);
const clampedValue = Math.min(
TEMPORAL_MOTION_BLUR_MAX_SAMPLE_COUNT,
Math.max(TEMPORAL_MOTION_BLUR_MIN_SAMPLE_COUNT, roundedValue),
);
if (clampedValue % 2 === 1) {
return clampedValue;
}
return clampedValue >= TEMPORAL_MOTION_BLUR_MAX_SAMPLE_COUNT
? clampedValue - 1
: clampedValue + 1;
}
export function getTemporalMotionBlurConfig(
amount: number | null | undefined,
overrides: TemporalMotionBlurOverrides = {},
): TemporalMotionBlurConfig | null {
const resolvedAmount = Number.isFinite(amount) ? Math.max(0, amount ?? 0) : 0;
if (resolvedAmount < MIN_BLUR_AMOUNT) {
return null;
}
const normalizedAmount = Math.min(1, resolvedAmount / MAX_BLUR_AMOUNT);
const sampleStepCount = Math.round(
normalizedAmount *
((TEMPORAL_MOTION_BLUR_AUTO_MAX_SAMPLE_COUNT - TEMPORAL_MOTION_BLUR_MIN_SAMPLE_COUNT) /
2),
);
const defaultSampleCount = TEMPORAL_MOTION_BLUR_MIN_SAMPLE_COUNT + sampleStepCount * 2;
const sampleCount =
normalizeTemporalMotionBlurSampleCount(overrides.sampleCount) ?? defaultSampleCount;
const shutterFraction = Number.isFinite(overrides.shutterFraction)
? Math.min(
TEMPORAL_MOTION_BLUR_MAX_SHUTTER_FRACTION,
Math.max(
TEMPORAL_MOTION_BLUR_MIN_SHUTTER_FRACTION,
overrides.shutterFraction ?? TEMPORAL_MOTION_BLUR_AUTO_MIN_SHUTTER_FRACTION,
),
)
: TEMPORAL_MOTION_BLUR_AUTO_MIN_SHUTTER_FRACTION +
normalizedAmount *
(TEMPORAL_MOTION_BLUR_AUTO_MAX_SHUTTER_FRACTION -
TEMPORAL_MOTION_BLUR_AUTO_MIN_SHUTTER_FRACTION);
return {
sampleCount,
shutterFraction,
weightCurvePower: 1.2 + normalizedAmount * 0.9,
};
}
export function buildTemporalSampleOffsetsUs(
frameDurationUs: number,
config: TemporalMotionBlurConfig,
): number[] {
const safeFrameDurationUs = Math.max(1, frameDurationUs);
const safeSampleCount = Math.max(1, Math.floor(config.sampleCount));
if (safeSampleCount === 1) {
return [0];
}
const shutterWindowUs =
safeFrameDurationUs *
Math.max(0, Math.min(TEMPORAL_MOTION_BLUR_MAX_SHUTTER_FRACTION, config.shutterFraction));
const startOffsetUs = -shutterWindowUs / 2;
const stepUs = shutterWindowUs / (safeSampleCount - 1);
return Array.from({ length: safeSampleCount }, (_, index) => startOffsetUs + stepUs * index);
}
export function buildTemporalSamplePlanUs(
frameDurationUs: number,
config: TemporalMotionBlurConfig,
): TemporalMotionBlurSample[] {
const offsetsUs = buildTemporalSampleOffsetsUs(frameDurationUs, config);
if (offsetsUs.length === 1) {
return [{ offsetUs: 0, weight: 1 }];
}
const centerIndex = (offsetsUs.length - 1) / 2;
const rawWeights = offsetsUs.map((_offsetUs, index) => {
const normalizedDistance = Math.abs(index - centerIndex) / Math.max(1, centerIndex);
const taperedWeight = Math.cos(normalizedDistance * (Math.PI / 2));
return (
TEMPORAL_MOTION_BLUR_WEIGHT_FLOOR +
(1 - TEMPORAL_MOTION_BLUR_WEIGHT_FLOOR) *
Math.pow(Math.max(0, taperedWeight), config.weightCurvePower)
);
});
const totalWeight = rawWeights.reduce((sum, weight) => sum + weight, 0) || 1;
return offsetsUs.map((offsetUs, index) => ({
offsetUs,
weight: rawWeights[index]! / totalWeight,
}));
}
export type { TemporalMotionBlurConfig, TemporalMotionBlurOverrides, TemporalMotionBlurSample };
+18 -4
View File
@@ -48,6 +48,9 @@ interface VideoExporterConfig extends ExportConfig {
shadowIntensity: number;
backgroundBlur: number;
zoomMotionBlur?: number;
zoomTemporalMotionBlur?: number;
zoomMotionBlurSampleCount?: number | null;
zoomMotionBlurShutterFraction?: number | null;
connectZooms?: boolean;
zoomInDurationMs?: number;
zoomInOverlapMs?: number;
@@ -71,6 +74,9 @@ interface VideoExporterConfig extends ExportConfig {
cursorStyle?: CursorStyle;
cursorSize?: number;
cursorSmoothing?: number;
cursorSpringStiffnessMultiplier?: number;
cursorSpringDampingMultiplier?: number;
cursorSpringMassMultiplier?: number;
cursorMotionBlur?: number;
cursorClickBounce?: number;
cursorClickBounceDuration?: number;
@@ -192,6 +198,9 @@ export class VideoExporter {
shadowIntensity: this.config.shadowIntensity,
backgroundBlur: this.config.backgroundBlur,
zoomMotionBlur: this.config.zoomMotionBlur,
zoomTemporalMotionBlur: this.config.zoomTemporalMotionBlur,
zoomMotionBlurSampleCount: this.config.zoomMotionBlurSampleCount,
zoomMotionBlurShutterFraction: this.config.zoomMotionBlurShutterFraction,
connectZooms: this.config.connectZooms,
zoomInDurationMs: this.config.zoomInDurationMs,
zoomInOverlapMs: this.config.zoomInOverlapMs,
@@ -219,6 +228,9 @@ export class VideoExporter {
cursorStyle: this.config.cursorStyle,
cursorSize: this.config.cursorSize,
cursorSmoothing: this.config.cursorSmoothing,
cursorSpringStiffnessMultiplier: this.config.cursorSpringStiffnessMultiplier,
cursorSpringDampingMultiplier: this.config.cursorSpringDampingMultiplier,
cursorSpringMassMultiplier: this.config.cursorSpringMassMultiplier,
cursorMotionBlur: this.config.cursorMotionBlur,
cursorClickBounce: this.config.cursorClickBounce,
cursorClickBounceDuration: this.config.cursorClickBounceDuration,
@@ -274,6 +286,8 @@ export class VideoExporter {
videoFrame,
sourceTimestampUs,
cursorTimestampUs,
frameDuration,
timestamp,
);
videoFrame.close();
@@ -981,7 +995,7 @@ export class VideoExporter {
this.finalizationStageMs.ffmpegAudioMuxBreakdown = result.metrics;
}
if (!result.success || !result.data) {
if (!result.success || !result.tempPath) {
return {
success: false,
error: result.error || "Failed to mux exported audio with FFmpeg",
@@ -989,11 +1003,11 @@ export class VideoExporter {
};
}
const blobData = new Uint8Array(result.data.byteLength);
blobData.set(result.data);
// Returning a temp path (instead of buffering the muxed bytes back into
// the renderer) is what keeps >2 GiB exports off Node's fs.readFile cap.
return {
success: true,
blob: new Blob([blobData.buffer], { type: "video/mp4" }),
tempFilePath: result.tempPath,
metrics: this.buildExportMetrics(),
};
}
-6
View File
@@ -1,8 +1,6 @@
export const SHORTCUT_ACTIONS = [
"addZoom",
"addTrim",
"splitClip",
"addSpeed",
"addAnnotation",
"addKeyframe",
"deleteSelected",
@@ -76,9 +74,7 @@ export function findConflict(
export const DEFAULT_SHORTCUTS: ShortcutsConfig = {
addZoom: { key: "z" },
addTrim: { key: "t" },
splitClip: { key: "c" },
addSpeed: { key: "s" },
addAnnotation: { key: "a" },
addKeyframe: { key: "f" },
deleteSelected: { key: "d", ctrl: true },
@@ -87,9 +83,7 @@ export const DEFAULT_SHORTCUTS: ShortcutsConfig = {
export const SHORTCUT_LABELS: Record<ShortcutAction, string> = {
addZoom: "Add Zoom",
addTrim: "Add Trim",
splitClip: "Split Clip",
addSpeed: "Add Speed",
addAnnotation: "Add Annotation",
addKeyframe: "Add Keyframe",
deleteSelected: "Delete Selected",
+4 -3
View File
@@ -12,9 +12,10 @@ describe("wallpapers", () => {
});
it("keeps the curated wallpaper list and default path aligned", () => {
expect(DEFAULT_WALLPAPER_PATH).toBe("/wallpapers/midnight-8.jpg");
expect(DEFAULT_WALLPAPER_RELATIVE_PATH).toBe("wallpapers/midnight-8.jpg");
expect(DEFAULT_WALLPAPER_PATH).toBe("/wallpapers/tahoe-light.jpg");
expect(DEFAULT_WALLPAPER_RELATIVE_PATH).toBe("wallpapers/tahoe-light.jpg");
expect(BUILT_IN_WALLPAPERS.at(0)?.publicPath).toBe(DEFAULT_WALLPAPER_PATH);
expect(BUILT_IN_WALLPAPERS.at(1)?.publicPath).toBe("/wallpapers/tahoe-dark.jpg");
expect(BUILT_IN_WALLPAPERS).toHaveLength(25);
});
@@ -37,8 +38,8 @@ describe("wallpapers", () => {
});
await expect(getAvailableWallpapers()).resolves.toEqual([
BUILT_IN_WALLPAPERS[0],
BUILT_IN_WALLPAPERS[2],
BUILT_IN_WALLPAPERS[4],
BUILT_IN_WALLPAPERS[15],
BUILT_IN_WALLPAPERS[16],
BUILT_IN_WALLPAPERS[23],
+4 -4
View File
@@ -9,14 +9,14 @@ const IMAGE_FILE_PATTERN = /\.(avif|gif|jpe?g|png|svg|webp)$/i;
const VIDEO_FILE_PATTERN = /\.(avi|m4v|mkv|mov|mp4|webm)$/i;
export const BUILT_IN_WALLPAPERS: BuiltInWallpaper[] = [
createWallpaperEntry("tahoe-light.jpg", "Tahoe Light"),
createWallpaperEntry("tahoe-dark.jpg", "Tahoe Dark"),
createWallpaperEntry("midnight-8.jpg", "Midnight 8"),
createWallpaperEntry("ipad-17-dark.jpg", "iPad 17 Dark"),
createWallpaperEntry("ipad-17-light.jpg", "iPad 17 Light"),
createWallpaperEntry("sequoia-blue.jpg", "Sequoia Blue"),
createWallpaperEntry("sequoia-blue-orange.jpg", "Sequoia Blue Orange"),
createWallpaperEntry("ventura.jpg", "Ventura"),
createWallpaperEntry("tahoe-light.jpg", "Tahoe Light"),
createWallpaperEntry("tahoe-dark.jpg", "Tahoe Dark"),
createWallpaperEntry("sonoma-clouds.jpg", "Sonoma Clouds"),
createWallpaperEntry("sonoma-light.jpg", "Sonoma Light"),
createWallpaperEntry("sonoma-dark.jpg", "Sonoma Dark"),
@@ -40,8 +40,8 @@ export const WALLPAPER_PATHS = BUILT_IN_WALLPAPERS.map((wallpaper) => wallpaper.
export const WALLPAPER_RELATIVE_PATHS = BUILT_IN_WALLPAPERS.map(
(wallpaper) => wallpaper.relativePath,
);
export const DEFAULT_WALLPAPER_PATH = "/wallpapers/midnight-8.jpg";
export const DEFAULT_WALLPAPER_RELATIVE_PATH = "wallpapers/midnight-8.jpg";
export const DEFAULT_WALLPAPER_PATH = "/wallpapers/tahoe-light.jpg";
export const DEFAULT_WALLPAPER_RELATIVE_PATH = "wallpapers/tahoe-light.jpg";
export function isVideoWallpaperSource(value: string): boolean {
if (!value) {