mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-25 23:35:43 +00:00
Merge remote-tracking branch 'upstream/main' into codex/feature/camera-crop
# Conflicts: # src/components/video-editor/VideoEditor.tsx
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Vendored
+32
-10
@@ -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 }>;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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-/);
|
||||
});
|
||||
});
|
||||
@@ -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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ export type RecordingSessionData = {
|
||||
videoPath: string;
|
||||
webcamPath?: string | null;
|
||||
timeOffsetMs?: number;
|
||||
hideOverlayCursorByDefault?: boolean;
|
||||
};
|
||||
|
||||
export type PauseSegment = {
|
||||
|
||||
+27
-11
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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]);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "字体",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = "";
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user