Merge branch 'codex/pr-229' into main

This commit is contained in:
Siddharth
2026-03-17 18:47:19 -07:00
36 changed files with 2603 additions and 531 deletions
+23
View File
@@ -42,3 +42,26 @@ jobs:
cache: npm
- run: npm ci
- run: npx vite build
e2e:
name: E2E Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npx playwright install --with-deps chromium
# Install Electron system dependencies not covered by Playwright's chromium deps
- run: npx electron . --version || sudo apt-get install -y libgbm-dev
- run: npm run build-vite
# xvfb provides a virtual display; Electron needs one on Linux even with show:false
- run: xvfb-run --auto-servernum npm run test:e2e
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: playwright-report/
retention-days: 7
+5 -1
View File
@@ -25,4 +25,8 @@ dist-ssr
*.sw?
release/**
*.kiro/
# npx electron-builder --mac --win
# npx electron-builder --mac --win
# Playwright
test-results
playwright-report/
+7 -6
View File
@@ -37,12 +37,13 @@
],
"icon": "icons/icons/mac/icon.icns",
"artifactName": "${productName}-Mac-${arch}-${version}-Installer.${ext}",
"extendInfo": {
"NSAudioCaptureUsageDescription": "OpenScreen needs audio capture permission to record system audio.",
"NSMicrophoneUsageDescription": "OpenScreen needs microphone access to record voice audio.",
"NSCameraUseContinuityCameraDeviceType": true,
"com.apple.security.device.audio-input": true
}
"extendInfo": {
"NSAudioCaptureUsageDescription": "OpenScreen needs audio capture permission to record system audio.",
"NSMicrophoneUsageDescription": "OpenScreen needs microphone access to record voice audio.",
"NSCameraUsageDescription": "OpenScreen needs camera access to record webcam video.",
"NSCameraUseContinuityCameraDeviceType": true,
"com.apple.security.device.audio-input": true
}
},
"linux": {
"target": [
+45 -2
View File
@@ -29,12 +29,38 @@ interface Window {
openSourceSelector: () => Promise<void>;
selectSource: (source: ProcessedDesktopSource) => Promise<ProcessedDesktopSource | null>;
getSelectedSource: () => Promise<ProcessedDesktopSource | null>;
requestCameraAccess: () => Promise<{
success: boolean;
granted: boolean;
status: string;
error?: string;
}>;
getAssetBasePath: () => Promise<string | null>;
storeRecordedVideo: (
videoData: ArrayBuffer,
fileName: string,
) => Promise<{ success: boolean; path?: string; message?: string }>;
getRecordedVideoPath: () => Promise<{ success: boolean; path?: string; message?: string }>;
) => Promise<{
success: boolean;
path?: string;
session?: import("../src/lib/recordingSession").RecordingSession;
message?: string;
error?: string;
}>;
storeRecordedSession: (
payload: import("../src/lib/recordingSession").StoreRecordedSessionInput,
) => Promise<{
success: boolean;
path?: string;
session?: import("../src/lib/recordingSession").RecordingSession;
message?: string;
error?: string;
}>;
getRecordedVideoPath: () => Promise<{
success: boolean;
path?: string;
message?: string;
error?: string;
}>;
setRecordingState: (recording: boolean) => Promise<void>;
getCursorTelemetry: (videoPath?: string) => Promise<{
success: boolean;
@@ -50,7 +76,24 @@ interface Window {
) => Promise<{ success: boolean; path?: string; message?: string; canceled?: boolean }>;
openVideoFilePicker: () => Promise<{ success: boolean; path?: string; canceled?: boolean }>;
setCurrentVideoPath: (path: string) => Promise<{ success: boolean }>;
setCurrentRecordingSession: (
session: import("../src/lib/recordingSession").RecordingSession | null,
) => Promise<{
success: boolean;
session?: import("../src/lib/recordingSession").RecordingSession;
}>;
getCurrentVideoPath: () => Promise<{ success: boolean; path?: string }>;
getCurrentRecordingSession: () => Promise<{
success: boolean;
session?: import("../src/lib/recordingSession").RecordingSession;
}>;
readBinaryFile: (filePath: string) => Promise<{
success: boolean;
data?: ArrayBuffer;
path?: string;
message?: string;
error?: string;
}>;
clearCurrentVideoPath: () => Promise<{ success: boolean }>;
saveProjectFile: (
projectData: unknown,
+194 -37
View File
@@ -1,11 +1,27 @@
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { app, BrowserWindow, desktopCapturer, dialog, ipcMain, screen, shell } from "electron";
import {
app,
BrowserWindow,
desktopCapturer,
dialog,
ipcMain,
screen,
shell,
systemPreferences,
} from "electron";
import {
normalizeProjectMedia,
normalizeRecordingSession,
type RecordingSession,
type StoreRecordedSessionInput,
} from "../../src/lib/recordingSession";
import { RECORDINGS_DIR } from "../main";
const PROJECT_FILE_EXTENSION = "openscreen";
const SHORTCUTS_FILE = path.join(app.getPath("userData"), "shortcuts.json");
const RECORDING_SESSION_SUFFIX = ".session.json";
type SelectedSource = {
name: string;
@@ -14,6 +30,7 @@ type SelectedSource = {
let selectedSource: SelectedSource | null = null;
let currentProjectPath: string | null = null;
let currentRecordingSession: RecordingSession | null = null;
function normalizePath(filePath: string) {
return path.resolve(filePath);
@@ -47,6 +64,54 @@ function isTrustedProjectPath(filePath?: string | null) {
return normalizePath(filePath) === normalizePath(currentProjectPath);
}
function setCurrentRecordingSessionState(session: RecordingSession | null) {
currentRecordingSession = session;
}
async function storeRecordedSessionFiles(payload: StoreRecordedSessionInput) {
const createdAt =
typeof payload.createdAt === "number" && Number.isFinite(payload.createdAt)
? payload.createdAt
: Date.now();
const screenVideoPath = path.join(RECORDINGS_DIR, payload.screen.fileName);
await fs.writeFile(screenVideoPath, Buffer.from(payload.screen.videoData));
let webcamVideoPath: string | undefined;
if (payload.webcam) {
webcamVideoPath = path.join(RECORDINGS_DIR, payload.webcam.fileName);
await fs.writeFile(webcamVideoPath, Buffer.from(payload.webcam.videoData));
}
const session: RecordingSession = webcamVideoPath
? { screenVideoPath, webcamVideoPath, createdAt }
: { screenVideoPath, createdAt };
setCurrentRecordingSessionState(session);
currentProjectPath = null;
const telemetryPath = `${screenVideoPath}.cursor.json`;
if (pendingCursorSamples.length > 0) {
await fs.writeFile(
telemetryPath,
JSON.stringify({ version: CURSOR_TELEMETRY_VERSION, samples: pendingCursorSamples }, null, 2),
"utf-8",
);
}
pendingCursorSamples = [];
const sessionManifestPath = path.join(
RECORDINGS_DIR,
`${path.parse(payload.screen.fileName).name}${RECORDING_SESSION_SUFFIX}`,
);
await fs.writeFile(sessionManifestPath, JSON.stringify(session, null, 2), "utf-8");
return {
success: true,
path: screenVideoPath,
session,
message: "Recording session stored successfully",
};
}
const CURSOR_TELEMETRY_VERSION = 1;
const CURSOR_SAMPLE_INTERVAL_MS = 100;
const MAX_CURSOR_SAMPLES = 60 * 60 * 10; // 1 hour @ 10Hz
@@ -129,6 +194,38 @@ export function registerIpcHandlers(
return selectedSource;
});
ipcMain.handle("request-camera-access", async () => {
if (process.platform !== "darwin") {
return { success: true, granted: true, status: "granted" };
}
try {
const status = systemPreferences.getMediaAccessStatus("camera");
if (status === "granted") {
return { success: true, granted: true, status };
}
if (status === "not-determined") {
const granted = await systemPreferences.askForMediaAccess("camera");
return {
success: true,
granted,
status: granted ? "granted" : systemPreferences.getMediaAccessStatus("camera"),
};
}
return { success: true, granted: false, status };
} catch (error) {
console.error("Failed to request camera access:", error);
return {
success: false,
granted: false,
status: "unknown",
error: String(error),
};
}
});
ipcMain.handle("open-source-selector", () => {
const sourceSelectorWin = getSourceSelectorWindow();
if (sourceSelectorWin) {
@@ -146,36 +243,30 @@ export function registerIpcHandlers(
createEditorWindow();
});
ipcMain.handle("store-recorded-video", async (_, videoData: ArrayBuffer, fileName: string) => {
ipcMain.handle("store-recorded-session", async (_, payload: StoreRecordedSessionInput) => {
try {
const videoPath = path.join(RECORDINGS_DIR, fileName);
await fs.writeFile(videoPath, Buffer.from(videoData));
currentProjectPath = null;
const telemetryPath = `${videoPath}.cursor.json`;
if (pendingCursorSamples.length > 0) {
await fs.writeFile(
telemetryPath,
JSON.stringify(
{ version: CURSOR_TELEMETRY_VERSION, samples: pendingCursorSamples },
null,
2,
),
"utf-8",
);
}
pendingCursorSamples = [];
return {
success: true,
path: videoPath,
message: "Video stored successfully",
};
return await storeRecordedSessionFiles(payload);
} catch (error) {
console.error("Failed to store video:", error);
console.error("Failed to store recording session:", error);
return {
success: false,
message: "Failed to store video",
message: "Failed to store recording session",
error: String(error),
};
}
});
ipcMain.handle("store-recorded-video", async (_, videoData: ArrayBuffer, fileName: string) => {
try {
return await storeRecordedSessionFiles({
screen: { videoData, fileName },
createdAt: Date.now(),
});
} catch (error) {
console.error("Failed to store recorded video:", error);
return {
success: false,
message: "Failed to store recorded video",
error: String(error),
};
}
@@ -183,8 +274,14 @@ export function registerIpcHandlers(
ipcMain.handle("get-recorded-video-path", async () => {
try {
if (currentRecordingSession?.screenVideoPath) {
return { success: true, path: currentRecordingSession.screenVideoPath };
}
const files = await fs.readdir(RECORDINGS_DIR);
const videoFiles = files.filter((file) => file.endsWith(".webm"));
const videoFiles = files.filter(
(file) => file.endsWith(".webm") && !file.endsWith("-webcam.webm"),
);
if (videoFiles.length === 0) {
return { success: false, message: "No recorded video found" };
@@ -200,6 +297,29 @@ export function registerIpcHandlers(
}
});
ipcMain.handle("read-binary-file", async (_, inputPath: string) => {
try {
const normalizedPath = normalizeVideoSourcePath(inputPath);
if (!normalizedPath) {
return { success: false, message: "Invalid file path" };
}
const data = await fs.readFile(normalizedPath);
return {
success: true,
data: data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength),
path: normalizedPath,
};
} catch (error) {
console.error("Failed to read binary file:", error);
return {
success: false,
message: "Failed to read binary file",
error: String(error),
};
}
});
ipcMain.handle("set-recording-state", (_, recording: boolean) => {
if (recording) {
stopCursorCapture();
@@ -221,7 +341,9 @@ export function registerIpcHandlers(
});
ipcMain.handle("get-cursor-telemetry", async (_, videoPath?: string) => {
const targetVideoPath = normalizeVideoSourcePath(videoPath ?? currentVideoPath);
const targetVideoPath = normalizeVideoSourcePath(
videoPath ?? currentRecordingSession?.screenVideoPath,
);
if (!targetVideoPath) {
return { success: true, samples: [] };
}
@@ -393,7 +515,6 @@ export function registerIpcHandlers(
}
});
let currentVideoPath: string | null = null;
ipcMain.handle(
"save-project-file",
async (_, projectData: unknown, suggestedName?: string, existingProjectPath?: string) => {
@@ -479,8 +600,17 @@ export function registerIpcHandlers(
const content = await fs.readFile(filePath, "utf-8");
const project = JSON.parse(content);
currentProjectPath = filePath;
if (project && typeof project === "object" && typeof project.videoPath === "string") {
currentVideoPath = normalizeVideoSourcePath(project.videoPath) ?? project.videoPath;
if (project && typeof project === "object") {
const rawProject = project as { media?: unknown; videoPath?: unknown };
const media =
normalizeProjectMedia(rawProject.media) ??
(typeof rawProject.videoPath === "string"
? {
screenVideoPath:
normalizeVideoSourcePath(rawProject.videoPath) ?? rawProject.videoPath,
}
: null);
setCurrentRecordingSessionState(media ? { ...media, createdAt: Date.now() } : null);
}
return {
@@ -506,8 +636,17 @@ export function registerIpcHandlers(
const content = await fs.readFile(currentProjectPath, "utf-8");
const project = JSON.parse(content);
if (project && typeof project === "object" && typeof project.videoPath === "string") {
currentVideoPath = normalizeVideoSourcePath(project.videoPath) ?? project.videoPath;
if (project && typeof project === "object") {
const rawProject = project as { media?: unknown; videoPath?: unknown };
const media =
normalizeProjectMedia(rawProject.media) ??
(typeof rawProject.videoPath === "string"
? {
screenVideoPath:
normalizeVideoSourcePath(rawProject.videoPath) ?? rawProject.videoPath,
}
: null);
setCurrentRecordingSessionState(media ? { ...media, createdAt: Date.now() } : null);
}
return {
success: true,
@@ -523,18 +662,36 @@ export function registerIpcHandlers(
};
}
});
ipcMain.handle("set-current-recording-session", (_, session: RecordingSession | null) => {
const normalized = normalizeRecordingSession(session);
setCurrentRecordingSessionState(normalized);
currentProjectPath = null;
return { success: true, session: normalized ?? undefined };
});
ipcMain.handle("get-current-recording-session", () => {
return currentRecordingSession
? { success: true, session: currentRecordingSession }
: { success: false };
});
ipcMain.handle("set-current-video-path", (_, path: string) => {
currentVideoPath = normalizeVideoSourcePath(path) ?? path;
setCurrentRecordingSessionState({
screenVideoPath: normalizeVideoSourcePath(path) ?? path,
createdAt: Date.now(),
});
currentProjectPath = null;
return { success: true };
});
ipcMain.handle("get-current-video-path", () => {
return currentVideoPath ? { success: true, path: currentVideoPath } : { success: false };
return currentRecordingSession?.screenVideoPath
? { success: true, path: currentRecordingSession.screenVideoPath }
: { success: false };
});
ipcMain.handle("clear-current-video-path", () => {
currentVideoPath = null;
setCurrentRecordingSessionState(null);
return { success: true };
});
+23 -8
View File
@@ -70,6 +70,19 @@ function createWindow() {
mainWindow = createHudOverlayWindow();
}
function showMainWindow() {
if (mainWindow && !mainWindow.isDestroyed()) {
if (mainWindow.isMinimized()) {
mainWindow.restore();
}
mainWindow.show();
mainWindow.focus();
return;
}
createWindow();
}
function isEditorWindow(window: BrowserWindow) {
return window.webContents.getURL().includes("windowType=editor");
}
@@ -177,6 +190,12 @@ function setupApplicationMenu() {
function createTray() {
tray = new Tray(defaultTrayIcon);
tray.on("click", () => {
showMainWindow();
});
tray.on("double-click", () => {
showMainWindow();
});
}
function getTrayIcon(filename: string) {
@@ -208,11 +227,7 @@ function updateTrayMenu(recording: boolean = false) {
{
label: "Open",
click: () => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.isMinimized() && mainWindow.restore();
} else {
createWindow();
}
showMainWindow();
},
},
{
@@ -318,12 +333,12 @@ app.on("activate", () => {
app.whenReady().then(async () => {
// Allow microphone/media permission checks
session.defaultSession.setPermissionCheckHandler((_webContents, permission) => {
const allowed = ["media", "audioCapture", "microphone"];
const allowed = ["media", "audioCapture", "microphone", "videoCapture", "camera"];
return allowed.includes(permission);
});
session.defaultSession.setPermissionRequestHandler((_webContents, permission, callback) => {
const allowed = ["media", "audioCapture", "microphone"];
const allowed = ["media", "audioCapture", "microphone", "videoCapture", "camera"];
callback(allowed.includes(permission));
});
@@ -355,7 +370,7 @@ app.whenReady().then(async () => {
if (!tray) createTray();
updateTrayMenu(recording);
if (!recording) {
if (mainWindow) mainWindow.restore();
showMainWindow();
}
},
);
+16
View File
@@ -1,4 +1,5 @@
import { contextBridge, ipcRenderer } from "electron";
import type { RecordingSession, StoreRecordedSessionInput } from "../src/lib/recordingSession";
contextBridge.exposeInMainWorld("electronAPI", {
hudOverlayHide: () => {
@@ -26,10 +27,16 @@ contextBridge.exposeInMainWorld("electronAPI", {
getSelectedSource: () => {
return ipcRenderer.invoke("get-selected-source");
},
requestCameraAccess: () => {
return ipcRenderer.invoke("request-camera-access");
},
storeRecordedVideo: (videoData: ArrayBuffer, fileName: string) => {
return ipcRenderer.invoke("store-recorded-video", videoData, fileName);
},
storeRecordedSession: (payload: StoreRecordedSessionInput) => {
return ipcRenderer.invoke("store-recorded-session", payload);
},
getRecordedVideoPath: () => {
return ipcRenderer.invoke("get-recorded-video-path");
@@ -57,9 +64,18 @@ contextBridge.exposeInMainWorld("electronAPI", {
setCurrentVideoPath: (path: string) => {
return ipcRenderer.invoke("set-current-video-path", path);
},
setCurrentRecordingSession: (session: RecordingSession | null) => {
return ipcRenderer.invoke("set-current-recording-session", session);
},
getCurrentVideoPath: () => {
return ipcRenderer.invoke("get-current-video-path");
},
getCurrentRecordingSession: () => {
return ipcRenderer.invoke("get-current-recording-session");
},
readBinaryFile: (filePath: string) => {
return ipcRenderer.invoke("read-binary-file", filePath);
},
clearCurrentVideoPath: () => {
return ipcRenderer.invoke("clear-current-video-path");
},
+3
View File
@@ -7,6 +7,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
const APP_ROOT = path.join(__dirname, "..");
const VITE_DEV_SERVER_URL = process.env["VITE_DEV_SERVER_URL"];
const RENDERER_DIST = path.join(APP_ROOT, "dist");
const HEADLESS = process.env["HEADLESS"] === "true";
let hudOverlayWindow: BrowserWindow | null = null;
@@ -41,6 +42,7 @@ export function createHudOverlayWindow(): BrowserWindow {
alwaysOnTop: true,
skipTaskbar: true,
hasShadow: false,
show: !HEADLESS,
webPreferences: {
preload: path.join(__dirname, "preload.mjs"),
nodeIntegration: false,
@@ -90,6 +92,7 @@ export function createEditorWindow(): BrowserWindow {
skipTaskbar: false,
title: "OpenScreen",
backgroundColor: "#000000",
show: !HEADLESS,
webPreferences: {
preload: path.join(__dirname, "preload.mjs"),
nodeIntegration: false,
+121 -36
View File
@@ -36,6 +36,7 @@
"mediabunny": "^1.25.1",
"motion": "^12.23.24",
"mp4box": "^2.2.0",
"pixi-filters": "^6.1.5",
"pixi.js": "^8.14.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
@@ -50,6 +51,7 @@
},
"devDependencies": {
"@biomejs/biome": "^2.3.13",
"@playwright/test": "^1.58.2",
"@types/node": "^25.0.3",
"@types/react": "^18.2.64",
"@types/react-dom": "^18.2.21",
@@ -116,7 +118,6 @@
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.3",
@@ -345,7 +346,6 @@
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz",
"integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=6.9.0"
}
@@ -1320,6 +1320,7 @@
"dev": true,
"license": "BSD-2-Clause",
"optional": true,
"peer": true,
"dependencies": {
"cross-dirname": "^0.1.0",
"debug": "^4.3.4",
@@ -1341,6 +1342,7 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
@@ -1357,6 +1359,7 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"universalify": "^2.0.0"
},
@@ -1371,6 +1374,7 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">= 10.0.0"
}
@@ -2054,7 +2058,6 @@
"integrity": "sha512-LTATglVUPGkPf15zX1wTMlZ0+AU7cGEGF6ekVF1crA8eHUWsGjrYTB+Ht4E3HTrCok8weQG+K01rJndCp/l4XA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.7.2",
"@jimp/core": "^0.16.13"
@@ -2097,7 +2100,6 @@
"integrity": "sha512-8Z1k96ZFxlhK2bgrY1JNWNwvaBeI/bciLM0yDOni2+aZwfIIiC7Y6PeWHTAvjHNjphz+XCt01WQmOYWCn0ML6g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.7.2",
"@jimp/utils": "^0.16.13"
@@ -2112,7 +2114,6 @@
"integrity": "sha512-PvLrfa8vkej3qinlebyhLpksJgCF5aiysDMSVhOZqwH5nQLLtDE9WYbnsofGw4r0VVpyw3H/ANCIzYTyCtP9Cg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.7.2",
"@jimp/utils": "^0.16.13"
@@ -2141,7 +2142,6 @@
"integrity": "sha512-xW+9BtEvoIkkH/Wde9ql4nAFbYLkVINhpgAE7VcBUsuuB34WUbcBl/taOuUYQrPEFQJ4jfXiAJZ2H/rvKjCVnQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.7.2",
"@jimp/utils": "^0.16.13",
@@ -2191,7 +2191,6 @@
"integrity": "sha512-WEl2tPVYwzYL8OKme6Go2xqiWgKsgxlMwyHabdAU4tXaRwOCnOI7v4021gCcBb9zn/oWwguHuKHmK30Fw2Z/PA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.7.2",
"@jimp/utils": "^0.16.13"
@@ -2335,7 +2334,6 @@
"integrity": "sha512-qoqtN8LDknm3fJm9nuPygJv30O3vGhSBD2TxrsCnhtOsxKAqVPJtFVdGd/qVuZ8nqQANQmTlfqTiK9mVWQ7MiQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.7.2",
"@jimp/utils": "^0.16.13"
@@ -2350,7 +2348,6 @@
"integrity": "sha512-Ev+Jjmj1nHYw897z9C3R9dYsPv7S2/nxdgfFb/h8hOwK0Ovd1k/+yYS46A0uj/JCKK0pQk8wOslYBkPwdnLorw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.7.2",
"@jimp/utils": "^0.16.13"
@@ -2368,7 +2365,6 @@
"integrity": "sha512-05POQaEJVucjTiSGMoH68ZiELc7QqpIpuQlZ2JBbhCV+WCbPFUBcGSmE7w4Jd0E2GvCho/NoMODLwgcVGQA97A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.7.2",
"@jimp/utils": "^0.16.13"
@@ -2795,6 +2791,7 @@
"resolved": "https://registry.npmjs.org/@pixi/color/-/color-7.4.3.tgz",
"integrity": "sha512-a6R+bXKeXMDcRmjYQoBIK+v2EYqxSX49wcjAY579EYM/WrFKS98nSees6lqVUcLKrcQh2DT9srJHX7XMny3voQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@pixi/colord": "^2.9.6"
}
@@ -2809,7 +2806,8 @@
"version": "7.4.3",
"resolved": "https://registry.npmjs.org/@pixi/constants/-/constants-7.4.3.tgz",
"integrity": "sha512-QGmwJUNQy/vVEHzL6VGQvnwawLZ1wceZMI8HwJAT4/I2uAzbBeFDdmCS8WsTpSWLZjF/DszDc1D8BFp4pVJ5UQ==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/@pixi/core": {
"version": "7.4.3",
@@ -2836,7 +2834,8 @@
"version": "7.4.3",
"resolved": "https://registry.npmjs.org/@pixi/extensions/-/extensions-7.4.3.tgz",
"integrity": "sha512-FhoiYkHQEDYHUE7wXhqfsTRz6KxLXjuMbSiAwnLb9uG1vAgp6q6qd6HEsf4X30YaZbLFY8a4KY6hFZWjF+4Fdw==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/@pixi/filter-drop-shadow": {
"version": "5.2.0",
@@ -2863,19 +2862,22 @@
"version": "7.4.3",
"resolved": "https://registry.npmjs.org/@pixi/math/-/math-7.4.3.tgz",
"integrity": "sha512-/uJOVhR2DOZ+zgdI6Bs/CwcXT4bNRKsS+TqX3ekRIxPCwaLra+Qdm7aDxT5cTToDzdxbKL5+rwiLu3Y1egILDw==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/@pixi/runner": {
"version": "7.4.3",
"resolved": "https://registry.npmjs.org/@pixi/runner/-/runner-7.4.3.tgz",
"integrity": "sha512-TJyfp7y23u5vvRAyYhVSa7ytq0PdKSvPLXu4G3meoFh1oxTLHH6g/RIzLuxUAThPG2z7ftthuW3qWq6dRV+dhw==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/@pixi/settings": {
"version": "7.4.3",
"resolved": "https://registry.npmjs.org/@pixi/settings/-/settings-7.4.3.tgz",
"integrity": "sha512-SmGK8smc0PxRB9nr0UJioEtE9hl4gvj9OedCvZx3bxBwA3omA5BmP3CyhQfN8XJ29+o2OUL01r3zAPVol4l4lA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@pixi/constants": "7.4.3",
"@types/css-font-loading-module": "^0.0.12",
@@ -2887,6 +2889,7 @@
"resolved": "https://registry.npmjs.org/@pixi/ticker/-/ticker-7.4.3.tgz",
"integrity": "sha512-tHsAD0iOUb6QSGGw+c8cyRBvxsq/NlfzIFBZLEHhWZ+Bx4a0MmXup6I/yJDGmyPCYE+ctCcAfY13wKAzdiVFgQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@pixi/extensions": "7.4.3",
"@pixi/settings": "7.4.3",
@@ -2898,6 +2901,7 @@
"resolved": "https://registry.npmjs.org/@pixi/utils/-/utils-7.4.3.tgz",
"integrity": "sha512-NO3Y9HAn2UKS1YdxffqsPp+kDpVm8XWvkZcS/E+rBzY9VTLnNOI7cawSRm+dacdET3a8Jad3aDKEDZ0HmAqAFA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@pixi/color": "7.4.3",
"@pixi/constants": "7.4.3",
@@ -2912,19 +2916,22 @@
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/@types/earcut/-/earcut-2.1.4.tgz",
"integrity": "sha512-qp3m9PPz4gULB9MhjGID7wpo3gJ4bTGXm7ltNDsmOvsPduTeHp8wSW9YckBj3mljeOh4F0m2z/0JKAALRKbmLQ==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/@pixi/utils/node_modules/earcut": {
"version": "2.2.4",
"resolved": "https://registry.npmjs.org/earcut/-/earcut-2.2.4.tgz",
"integrity": "sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==",
"license": "ISC"
"license": "ISC",
"peer": true
},
"node_modules/@pixi/utils/node_modules/eventemitter3": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/@pkgjs/parseargs": {
"version": "0.11.0",
@@ -2936,6 +2943,21 @@
"node": ">=14"
}
},
"node_modules/@playwright/test": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
"dev": true,
"dependencies": {
"playwright": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@radix-ui/number": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
@@ -4380,6 +4402,12 @@
"@types/events": "*"
}
},
"node_modules/@types/gradient-parser": {
"version": "0.1.5",
"resolved": "https://registry.npmjs.org/@types/gradient-parser/-/gradient-parser-0.1.5.tgz",
"integrity": "sha512-r7K3NkJz3A95WkVVmjs0NcchhHstC2C/VIYNX4JC6tieviUNo774FFeOHjThr3Vw/WCeMP9kAT77MKbIRlO/4w==",
"license": "MIT"
},
"node_modules/@types/http-cache-semantics": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz",
@@ -4439,7 +4467,6 @@
"integrity": "sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.0.2"
@@ -4451,7 +4478,6 @@
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^18.0.0"
}
@@ -4760,7 +4786,6 @@
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
@@ -5581,7 +5606,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.9",
"caniuse-lite": "^1.0.30001746",
@@ -5894,6 +5918,7 @@
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"license": "MIT",
"peer": true,
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
@@ -6343,7 +6368,8 @@
"integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==",
"dev": true,
"license": "MIT",
"optional": true
"optional": true,
"peer": true
},
"node_modules/cross-spawn": {
"version": "7.0.6",
@@ -6639,7 +6665,6 @@
"integrity": "sha512-uOOBA3f+kW3o4KpSoMQ6SNpdXU7WtxlJRb9vCZgOvqhTz4b3GjcoWKstdisizNZLsylhTMv8TLHFPFW0Uxsj/g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"app-builder-lib": "26.7.0",
"builder-util": "26.4.1",
@@ -7066,6 +7091,7 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@electron/asar": "^3.2.1",
"debug": "^4.1.1",
@@ -7086,6 +7112,7 @@
"integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"graceful-fs": "^4.1.2",
"jsonfile": "^4.0.0",
@@ -8768,7 +8795,6 @@
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"license": "MIT",
"peer": true,
"bin": {
"jiti": "bin/jiti.js"
}
@@ -10331,6 +10357,7 @@
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.4"
},
@@ -10846,6 +10873,18 @@
"node": ">=4.0.0"
}
},
"node_modules/pixi-filters": {
"version": "6.1.5",
"resolved": "https://registry.npmjs.org/pixi-filters/-/pixi-filters-6.1.5.tgz",
"integrity": "sha512-Ewb/J+kxAbaNN+0/ATJbglAJG+skGJfh7BIDP3ILIDdD6wWk1p0pGa25pVf1T8hGBOQSUNVAmwwJBwkj+cyLLA==",
"license": "MIT",
"dependencies": {
"@types/gradient-parser": "^0.1.2"
},
"peerDependencies": {
"pixi.js": ">=8.0.0-0"
}
},
"node_modules/pixi.js": {
"version": "8.14.0",
"resolved": "https://registry.npmjs.org/pixi.js/-/pixi.js-8.14.0.tgz",
@@ -10869,6 +10908,50 @@
"url": "https://opencollective.com/pixijs"
}
},
"node_modules/playwright": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
"dev": true,
"dependencies": {
"playwright-core": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
"dev": true,
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/plist": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz",
@@ -10920,7 +11003,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -11065,6 +11147,7 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"commander": "^9.4.0"
},
@@ -11082,6 +11165,7 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": "^12.20.0 || >=14"
}
@@ -11230,6 +11314,7 @@
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
"integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
"license": "BSD-3-Clause",
"peer": true,
"dependencies": {
"side-channel": "^1.1.0"
},
@@ -11288,7 +11373,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@@ -11301,7 +11385,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@@ -12092,6 +12175,7 @@
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
"license": "MIT",
"peer": true,
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3",
@@ -12111,6 +12195,7 @@
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
"license": "MIT",
"peer": true,
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3"
@@ -12127,6 +12212,7 @@
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"license": "MIT",
"peer": true,
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
@@ -12145,6 +12231,7 @@
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"license": "MIT",
"peer": true,
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
@@ -12792,7 +12879,6 @@
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.18.tgz",
"integrity": "sha512-6A2rnmW5xZMdw11LYjhcI5846rt9pbLSabY5XPxo+XWdxwZaFEn47Go4NzFiHu9sNNmr/kXivP1vStfvMaK1GQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
"arg": "^5.0.2",
@@ -12865,6 +12951,7 @@
"integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"mkdirp": "^0.5.1",
"rimraf": "~2.6.2"
@@ -12928,6 +13015,7 @@
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"minimist": "^1.2.6"
},
@@ -12942,6 +13030,7 @@
"deprecated": "Rimraf versions prior to v4 are no longer supported",
"dev": true,
"license": "ISC",
"peer": true,
"dependencies": {
"glob": "^7.1.3"
},
@@ -12955,7 +13044,6 @@
"integrity": "sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw==",
"dev": true,
"license": "BSD-2-Clause",
"peer": true,
"dependencies": {
"@jridgewell/source-map": "^0.3.3",
"acorn": "^8.15.0",
@@ -13108,7 +13196,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -13342,6 +13429,7 @@
"resolved": "https://registry.npmjs.org/url/-/url-0.11.4.tgz",
"integrity": "sha512-oCwdVC7mTuWiPyjLUz/COz5TLk6wgp0RCsN+wHZ2Ekneac9w8uuV0njcbbie2ME+Vs+d6duwmYuR3HgQXs1fOg==",
"license": "MIT",
"peer": true,
"dependencies": {
"punycode": "^1.4.1",
"qs": "^6.12.3"
@@ -13354,7 +13442,8 @@
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz",
"integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/use-callback-ref": {
"version": "1.3.3",
@@ -13468,7 +13557,6 @@
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.43",
@@ -13543,8 +13631,7 @@
"resolved": "https://registry.npmjs.org/vite-plugin-electron-renderer/-/vite-plugin-electron-renderer-0.14.6.tgz",
"integrity": "sha512-oqkWFa7kQIkvHXG7+Mnl1RTroA4sP0yesKatmAy0gjZC4VwUqlvF9IvOpHd1fpLWsqYX/eZlVxlhULNtaQ78Jw==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/vitest": {
"version": "4.0.16",
@@ -14108,7 +14195,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -14122,7 +14208,6 @@
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
+4
View File
@@ -15,6 +15,8 @@
"build:linux": "tsc && vite build && electron-builder --linux",
"test": "vitest --run",
"test:watch": "vitest",
"build-vite": "tsc && vite build",
"test:e2e": "playwright test",
"prepare": "husky"
},
"dependencies": {
@@ -46,6 +48,7 @@
"mediabunny": "^1.25.1",
"motion": "^12.23.24",
"mp4box": "^2.2.0",
"pixi-filters": "^6.1.5",
"pixi.js": "^8.14.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
@@ -60,6 +63,7 @@
},
"devDependencies": {
"@biomejs/biome": "^2.3.13",
"@playwright/test": "^1.58.2",
"@types/node": "^25.0.3",
"@types/react": "^18.2.64",
"@types/react-dom": "^18.2.21",
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from "@playwright/test";
export default defineConfig({
testDir: "./tests/e2e",
timeout: 120_000, // GIF encoding is CPU-bound; give it room
retries: 0,
reporter: "list",
});
+25 -1
View File
@@ -4,7 +4,16 @@ import { BsRecordCircle } from "react-icons/bs";
import { FaRegStopCircle } from "react-icons/fa";
import { FaFolderOpen } from "react-icons/fa6";
import { FiMinus, FiX } from "react-icons/fi";
import { MdMic, MdMicOff, MdMonitor, MdVideoFile, MdVolumeOff, MdVolumeUp } from "react-icons/md";
import {
MdMic,
MdMicOff,
MdMonitor,
MdVideocam,
MdVideocamOff,
MdVideoFile,
MdVolumeOff,
MdVolumeUp,
} from "react-icons/md";
import { RxDragHandleDots2 } from "react-icons/rx";
import { useAudioLevelMeter } from "../../hooks/useAudioLevelMeter";
import { useMicrophoneDevices } from "../../hooks/useMicrophoneDevices";
@@ -23,6 +32,8 @@ const ICON_CONFIG = {
volumeOff: { icon: MdVolumeOff, size: ICON_SIZE },
micOn: { icon: MdMic, size: ICON_SIZE },
micOff: { icon: MdMicOff, size: ICON_SIZE },
webcamOn: { icon: MdVideocam, size: ICON_SIZE },
webcamOff: { icon: MdVideocamOff, size: ICON_SIZE },
stop: { icon: FaRegStopCircle, size: ICON_SIZE },
record: { icon: BsRecordCircle, size: ICON_SIZE },
videoFile: { icon: MdVideoFile, size: ICON_SIZE },
@@ -57,6 +68,8 @@ export function LaunchWindow() {
setMicrophoneDeviceId,
systemAudioEnabled,
setSystemAudioEnabled,
webcamEnabled,
setWebcamEnabled,
} = useScreenRecorder();
const [recordingStart, setRecordingStart] = useState<number | null>(null);
const [elapsed, setElapsed] = useState(0);
@@ -233,6 +246,17 @@ export function LaunchWindow() {
? getIcon("micOn", "text-green-400")
: getIcon("micOff", "text-white/40")}
</button>
<button
className={`${hudIconBtnClasses} ${webcamEnabled ? "drop-shadow-[0_0_4px_rgba(74,222,128,0.4)]" : ""}`}
onClick={() => {
void setWebcamEnabled(!webcamEnabled);
}}
title={webcamEnabled ? "Disable webcam" : "Enable webcam"}
>
{webcamEnabled
? getIcon("webcamOn", "text-green-400")
: getIcon("webcamOff", "text-white/40")}
</button>
</div>
{/* Record/Stop group */}
+26 -11
View File
@@ -33,6 +33,7 @@ import type { ExportFormat, ExportQuality, GifFrameRate, GifSizePreset } from "@
import { GIF_FRAME_RATES, GIF_SIZE_PRESETS } from "@/lib/exporter";
import { cn } from "@/lib/utils";
import { type AspectRatio } from "@/utils/aspectRatioUtils";
import { getTestId } from "@/utils/getTestId";
import { AnnotationSettingsPanel } from "./AnnotationSettingsPanel";
import { CropControl } from "./CropControl";
import { KeyboardShortcutsHelp } from "./KeyboardShortcutsHelp";
@@ -92,8 +93,9 @@ interface SettingsPanelProps {
onShadowCommit?: () => void;
showBlur?: boolean;
onBlurChange?: (showBlur: boolean) => void;
motionBlurEnabled?: boolean;
onMotionBlurChange?: (enabled: boolean) => void;
motionBlurAmount?: number;
onMotionBlurChange?: (amount: number) => void;
onMotionBlurCommit?: () => void;
borderRadius?: number;
onBorderRadiusChange?: (radius: number) => void;
onBorderRadiusCommit?: () => void;
@@ -157,8 +159,9 @@ export function SettingsPanel({
onShadowCommit,
showBlur,
onBlurChange,
motionBlurEnabled = false,
motionBlurAmount = 0,
onMotionBlurChange,
onMotionBlurCommit,
borderRadius = 0,
onBorderRadiusChange,
onBorderRadiusCommit,
@@ -574,14 +577,6 @@ export function SettingsPanel({
</AccordionTrigger>
<AccordionContent className="pb-3">
<div className="grid grid-cols-2 gap-2 mb-3">
<div className="flex items-center justify-between p-2 rounded-lg bg-white/5 border border-white/5">
<div className="text-[10px] font-medium text-slate-300">Motion Blur</div>
<Switch
checked={motionBlurEnabled}
onCheckedChange={onMotionBlurChange}
className="data-[state=checked]:bg-[#34B27B] scale-90"
/>
</div>
<div className="flex items-center justify-between p-2 rounded-lg bg-white/5 border border-white/5">
<div className="text-[10px] font-medium text-slate-300">Blur BG</div>
<Switch
@@ -593,6 +588,23 @@ export function SettingsPanel({
</div>
<div className="grid grid-cols-2 gap-2">
<div className="p-2 rounded-lg bg-white/5 border border-white/5">
<div className="flex items-center justify-between mb-1">
<div className="text-[10px] font-medium text-slate-300">Motion Blur</div>
<span className="text-[10px] text-slate-500 font-mono">
{motionBlurAmount === 0 ? "off" : motionBlurAmount.toFixed(2)}
</span>
</div>
<Slider
value={[motionBlurAmount]}
onValueChange={(values) => onMotionBlurChange?.(values[0])}
onValueCommit={() => onMotionBlurCommit?.()}
min={0}
max={1}
step={0.01}
className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3"
/>
</div>
<div className="p-2 rounded-lg bg-white/5 border border-white/5">
<div className="flex items-center justify-between mb-1">
<div className="text-[10px] font-medium text-slate-300">Shadow</div>
@@ -957,6 +969,7 @@ export function SettingsPanel({
MP4
</button>
<button
data-testid={getTestId("gif-format-button")}
onClick={() => onExportFormatChange?.("gif")}
className={cn(
"flex-1 flex items-center justify-center gap-1.5 py-2 rounded-lg border transition-all text-xs font-medium",
@@ -1031,6 +1044,7 @@ export function SettingsPanel({
{Object.entries(GIF_SIZE_PRESETS).map(([key, _preset]) => (
<button
key={key}
data-testid={getTestId(`gif-size-button-${key}`)}
onClick={() => onGifSizePresetChange?.(key as GifSizePreset)}
className={cn(
"rounded-md transition-all text-[10px] font-medium",
@@ -1082,6 +1096,7 @@ export function SettingsPanel({
</div>
<Button
data-testid={getTestId("export-button")}
type="button"
size="lg"
onClick={onExport}
+81 -28
View File
@@ -17,6 +17,7 @@ import {
type GifSizePreset,
VideoExporter,
} from "@/lib/exporter";
import type { ProjectMedia } from "@/lib/recordingSession";
import { matchesShortcut } from "@/lib/shortcuts";
import { getAspectRatioValue, getNativeAspectRatioValue } from "@/utils/aspectRatioUtils";
import { ExportDialog } from "./ExportDialog";
@@ -26,6 +27,7 @@ import {
deriveNextId,
fromFileUrl,
normalizeProjectEditor,
resolveProjectMedia,
toFileUrl,
validateProjectData,
} from "./projectPersistence";
@@ -70,7 +72,7 @@ export default function VideoEditor() {
wallpaper,
shadowIntensity,
showBlur,
motionBlurEnabled,
motionBlurAmount,
borderRadius,
padding,
aspectRatio,
@@ -79,6 +81,8 @@ export default function VideoEditor() {
// ── Non-undoable state
const [videoPath, setVideoPath] = useState<string | null>(null);
const [videoSourcePath, setVideoSourcePath] = useState<string | null>(null);
const [webcamVideoPath, setWebcamVideoPath] = useState<string | null>(null);
const [webcamVideoSourcePath, setWebcamVideoSourcePath] = useState<string | null>(null);
const [currentProjectPath, setCurrentProjectPath] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -111,6 +115,19 @@ export default function VideoEditor() {
const nextAnnotationZIndexRef = useRef(1);
const exporterRef = useRef<VideoExporter | null>(null);
const currentProjectMedia = useMemo<ProjectMedia | null>(() => {
const screenVideoPath = videoSourcePath ?? (videoPath ? fromFileUrl(videoPath) : null);
if (!screenVideoPath) {
return null;
}
const webcamSourcePath =
webcamVideoSourcePath ?? (webcamVideoPath ? fromFileUrl(webcamVideoPath) : null);
return webcamSourcePath
? { screenVideoPath, webcamVideoPath: webcamSourcePath }
: { screenVideoPath };
}, [videoPath, videoSourcePath, webcamVideoPath, webcamVideoSourcePath]);
const applyLoadedProject = useCallback(
async (candidate: unknown, path?: string | null) => {
if (!validateProjectData(candidate)) {
@@ -118,7 +135,12 @@ export default function VideoEditor() {
}
const project = candidate;
const sourcePath = fromFileUrl(project.videoPath);
const media = resolveProjectMedia(project);
if (!media) {
return false;
}
const sourcePath = fromFileUrl(media.screenVideoPath);
const webcamSourcePath = media.webcamVideoPath ? fromFileUrl(media.webcamVideoPath) : null;
const normalizedEditor = normalizeProjectEditor(project.editor);
try {
@@ -133,13 +155,15 @@ export default function VideoEditor() {
setError(null);
setVideoSourcePath(sourcePath);
setVideoPath(toFileUrl(sourcePath));
setWebcamVideoSourcePath(webcamSourcePath);
setWebcamVideoPath(webcamSourcePath ? toFileUrl(webcamSourcePath) : null);
setCurrentProjectPath(path ?? null);
pushState({
wallpaper: normalizedEditor.wallpaper,
shadowIntensity: normalizedEditor.shadowIntensity,
showBlur: normalizedEditor.showBlur,
motionBlurEnabled: normalizedEditor.motionBlurEnabled,
motionBlurAmount: normalizedEditor.motionBlurAmount,
borderRadius: normalizedEditor.borderRadius,
padding: normalizedEditor.padding,
cropRegion: normalizedEditor.cropRegion,
@@ -182,23 +206,31 @@ export default function VideoEditor() {
0,
) + 1;
setLastSavedSnapshot(JSON.stringify(createProjectData(sourcePath, normalizedEditor)));
setLastSavedSnapshot(
JSON.stringify(
createProjectData(
webcamSourcePath
? { screenVideoPath: sourcePath, webcamVideoPath: webcamSourcePath }
: { screenVideoPath: sourcePath },
normalizedEditor,
),
),
);
return true;
},
[pushState],
);
const currentProjectSnapshot = useMemo(() => {
const sourcePath = videoSourcePath ?? (videoPath ? fromFileUrl(videoPath) : null);
if (!sourcePath) {
if (!currentProjectMedia) {
return null;
}
return JSON.stringify(
createProjectData(sourcePath, {
createProjectData(currentProjectMedia, {
wallpaper,
shadowIntensity,
showBlur,
motionBlurEnabled,
motionBlurAmount,
borderRadius,
padding,
cropRegion,
@@ -215,12 +247,11 @@ export default function VideoEditor() {
}),
);
}, [
videoPath,
videoSourcePath,
currentProjectMedia,
wallpaper,
shadowIntensity,
showBlur,
motionBlurEnabled,
motionBlurAmount,
borderRadius,
padding,
cropRegion,
@@ -257,11 +288,29 @@ export default function VideoEditor() {
}
}
const currentSessionResult = await window.electronAPI.getCurrentRecordingSession();
if (currentSessionResult.success && currentSessionResult.session) {
const session = currentSessionResult.session;
const sourcePath = fromFileUrl(session.screenVideoPath);
const webcamSourcePath = session.webcamVideoPath
? fromFileUrl(session.webcamVideoPath)
: null;
setVideoSourcePath(sourcePath);
setVideoPath(toFileUrl(sourcePath));
setWebcamVideoSourcePath(webcamSourcePath);
setWebcamVideoPath(webcamSourcePath ? toFileUrl(webcamSourcePath) : null);
setCurrentProjectPath(null);
setLastSavedSnapshot(null);
return;
}
const result = await window.electronAPI.getCurrentVideoPath();
if (result.success && result.path) {
const sourcePath = fromFileUrl(result.path);
setVideoSourcePath(sourcePath);
setVideoPath(toFileUrl(sourcePath));
setWebcamVideoSourcePath(null);
setWebcamVideoPath(null);
setCurrentProjectPath(null);
setLastSavedSnapshot(null);
} else {
@@ -284,17 +333,16 @@ export default function VideoEditor() {
return false;
}
const sourcePath = videoSourcePath ?? fromFileUrl(videoPath);
if (!sourcePath) {
if (!currentProjectMedia) {
toast.error("Unable to determine source video path");
return false;
}
const projectData = createProjectData(sourcePath, {
const projectData = createProjectData(currentProjectMedia, {
wallpaper,
shadowIntensity,
showBlur,
motionBlurEnabled,
motionBlurAmount,
borderRadius,
padding,
cropRegion,
@@ -311,7 +359,7 @@ export default function VideoEditor() {
});
const fileNameBase =
sourcePath
currentProjectMedia.screenVideoPath
.split(/[\\/]/)
.pop()
?.replace(/\.[^.]+$/, "") || `project-${Date.now()}`;
@@ -341,13 +389,12 @@ export default function VideoEditor() {
return true;
},
[
videoPath,
videoSourcePath,
currentProjectMedia,
currentProjectPath,
wallpaper,
shadowIntensity,
showBlur,
motionBlurEnabled,
motionBlurAmount,
borderRadius,
padding,
cropRegion,
@@ -361,6 +408,7 @@ export default function VideoEditor() {
gifFrameRate,
gifLoop,
gifSizePreset,
videoPath,
],
);
@@ -420,7 +468,7 @@ export default function VideoEditor() {
let mounted = true;
async function loadCursorTelemetry() {
const sourcePath = videoSourcePath ?? (videoPath ? fromFileUrl(videoPath) : null);
const sourcePath = currentProjectMedia?.screenVideoPath ?? null;
if (!sourcePath) {
if (mounted) {
@@ -447,7 +495,7 @@ export default function VideoEditor() {
return () => {
mounted = false;
};
}, [videoPath, videoSourcePath]);
}, [currentProjectMedia]);
function togglePlayPause() {
const playback = videoPlaybackRef.current;
@@ -921,6 +969,7 @@ export default function VideoEditor() {
// GIF Export
const gifExporter = new GifExporter({
videoUrl: videoPath,
webcamVideoUrl: webcamVideoPath || undefined,
width: settings.gifConfig.width,
height: settings.gifConfig.height,
frameRate: settings.gifConfig.frameRate,
@@ -933,7 +982,7 @@ export default function VideoEditor() {
showShadow: shadowIntensity > 0,
shadowIntensity,
showBlur,
motionBlurEnabled,
motionBlurAmount,
borderRadius,
padding,
videoPadding: padding,
@@ -1048,6 +1097,7 @@ export default function VideoEditor() {
const exporter = new VideoExporter({
videoUrl: videoPath,
webcamVideoUrl: webcamVideoPath || undefined,
width: exportWidth,
height: exportHeight,
frameRate: 60,
@@ -1060,7 +1110,7 @@ export default function VideoEditor() {
showShadow: shadowIntensity > 0,
shadowIntensity,
showBlur,
motionBlurEnabled,
motionBlurAmount,
borderRadius,
padding,
cropRegion,
@@ -1115,13 +1165,14 @@ export default function VideoEditor() {
},
[
videoPath,
webcamVideoPath,
wallpaper,
zoomRegions,
trimRegions,
speedRegions,
shadowIntensity,
showBlur,
motionBlurEnabled,
motionBlurAmount,
borderRadius,
padding,
cropRegion,
@@ -1251,10 +1302,11 @@ export default function VideoEditor() {
}}
>
<VideoPlayback
key={videoPath || "no-video"}
key={`${videoPath || "no-video"}:${webcamVideoPath || "no-webcam"}`}
aspectRatio={aspectRatio}
ref={videoPlaybackRef}
videoPath={videoPath || ""}
webcamVideoPath={webcamVideoPath || undefined}
onDurationChange={setDuration}
onTimeUpdate={setCurrentTime}
currentTime={currentTime}
@@ -1270,7 +1322,7 @@ export default function VideoEditor() {
showShadow={shadowIntensity > 0}
shadowIntensity={shadowIntensity}
showBlur={showBlur}
motionBlurEnabled={motionBlurEnabled}
motionBlurAmount={motionBlurAmount}
borderRadius={borderRadius}
padding={padding}
cropRegion={cropRegion}
@@ -1369,8 +1421,9 @@ export default function VideoEditor() {
onShadowCommit={commitState}
showBlur={showBlur}
onBlurChange={(v) => pushState({ showBlur: v })}
motionBlurEnabled={motionBlurEnabled}
onMotionBlurChange={(v) => pushState({ motionBlurEnabled: v })}
motionBlurAmount={motionBlurAmount}
onMotionBlurChange={(v) => updateState({ motionBlurAmount: v })}
onMotionBlurCommit={commitState}
borderRadius={borderRadius}
onBorderRadiusChange={(v) => updateState({ borderRadius: v })}
onBorderRadiusCommit={commitState}
+274 -61
View File
@@ -7,6 +7,7 @@ import {
Texture,
VideoSource,
} from "pixi.js";
import { MotionBlurFilter } from "pixi-filters/motion-blur";
import type React from "react";
import {
forwardRef,
@@ -18,6 +19,7 @@ import {
useState,
} from "react";
import { getAssetPath } from "@/lib/assetPath";
import { computeWebcamOverlayLayout, type WebcamOverlayLayout } from "@/lib/webcamOverlay";
import {
type AspectRatio,
formatAspectRatioForCSS,
@@ -33,17 +35,28 @@ import {
type ZoomFocus,
type ZoomRegion,
} from "./types";
import { DEFAULT_FOCUS, MIN_DELTA, SMOOTHING_FACTOR } from "./videoPlayback/constants";
import {
DEFAULT_FOCUS,
ZOOM_SCALE_DEADZONE,
ZOOM_TRANSLATION_DEADZONE_PX,
} from "./videoPlayback/constants";
import { clampFocusToStage as clampFocusToStageUtil } from "./videoPlayback/focusUtils";
import { layoutVideoContent as layoutVideoContentUtil } from "./videoPlayback/layoutUtils";
import { clamp01 } from "./videoPlayback/mathUtils";
import { updateOverlayIndicator } from "./videoPlayback/overlayUtils";
import { createVideoEventHandlers } from "./videoPlayback/videoEventHandlers";
import { findDominantRegion } from "./videoPlayback/zoomRegionUtils";
import { applyZoomTransform } from "./videoPlayback/zoomTransform";
import {
applyZoomTransform,
computeFocusFromTransform,
computeZoomTransform,
createMotionBlurState,
type MotionBlurState,
} from "./videoPlayback/zoomTransform";
interface VideoPlaybackProps {
videoPath: string;
webcamVideoPath?: string;
onDurationChange: (duration: number) => void;
onTimeUpdate: (time: number) => void;
currentTime: number;
@@ -59,7 +72,7 @@ interface VideoPlaybackProps {
showShadow?: boolean;
shadowIntensity?: number;
showBlur?: boolean;
motionBlurEnabled?: boolean;
motionBlurAmount?: number;
borderRadius?: number;
padding?: number;
cropRegion?: import("./types").CropRegion;
@@ -87,6 +100,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
(
{
videoPath,
webcamVideoPath,
onDurationChange,
onTimeUpdate,
currentTime,
@@ -102,7 +116,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
showShadow,
shadowIntensity = 0,
showBlur,
motionBlurEnabled = false,
motionBlurAmount = 0,
borderRadius = 0,
padding = 50,
cropRegion,
@@ -118,7 +132,9 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
ref,
) => {
const videoRef = useRef<HTMLVideoElement | null>(null);
const webcamVideoRef = useRef<HTMLVideoElement | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const stageRef = useRef<HTMLDivElement | null>(null);
const appRef = useRef<Application | null>(null);
const videoSpriteRef = useRef<Sprite | null>(null);
const videoContainerRef = useRef<Container | null>(null);
@@ -128,6 +144,11 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
const [videoReady, setVideoReady] = useState(false);
const overlayRef = useRef<HTMLDivElement | null>(null);
const focusIndicatorRef = useRef<HTMLDivElement | null>(null);
const [webcamLayout, setWebcamLayout] = useState<WebcamOverlayLayout | null>(null);
const [webcamDimensions, setWebcamDimensions] = useState<{
width: number;
height: number;
} | null>(null);
const currentTimeRef = useRef(0);
const zoomRegionsRef = useRef<ZoomRegion[]>([]);
const selectedZoomIdRef = useRef<string | null>(null);
@@ -135,8 +156,13 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
scale: 1,
focusX: DEFAULT_FOCUS.cx,
focusY: DEFAULT_FOCUS.cy,
progress: 0,
x: 0,
y: 0,
appliedScale: 1,
});
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 });
@@ -152,7 +178,8 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
const layoutVideoContentRef = useRef<(() => void) | null>(null);
const trimRegionsRef = useRef<TrimRegion[]>([]);
const speedRegionsRef = useRef<SpeedRegion[]>([]);
const motionBlurEnabledRef = useRef(motionBlurEnabled);
const motionBlurAmountRef = useRef(motionBlurAmount);
const motionBlurStateRef = useRef<MotionBlurState>(createMotionBlurState());
const onTimeUpdateRef = useRef(onTimeUpdate);
const onPlayStateChangeRef = useRef(onPlayStateChange);
const videoReadyRafRef = useRef<number | null>(null);
@@ -382,8 +409,8 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
}, [speedRegions]);
useEffect(() => {
motionBlurEnabledRef.current = motionBlurEnabled;
}, [motionBlurEnabled]);
motionBlurAmountRef.current = motionBlurAmount;
}, [motionBlurAmount]);
useEffect(() => {
onTimeUpdateRef.current = onTimeUpdate;
@@ -416,8 +443,15 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
scale: 1,
focusX: DEFAULT_FOCUS.cx,
focusY: DEFAULT_FOCUS.cy,
progress: 0,
x: 0,
y: 0,
appliedScale: 1,
};
// Reset motion blur state for clean transitions
motionBlurStateRef.current = createMotionBlurState();
if (blurFilterRef.current) {
blurFilterRef.current.blur = 0;
}
@@ -450,7 +484,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
focusY: DEFAULT_FOCUS.cy,
motionIntensity: 0,
isPlaying: false,
motionBlurEnabled: motionBlurEnabledRef.current,
motionBlurAmount: motionBlurAmountRef.current,
});
requestAnimationFrame(() => {
@@ -609,14 +643,20 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
scale: 1,
focusX: DEFAULT_FOCUS.cx,
focusY: DEFAULT_FOCUS.cy,
progress: 0,
x: 0,
y: 0,
appliedScale: 1,
};
const blurFilter = new BlurFilter();
blurFilter.quality = 3;
blurFilter.resolution = app.renderer.resolution;
blurFilter.blur = 0;
videoContainer.filters = [blurFilter];
const motionBlurFilter = new MotionBlurFilter([0, 0], 5, 0);
videoContainer.filters = [blurFilter, motionBlurFilter];
blurFilterRef.current = blurFilter;
motionBlurFilterRef.current = motionBlurFilter;
layoutVideoContentRef.current?.();
video.pause();
@@ -666,6 +706,10 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
blurFilterRef.current.destroy();
blurFilterRef.current = null;
}
if (motionBlurFilterRef.current) {
motionBlurFilterRef.current.destroy();
motionBlurFilterRef.current = null;
}
videoTexture.destroy(true);
videoSpriteRef.current = null;
@@ -680,97 +724,154 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
const videoContainer = videoContainerRef.current;
if (!app || !videoSprite || !videoContainer) return;
const applyTransform = (motionIntensity: number) => {
const applyTransformFn = (
transform: { scale: number; x: number; y: number },
targetFocus: ZoomFocus,
motionIntensity: number,
motionVector: { x: number; y: number },
) => {
const cameraContainer = cameraContainerRef.current;
if (!cameraContainer) return;
const state = animationStateRef.current;
applyZoomTransform({
const appliedTransform = applyZoomTransform({
cameraContainer,
blurFilter: blurFilterRef.current,
motionBlurFilter: motionBlurFilterRef.current,
stageSize: stageSizeRef.current,
baseMask: baseMaskRef.current,
zoomScale: state.scale,
focusX: state.focusX,
focusY: state.focusY,
zoomProgress: state.progress,
focusX: targetFocus.cx,
focusY: targetFocus.cy,
motionIntensity,
motionVector,
isPlaying: isPlayingRef.current,
motionBlurEnabled: motionBlurEnabledRef.current,
motionBlurAmount: motionBlurAmountRef.current,
transformOverride: transform,
motionBlurState: motionBlurStateRef.current,
frameTimeMs: performance.now(),
});
state.x = appliedTransform.x;
state.y = appliedTransform.y;
state.appliedScale = appliedTransform.scale;
};
const ticker = () => {
const { region, strength } = findDominantRegion(
const { region, strength, blendedScale, transition } = findDominantRegion(
zoomRegionsRef.current,
currentTimeRef.current,
{ connectZooms: true },
);
const defaultFocus = DEFAULT_FOCUS;
let targetScaleFactor = 1;
let targetFocus = defaultFocus;
let targetProgress = 0;
// If a zoom is selected but video is not playing, show default unzoomed view
// (the overlay will show where the zoom will be)
const selectedId = selectedZoomIdRef.current;
const hasSelectedZoom = selectedId !== null;
const shouldShowUnzoomedView = hasSelectedZoom && !isPlayingRef.current;
if (region && strength > 0 && !shouldShowUnzoomedView) {
const zoomScale = ZOOM_DEPTH_SCALES[region.depth];
const regionFocus = clampFocusToStage(region.focus, region.depth);
const zoomScale = blendedScale ?? ZOOM_DEPTH_SCALES[region.depth];
const regionFocus = region.focus;
// Interpolate scale and focus based on region strength
targetScaleFactor = 1 + (zoomScale - 1) * strength;
targetFocus = {
cx: defaultFocus.cx + (regionFocus.cx - defaultFocus.cx) * strength,
cy: defaultFocus.cy + (regionFocus.cy - defaultFocus.cy) * strength,
};
targetScaleFactor = zoomScale;
targetFocus = regionFocus;
targetProgress = strength;
// Handle connected zoom transitions (pan between adjacent zoom regions)
if (transition) {
const startTransform = computeZoomTransform({
stageSize: stageSizeRef.current,
baseMask: baseMaskRef.current,
zoomScale: transition.startScale,
zoomProgress: 1,
focusX: transition.startFocus.cx,
focusY: transition.startFocus.cy,
});
const endTransform = computeZoomTransform({
stageSize: stageSizeRef.current,
baseMask: baseMaskRef.current,
zoomScale: transition.endScale,
zoomProgress: 1,
focusX: transition.endFocus.cx,
focusY: transition.endFocus.cy,
});
const interpolatedTransform = {
scale:
startTransform.scale +
(endTransform.scale - startTransform.scale) * transition.progress,
x: startTransform.x + (endTransform.x - startTransform.x) * transition.progress,
y: startTransform.y + (endTransform.y - startTransform.y) * transition.progress,
};
targetScaleFactor = interpolatedTransform.scale;
targetFocus = computeFocusFromTransform({
stageSize: stageSizeRef.current,
baseMask: baseMaskRef.current,
zoomScale: interpolatedTransform.scale,
x: interpolatedTransform.x,
y: interpolatedTransform.y,
});
targetProgress = 1;
}
}
const state = animationStateRef.current;
const prevScale = state.appliedScale;
const prevX = state.x;
const prevY = state.y;
const prevScale = state.scale;
const prevFocusX = state.focusX;
const prevFocusY = state.focusY;
state.scale = targetScaleFactor;
state.focusX = targetFocus.cx;
state.focusY = targetFocus.cy;
state.progress = targetProgress;
const scaleDelta = targetScaleFactor - state.scale;
const focusXDelta = targetFocus.cx - state.focusX;
const focusYDelta = targetFocus.cy - state.focusY;
const projectedTransform = computeZoomTransform({
stageSize: stageSizeRef.current,
baseMask: baseMaskRef.current,
zoomScale: state.scale,
zoomProgress: state.progress,
focusX: state.focusX,
focusY: state.focusY,
});
let nextScale = prevScale;
let nextFocusX = prevFocusX;
let nextFocusY = prevFocusY;
if (Math.abs(scaleDelta) > MIN_DELTA) {
nextScale = prevScale + scaleDelta * SMOOTHING_FACTOR;
} else {
nextScale = targetScaleFactor;
}
if (Math.abs(focusXDelta) > MIN_DELTA) {
nextFocusX = prevFocusX + focusXDelta * SMOOTHING_FACTOR;
} else {
nextFocusX = targetFocus.cx;
}
if (Math.abs(focusYDelta) > MIN_DELTA) {
nextFocusY = prevFocusY + focusYDelta * SMOOTHING_FACTOR;
} else {
nextFocusY = targetFocus.cy;
}
state.scale = nextScale;
state.focusX = nextFocusX;
state.focusY = nextFocusY;
const appliedScale =
Math.abs(projectedTransform.scale - prevScale) < ZOOM_SCALE_DEADZONE
? projectedTransform.scale
: projectedTransform.scale;
const appliedX =
Math.abs(projectedTransform.x - prevX) < ZOOM_TRANSLATION_DEADZONE_PX
? projectedTransform.x
: projectedTransform.x;
const appliedY =
Math.abs(projectedTransform.y - prevY) < ZOOM_TRANSLATION_DEADZONE_PX
? projectedTransform.y
: projectedTransform.y;
const motionIntensity = Math.max(
Math.abs(nextScale - prevScale),
Math.abs(nextFocusX - prevFocusX),
Math.abs(nextFocusY - prevFocusY),
Math.abs(appliedScale - prevScale),
Math.abs(appliedX - prevX) / Math.max(1, stageSizeRef.current.width),
Math.abs(appliedY - prevY) / Math.max(1, stageSizeRef.current.height),
);
applyTransform(motionIntensity);
const motionVector = {
x: appliedX - prevX,
y: appliedY - prevY,
};
applyTransformFn(
{ scale: appliedScale, x: appliedX, y: appliedY },
targetFocus,
motionIntensity,
motionVector,
);
};
app.ticker.add(ticker);
@@ -779,7 +880,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
app.ticker.remove(ticker);
}
};
}, [pixiReady, videoReady, clampFocusToStage]);
}, [pixiReady, videoReady]);
const handleLoadedMetadata = (e: React.SyntheticEvent<HTMLVideoElement, Event>) => {
const video = e.currentTarget;
@@ -810,6 +911,96 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
const [resolvedWallpaper, setResolvedWallpaper] = useState<string | null>(null);
useEffect(() => {
const webcamVideo = webcamVideoRef.current;
if (!webcamVideo || !webcamVideoPath) {
setWebcamDimensions(null);
return;
}
const handleLoadedMetadata = () => {
if (webcamVideo.videoWidth > 0 && webcamVideo.videoHeight > 0) {
setWebcamDimensions({
width: webcamVideo.videoWidth,
height: webcamVideo.videoHeight,
});
}
};
webcamVideo.addEventListener("loadedmetadata", handleLoadedMetadata);
handleLoadedMetadata();
return () => {
webcamVideo.removeEventListener("loadedmetadata", handleLoadedMetadata);
};
}, [webcamVideoPath]);
useEffect(() => {
const stage = stageRef.current;
if (!stage || !webcamDimensions) {
setWebcamLayout(null);
return;
}
const updateLayout = () => {
const layout = computeWebcamOverlayLayout({
stageWidth: stage.clientWidth,
stageHeight: stage.clientHeight,
videoWidth: webcamDimensions.width,
videoHeight: webcamDimensions.height,
});
setWebcamLayout(layout);
};
updateLayout();
if (typeof ResizeObserver === "undefined") {
return;
}
const observer = new ResizeObserver(updateLayout);
observer.observe(stage);
return () => observer.disconnect();
}, [webcamDimensions]);
useEffect(() => {
const webcamVideo = webcamVideoRef.current;
if (!webcamVideo || !webcamVideoPath) {
return;
}
const activeSpeedRegion =
speedRegions.find(
(region) => currentTime * 1000 >= region.startMs && currentTime * 1000 < region.endMs,
) ?? null;
webcamVideo.playbackRate = activeSpeedRegion ? activeSpeedRegion.speed : 1;
if (!isPlaying) {
webcamVideo.pause();
if (Math.abs(webcamVideo.currentTime - currentTime) > 0.05) {
webcamVideo.currentTime = currentTime;
}
return;
}
if (Math.abs(webcamVideo.currentTime - currentTime) > 0.15) {
webcamVideo.currentTime = currentTime;
}
webcamVideo.play().catch(() => {
// Ignore webcam autoplay restoration failures.
});
}, [currentTime, isPlaying, speedRegions, webcamVideoPath]);
useEffect(() => {
const webcamVideo = webcamVideoRef.current;
if (!webcamVideo || !webcamVideoPath) {
return;
}
webcamVideo.pause();
webcamVideo.currentTime = 0;
}, [webcamVideoPath]);
useEffect(() => {
let mounted = true;
(async () => {
@@ -884,6 +1075,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
return (
<div
ref={stageRef}
className="relative rounded-sm overflow-hidden"
style={{
width: "100%",
@@ -917,12 +1109,33 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
: "none",
}}
/>
{webcamVideoPath && (
<video
ref={webcamVideoRef}
src={webcamVideoPath}
className="absolute object-cover pointer-events-none"
style={{
left: webcamLayout?.x ?? 0,
top: webcamLayout?.y ?? 0,
width: webcamLayout?.width ?? 0,
height: webcamLayout?.height ?? 0,
borderRadius: webcamLayout?.borderRadius ?? 0,
boxShadow: "0 12px 36px rgba(0,0,0,0.35), 0 4px 12px rgba(0,0,0,0.22)",
zIndex: 20,
opacity: webcamLayout ? 1 : 0,
backgroundColor: "#000",
}}
muted
preload="metadata"
playsInline
/>
)}
{/* Only render overlay after PIXI and video are fully initialized */}
{pixiReady && videoReady && (
<div
ref={overlayRef}
className="absolute inset-0 select-none"
style={{ pointerEvents: "none" }}
style={{ pointerEvents: "none", zIndex: 30 }}
onPointerDown={handleOverlayPointerDown}
onPointerMove={handleOverlayPointerMove}
onPointerUp={handleOverlayPointerUp}
@@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import {
createProjectData,
PROJECT_VERSION,
resolveProjectMedia,
validateProjectData,
} from "./projectPersistence";
describe("projectPersistence media compatibility", () => {
it("accepts legacy projects with a single videoPath", () => {
const project = {
version: 1,
videoPath: "/tmp/screen.webm",
editor: {},
};
expect(validateProjectData(project)).toBe(true);
expect(resolveProjectMedia(project)).toEqual({
screenVideoPath: "/tmp/screen.webm",
});
});
it("creates version 2 projects with explicit media", () => {
const project = createProjectData(
{
screenVideoPath: "/tmp/screen.webm",
webcamVideoPath: "/tmp/webcam.webm",
},
{
wallpaper: "/wallpapers/wallpaper1.jpg",
shadowIntensity: 0,
showBlur: false,
motionBlurAmount: 0,
borderRadius: 0,
padding: 50,
cropRegion: { x: 0, y: 0, width: 1, height: 1 },
zoomRegions: [],
trimRegions: [],
speedRegions: [],
annotationRegions: [],
aspectRatio: "16:9",
exportQuality: "good",
exportFormat: "mp4",
gifFrameRate: 15,
gifLoop: true,
gifSizePreset: "medium",
},
);
expect(project.version).toBe(PROJECT_VERSION);
expect(project.media).toEqual({
screenVideoPath: "/tmp/screen.webm",
webcamVideoPath: "/tmp/webcam.webm",
});
expect(validateProjectData(project)).toBe(true);
});
});
@@ -1,4 +1,6 @@
import type { ExportFormat, ExportQuality, GifFrameRate, GifSizePreset } from "@/lib/exporter";
import type { ProjectMedia } from "@/lib/recordingSession";
import { normalizeProjectMedia } from "@/lib/recordingSession";
import { ASPECT_RATIOS, type AspectRatio } from "@/utils/aspectRatioUtils";
import {
type AnnotationRegion,
@@ -22,13 +24,13 @@ export const WALLPAPER_PATHS = Array.from(
(_, i) => `/wallpapers/wallpaper${i + 1}.jpg`,
);
export const PROJECT_VERSION = 1;
export const PROJECT_VERSION = 2;
export interface ProjectEditorState {
wallpaper: string;
shadowIntensity: number;
showBlur: boolean;
motionBlurEnabled: boolean;
motionBlurAmount: number;
borderRadius: number;
padding: number;
cropRegion: CropRegion;
@@ -46,8 +48,9 @@ export interface ProjectEditorState {
export interface EditorProjectData {
version: number;
videoPath: string;
media?: ProjectMedia;
editor: ProjectEditorState;
videoPath?: string;
}
function isFiniteNumber(value: unknown): value is number {
@@ -139,11 +142,26 @@ export function validateProjectData(candidate: unknown): candidate is EditorProj
if (!candidate || typeof candidate !== "object") return false;
const project = candidate as Partial<EditorProjectData>;
if (typeof project.version !== "number") return false;
if (typeof project.videoPath !== "string" || !project.videoPath) return false;
if (!resolveProjectMedia(project)) return false;
if (!project.editor || typeof project.editor !== "object") return false;
return true;
}
export function resolveProjectMedia(
candidate: Partial<EditorProjectData> | { media?: unknown; videoPath?: unknown },
): ProjectMedia | null {
const media = normalizeProjectMedia(candidate.media);
if (media) {
return media;
}
if (typeof candidate.videoPath === "string" && candidate.videoPath.trim()) {
return { screenVideoPath: candidate.videoPath };
}
return null;
}
export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): ProjectEditorState {
const validAspectRatios = new Set<AspectRatio>(ASPECT_RATIOS);
@@ -302,8 +320,13 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
wallpaper: typeof editor.wallpaper === "string" ? editor.wallpaper : WALLPAPER_PATHS[0],
shadowIntensity: typeof editor.shadowIntensity === "number" ? editor.shadowIntensity : 0,
showBlur: typeof editor.showBlur === "boolean" ? editor.showBlur : false,
motionBlurEnabled:
typeof editor.motionBlurEnabled === "boolean" ? editor.motionBlurEnabled : false,
motionBlurAmount: isFiniteNumber(editor.motionBlurAmount)
? clamp(editor.motionBlurAmount, 0, 1)
: typeof (editor as { motionBlurEnabled?: unknown }).motionBlurEnabled === "boolean"
? (editor as { motionBlurEnabled?: boolean }).motionBlurEnabled
? 0.35
: 0
: 0,
borderRadius: typeof editor.borderRadius === "number" ? editor.borderRadius : 0,
padding: isFiniteNumber(editor.padding) ? clamp(editor.padding, 0, 100) : 50,
cropRegion: {
@@ -341,12 +364,12 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
}
export function createProjectData(
videoPath: string,
media: ProjectMedia,
editor: ProjectEditorState,
): EditorProjectData {
return {
version: PROJECT_VERSION,
videoPath,
media,
editor,
};
}
@@ -1,7 +1,10 @@
import type { ZoomFocus } from "../types";
export const DEFAULT_FOCUS: ZoomFocus = { cx: 0.5, cy: 0.5 };
export const TRANSITION_WINDOW_MS = 320;
export const SMOOTHING_FACTOR = 0.12;
export const TRANSITION_WINDOW_MS = 1015.05;
export const ZOOM_IN_TRANSITION_WINDOW_MS = TRANSITION_WINDOW_MS * 1.5;
export const MIN_DELTA = 0.0001;
export const VIEWPORT_SCALE = 0.8;
export const SMOOTHING_FACTOR = 0.12;
export const ZOOM_TRANSLATION_DEADZONE_PX = 1.25;
export const ZOOM_SCALE_DEADZONE = 0.002;
@@ -5,28 +5,93 @@ interface StageSize {
height: number;
}
function clamp(value: number, min: number, max: number) {
return Math.max(min, Math.min(max, value));
}
function easeIntoBoundary(normalized: number) {
const t = clamp(normalized, 0, 1);
return -t * t * t + 2 * t * t;
}
function softClampToRange(value: number, min: number, max: number, softness: number) {
const clamped = clamp(value, min, max);
if (softness <= 0 || max <= min) {
return clamped;
}
if (clamped < min + softness) {
const normalized = (clamped - min) / softness;
return min + softness * easeIntoBoundary(normalized);
}
if (clamped > max - softness) {
const normalized = (max - clamped) / softness;
return max - softness * easeIntoBoundary(normalized);
}
return clamped;
}
function getFocusBounds(depth: ZoomDepth) {
const zoomScale = ZOOM_DEPTH_SCALES[depth];
return getFocusBoundsForScale(zoomScale);
}
function getFocusBoundsForScale(zoomScale: number) {
const marginX = 1 / (2 * zoomScale);
const marginY = 1 / (2 * zoomScale);
return {
minX: marginX,
maxX: 1 - marginX,
minY: marginY,
maxY: 1 - marginY,
};
}
export function clampFocusToStage(
focus: ZoomFocus,
depth: ZoomDepth,
stageSize: StageSize,
_stageSize: StageSize,
): ZoomFocus {
if (!stageSize.width || !stageSize.height) {
return clampFocusToDepth(focus, depth);
}
const zoomScale = ZOOM_DEPTH_SCALES[depth];
const windowWidth = stageSize.width / zoomScale;
const windowHeight = stageSize.height / zoomScale;
const marginX = windowWidth / (2 * stageSize.width);
const marginY = windowHeight / (2 * stageSize.height);
const baseFocus = clampFocusToDepth(focus, depth);
const bounds = getFocusBounds(depth);
return {
cx: Math.max(marginX, Math.min(1 - marginX, baseFocus.cx)),
cy: Math.max(marginY, Math.min(1 - marginY, baseFocus.cy)),
cx: clamp(baseFocus.cx, bounds.minX, bounds.maxX),
cy: clamp(baseFocus.cy, bounds.minY, bounds.maxY),
};
}
export function clampFocusToScale(focus: ZoomFocus, zoomScale: number): ZoomFocus {
const baseFocus = {
cx: clamp(focus.cx, 0, 1),
cy: clamp(focus.cy, 0, 1),
};
const bounds = getFocusBoundsForScale(zoomScale);
return {
cx: clamp(baseFocus.cx, bounds.minX, bounds.maxX),
cy: clamp(baseFocus.cy, bounds.minY, bounds.maxY),
};
}
export function softenFocusToScale(focus: ZoomFocus, zoomScale: number): ZoomFocus {
const baseFocus = {
cx: clamp(focus.cx, 0, 1),
cy: clamp(focus.cy, 0, 1),
};
const bounds = getFocusBoundsForScale(zoomScale);
const horizontalRange = bounds.maxX - bounds.minX;
const verticalRange = bounds.maxY - bounds.minY;
const horizontalSoftness = Math.min(0.12, horizontalRange * 0.35);
const verticalSoftness = Math.min(0.12, verticalRange * 0.35);
return {
cx: softClampToRange(baseFocus.cx, bounds.minX, bounds.maxX, horizontalSoftness),
cy: softClampToRange(baseFocus.cy, bounds.minY, bounds.maxY, verticalSoftness),
};
}
@@ -2,7 +2,85 @@ export function clamp01(value: number) {
return Math.max(0, Math.min(1, value));
}
function sampleCubicBezier(a1: number, a2: number, t: number) {
const oneMinusT = 1 - t;
return 3 * a1 * oneMinusT * oneMinusT * t + 3 * a2 * oneMinusT * t * t + t * t * t;
}
function sampleCubicBezierDerivative(a1: number, a2: number, t: number) {
const oneMinusT = 1 - t;
return 3 * a1 * oneMinusT * oneMinusT + 6 * (a2 - a1) * oneMinusT * t + 3 * (1 - a2) * t * t;
}
export function cubicBezier(x1: number, y1: number, x2: number, y2: number, t: number) {
const targetX = clamp01(t);
let solvedT = targetX;
for (let i = 0; i < 8; i += 1) {
const currentX = sampleCubicBezier(x1, x2, solvedT) - targetX;
const currentDerivative = sampleCubicBezierDerivative(x1, x2, solvedT);
if (Math.abs(currentX) < 1e-6 || Math.abs(currentDerivative) < 1e-6) {
break;
}
solvedT -= currentX / currentDerivative;
}
let lower = 0;
let upper = 1;
solvedT = clamp01(solvedT);
for (let i = 0; i < 10; i += 1) {
const currentX = sampleCubicBezier(x1, x2, solvedT);
if (Math.abs(currentX - targetX) < 1e-6) {
break;
}
if (currentX < targetX) {
lower = solvedT;
} else {
upper = solvedT;
}
solvedT = (lower + upper) / 2;
}
return sampleCubicBezier(y1, y2, solvedT);
}
export function easeOutExpo(t: number) {
const clamped = clamp01(t);
if (clamped === 1) {
return 1;
}
return 1 - Math.pow(2, -7 * clamped);
}
export function easeOutScreenStudio(t: number) {
return cubicBezier(0.16, 1, 0.3, 1, t);
}
export function smoothStep(t: number) {
const clamped = clamp01(t);
return clamped * clamped * (3 - 2 * clamped);
}
/**
* Gentle ease-in-out cubic — slow start, smooth middle, gentle landing.
* Used for zoom-in transitions.
*/
export function easeInOutCubic(t: number) {
const x = clamp01(t);
return x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2;
}
/**
* Ease-out cubic — starts at speed, then decelerates to a gentle stop.
* Used for zoom-out transitions so strength eases smoothly to zero.
*/
export function easeOutCubic(t: number) {
const x = clamp01(t);
return 1 - Math.pow(1 - x, 3);
}
@@ -1,31 +1,224 @@
import type { ZoomRegion } from "../types";
import { TRANSITION_WINDOW_MS } from "./constants";
import { smoothStep } from "./mathUtils";
import type { ZoomFocus, ZoomRegion } from "../types";
import { ZOOM_DEPTH_SCALES } from "../types";
import { TRANSITION_WINDOW_MS, ZOOM_IN_TRANSITION_WINDOW_MS } from "./constants";
import { clampFocusToScale } from "./focusUtils";
import { clamp01, cubicBezier, easeOutScreenStudio } from "./mathUtils";
const CHAINED_ZOOM_PAN_GAP_MS = 1500;
const CONNECTED_ZOOM_PAN_DURATION_MS = 1000;
const ZOOM_IN_OVERLAP_MS = 500;
type DominantRegionOptions = {
connectZooms?: boolean;
};
type ConnectedRegionPair = {
currentRegion: ZoomRegion;
nextRegion: ZoomRegion;
transitionStart: number;
transitionEnd: number;
};
type ConnectedPanTransition = {
progress: number;
startFocus: ZoomFocus;
endFocus: ZoomFocus;
startScale: number;
endScale: number;
};
function lerp(start: number, end: number, amount: number) {
return start + (end - start) * amount;
}
function easeConnectedPan(value: number) {
return cubicBezier(0.1, 0.0, 0.2, 1.0, value);
}
export function computeRegionStrength(region: ZoomRegion, timeMs: number) {
const leadInStart = region.startMs - TRANSITION_WINDOW_MS;
const zoomInEnd = region.startMs + ZOOM_IN_OVERLAP_MS;
const leadInStart = zoomInEnd - ZOOM_IN_TRANSITION_WINDOW_MS;
const leadOutEnd = region.endMs + TRANSITION_WINDOW_MS;
if (timeMs < leadInStart || timeMs > leadOutEnd) {
return 0;
}
const fadeIn = smoothStep((timeMs - leadInStart) / TRANSITION_WINDOW_MS);
const fadeOut = smoothStep((leadOutEnd - timeMs) / TRANSITION_WINDOW_MS);
return Math.min(fadeIn, fadeOut);
if (timeMs < zoomInEnd) {
const progress = (timeMs - leadInStart) / ZOOM_IN_TRANSITION_WINDOW_MS;
return easeOutScreenStudio(progress);
}
if (timeMs <= region.endMs) {
return 1;
}
const progress = clamp01((timeMs - region.endMs) / TRANSITION_WINDOW_MS);
return 1 - easeOutScreenStudio(progress);
}
export function findDominantRegion(regions: ZoomRegion[], timeMs: number) {
let bestRegion: ZoomRegion | null = null;
let bestStrength = 0;
function getLinearFocus(start: ZoomFocus, end: ZoomFocus, amount: number): ZoomFocus {
return {
cx: lerp(start.cx, end.cx, amount),
cy: lerp(start.cy, end.cy, amount),
};
}
for (const region of regions) {
const strength = computeRegionStrength(region, timeMs);
if (strength > bestStrength) {
bestStrength = strength;
bestRegion = region;
function getResolvedFocus(region: ZoomRegion, zoomScale: number): ZoomFocus {
return clampFocusToScale(region.focus, zoomScale);
}
function getConnectedRegionPairs(regions: ZoomRegion[]) {
const sortedRegions = [...regions].sort((a, b) => a.startMs - b.startMs);
const pairs: ConnectedRegionPair[] = [];
for (let index = 0; index < sortedRegions.length - 1; index += 1) {
const currentRegion = sortedRegions[index];
const nextRegion = sortedRegions[index + 1];
const gapMs = nextRegion.startMs - currentRegion.endMs;
if (gapMs > CHAINED_ZOOM_PAN_GAP_MS) {
continue;
}
pairs.push({
currentRegion,
nextRegion,
transitionStart: currentRegion.endMs,
transitionEnd: currentRegion.endMs + CONNECTED_ZOOM_PAN_DURATION_MS,
});
}
return pairs;
}
function getActiveRegion(
regions: ZoomRegion[],
timeMs: number,
connectedPairs: ConnectedRegionPair[],
) {
const activeRegions = regions
.map((region) => {
const outgoingPair = connectedPairs.find((pair) => pair.currentRegion.id === region.id);
if (outgoingPair && timeMs > outgoingPair.currentRegion.endMs) {
return { region, strength: 0 };
}
const incomingPair = connectedPairs.find((pair) => pair.nextRegion.id === region.id);
if (incomingPair && timeMs < incomingPair.transitionEnd) {
return { region, strength: 0 };
}
return { region, strength: computeRegionStrength(region, timeMs) };
})
.filter((entry) => entry.strength > 0)
.sort((left, right) => {
if (right.strength !== left.strength) {
return right.strength - left.strength;
}
return right.region.startMs - left.region.startMs;
});
if (activeRegions.length === 0) {
return null;
}
const activeRegion = activeRegions[0].region;
const activeScale = ZOOM_DEPTH_SCALES[activeRegion.depth];
return {
region: {
...activeRegion,
focus: getResolvedFocus(activeRegion, activeScale),
},
strength: activeRegions[0].strength,
blendedScale: null,
};
}
function getConnectedRegionHold(timeMs: number, connectedPairs: ConnectedRegionPair[]) {
for (const pair of connectedPairs) {
if (timeMs > pair.transitionEnd && timeMs < pair.nextRegion.startMs) {
const nextScale = ZOOM_DEPTH_SCALES[pair.nextRegion.depth];
return {
region: {
...pair.nextRegion,
focus: getResolvedFocus(pair.nextRegion, nextScale),
},
strength: 1,
blendedScale: null,
};
}
}
return { region: bestRegion, strength: bestStrength };
return null;
}
function getConnectedRegionTransition(connectedPairs: ConnectedRegionPair[], timeMs: number) {
for (const pair of connectedPairs) {
const { currentRegion, nextRegion, transitionStart, transitionEnd } = pair;
if (timeMs < transitionStart || timeMs > transitionEnd) {
continue;
}
const transitionProgress = easeConnectedPan(
clamp01((timeMs - transitionStart) / Math.max(1, transitionEnd - transitionStart)),
);
const currentScale = ZOOM_DEPTH_SCALES[currentRegion.depth];
const nextScale = ZOOM_DEPTH_SCALES[nextRegion.depth];
const transitionScale = lerp(currentScale, nextScale, transitionProgress);
const currentFocus = getResolvedFocus(currentRegion, currentScale);
const nextFocus = getResolvedFocus(nextRegion, nextScale);
const transitionFocus = getLinearFocus(currentFocus, nextFocus, transitionProgress);
return {
region: {
...nextRegion,
focus: transitionFocus,
},
strength: 1,
blendedScale: transitionScale,
transition: {
progress: transitionProgress,
startFocus: currentFocus,
endFocus: nextFocus,
startScale: currentScale,
endScale: nextScale,
},
};
}
return null;
}
export function findDominantRegion(
regions: ZoomRegion[],
timeMs: number,
options: DominantRegionOptions = {},
): {
region: ZoomRegion | null;
strength: number;
blendedScale: number | null;
transition: ConnectedPanTransition | null;
} {
const connectedPairs = options.connectZooms ? getConnectedRegionPairs(regions) : [];
if (options.connectZooms) {
const connectedTransition = getConnectedRegionTransition(connectedPairs, timeMs);
if (connectedTransition) {
return connectedTransition;
}
const connectedHold = getConnectedRegionHold(timeMs, connectedPairs);
if (connectedHold) {
return { ...connectedHold, transition: null };
}
}
const activeRegion = getActiveRegion(regions, timeMs, connectedPairs);
return activeRegion
? { ...activeRegion, transition: null }
: { region: null, strength: 0, blendedScale: null, transition: null };
}
@@ -1,61 +1,249 @@
import { BlurFilter, Container } from "pixi.js";
import { MotionBlurFilter } from "pixi-filters/motion-blur";
const PEAK_VELOCITY_PPS = 1400;
const MAX_BLUR_PX = 14;
const VELOCITY_THRESHOLD_PPS = 12;
const MAX_AMOUNT_BOOST = 2.2;
function getMotionBlurAmountResponse(motionBlurAmount: number) {
const clampedAmount = Math.min(1, Math.max(0, motionBlurAmount));
// Keep the low end usable while giving the top of the slider substantially more headroom.
return clampedAmount * (1 + (MAX_AMOUNT_BOOST - 1) * clampedAmount);
}
export interface MotionBlurState {
lastFrameTimeMs: number;
prevCamX: number;
prevCamY: number;
prevCamScale: number;
initialized: boolean;
}
export function createMotionBlurState(): MotionBlurState {
return {
lastFrameTimeMs: 0,
prevCamX: 0,
prevCamY: 0,
prevCamScale: 1,
initialized: false,
};
}
interface TransformParams {
cameraContainer: Container;
blurFilter: BlurFilter | null;
motionBlurFilter?: MotionBlurFilter | null;
stageSize: { width: number; height: number };
baseMask: { x: number; y: number; width: number; height: number };
zoomScale: number;
zoomProgress?: number;
focusX: number;
focusY: number;
motionIntensity: number;
motionVector?: { x: number; y: number };
isPlaying: boolean;
motionBlurEnabled?: boolean;
motionBlurAmount?: number;
transformOverride?: AppliedTransform;
motionBlurState?: MotionBlurState;
frameTimeMs?: number;
}
export function applyZoomTransform({
cameraContainer,
blurFilter,
interface AppliedTransform {
scale: number;
x: number;
y: number;
}
interface FocusFromTransformGeometry {
stageSize: { width: number; height: number };
baseMask: { x: number; y: number; width: number; height: number };
zoomScale: number;
x: number;
y: number;
}
interface ZoomTransformGeometry {
stageSize: { width: number; height: number };
baseMask: { x: number; y: number; width: number; height: number };
zoomScale: number;
zoomProgress?: number;
focusX: number;
focusY: number;
}
export function computeZoomTransform({
stageSize,
baseMask,
zoomScale,
zoomProgress = 1,
focusX,
focusY,
motionIntensity,
isPlaying,
motionBlurEnabled = false,
}: TransformParams) {
}: ZoomTransformGeometry): AppliedTransform {
if (
stageSize.width <= 0 ||
stageSize.height <= 0 ||
baseMask.width <= 0 ||
baseMask.height <= 0
) {
return;
return { scale: 1, x: 0, y: 0 };
}
// The focus point in stage coordinates (where the user clicked/selected)
const focusStagePxX = focusX * stageSize.width;
const focusStagePxY = focusY * stageSize.height;
// Stage center (where we want the focus to end up after zoom)
const progress = Math.min(1, Math.max(0, zoomProgress));
const focusStagePxX = baseMask.x + focusX * baseMask.width;
const focusStagePxY = baseMask.y + focusY * baseMask.height;
const stageCenterX = stageSize.width / 2;
const stageCenterY = stageSize.height / 2;
const scale = 1 + (zoomScale - 1) * progress;
const finalX = stageCenterX - focusStagePxX * zoomScale;
const finalY = stageCenterY - focusStagePxY * zoomScale;
// Apply zoom scale to camera container
cameraContainer.scale.set(zoomScale);
// Calculate camera position to keep focus point centered
// After scaling, the focus point moves to (focusX * zoomScale, focusY * zoomScale)
// We want it at stage center, so offset = center - (focus * scale)
const cameraX = stageCenterX - focusStagePxX * zoomScale;
const cameraY = stageCenterY - focusStagePxY * zoomScale;
cameraContainer.position.set(cameraX, cameraY);
if (blurFilter) {
const shouldBlur = motionBlurEnabled && isPlaying && motionIntensity > 0.0005;
const motionBlur = shouldBlur ? Math.min(6, motionIntensity * 120) : 0;
blurFilter.blur = motionBlur;
}
return {
scale,
x: finalX * progress,
y: finalY * progress,
};
}
export function computeFocusFromTransform({
stageSize,
baseMask,
zoomScale,
x,
y,
}: FocusFromTransformGeometry) {
if (
stageSize.width <= 0 ||
stageSize.height <= 0 ||
baseMask.width <= 0 ||
baseMask.height <= 0 ||
zoomScale <= 0
) {
return { cx: 0.5, cy: 0.5 };
}
const stageCenterX = stageSize.width / 2;
const stageCenterY = stageSize.height / 2;
const focusStagePxX = (stageCenterX - x) / zoomScale;
const focusStagePxY = (stageCenterY - y) / zoomScale;
return {
cx: (focusStagePxX - baseMask.x) / baseMask.width,
cy: (focusStagePxY - baseMask.y) / baseMask.height,
};
}
export function applyZoomTransform({
cameraContainer,
blurFilter,
motionBlurFilter,
stageSize,
baseMask,
zoomScale,
zoomProgress = 1,
focusX,
focusY,
motionIntensity: _motionIntensity,
motionVector: _motionVector,
isPlaying,
motionBlurAmount = 0,
transformOverride,
motionBlurState,
frameTimeMs,
}: TransformParams): AppliedTransform {
if (
stageSize.width <= 0 ||
stageSize.height <= 0 ||
baseMask.width <= 0 ||
baseMask.height <= 0
) {
return { scale: 1, x: 0, y: 0 };
}
const transform =
transformOverride ??
computeZoomTransform({
stageSize,
baseMask,
zoomScale,
zoomProgress,
focusX,
focusY,
});
// Apply position & scale to camera container
cameraContainer.scale.set(transform.scale);
cameraContainer.position.set(transform.x, transform.y);
if (motionBlurState && motionBlurFilter && motionBlurAmount > 0 && isPlaying) {
const now = frameTimeMs ?? performance.now();
if (!motionBlurState.initialized) {
motionBlurState.prevCamX = transform.x;
motionBlurState.prevCamY = transform.y;
motionBlurState.prevCamScale = transform.scale;
motionBlurState.lastFrameTimeMs = now;
motionBlurState.initialized = true;
motionBlurFilter.velocity = { x: 0, y: 0 };
motionBlurFilter.kernelSize = 5;
motionBlurFilter.offset = 0;
if (blurFilter) blurFilter.blur = 0;
} else {
const dtMs = Math.min(80, Math.max(1, now - motionBlurState.lastFrameTimeMs));
const dtSeconds = dtMs / 1000;
motionBlurState.lastFrameTimeMs = now;
const amountResponse = getMotionBlurAmountResponse(motionBlurAmount);
// Camera displacement this frame (stage-px)
const dx = transform.x - motionBlurState.prevCamX;
const dy = transform.y - motionBlurState.prevCamY;
const dScale = transform.scale - motionBlurState.prevCamScale;
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 normalised = Math.min(1, speed / PEAK_VELOCITY_PPS);
const targetBlur =
speed < VELOCITY_THRESHOLD_PPS ? 0 : normalised * normalised * MAX_BLUR_PX * amountResponse;
const dirMag = Math.sqrt(velocityX * velocityX + velocityY * velocityY) || 1;
const velocityScale = targetBlur * 2.4;
motionBlurFilter.velocity =
targetBlur > 0
? { x: (velocityX / dirMag) * velocityScale, y: (velocityY / dirMag) * velocityScale }
: { x: 0, y: 0 };
motionBlurFilter.kernelSize = targetBlur > 8 ? 15 : targetBlur > 4 ? 11 : 7;
motionBlurFilter.offset = targetBlur > 0.5 ? -0.2 : 0;
if (blurFilter) {
blurFilter.blur = 0;
}
}
} else {
if (motionBlurFilter) {
motionBlurFilter.velocity = { x: 0, y: 0 };
motionBlurFilter.kernelSize = 5;
motionBlurFilter.offset = 0;
}
if (blurFilter) {
blurFilter.blur = 0;
}
if (motionBlurState) {
motionBlurState.initialized = false;
}
}
return {
scale: transform.scale,
x: transform.x,
y: transform.y,
};
}
+2 -2
View File
@@ -20,7 +20,7 @@ export interface EditorState {
wallpaper: string;
shadowIntensity: number;
showBlur: boolean;
motionBlurEnabled: boolean;
motionBlurAmount: number;
borderRadius: number;
padding: number;
aspectRatio: AspectRatio;
@@ -35,7 +35,7 @@ export const INITIAL_EDITOR_STATE: EditorState = {
wallpaper: "/wallpapers/wallpaper1.jpg",
shadowIntensity: 0,
showBlur: false,
motionBlurEnabled: false,
motionBlurAmount: 0,
borderRadius: 0,
padding: 50,
aspectRatio: "16:9",
+289 -111
View File
@@ -1,8 +1,7 @@
import { fixWebmDuration } from "@fix-webm-duration/fix";
import { useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
// Target visually lossless 4K @ 60fps; fall back gracefully when hardware cannot keep up
const TARGET_FRAME_RATE = 60;
const MIN_FRAME_RATE = 30;
const TARGET_WIDTH = 3840;
@@ -12,18 +11,15 @@ const QHD_WIDTH = 2560;
const QHD_HEIGHT = 1440;
const QHD_PIXELS = QHD_WIDTH * QHD_HEIGHT;
// Bitrates (bits per second) per resolution tier
const BITRATE_4K = 45_000_000;
const BITRATE_QHD = 28_000_000;
const BITRATE_BASE = 18_000_000;
const HIGH_FRAME_RATE_THRESHOLD = 60;
const HIGH_FRAME_RATE_BOOST = 1.7;
// Fallback track settings when the driver reports nothing
const DEFAULT_WIDTH = 1920;
const DEFAULT_HEIGHT = 1080;
// Codec alignment: VP9/AV1 require dimensions divisible by 2
const CODEC_ALIGNMENT = 2;
const RECORDER_TIMESLICE_MS = 1000;
@@ -31,12 +27,15 @@ const BITS_PER_MEGABIT = 1_000_000;
const CHROME_MEDIA_SOURCE = "desktop";
const RECORDING_FILE_PREFIX = "recording-";
const VIDEO_FILE_EXTENSION = ".webm";
const WEBCAM_FILE_SUFFIX = "-webcam";
const AUDIO_BITRATE_VOICE = 128_000;
const AUDIO_BITRATE_SYSTEM = 192_000;
// Boost mic slightly when mixing with system audio so voice isn't drowned out
const MIC_GAIN_BOOST = 1.4;
const WEBCAM_TARGET_WIDTH = 1280;
const WEBCAM_TARGET_HEIGHT = 720;
const WEBCAM_TARGET_FRAME_RATE = 30;
type UseScreenRecorderReturn = {
recording: boolean;
@@ -47,20 +46,54 @@ type UseScreenRecorderReturn = {
setMicrophoneDeviceId: (deviceId: string | undefined) => void;
systemAudioEnabled: boolean;
setSystemAudioEnabled: (enabled: boolean) => void;
webcamEnabled: boolean;
setWebcamEnabled: (enabled: boolean) => Promise<boolean>;
};
type RecorderHandle = {
recorder: MediaRecorder;
recordedBlobPromise: Promise<Blob>;
};
function createRecorderHandle(stream: MediaStream, options: MediaRecorderOptions): RecorderHandle {
const recorder = new MediaRecorder(stream, options);
const chunks: Blob[] = [];
const mimeType = options.mimeType || "video/webm";
const recordedBlobPromise = new Promise<Blob>((resolve, reject) => {
recorder.ondataavailable = (event: BlobEvent) => {
if (event.data && event.data.size > 0) {
chunks.push(event.data);
}
};
recorder.onerror = () => {
reject(new Error("Recording failed"));
};
recorder.onstop = () => {
resolve(new Blob(chunks, { type: mimeType }));
};
});
recorder.start(RECORDER_TIMESLICE_MS);
return { recorder, recordedBlobPromise };
}
export function useScreenRecorder(): UseScreenRecorderReturn {
const [recording, setRecording] = useState(false);
const [microphoneEnabled, setMicrophoneEnabled] = useState(false);
const [microphoneDeviceId, setMicrophoneDeviceId] = useState<string | undefined>(undefined);
const [systemAudioEnabled, setSystemAudioEnabled] = useState(false);
const mediaRecorder = useRef<MediaRecorder | null>(null);
const [webcamEnabled, setWebcamEnabledState] = useState(false);
const screenRecorder = useRef<RecorderHandle | null>(null);
const webcamRecorder = useRef<RecorderHandle | null>(null);
const stream = useRef<MediaStream | null>(null);
const screenStream = useRef<MediaStream | null>(null);
const microphoneStream = useRef<MediaStream | null>(null);
const webcamStream = useRef<MediaStream | null>(null);
const mixingContext = useRef<AudioContext | null>(null);
const chunks = useRef<Blob[]>([]);
const startTime = useRef<number>(0);
const recordingId = useRef<number>(0);
const finalizingRecordingId = useRef<number | null>(null);
const allowAutoFinalize = useRef(false);
const selectMimeType = () => {
const preferred = [
@@ -90,29 +123,174 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
return Math.round(BITRATE_BASE * highFrameRateBoost);
};
const stopRecording = useRef(() => {
if (mediaRecorder.current?.state === "recording") {
if (stream.current) {
stream.current.getTracks().forEach((track) => track.stop());
}
if (screenStream.current) {
screenStream.current.getTracks().forEach((track) => track.stop());
screenStream.current = null;
}
if (microphoneStream.current) {
microphoneStream.current.getTracks().forEach((track) => track.stop());
microphoneStream.current = null;
}
if (mixingContext.current) {
mixingContext.current.close().catch(() => {
// Ignore close errors during recorder teardown.
});
mixingContext.current = null;
}
mediaRecorder.current.stop();
setRecording(false);
const teardownMedia = useCallback(() => {
if (stream.current) {
stream.current.getTracks().forEach((track) => track.stop());
stream.current = null;
}
if (screenStream.current) {
screenStream.current.getTracks().forEach((track) => track.stop());
screenStream.current = null;
}
if (microphoneStream.current) {
microphoneStream.current.getTracks().forEach((track) => track.stop());
microphoneStream.current = null;
}
if (webcamStream.current) {
webcamStream.current.getTracks().forEach((track) => track.stop());
webcamStream.current = null;
}
if (mixingContext.current) {
mixingContext.current.close().catch(() => {
// Ignore close errors during recorder teardown.
});
mixingContext.current = null;
}
}, []);
const setWebcamEnabled = useCallback(async (enabled: boolean) => {
if (!enabled) {
setWebcamEnabledState(false);
return true;
}
const accessResult = await window.electronAPI.requestCameraAccess();
if (!accessResult.success) {
toast.error("Failed to request camera access.");
return false;
}
if (!accessResult.granted) {
toast.error("Camera access is blocked. Enable it in system settings to use the webcam.");
return false;
}
try {
const probeStream = await navigator.mediaDevices.getUserMedia({
audio: false,
video: true,
});
probeStream.getTracks().forEach((track) => track.stop());
setWebcamEnabledState(true);
return true;
} catch (error) {
console.warn("Failed to preflight webcam access:", error);
toast.error("Camera access denied. Webcam overlay will stay disabled.");
return false;
}
}, []);
const finalizeRecording = useCallback(
(
activeScreenRecorder: RecorderHandle,
activeWebcamRecorder: RecorderHandle | null,
duration: number,
activeRecordingId: number,
) => {
if (finalizingRecordingId.current === activeRecordingId) {
return;
}
finalizingRecordingId.current = activeRecordingId;
if (screenRecorder.current === activeScreenRecorder) {
screenRecorder.current = null;
}
if (activeWebcamRecorder && webcamRecorder.current === activeWebcamRecorder) {
webcamRecorder.current = null;
}
teardownMedia();
setRecording(false);
window.electronAPI?.setRecordingState(false);
void (async () => {
try {
const screenBlob = await activeScreenRecorder.recordedBlobPromise;
if (screenBlob.size === 0) {
return;
}
const fixedScreenBlob = await fixWebmDuration(screenBlob, duration);
let fixedWebcamBlob: Blob | null = null;
if (activeWebcamRecorder) {
const webcamBlob = await activeWebcamRecorder.recordedBlobPromise.catch(() => null);
if (webcamBlob && webcamBlob.size > 0) {
fixedWebcamBlob = await fixWebmDuration(webcamBlob, duration);
}
}
const screenFileName = `${RECORDING_FILE_PREFIX}${activeRecordingId}${VIDEO_FILE_EXTENSION}`;
const webcamFileName = `${RECORDING_FILE_PREFIX}${activeRecordingId}${WEBCAM_FILE_SUFFIX}${VIDEO_FILE_EXTENSION}`;
const result = await window.electronAPI.storeRecordedSession({
screen: {
videoData: await fixedScreenBlob.arrayBuffer(),
fileName: screenFileName,
},
webcam: fixedWebcamBlob
? {
videoData: await fixedWebcamBlob.arrayBuffer(),
fileName: webcamFileName,
}
: undefined,
createdAt: activeRecordingId,
});
if (!result.success) {
console.error("Failed to store recording session:", result.message);
return;
}
if (result.session) {
await window.electronAPI.setCurrentRecordingSession(result.session);
} else if (result.path) {
await window.electronAPI.setCurrentVideoPath(result.path);
}
await window.electronAPI.switchToEditor();
} catch (error) {
console.error("Error saving recording:", error);
} finally {
if (finalizingRecordingId.current === activeRecordingId) {
finalizingRecordingId.current = null;
}
}
})();
},
[teardownMedia],
);
const stopRecording = useRef(() => {
const activeScreenRecorder = screenRecorder.current;
if (!activeScreenRecorder) {
return;
}
const activeWebcamRecorder = webcamRecorder.current;
const duration = Date.now() - startTime.current;
const activeRecordingId = recordingId.current;
finalizeRecording(
activeScreenRecorder,
activeWebcamRecorder ?? null,
duration,
activeRecordingId,
);
if (activeScreenRecorder.recorder.state === "recording") {
try {
activeScreenRecorder.recorder.stop();
} catch {
// Recorder may already be stopping.
}
}
if (activeWebcamRecorder) {
if (activeWebcamRecorder.recorder.state === "recording") {
try {
activeWebcamRecorder.recorder.stop();
} catch {
// Recorder may already be stopping.
}
}
}
});
@@ -127,30 +305,27 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
return () => {
if (cleanup) cleanup();
allowAutoFinalize.current = false;
if (mediaRecorder.current?.state === "recording") {
mediaRecorder.current.stop();
if (screenRecorder.current?.recorder.state === "recording") {
try {
screenRecorder.current.recorder.stop();
} catch {
// Ignore recorder teardown errors during cleanup.
}
}
if (stream.current) {
stream.current.getTracks().forEach((track) => track.stop());
stream.current = null;
}
if (screenStream.current) {
screenStream.current.getTracks().forEach((track) => track.stop());
screenStream.current = null;
}
if (microphoneStream.current) {
microphoneStream.current.getTracks().forEach((track) => track.stop());
microphoneStream.current = null;
}
if (mixingContext.current) {
mixingContext.current.close().catch(() => {
// Ignore close errors during cleanup.
});
mixingContext.current = null;
if (webcamRecorder.current?.recorder.state === "recording") {
try {
webcamRecorder.current.recorder.stop();
} catch {
// Ignore recorder teardown errors during cleanup.
}
}
screenRecorder.current = null;
webcamRecorder.current = null;
teardownMedia();
};
}, []);
}, [teardownMedia]);
const startRecording = async () => {
try {
@@ -200,7 +375,6 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
}
screenStream.current = screenMediaStream;
// If microphone is enabled, request mic stream
if (microphoneEnabled) {
try {
microphoneStream.current = await navigator.mediaDevices.getUserMedia({
@@ -225,7 +399,27 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
}
}
// Combine streams
if (webcamEnabled) {
try {
webcamStream.current = await navigator.mediaDevices.getUserMedia({
audio: false,
video: {
width: { ideal: WEBCAM_TARGET_WIDTH },
height: { ideal: WEBCAM_TARGET_HEIGHT },
frameRate: { ideal: WEBCAM_TARGET_FRAME_RATE, max: WEBCAM_TARGET_FRAME_RATE },
},
});
} catch (cameraError) {
console.warn("Failed to get webcam access:", cameraError);
if (webcamStream.current) {
webcamStream.current.getTracks().forEach((track) => track.stop());
webcamStream.current = null;
}
setWebcamEnabledState(false);
toast.error("Camera access denied. Recording will continue without webcam.");
}
}
stream.current = new MediaStream();
const videoTrack = screenMediaStream.getVideoTracks()[0];
if (!videoTrack) {
@@ -237,7 +431,6 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
const micAudioTrack = microphoneStream.current?.getAudioTracks()[0];
if (systemAudioTrack && micAudioTrack) {
// Mix system audio + mic using Web Audio API
const ctx = new AudioContext();
mixingContext.current = ctx;
const systemSource = ctx.createMediaStreamSource(new MediaStream([systemAudioTrack]));
@@ -253,6 +446,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
} else if (micAudioTrack) {
stream.current.addTrack(micAudioTrack);
}
try {
await videoTrack.applyConstraints({
frameRate: { ideal: TARGET_FRAME_RATE, max: TARGET_FRAME_RATE },
@@ -272,7 +466,6 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
frameRate = TARGET_FRAME_RATE,
} = videoTrack.getSettings();
// Ensure dimensions are divisible by 2 for VP9/AV1 codec compatibility
width = Math.floor(width / CODEC_ALIGNMENT) * CODEC_ALIGNMENT;
height = Math.floor(height / CODEC_ALIGNMENT) * CODEC_ALIGNMENT;
@@ -286,56 +479,54 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
);
const hasAudio = stream.current.getAudioTracks().length > 0;
chunks.current = [];
const recorder = new MediaRecorder(stream.current, {
screenRecorder.current = createRecorderHandle(stream.current, {
mimeType,
videoBitsPerSecond,
...(hasAudio
? { audioBitsPerSecond: systemAudioTrack ? AUDIO_BITRATE_SYSTEM : AUDIO_BITRATE_VOICE }
: {}),
});
mediaRecorder.current = recorder;
recorder.ondataavailable = (e) => {
if (e.data && e.data.size > 0) chunks.current.push(e.data);
};
recorder.onstop = async () => {
stream.current = null;
if (chunks.current.length === 0) return;
const duration = Date.now() - startTime.current;
const recordedChunks = chunks.current;
const buggyBlob = new Blob(recordedChunks, { type: mimeType });
// Clear chunks early to free memory immediately after blob creation
chunks.current = [];
const timestamp = Date.now();
const videoFileName = `${RECORDING_FILE_PREFIX}${timestamp}${VIDEO_FILE_EXTENSION}`;
screenRecorder.current.recorder.addEventListener(
"error",
() => {
setRecording(false);
},
{ once: true },
);
try {
const videoBlob = await fixWebmDuration(buggyBlob, duration);
const arrayBuffer = await videoBlob.arrayBuffer();
const videoResult = await window.electronAPI.storeRecordedVideo(
arrayBuffer,
videoFileName,
);
if (!videoResult.success) {
console.error("Failed to store video:", videoResult.message);
return;
}
if (webcamStream.current) {
webcamRecorder.current = createRecorderHandle(webcamStream.current, {
mimeType,
videoBitsPerSecond: Math.min(videoBitsPerSecond, BITRATE_BASE),
});
}
if (videoResult.path) {
await window.electronAPI.setCurrentVideoPath(videoResult.path);
}
await window.electronAPI.switchToEditor();
} catch (error) {
console.error("Error saving recording:", error);
}
};
recorder.onerror = () => setRecording(false);
recorder.start(RECORDER_TIMESLICE_MS);
startTime.current = Date.now();
recordingId.current = Date.now();
startTime.current = recordingId.current;
allowAutoFinalize.current = true;
setRecording(true);
window.electronAPI?.setRecordingState(true);
const activeScreenRecorder = screenRecorder.current;
const activeWebcamRecorder = webcamRecorder.current;
const activeRecordingId = recordingId.current;
if (activeScreenRecorder) {
activeScreenRecorder.recorder.addEventListener(
"stop",
() => {
if (!allowAutoFinalize.current) {
return;
}
finalizeRecording(
activeScreenRecorder,
activeWebcamRecorder ?? null,
Math.max(0, Date.now() - startTime.current),
activeRecordingId,
);
},
{ once: true },
);
}
} catch (error) {
console.error("Failed to start recording:", error);
const errorMsg = error instanceof Error ? error.message : "Failed to start recording";
@@ -345,24 +536,9 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
toast.error(errorMsg);
}
setRecording(false);
if (stream.current) {
stream.current.getTracks().forEach((track) => track.stop());
stream.current = null;
}
if (screenStream.current) {
screenStream.current.getTracks().forEach((track) => track.stop());
screenStream.current = null;
}
if (microphoneStream.current) {
microphoneStream.current.getTracks().forEach((track) => track.stop());
microphoneStream.current = null;
}
if (mixingContext.current) {
mixingContext.current.close().catch(() => {
// Ignore close errors during error recovery.
});
mixingContext.current = null;
}
screenRecorder.current = null;
webcamRecorder.current = null;
teardownMedia();
}
};
@@ -379,5 +555,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
setMicrophoneDeviceId,
systemAudioEnabled,
setSystemAudioEnabled,
webcamEnabled,
setWebcamEnabled,
};
}
+77
View File
@@ -0,0 +1,77 @@
type PendingConsumer = {
resolve: (frame: VideoFrame | null) => void;
reject: (error: Error) => void;
};
export class AsyncVideoFrameQueue {
private frames: VideoFrame[] = [];
private consumers: PendingConsumer[] = [];
private error: Error | null = null;
private closed = false;
get length() {
return this.frames.length;
}
enqueue(frame: VideoFrame) {
if (this.closed) {
frame.close();
return;
}
const consumer = this.consumers.shift();
if (consumer) {
consumer.resolve(frame);
return;
}
this.frames.push(frame);
}
fail(error: Error) {
this.error = error;
this.closed = true;
const consumers = this.consumers.splice(0);
for (const consumer of consumers) {
consumer.reject(error);
}
for (const frame of this.frames) {
frame.close();
}
this.frames = [];
}
close() {
this.closed = true;
const consumers = this.consumers.splice(0);
for (const consumer of consumers) {
consumer.resolve(null);
}
}
async dequeue(): Promise<VideoFrame | null> {
if (this.error) {
throw this.error;
}
if (this.frames.length > 0) {
return this.frames.shift() ?? null;
}
if (this.closed) {
return null;
}
return await new Promise<VideoFrame | null>((resolve, reject) => {
this.consumers.push({ resolve, reject });
});
}
destroy() {
this.close();
for (const frame of this.frames) {
frame.close();
}
this.frames = [];
}
}
+161 -74
View File
@@ -7,6 +7,7 @@ import {
Texture,
type TextureSourceLike,
} from "pixi.js";
import { MotionBlurFilter } from "pixi-filters/motion-blur";
import type {
AnnotationRegion,
CropRegion,
@@ -17,13 +18,19 @@ import type {
import { ZOOM_DEPTH_SCALES } from "@/components/video-editor/types";
import {
DEFAULT_FOCUS,
MIN_DELTA,
SMOOTHING_FACTOR,
ZOOM_SCALE_DEADZONE,
ZOOM_TRANSLATION_DEADZONE_PX,
} from "@/components/video-editor/videoPlayback/constants";
import { clampFocusToStage as clampFocusToStageUtil } from "@/components/video-editor/videoPlayback/focusUtils";
import { findDominantRegion } from "@/components/video-editor/videoPlayback/zoomRegionUtils";
import { applyZoomTransform } from "@/components/video-editor/videoPlayback/zoomTransform";
import { getAssetPath } from "@/lib/assetPath";
import {
applyZoomTransform,
computeFocusFromTransform,
computeZoomTransform,
createMotionBlurState,
type MotionBlurState,
} from "@/components/video-editor/videoPlayback/zoomTransform";
import { computeWebcamOverlayLayout } from "@/lib/webcamOverlay";
import { renderAnnotations } from "./annotationRenderer";
interface FrameRenderConfig {
@@ -34,12 +41,14 @@ interface FrameRenderConfig {
showShadow: boolean;
shadowIntensity: number;
showBlur: boolean;
motionBlurEnabled?: boolean;
motionBlurAmount?: number;
borderRadius?: number;
padding?: number;
cropRegion: CropRegion;
videoWidth: number;
videoHeight: number;
webcamWidth?: number;
webcamHeight?: number;
annotationRegions?: AnnotationRegion[];
speedRegions?: SpeedRegion[];
previewWidth?: number;
@@ -50,6 +59,10 @@ interface AnimationState {
scale: number;
focusX: number;
focusY: number;
progress: number;
x: number;
y: number;
appliedScale: number;
}
interface LayoutCache {
@@ -70,6 +83,7 @@ export class FrameRenderer {
private backgroundSprite: HTMLCanvasElement | null = null;
private maskGraphics: Graphics | null = null;
private blurFilter: BlurFilter | null = null;
private motionBlurFilter: MotionBlurFilter | null = null;
private shadowCanvas: HTMLCanvasElement | null = null;
private shadowCtx: CanvasRenderingContext2D | null = null;
private compositeCanvas: HTMLCanvasElement | null = null;
@@ -78,6 +92,7 @@ export class FrameRenderer {
private animationState: AnimationState;
private layoutCache: LayoutCache | null = null;
private currentVideoTime = 0;
private motionBlurState: MotionBlurState = createMotionBlurState();
constructor(config: FrameRenderConfig) {
this.config = config;
@@ -85,6 +100,10 @@ export class FrameRenderer {
scale: 1,
focusX: DEFAULT_FOCUS.cx,
focusY: DEFAULT_FOCUS.cy,
progress: 0,
x: 0,
y: 0,
appliedScale: 1,
};
}
@@ -130,7 +149,8 @@ export class FrameRenderer {
this.blurFilter.quality = 5;
this.blurFilter.resolution = this.app.renderer.resolution;
this.blurFilter.blur = 0;
this.videoContainer.filters = [this.blurFilter];
this.motionBlurFilter = new MotionBlurFilter([0, 0], 5, 0);
this.videoContainer.filters = [this.blurFilter, this.motionBlurFilter];
// Setup composite canvas for final output with shadows
this.compositeCanvas = document.createElement("canvas");
@@ -179,14 +199,18 @@ export class FrameRenderer {
) {
// Image background
const img = new Image();
const imageUrl = await this.resolveWallpaperImageUrl(wallpaper);
// Don't set crossOrigin for same-origin images to avoid CORS taint.
if (
imageUrl.startsWith("http") &&
window.location.origin &&
!imageUrl.startsWith(window.location.origin)
) {
img.crossOrigin = "anonymous";
// Don't set crossOrigin for same-origin images to avoid CORS taint
// Only set it for cross-origin URLs
let imageUrl: string;
if (wallpaper.startsWith("http")) {
imageUrl = wallpaper;
if (!imageUrl.startsWith(window.location.origin)) {
img.crossOrigin = "anonymous";
}
} else if (wallpaper.startsWith("file://") || wallpaper.startsWith("data:")) {
imageUrl = wallpaper;
} else {
imageUrl = window.location.origin + wallpaper;
}
await new Promise<void>((resolve, reject) => {
@@ -280,24 +304,11 @@ export class FrameRenderer {
this.backgroundSprite = bgCanvas;
}
private async resolveWallpaperImageUrl(wallpaper: string): Promise<string> {
if (
wallpaper.startsWith("file://") ||
wallpaper.startsWith("data:") ||
wallpaper.startsWith("http")
) {
return wallpaper;
}
const resolved = await getAssetPath(wallpaper.replace(/^\/+/, ""));
if (resolved.startsWith("/") && window.location.protocol.startsWith("http")) {
return `${window.location.origin}${resolved}`;
}
return resolved;
}
async renderFrame(videoFrame: VideoFrame, timestamp: number): Promise<void> {
async renderFrame(
videoFrame: VideoFrame,
timestamp: number,
webcamFrame?: VideoFrame | null,
): Promise<void> {
if (!this.app || !this.videoContainer || !this.cameraContainer) {
throw new Error("Renderer not initialized");
}
@@ -338,21 +349,25 @@ export class FrameRenderer {
applyZoomTransform({
cameraContainer: this.cameraContainer,
blurFilter: this.blurFilter,
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: maxMotionIntensity,
isPlaying: true,
motionBlurEnabled: this.config.motionBlurEnabled ?? false,
motionBlurAmount: this.config.motionBlurAmount ?? 0,
motionBlurState: this.motionBlurState,
frameTimeMs: timeMs,
});
// Render the PixiJS stage to its canvas (video only, transparent background)
this.app.renderer.render(this.app.stage);
// Composite with shadows to final output canvas
this.compositeWithShadows();
this.compositeWithShadows(webcamFrame);
// Render annotations on top if present
if (
@@ -456,67 +471,108 @@ export class FrameRenderer {
private updateAnimationState(timeMs: number): number {
if (!this.cameraContainer || !this.layoutCache) return 0;
const { region, strength } = findDominantRegion(this.config.zoomRegions, timeMs);
const { region, strength, blendedScale, transition } = findDominantRegion(
this.config.zoomRegions,
timeMs,
{ connectZooms: true },
);
const defaultFocus = DEFAULT_FOCUS;
let targetScaleFactor = 1;
let targetFocus = { ...defaultFocus };
let targetProgress = 0;
if (region && strength > 0) {
const zoomScale = ZOOM_DEPTH_SCALES[region.depth];
const zoomScale = blendedScale ?? ZOOM_DEPTH_SCALES[region.depth];
const regionFocus = this.clampFocusToStage(region.focus, region.depth);
targetScaleFactor = 1 + (zoomScale - 1) * strength;
targetFocus = {
cx: defaultFocus.cx + (regionFocus.cx - defaultFocus.cx) * strength,
cy: defaultFocus.cy + (regionFocus.cy - defaultFocus.cy) * strength,
};
targetScaleFactor = zoomScale;
targetFocus = regionFocus;
targetProgress = strength;
if (transition) {
const startTransform = computeZoomTransform({
stageSize: this.layoutCache.stageSize,
baseMask: this.layoutCache.maskRect,
zoomScale: transition.startScale,
zoomProgress: 1,
focusX: transition.startFocus.cx,
focusY: transition.startFocus.cy,
});
const endTransform = computeZoomTransform({
stageSize: this.layoutCache.stageSize,
baseMask: this.layoutCache.maskRect,
zoomScale: transition.endScale,
zoomProgress: 1,
focusX: transition.endFocus.cx,
focusY: transition.endFocus.cy,
});
const interpolatedTransform = {
scale:
startTransform.scale +
(endTransform.scale - startTransform.scale) * transition.progress,
x: startTransform.x + (endTransform.x - startTransform.x) * transition.progress,
y: startTransform.y + (endTransform.y - startTransform.y) * transition.progress,
};
targetScaleFactor = interpolatedTransform.scale;
targetFocus = computeFocusFromTransform({
stageSize: this.layoutCache.stageSize,
baseMask: this.layoutCache.maskRect,
zoomScale: interpolatedTransform.scale,
x: interpolatedTransform.x,
y: interpolatedTransform.y,
});
targetProgress = 1;
}
}
const state = this.animationState;
const prevScale = state.scale;
const prevFocusX = state.focusX;
const prevFocusY = state.focusY;
const prevScale = state.appliedScale;
const prevX = state.x;
const prevY = state.y;
const scaleDelta = targetScaleFactor - state.scale;
const focusXDelta = targetFocus.cx - state.focusX;
const focusYDelta = targetFocus.cy - state.focusY;
state.scale = targetScaleFactor;
state.focusX = targetFocus.cx;
state.focusY = targetFocus.cy;
state.progress = targetProgress;
let nextScale = prevScale;
let nextFocusX = prevFocusX;
let nextFocusY = prevFocusY;
const projectedTransform = computeZoomTransform({
stageSize: this.layoutCache.stageSize,
baseMask: this.layoutCache.maskRect,
zoomScale: state.scale,
zoomProgress: state.progress,
focusX: state.focusX,
focusY: state.focusY,
});
if (Math.abs(scaleDelta) > MIN_DELTA) {
nextScale = prevScale + scaleDelta * SMOOTHING_FACTOR;
} else {
nextScale = targetScaleFactor;
}
const appliedScale =
Math.abs(projectedTransform.scale - prevScale) < ZOOM_SCALE_DEADZONE
? projectedTransform.scale
: projectedTransform.scale;
const appliedX =
Math.abs(projectedTransform.x - prevX) < ZOOM_TRANSLATION_DEADZONE_PX
? projectedTransform.x
: projectedTransform.x;
const appliedY =
Math.abs(projectedTransform.y - prevY) < ZOOM_TRANSLATION_DEADZONE_PX
? projectedTransform.y
: projectedTransform.y;
if (Math.abs(focusXDelta) > MIN_DELTA) {
nextFocusX = prevFocusX + focusXDelta * SMOOTHING_FACTOR;
} else {
nextFocusX = targetFocus.cx;
}
if (Math.abs(focusYDelta) > MIN_DELTA) {
nextFocusY = prevFocusY + focusYDelta * SMOOTHING_FACTOR;
} else {
nextFocusY = targetFocus.cy;
}
state.scale = nextScale;
state.focusX = nextFocusX;
state.focusY = nextFocusY;
state.x = appliedX;
state.y = appliedY;
state.appliedScale = appliedScale;
return Math.max(
Math.abs(nextScale - prevScale),
Math.abs(nextFocusX - prevFocusX),
Math.abs(nextFocusY - prevFocusY),
Math.abs(appliedScale - prevScale),
Math.abs(appliedX - prevX) / Math.max(1, this.layoutCache.stageSize.width),
Math.abs(appliedY - prevY) / Math.max(1, this.layoutCache.stageSize.height),
);
}
private compositeWithShadows(): void {
private compositeWithShadows(webcamFrame?: VideoFrame | null): void {
if (!this.compositeCanvas || !this.compositeCtx || !this.app) return;
const videoCanvas = this.app.canvas as HTMLCanvasElement;
@@ -571,6 +627,36 @@ export class FrameRenderer {
} else {
ctx.drawImage(videoCanvas, 0, 0, w, h);
}
if (webcamFrame && this.config.webcamWidth && this.config.webcamHeight) {
const layout = computeWebcamOverlayLayout({
stageWidth: w,
stageHeight: h,
videoWidth: this.config.webcamWidth,
videoHeight: this.config.webcamHeight,
});
if (layout) {
ctx.save();
ctx.beginPath();
ctx.roundRect(layout.x, layout.y, layout.width, layout.height, layout.borderRadius);
ctx.closePath();
ctx.shadowColor = "rgba(0,0,0,0.35)";
ctx.shadowBlur = 24;
ctx.shadowOffsetY = 10;
ctx.fillStyle = "#000000";
ctx.fill();
ctx.clip();
ctx.drawImage(
webcamFrame as unknown as CanvasImageSource,
layout.x,
layout.y,
layout.width,
layout.height,
);
ctx.restore();
}
}
}
getCanvas(): HTMLCanvasElement {
@@ -594,6 +680,7 @@ export class FrameRenderer {
this.videoContainer = null;
this.maskGraphics = null;
this.blurFilter = null;
this.motionBlurFilter = null;
this.shadowCanvas = null;
this.shadowCtx = null;
this.compositeCanvas = null;
+94 -27
View File
@@ -6,6 +6,7 @@ import type {
TrimRegion,
ZoomRegion,
} from "@/components/video-editor/types";
import { AsyncVideoFrameQueue } from "./asyncVideoFrameQueue";
import { FrameRenderer } from "./frameRenderer";
import { StreamingVideoDecoder } from "./streamingDecoder";
import type {
@@ -20,6 +21,7 @@ const GIF_WORKER_URL = new URL("gif.js/dist/gif.worker.js", import.meta.url).toS
interface GifExporterConfig {
videoUrl: string;
webcamVideoUrl?: string;
width: number;
height: number;
frameRate: GifFrameRate;
@@ -32,7 +34,7 @@ interface GifExporterConfig {
showShadow: boolean;
shadowIntensity: number;
showBlur: boolean;
motionBlurEnabled?: boolean;
motionBlurAmount?: number;
borderRadius?: number;
padding?: number;
videoPadding?: number;
@@ -80,6 +82,7 @@ export function calculateOutputDimensions(
export class GifExporter {
private config: GifExporterConfig;
private streamingDecoder: StreamingVideoDecoder | null = null;
private webcamDecoder: StreamingVideoDecoder | null = null;
private renderer: FrameRenderer | null = null;
private gif: GIF | null = null;
private cancelled = false;
@@ -89,6 +92,7 @@ export class GifExporter {
}
async export(): Promise<ExportResult> {
let webcamFrameQueue: AsyncVideoFrameQueue | null = null;
try {
this.cleanup();
this.cancelled = false;
@@ -96,6 +100,11 @@ export class GifExporter {
// Initialize streaming decoder and load video metadata
this.streamingDecoder = new StreamingVideoDecoder();
const videoInfo = await this.streamingDecoder.loadMetadata(this.config.videoUrl);
let webcamInfo: Awaited<ReturnType<StreamingVideoDecoder["loadMetadata"]>> | null = null;
if (this.config.webcamVideoUrl) {
this.webcamDecoder = new StreamingVideoDecoder();
webcamInfo = await this.webcamDecoder.loadMetadata(this.config.webcamVideoUrl);
}
// Initialize frame renderer
this.renderer = new FrameRenderer({
@@ -106,12 +115,14 @@ export class GifExporter {
showShadow: this.config.showShadow,
shadowIntensity: this.config.shadowIntensity,
showBlur: this.config.showBlur,
motionBlurEnabled: this.config.motionBlurEnabled,
motionBlurAmount: this.config.motionBlurAmount,
borderRadius: this.config.borderRadius,
padding: this.config.padding,
cropRegion: this.config.cropRegion,
videoWidth: videoInfo.width,
videoHeight: videoInfo.height,
webcamWidth: webcamInfo?.width,
webcamHeight: webcamInfo?.height,
annotationRegions: this.config.annotationRegions,
speedRegions: this.config.speedRegions,
previewWidth: this.config.previewWidth,
@@ -155,6 +166,37 @@ export class GifExporter {
console.log("[GifExporter] Using streaming decode (web-demuxer + VideoDecoder)");
let frameIndex = 0;
webcamFrameQueue = this.config.webcamVideoUrl ? new AsyncVideoFrameQueue() : null;
let webcamDecodeError: Error | null = null;
const webcamDecodePromise =
this.webcamDecoder && webcamFrameQueue
? (() => {
const queue = webcamFrameQueue;
return this.webcamDecoder
.decodeAll(
this.config.frameRate,
this.config.trimRegions,
this.config.speedRegions,
async (webcamFrame) => {
while (queue.length >= 12 && !this.cancelled) {
await new Promise((resolve) => setTimeout(resolve, 2));
}
queue.enqueue(webcamFrame);
},
)
.catch((error) => {
webcamDecodeError = error instanceof Error ? error : new Error(String(error));
throw error;
})
.finally(() => {
if (webcamDecodeError) {
queue.fail(webcamDecodeError);
} else {
queue.close();
}
});
})()
: null;
// Stream decode and process frames — no seeking!
await this.streamingDecoder.decodeAll(
@@ -162,32 +204,42 @@ export class GifExporter {
this.config.trimRegions,
this.config.speedRegions,
async (videoFrame, _exportTimestampUs, sourceTimestampMs) => {
if (this.cancelled) {
let webcamFrame: VideoFrame | null = null;
try {
if (this.cancelled) {
return;
}
webcamFrame = webcamFrameQueue ? await webcamFrameQueue.dequeue() : null;
const renderer = this.renderer;
if (this.cancelled || !renderer) {
return;
}
// Render the frame with all effects using source timestamp
const sourceTimestampUs = sourceTimestampMs * 1000; // Convert to microseconds
await renderer.renderFrame(videoFrame, sourceTimestampUs, webcamFrame);
// Get the rendered canvas and add to GIF
const canvas = renderer.getCanvas();
// Add frame to GIF encoder with delay
this.gif!.addFrame(canvas, { delay: frameDelay, copy: true });
frameIndex++;
// Update progress
if (this.config.onProgress) {
this.config.onProgress({
currentFrame: frameIndex,
totalFrames,
percentage: (frameIndex / totalFrames) * 100,
estimatedTimeRemaining: 0,
});
}
} finally {
videoFrame.close();
return;
}
// Render the frame with all effects using source timestamp
const sourceTimestampUs = sourceTimestampMs * 1000; // Convert to microseconds
await this.renderer!.renderFrame(videoFrame, sourceTimestampUs);
videoFrame.close();
// Get the rendered canvas and add to GIF
const canvas = this.renderer!.getCanvas();
// Add frame to GIF encoder with delay
this.gif!.addFrame(canvas, { delay: frameDelay, copy: true });
frameIndex++;
// Update progress
if (this.config.onProgress) {
this.config.onProgress({
currentFrame: frameIndex,
totalFrames,
percentage: (frameIndex / totalFrames) * 100,
estimatedTimeRemaining: 0,
});
webcamFrame?.close();
}
},
);
@@ -196,6 +248,8 @@ export class GifExporter {
return { success: false, error: "Export cancelled" };
}
await webcamDecodePromise;
// Update progress to show we're now in the finalizing phase
if (this.config.onProgress) {
this.config.onProgress({
@@ -239,6 +293,7 @@ export class GifExporter {
error: error instanceof Error ? error.message : String(error),
};
} finally {
webcamFrameQueue?.destroy();
this.cleanup();
}
}
@@ -248,6 +303,9 @@ export class GifExporter {
if (this.streamingDecoder) {
this.streamingDecoder.cancel();
}
if (this.webcamDecoder) {
this.webcamDecoder.cancel();
}
if (this.gif) {
this.gif.abort();
}
@@ -264,6 +322,15 @@ export class GifExporter {
this.streamingDecoder = null;
}
if (this.webcamDecoder) {
try {
this.webcamDecoder.destroy();
} catch (e) {
console.warn("Error destroying webcam decoder:", e);
}
this.webcamDecoder = null;
}
if (this.renderer) {
try {
this.renderer.destroy();
+28 -2
View File
@@ -32,11 +32,37 @@ export class StreamingVideoDecoder {
private cancelled = false;
private metadata: DecodedVideoInfo | null = null;
async loadMetadata(videoUrl: string): Promise<DecodedVideoInfo> {
private async loadSourceFile(videoUrl: string): Promise<{ file: File; blob: Blob }> {
const isRemoteUrl = /^(https?:|blob:|data:)/i.test(videoUrl);
if (!isRemoteUrl && window.electronAPI?.readBinaryFile) {
const result = await window.electronAPI.readBinaryFile(videoUrl);
if (!result.success || !result.data) {
throw new Error(result.message || result.error || "Failed to read source video");
}
const filename = (result.path || videoUrl).split(/[\\/]/).pop() || "video";
const blob = new Blob([result.data]);
return {
blob,
file: new File([blob], filename, { type: blob.type || "application/octet-stream" }),
};
}
const response = await fetch(videoUrl);
if (!response.ok) {
throw new Error(`Failed to fetch source video: ${response.status} ${response.statusText}`);
}
const blob = await response.blob();
const filename = videoUrl.split("/").pop() || "video";
const file = new File([blob], filename, { type: blob.type });
return {
blob,
file: new File([blob], filename, { type: blob.type }),
};
}
async loadMetadata(videoUrl: string): Promise<DecodedVideoInfo> {
const { file } = await this.loadSourceFile(videoUrl);
// Relative URL so it resolves correctly in both dev (http) and packaged (file://) builds
const wasmUrl = new URL("./wasm/web-demuxer.wasm", window.location.href).href;
+120 -52
View File
@@ -5,6 +5,7 @@ import type {
TrimRegion,
ZoomRegion,
} from "@/components/video-editor/types";
import { AsyncVideoFrameQueue } from "./asyncVideoFrameQueue";
import { AudioProcessor } from "./audioEncoder";
import { FrameRenderer } from "./frameRenderer";
import { VideoMuxer } from "./muxer";
@@ -13,6 +14,7 @@ import type { ExportConfig, ExportProgress, ExportResult } from "./types";
interface VideoExporterConfig extends ExportConfig {
videoUrl: string;
webcamVideoUrl?: string;
wallpaper: string;
zoomRegions: ZoomRegion[];
trimRegions?: TrimRegion[];
@@ -20,7 +22,7 @@ interface VideoExporterConfig extends ExportConfig {
showShadow: boolean;
shadowIntensity: number;
showBlur: boolean;
motionBlurEnabled?: boolean;
motionBlurAmount?: number;
borderRadius?: number;
padding?: number;
videoPadding?: number;
@@ -38,6 +40,7 @@ export class VideoExporter {
private encoder: VideoEncoder | null = null;
private muxer: VideoMuxer | null = null;
private audioProcessor: AudioProcessor | null = null;
private webcamDecoder: StreamingVideoDecoder | null = null;
private cancelled = false;
private encodeQueue = 0;
// Increased queue size for better throughput with hardware encoding
@@ -53,6 +56,7 @@ export class VideoExporter {
}
async export(): Promise<ExportResult> {
let webcamFrameQueue: AsyncVideoFrameQueue | null = null;
try {
this.cleanup();
this.cancelled = false;
@@ -60,6 +64,11 @@ export class VideoExporter {
// Initialize streaming decoder and load video metadata
this.streamingDecoder = new StreamingVideoDecoder();
const videoInfo = await this.streamingDecoder.loadMetadata(this.config.videoUrl);
let webcamInfo: Awaited<ReturnType<StreamingVideoDecoder["loadMetadata"]>> | null = null;
if (this.config.webcamVideoUrl) {
this.webcamDecoder = new StreamingVideoDecoder();
webcamInfo = await this.webcamDecoder.loadMetadata(this.config.webcamVideoUrl);
}
// Initialize frame renderer
this.renderer = new FrameRenderer({
@@ -70,12 +79,14 @@ export class VideoExporter {
showShadow: this.config.showShadow,
shadowIntensity: this.config.shadowIntensity,
showBlur: this.config.showBlur,
motionBlurEnabled: this.config.motionBlurEnabled,
motionBlurAmount: this.config.motionBlurAmount,
borderRadius: this.config.borderRadius,
padding: this.config.padding,
cropRegion: this.config.cropRegion,
videoWidth: videoInfo.width,
videoHeight: videoInfo.height,
webcamWidth: webcamInfo?.width,
webcamHeight: webcamInfo?.height,
annotationRegions: this.config.annotationRegions,
speedRegions: this.config.speedRegions,
previewWidth: this.config.previewWidth,
@@ -106,6 +117,37 @@ export class VideoExporter {
const frameDuration = 1_000_000 / this.config.frameRate; // in microseconds
let frameIndex = 0;
webcamFrameQueue = this.config.webcamVideoUrl ? new AsyncVideoFrameQueue() : null;
let webcamDecodeError: Error | null = null;
const webcamDecodePromise =
this.webcamDecoder && webcamFrameQueue
? (() => {
const queue = webcamFrameQueue;
return this.webcamDecoder
.decodeAll(
this.config.frameRate,
this.config.trimRegions,
this.config.speedRegions,
async (webcamFrame) => {
while (queue.length >= 12 && !this.cancelled) {
await new Promise((resolve) => setTimeout(resolve, 2));
}
queue.enqueue(webcamFrame);
},
)
.catch((error) => {
webcamDecodeError = error instanceof Error ? error : new Error(String(error));
throw error;
})
.finally(() => {
if (webcamDecodeError) {
queue.fail(webcamDecodeError);
} else {
queue.close();
}
});
})()
: null;
// Stream decode and process frames — no seeking!
await this.streamingDecoder.decodeAll(
@@ -113,61 +155,72 @@ export class VideoExporter {
this.config.trimRegions,
this.config.speedRegions,
async (videoFrame, _exportTimestampUs, sourceTimestampMs) => {
if (this.cancelled) {
videoFrame.close();
return;
}
let webcamFrame: VideoFrame | null = null;
try {
if (this.cancelled) {
return;
}
const timestamp = frameIndex * frameDuration;
const timestamp = frameIndex * frameDuration;
webcamFrame = webcamFrameQueue ? await webcamFrameQueue.dequeue() : null;
const renderer = this.renderer;
if (this.cancelled || !renderer) {
return;
}
// Render the frame with all effects using source timestamp
const sourceTimestampUs = sourceTimestampMs * 1000; // Convert to microseconds
await this.renderer!.renderFrame(videoFrame, sourceTimestampUs);
videoFrame.close();
// Render the frame with all effects using source timestamp
const sourceTimestampUs = sourceTimestampMs * 1000; // Convert to microseconds
await renderer.renderFrame(videoFrame, sourceTimestampUs, webcamFrame);
const canvas = this.renderer!.getCanvas();
const canvas = renderer.getCanvas();
// Create VideoFrame from canvas on GPU without reading pixels
// @ts-expect-error - colorSpace not in TypeScript definitions but works at runtime
const exportFrame = new VideoFrame(canvas, {
timestamp,
duration: frameDuration,
colorSpace: {
primaries: "bt709",
transfer: "iec61966-2-1",
matrix: "rgb",
fullRange: true,
},
});
// Check encoder queue before encoding to keep it full
while (
this.encoder &&
this.encoder.encodeQueueSize >= this.MAX_ENCODE_QUEUE &&
!this.cancelled
) {
await new Promise((resolve) => setTimeout(resolve, 5));
}
if (this.encoder && this.encoder.state === "configured") {
this.encodeQueue++;
this.encoder.encode(exportFrame, { keyFrame: frameIndex % 150 === 0 });
} else {
console.warn(`[Frame ${frameIndex}] Encoder not ready! State: ${this.encoder?.state}`);
}
exportFrame.close();
frameIndex++;
// Update progress
if (this.config.onProgress) {
this.config.onProgress({
currentFrame: frameIndex,
totalFrames,
percentage: (frameIndex / totalFrames) * 100,
estimatedTimeRemaining: 0,
// Create VideoFrame from canvas on GPU without reading pixels
// @ts-expect-error - colorSpace not in TypeScript definitions but works at runtime
const exportFrame = new VideoFrame(canvas, {
timestamp,
duration: frameDuration,
colorSpace: {
primaries: "bt709",
transfer: "iec61966-2-1",
matrix: "rgb",
fullRange: true,
},
});
// Check encoder queue before encoding to keep it full
while (
this.encoder &&
this.encoder.encodeQueueSize >= this.MAX_ENCODE_QUEUE &&
!this.cancelled
) {
await new Promise((resolve) => setTimeout(resolve, 5));
}
if (this.encoder && this.encoder.state === "configured") {
this.encodeQueue++;
this.encoder.encode(exportFrame, { keyFrame: frameIndex % 150 === 0 });
} else {
console.warn(
`[Frame ${frameIndex}] Encoder not ready! State: ${this.encoder?.state}`,
);
}
exportFrame.close();
frameIndex++;
// Update progress
if (this.config.onProgress) {
this.config.onProgress({
currentFrame: frameIndex,
totalFrames,
percentage: (frameIndex / totalFrames) * 100,
estimatedTimeRemaining: 0,
});
}
} finally {
videoFrame.close();
webcamFrame?.close();
}
},
);
@@ -176,6 +229,8 @@ export class VideoExporter {
return { success: false, error: "Export cancelled" };
}
await webcamDecodePromise;
// Finalize encoding
if (this.encoder && this.encoder.state === "configured") {
await this.encoder.flush();
@@ -222,6 +277,7 @@ export class VideoExporter {
error: error instanceof Error ? error.message : String(error),
};
} finally {
webcamFrameQueue?.destroy();
this.cleanup();
}
}
@@ -332,6 +388,9 @@ export class VideoExporter {
if (this.streamingDecoder) {
this.streamingDecoder.cancel();
}
if (this.webcamDecoder) {
this.webcamDecoder.cancel();
}
if (this.audioProcessor) {
this.audioProcessor.cancel();
}
@@ -359,6 +418,15 @@ export class VideoExporter {
this.streamingDecoder = null;
}
if (this.webcamDecoder) {
try {
this.webcamDecoder.destroy();
} catch (e) {
console.warn("Error destroying webcam decoder:", e);
}
this.webcamDecoder = null;
}
if (this.renderer) {
try {
this.renderer.destroy();
+69
View File
@@ -0,0 +1,69 @@
export interface ProjectMedia {
screenVideoPath: string;
webcamVideoPath?: string;
}
export interface RecordingSession extends ProjectMedia {
createdAt: number;
}
export interface RecordedVideoAssetInput {
fileName: string;
videoData: ArrayBuffer;
}
export interface StoreRecordedSessionInput {
screen: RecordedVideoAssetInput;
webcam?: RecordedVideoAssetInput;
createdAt?: number;
}
function normalizePath(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed ? trimmed : undefined;
}
export function normalizeProjectMedia(candidate: unknown): ProjectMedia | null {
if (!candidate || typeof candidate !== "object") {
return null;
}
const raw = candidate as Partial<ProjectMedia>;
const screenVideoPath = normalizePath(raw.screenVideoPath);
if (!screenVideoPath) {
return null;
}
const webcamVideoPath = normalizePath(raw.webcamVideoPath);
return webcamVideoPath
? { screenVideoPath, webcamVideoPath }
: {
screenVideoPath,
};
}
export function normalizeRecordingSession(candidate: unknown): RecordingSession | null {
if (!candidate || typeof candidate !== "object") {
return null;
}
const raw = candidate as Partial<RecordingSession>;
const media = normalizeProjectMedia(raw);
if (!media) {
return null;
}
return {
...media,
createdAt:
typeof raw.createdAt === "number" && Number.isFinite(raw.createdAt)
? raw.createdAt
: Date.now(),
};
}
+33
View File
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { computeWebcamOverlayLayout } from "./webcamOverlay";
describe("computeWebcamOverlayLayout", () => {
it("anchors the overlay in the lower-right corner", () => {
const layout = computeWebcamOverlayLayout({
stageWidth: 1920,
stageHeight: 1080,
videoWidth: 1280,
videoHeight: 720,
});
expect(layout).not.toBeNull();
expect(layout!.x + layout!.width).toBeLessThanOrEqual(1920);
expect(layout!.y + layout!.height).toBeLessThanOrEqual(1080);
expect(layout!.x).toBeGreaterThan(1920 / 2);
expect(layout!.y).toBeGreaterThan(1080 / 2);
});
it("keeps the overlay within the configured stage fraction while preserving aspect ratio", () => {
const layout = computeWebcamOverlayLayout({
stageWidth: 1280,
stageHeight: 720,
videoWidth: 1920,
videoHeight: 1080,
});
expect(layout).not.toBeNull();
expect(layout!.width).toBeLessThanOrEqual(Math.round(1280 * 0.18) + 1);
expect(layout!.height).toBeLessThanOrEqual(Math.round(720 * 0.18) + 1);
expect(Math.abs(layout!.width * 1080 - layout!.height * 1920)).toBeLessThanOrEqual(1920);
});
});
+45
View File
@@ -0,0 +1,45 @@
export interface WebcamOverlayLayout {
x: number;
y: number;
width: number;
height: number;
margin: number;
borderRadius: number;
}
const MAX_STAGE_FRACTION = 0.18;
const MARGIN_FRACTION = 0.02;
const MIN_SIZE = 96;
const MAX_BORDER_RADIUS = 24;
export function computeWebcamOverlayLayout(params: {
stageWidth: number;
stageHeight: number;
videoWidth: number;
videoHeight: number;
}): WebcamOverlayLayout | null {
const { stageWidth, stageHeight, videoWidth, videoHeight } = params;
if (stageWidth <= 0 || stageHeight <= 0 || videoWidth <= 0 || videoHeight <= 0) {
return null;
}
const margin = Math.max(12, Math.round(Math.min(stageWidth, stageHeight) * MARGIN_FRACTION));
const maxWidth = Math.max(MIN_SIZE, stageWidth * MAX_STAGE_FRACTION);
const maxHeight = Math.max(MIN_SIZE, stageHeight * MAX_STAGE_FRACTION);
const scale = Math.min(maxWidth / videoWidth, maxHeight / videoHeight);
const width = Math.round(videoWidth * scale);
const height = Math.round(videoHeight * scale);
return {
x: Math.max(0, Math.round(stageWidth - margin - width)),
y: Math.max(0, Math.round(stageHeight - margin - height)),
width,
height,
margin,
borderRadius: Math.min(
MAX_BORDER_RADIUS,
Math.max(12, Math.round(Math.min(width, height) * 0.12)),
),
};
}
+5
View File
@@ -0,0 +1,5 @@
export type TestId = `gif-size-button-${string}` | "export-button" | `gif-format-button`;
export function getTestId(testId: TestId) {
return `testId-${testId}`;
}
+27 -1
View File
@@ -22,13 +22,29 @@ interface Window {
openSourceSelector: () => Promise<void>;
selectSource: (source: ProcessedDesktopSource) => Promise<ProcessedDesktopSource | null>;
getSelectedSource: () => Promise<ProcessedDesktopSource | null>;
requestCameraAccess: () => Promise<{
success: boolean;
granted: boolean;
status: string;
error?: string;
}>;
storeRecordedVideo: (
videoData: ArrayBuffer,
fileName: string,
) => Promise<{
success: boolean;
path?: string;
message: string;
session?: import("./lib/recordingSession").RecordingSession;
message?: string;
error?: string;
}>;
storeRecordedSession: (
payload: import("./lib/recordingSession").StoreRecordedSessionInput,
) => Promise<{
success: boolean;
path?: string;
session?: import("./lib/recordingSession").RecordingSession;
message?: string;
error?: string;
}>;
getRecordedVideoPath: () => Promise<{
@@ -58,7 +74,17 @@ interface Window {
}>;
openVideoFilePicker: () => Promise<{ success: boolean; path?: string; canceled?: boolean }>;
setCurrentVideoPath: (path: string) => Promise<{ success: boolean }>;
setCurrentRecordingSession: (
session: import("./lib/recordingSession").RecordingSession | null,
) => Promise<{
success: boolean;
session?: import("./lib/recordingSession").RecordingSession;
}>;
getCurrentVideoPath: () => Promise<{ success: boolean; path?: string }>;
getCurrentRecordingSession: () => Promise<{
success: boolean;
session?: import("./lib/recordingSession").RecordingSession;
}>;
clearCurrentVideoPath: () => Promise<{ success: boolean }>;
saveProjectFile: (
projectData: unknown,
+120
View File
@@ -0,0 +1,120 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { _electron as electron, expect, test } from "@playwright/test";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.join(__dirname, "../..");
const MAIN_JS = path.join(ROOT, "dist-electron/main.js");
const TEST_VIDEO = path.join(__dirname, "../fixtures/sample.webm");
test("exports a GIF from a loaded video", async () => {
const outputPath = path.join(os.tmpdir(), `test-gif-export-${Date.now()}.gif`);
const app = await electron.launch({
args: [
MAIN_JS,
// Required in CI sandbox environments (GitHub Actions, Docker, etc.)
"--no-sandbox",
],
env: {
...process.env,
// Set HEADLESS=false to show windows while debugging.
HEADLESS: process.env["HEADLESS"] ?? "true",
},
});
// Print all main-process stdout/stderr so failures are diagnosable.
app.process().stdout?.on("data", (d) => process.stdout.write(`[electron] ${d}`));
app.process().stderr?.on("data", (d) => process.stderr.write(`[electron] ${d}`));
try {
// ── 1. Wait for the HUD overlay window. The window is created after
// registerIpcHandlers() completes, so all IPC handlers are live
// by the time firstWindow() resolves.
const hudWindow = await app.firstWindow({ timeout: 60_000 });
await hudWindow.waitForLoadState("domcontentloaded");
// ── 2. Intercept the native save dialog in the main process.
// Must happen after firstWindow() so registerIpcHandlers() has
// already registered its version — otherwise our early handle()
// call causes registerIpcHandlers() to throw and abort, leaving
// other handlers (like set-current-video-path) never registered.
// Store the exported buffer as a base64 global in the main process.
// We can't use require() or import() inside app.evaluate() because the
// main process is ESM and Playwright runs the callback via eval(), which
// has no dynamic-import hook. We retrieve and write the file below after
// the export finishes.
await app.evaluate(({ ipcMain }) => {
ipcMain.removeHandler("save-exported-video");
ipcMain.handle(
"save-exported-video",
(_event: Electron.IpcMainInvokeEvent, buffer: ArrayBuffer) => {
(globalThis as Record<string, unknown>)["__testExportData"] =
Buffer.from(buffer).toString("base64");
return { success: true, path: "pending" };
},
);
});
await hudWindow.evaluate((videoPath: string) => {
window.electronAPI.setCurrentVideoPath(videoPath);
try {
window.electronAPI.switchToEditor();
} catch {
// Expected: HUD window closes during this call, killing the context.
}
}, TEST_VIDEO);
// ── 3. Switch to the editor window. This closes the HUD and opens
// a new BrowserWindow with ?windowType=editor.
const editorWindow = await app.waitForEvent("window", {
predicate: (w) => w.url().includes("windowType=editor"),
timeout: 15_000,
});
// WebCodecs (VideoEncoder) may not be registered in the renderer on first
// load of a second BrowserWindow. A single reload ensures the feature is
// fully initialized before we start encoding.
await editorWindow.reload();
await editorWindow.waitForLoadState("domcontentloaded");
await expect(editorWindow.getByText("Loading video...")).not.toBeVisible({
timeout: 15_000,
});
// ── 5. Select GIF as the export format.
await editorWindow.getByTestId("testId-gif-format-button").click();
await editorWindow.getByTestId("testId-export-button").click();
// ── 6. Wait for the toast to say exported successfully
await expect(editorWindow.getByText(`GIF exported successfully to pending`)).toBeVisible({
timeout: 90_000,
});
// ── 7. Write the captured buffer from the main-process global to disk.
const base64 = await app.evaluate(
() => (globalThis as Record<string, unknown>)["__testExportData"] as string,
);
fs.writeFileSync(outputPath, Buffer.from(base64, "base64"));
// ── 8. Verify the file on disk is a valid GIF.
expect(fs.existsSync(outputPath), `GIF not found at ${outputPath}`).toBe(true);
const header = Buffer.alloc(6);
const fd = fs.openSync(outputPath, "r");
fs.readSync(fd, header, 0, 6, 0);
fs.closeSync(fd);
// GIF magic bytes are either "GIF87a" or "GIF89a"
expect(header.toString("ascii")).toMatch(/^GIF8[79]a/);
const stats = fs.statSync(outputPath);
expect(stats.size).toBeGreaterThan(1024); // at least 1 KB
} finally {
await app.close();
if (fs.existsSync(outputPath)) {
fs.unlinkSync(outputPath);
}
}
});
BIN
View File
Binary file not shown.