Compare commits

..
Author SHA1 Message Date
Siddharth 4b8c95f04f bring back show folder 2026-03-17 19:05:59 -07:00
Siddharth 0f123283b3 Merge remote-tracking branch 'origin/main' into main 2026-03-17 18:55:46 -07:00
Siddharth b33ec5e2d7 fix: restore webcam sessions and stop export deadlocks 2026-03-17 18:50:05 -07:00
Siddharth 0a0dd088c3 Merge branch 'codex/pr-229' into main 2026-03-17 18:47:19 -07:00
Sid 2669b380a3 Merge pull request #216 from prayaslashkari/feature/restart-recording
feat: Add Restart Recording Functionality
2026-03-17 16:22:30 -07:00
Sid 0935dac70a Merge pull request #228 from prayaslashkari/feature/resizeable-video-editor
refactor: Resizable Video Editor Layout, Migrated inline styles to TailwindCSS
2026-03-17 15:37:56 -07:00
Prayas Lashkari e2147bec63 feat: enhance restart recording functionality to prevent concurrent restarts 2026-03-17 13:48:31 -04:00
Marcus Schiesser 3d2d0a4dbc fix: always release exporter video frames 2026-03-17 20:35:21 +08:00
Marcus Schiesser 1591f7dfcb fix: restore passing checks for webcam overlay changes 2026-03-17 20:29:13 +08:00
Marcus Schiesser c3e4c86b33 fix: reset webcam state on access denial 2026-03-17 20:07:10 +08:00
Marcus Schiesser 942a7e599a fix: allow webcam toggle while recording 2026-03-17 20:05:37 +08:00
Marcus Schiesser 776ed954f2 fix: always tear down webcam export queues 2026-03-17 20:03:14 +08:00
Marcus Schiesser f1a453b9b2 fix: finalize externally stopped recordings 2026-03-17 19:57:45 +08:00
Marcus Schiesser e4263d4597 fix: sync webcam preview playback speed 2026-03-17 19:37:12 +08:00
Marcus Schiesser 2fb5b3b574 Add webcam recording overlay support 2026-03-17 19:09:34 +08:00
Prayas Lashkari 9a5d94a1c8 refactor: update VideoEditor layout and add config.json for setup and teardown 2026-03-17 02:12:44 -04:00
Prayas Lashkari 119c3acb18 feat: implement async restart recording functionality to ensure proper session handling 2026-03-17 01:57:55 -04:00
Sid 881acdb26f Merge pull request #225 from elevchyt/notification-area-hud-open
notification area hud open fix with small window open refactor
2026-03-16 21:03:37 -07:00
Sid 4a308fde12 Merge pull request #223 from marcgabe15/marcdiaz/e2e
E2E Testing with Playwright
2026-03-16 20:59:04 -07:00
Sid fc8a4db8f1 Merge pull request #222 from EtienneLescot/fix/export-local-file-loading
fix: read local export sources through electron IPC
2026-03-16 20:56:15 -07:00
Marc Diaz ac4f82484b revert change 2026-03-16 13:46:57 -04:00
Marc Diaz e9f0fda397 fix: possible race condition on test 2026-03-16 13:27:44 -04:00
elevchyt 4655e71ca5 notification area hud open fix with small window open refactor 2026-03-16 19:25:12 +02:00
Marc Diaz e82332647a fix: remove ffmpeg 2026-03-16 11:31:05 -04:00
Marc Diaz 9fb91dd17b Merge pull request #1 from marcgabe15/marcdiaz/test
feat(test): add an e2e test
2026-03-16 11:28:13 -04:00
Sid c8cf052fc9 Merge pull request #221 from EtienneLescot/feat/motion-blur-slider
feat: replace motion blur toggle with intensity slider
2026-03-16 08:27:38 -07:00
Marc Diaz 61d89831bb fix: add xvfb run 2026-03-16 11:24:30 -04:00
Marc Diaz 9f6ef0f582 feat(test): add an e2e test 2026-03-16 11:17:26 -04:00
Etienne Lescot ea68300634 fix: read local export sources via electron ipc 2026-03-16 13:01:32 +01:00
Etienne Lescot 446e3a35fc fix: avoid history checkpoint spam on motion blur drag 2026-03-16 12:51:54 +01:00
Etienne Lescot c35a33203b fix: increase motion blur intensity range 2026-03-16 12:40:08 +01:00
Etienne Lescot dd84edaf41 feat: replace motion blur toggle with intensity slider
Motion blur was a boolean switch (on/off). This changes it to a slider
from 0 (off) to 1 (full intensity), with 0.35 as the recommended sweet
spot per feedback on PR #207.

- EditorState/ProjectEditorState: motionBlurEnabled:bool → motionBlurAmount:number
- SettingsPanel: Switch → Slider (0–1, step 0.01); shows 'off' or value
- VideoPlayback/zoomTransform: scale blur by amount instead of boolean gate
- FrameRenderer/VideoExporter/GifExporter: propagate numeric amount
- projectPersistence: backward-compat loader (old true → 0.35, false → 0)
2026-03-16 12:22:16 +01:00
Sid 9d71f509b8 Merge pull request #207 from EtienneLescot/feat/recordly-cursor-pipeline
feat: rework zoom transitions and motion blur
2026-03-15 19:30:45 -07:00
Siddharth 9687157aba Merge main into PR #186 and resolve SourceSelector conflict 2026-03-15 18:27:29 -07:00
Siddharth e2075f15e9 Merge main into PR #185 and resolve native aspect conflicts 2026-03-15 17:13:53 -07:00
Siddharth d182854270 Merge PR #184: resolve crop control conflicts 2026-03-15 16:52:46 -07:00
Etienne Lescot 7a8d0f449a feat: narrow PR to zoom transitions and motion blur 2026-03-15 10:29:23 +01:00
Prayas Lashkari 0727b61de7 feat: add restart recording functionality in LaunchWindow and useScreenRecorder 2026-03-15 02:07:39 -04:00
Siddharth 56988e86e2 custom install loc 2026-03-14 15:57:22 -07:00
Sid 965d3e5f4c Merge pull request #211 from prayaslashkari/bug/crop-window
fix: Fix crop window behavior
2026-03-14 12:48:10 -07:00
Siddharth 5f6576768c normalize paths on all OS 2026-03-14 12:43:12 -07:00
Prayas Lashkari 6c086be1b6 fix: rename crop dropdown state to crop modal for clarity 2026-03-14 15:32:59 -04:00
Prayas Lashkari b52d27bf56 fix: add peer dependencies to package-lock.json 2026-03-14 15:26:27 -04:00
Prayas Lashkari e3c922d032 feat: add crop functionality with snapshot handling in SettingsPanel 2026-03-14 15:25:51 -04:00
Siddharth 16dea49fa8 fix audio desync and speed issue 2026-03-14 11:58:43 -07:00
Sid 575a339550 Merge pull request #206 from EtienneLescot/fix/windows-export-stall
Fix Windows export finalization stalls
2026-03-14 11:15:26 -07:00
Sid e5fa783b59 Merge pull request #205 from EtienneLescot/fix/issue-197-windows-paths
Fix Windows cursor telemetry path resolution
2026-03-14 09:46:51 -07:00
Etienne Lescot b5cc7777d7 Fix export finalization stalls on Windows 2026-03-14 11:57:59 +01:00
Etienne Lescot e72fb8252c Fix Windows cursor telemetry path resolution 2026-03-14 11:22:45 +01:00
Siddharth 5e8bb99e96 fix playback callback to not be in pixi setup dependency 2026-03-13 23:00:11 -07:00
Siddharth 1b08618831 project save/ close fix 2026-03-13 19:37:00 -07:00
Sid 144e34318e Merge pull request #204 from marcgabe15/feature/increase-worker-count
feat(gif-worker): increase amount of web workers based on hardwarecon…
2026-03-13 19:18:58 -07:00
Sid 6035719252 Merge pull request #202 from prayaslashkari/refactor/launch-window-ux
feat: UX Improvements in Launch Window
2026-03-13 18:50:03 -07:00
Sid 2af33894e9 Merge pull request #174 from FabLrc/feature/undo-redo
feat: implement undo/redo functionality in video editor
2026-03-13 18:49:27 -07:00
Marc Diaz 63fd87612e feat(gif-worker): increase amount of web workers based on hardwareconcurrecy 2026-03-13 17:32:20 -04:00
FabLrc 4b79909116 fix: stabilize lint/typecheck and shortcut typing 2026-03-13 11:24:54 +01:00
FabLrc 0a6895e89f Merge origin/main into feature/undo-redo 2026-03-13 10:55:40 +01:00
Prayas Lashkari 36a0a304d5 refactor: clean up imports and streamline JSX formatting in LaunchWindow component 2026-03-13 00:17:08 -04:00
Siddharth 4f68df1db8 fix exporter 2026-03-12 21:16:20 -07:00
Prayas Lashkari 7422e16b1e refactor: update package-lock.json to version 1.2.0 and add @radix-ui/react-tooltip dependency 2026-03-12 22:47:28 -04:00
Prayas Lashkari 151a3b2902 refactor: integrate Tooltip component and enhance LaunchWindow with tooltips 2026-03-12 22:14:44 -04:00
Prayas Lashkari 066832a3bd refactor: enhance LaunchWindow styles and structure for improved UX 2026-03-12 18:43:36 -04:00
Prayas Lashkari 118158b8ee refactor: add new animations and boxShadow styles for mic panel and recording effects 2026-03-12 18:43:24 -04:00
Prayas Lashkari 948e2b1e4a refactor: added timeUtils 2026-03-12 18:43:10 -04:00
Prayas Lashkari c48243360b refactor: improve icon handling and formatting in LaunchWindow component 2026-03-12 17:15:18 -04:00
Siddharth 7833dee014 fix microphone permission in build 2026-03-08 14:07:42 -07:00
HemkeshandClaude Opus 4.6 dcf35a6ede Default to Windows tab when no screens available and show source counts
On Linux (e.g. Ubuntu), screen sources are often empty. This defaults
the source selector to the Windows tab when there are no screens, and
shows the count of each source type in the tab labels.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 21:51:09 -06:00
HemkeshandClaude Opus 4.6 c8ebef026b Add "Native" aspect ratio option to export at cropped video dimensions
Adds a "Native" option to the aspect ratio dropdown that uses the cropped
video's actual aspect ratio, so the video fills the entire frame with no
background visible. Selecting Native also sets padding to 0 automatically.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 21:48:47 -06:00
HemkeshandClaude Opus 4.6 7226632fc4 Add precise crop controls with numeric inputs, aspect ratio presets, and drag-to-move
- Add X, Y, W, H pixel input fields in the crop modal for exact positioning
- Add aspect ratio preset dropdown (16:9, 9:16, 4:3, 3:4, 1:1, 21:9, Free)
- Add lock/unlock button to maintain aspect ratio when resizing
- Display source video resolution for reference
- Add drag-to-move: click inside the crop area to pan it around
- Fix dropdown styling for dark mode

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 21:37:17 -06:00
FabLrc 2ccead5fb9 Merge remote-tracking branch 'origin/main' into feature/undo-redo
# Conflicts:
#	src/components/video-editor/VideoEditor.tsx
2026-03-04 12:41:49 +01:00
FabLrc 6d44dafd96 fix: Fixing speed undoable and add undo/redo to the list of shortucts configuration 2026-03-02 16:26:42 +01:00
FabLrc e6e3abb88c Merge branch 'main' into feature/undo-redo
# Conflicts:
#	src/components/video-editor/KeyboardShortcutsHelp.tsx
#	src/components/video-editor/VideoEditor.tsx
#	src/components/video-editor/timeline/TimelineEditor.tsx
2026-03-02 15:45:03 +01:00
FabLrc 0e85679b14 feat: implement undo/redo functionality in video editor 2026-03-01 12:47:52 +01:00
54 changed files with 4797 additions and 1500 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/
+19 -11
View File
@@ -28,6 +28,7 @@
],
"mac": {
"hardenedRuntime": false,
"target": [
{
"target": "dmg",
@@ -36,10 +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.",
"NSCameraUseContinuityCameraDeviceType": 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": [
@@ -49,10 +53,14 @@
"artifactName": "${productName}-Linux-${version}.${ext}",
"category": "AudioVideo"
},
"win": {
"target": [
"nsis"
],
"icon": "icons/icons/win/icon.ico"
}
}
"win": {
"target": [
"nsis"
],
"icon": "icons/icons/win/icon.ico"
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true
}
}
+49 -5
View File
@@ -27,13 +27,40 @@ interface Window {
getSources: (opts: Electron.SourcesOptions) => Promise<ProcessedDesktopSource[]>;
switchToEditor: () => Promise<void>;
openSourceSelector: () => Promise<void>;
selectSource: (source: any) => Promise<any>;
getSelectedSource: () => Promise<any>;
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;
@@ -49,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,
@@ -91,7 +135,7 @@ interface Window {
hudOverlayClose: () => void;
setMicrophoneExpanded: (expanded: boolean) => void;
setHasUnsavedChanges: (hasChanges: boolean) => void;
onRequestSaveBeforeClose: (callback: () => Promise<void>) => () => void;
onRequestSaveBeforeClose: (callback: () => Promise<boolean> | boolean) => () => void;
};
}
+273 -40
View File
@@ -1,10 +1,27 @@
import fs from "node:fs/promises";
import path from "node:path";
import { app, BrowserWindow, desktopCapturer, dialog, ipcMain, screen, shell } from "electron";
import { fileURLToPath, pathToFileURL } from "node:url";
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;
@@ -13,11 +30,33 @@ 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);
}
function normalizeVideoSourcePath(videoPath?: string | null): string | null {
if (typeof videoPath !== "string") {
return null;
}
const trimmed = videoPath.trim();
if (!trimmed) {
return null;
}
if (/^file:\/\//i.test(trimmed)) {
try {
return fileURLToPath(trimmed);
} catch {
// Fall through and keep best-effort string path below.
}
}
return trimmed;
}
function isTrustedProjectPath(filePath?: string | null) {
if (!filePath || !currentProjectPath) {
return false;
@@ -25,6 +64,101 @@ function isTrustedProjectPath(filePath?: string | null) {
return normalizePath(filePath) === normalizePath(currentProjectPath);
}
function setCurrentRecordingSessionState(session: RecordingSession | null) {
currentRecordingSession = session;
}
function getSessionManifestPathForVideo(videoPath: string) {
const parsed = path.parse(videoPath);
const baseName = parsed.name.endsWith("-webcam")
? parsed.name.slice(0, -"-webcam".length)
: parsed.name;
return path.join(parsed.dir, `${baseName}${RECORDING_SESSION_SUFFIX}`);
}
async function loadRecordedSessionForVideoPath(
videoPath: string,
): Promise<RecordingSession | null> {
const normalizedVideoPath = normalizeVideoSourcePath(videoPath);
if (!normalizedVideoPath) {
return null;
}
try {
const manifestPath = getSessionManifestPathForVideo(normalizedVideoPath);
const content = await fs.readFile(manifestPath, "utf-8");
const session = normalizeRecordingSession(JSON.parse(content));
if (!session) {
return null;
}
const normalizedSession: RecordingSession = {
...session,
screenVideoPath: normalizeVideoSourcePath(session.screenVideoPath) ?? session.screenVideoPath,
...(session.webcamVideoPath
? {
webcamVideoPath:
normalizeVideoSourcePath(session.webcamVideoPath) ?? session.webcamVideoPath,
}
: {}),
};
const targetPath = normalizePath(normalizedVideoPath);
const screenMatches = normalizePath(normalizedSession.screenVideoPath) === targetPath;
const webcamMatches = normalizedSession.webcamVideoPath
? normalizePath(normalizedSession.webcamVideoPath) === targetPath
: false;
return screenMatches || webcamMatches ? normalizedSession : null;
} catch {
return null;
}
}
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
@@ -107,6 +241,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) {
@@ -124,36 +290,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),
};
}
@@ -161,8 +321,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" };
@@ -178,6 +344,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();
@@ -199,7 +388,9 @@ export function registerIpcHandlers(
});
ipcMain.handle("get-cursor-telemetry", async (_, videoPath?: string) => {
const targetVideoPath = videoPath ?? currentVideoPath;
const targetVideoPath = normalizeVideoSourcePath(
videoPath ?? currentRecordingSession?.screenVideoPath,
);
if (!targetVideoPath) {
return { success: true, samples: [] };
}
@@ -265,9 +456,11 @@ export function registerIpcHandlers(
ipcMain.handle("get-asset-base-path", () => {
try {
if (app.isPackaged) {
return path.join(process.resourcesPath, "assets");
const assetPath = path.join(process.resourcesPath, "assets");
return pathToFileURL(`${assetPath}${path.sep}`).toString();
}
return path.join(app.getAppPath(), "public", "assets");
const assetPath = path.join(app.getAppPath(), "public", "assets");
return pathToFileURL(`${assetPath}${path.sep}`).toString();
} catch (err) {
console.error("Failed to resolve asset base path:", err);
return null;
@@ -369,7 +562,6 @@ export function registerIpcHandlers(
}
});
let currentVideoPath: string | null = null;
ipcMain.handle(
"save-project-file",
async (_, projectData: unknown, suggestedName?: string, existingProjectPath?: string) => {
@@ -455,8 +647,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 = 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 {
@@ -482,8 +683,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 = 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,
@@ -499,18 +709,41 @@ export function registerIpcHandlers(
};
}
});
ipcMain.handle("set-current-video-path", (_, path: string) => {
currentVideoPath = path;
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", async (_, path: string) => {
const restoredSession = await loadRecordedSessionForVideoPath(path);
if (restoredSession) {
setCurrentRecordingSessionState(restoredSession);
} else {
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 };
});
+74 -15
View File
@@ -1,7 +1,17 @@
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { app, BrowserWindow, dialog, ipcMain, Menu, nativeImage, Tray } from "electron";
import {
app,
BrowserWindow,
dialog,
ipcMain,
Menu,
nativeImage,
session,
systemPreferences,
Tray,
} from "electron";
import { registerIpcHandlers } from "./ipc/handlers";
import { createEditorWindow, createHudOverlayWindow, createSourceSelectorWindow } from "./windows";
@@ -60,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");
}
@@ -167,6 +190,12 @@ function setupApplicationMenu() {
function createTray() {
tray = new Tray(defaultTrayIcon);
tray.on("click", () => {
showMainWindow();
});
tray.on("double-click", () => {
showMainWindow();
});
}
function getTrayIcon(filename: string) {
@@ -198,11 +227,7 @@ function updateTrayMenu(recording: boolean = false) {
{
label: "Open",
click: () => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.isMinimized() && mainWindow.restore();
} else {
createWindow();
}
showMainWindow();
},
},
{
@@ -224,6 +249,21 @@ ipcMain.on("set-has-unsaved-changes", (_, hasChanges: boolean) => {
editorHasUnsavedChanges = hasChanges;
});
function forceCloseEditorWindow(windowToClose: BrowserWindow | null) {
if (!windowToClose || windowToClose.isDestroyed()) return;
isForceClosing = true;
setImmediate(() => {
try {
if (!windowToClose.isDestroyed()) {
windowToClose.close();
}
} finally {
isForceClosing = false;
}
});
}
function createEditorWindowWrapper() {
if (mainWindow) {
isForceClosing = true;
@@ -249,19 +289,19 @@ function createEditorWindowWrapper() {
detail: "Do you want to save your project before closing?",
});
const windowToClose = mainWindow;
if (!windowToClose || windowToClose.isDestroyed()) return;
if (choice === 0) {
// Save & Close — tell renderer to save, then close
mainWindow!.webContents.send("request-save-before-close");
ipcMain.once("save-before-close-done", () => {
isForceClosing = true;
mainWindow?.close();
isForceClosing = false;
windowToClose.webContents.send("request-save-before-close");
ipcMain.once("save-before-close-done", (_, shouldClose: boolean) => {
if (!shouldClose) return;
forceCloseEditorWindow(windowToClose);
});
} else if (choice === 1) {
// Discard & Close
isForceClosing = true;
mainWindow?.close();
isForceClosing = false;
forceCloseEditorWindow(windowToClose);
}
// choice === 2: Cancel — do nothing, window stays open
});
@@ -291,6 +331,25 @@ app.on("activate", () => {
// Register all IPC handlers when app is ready
app.whenReady().then(async () => {
// Allow microphone/media permission checks
session.defaultSession.setPermissionCheckHandler((_webContents, permission) => {
const allowed = ["media", "audioCapture", "microphone", "videoCapture", "camera"];
return allowed.includes(permission);
});
session.defaultSession.setPermissionRequestHandler((_webContents, permission, callback) => {
const allowed = ["media", "audioCapture", "microphone", "videoCapture", "camera"];
callback(allowed.includes(permission));
});
// Request microphone permission from macOS
if (process.platform === "darwin") {
const micStatus = systemPreferences.getMediaAccessStatus("microphone");
if (micStatus !== "granted") {
await systemPreferences.askForMediaAccess("microphone");
}
}
// Listen for HUD overlay quit event (macOS only)
ipcMain.on("hud-overlay-close", () => {
app.quit();
@@ -311,7 +370,7 @@ app.whenReady().then(async () => {
if (!tray) createTray();
updateTrayMenu(recording);
if (!recording) {
if (mainWindow) mainWindow.restore();
showMainWindow();
}
},
);
+24 -4
View File
@@ -1,4 +1,5 @@
import { contextBridge, ipcRenderer } from "electron";
import type { RecordingSession, StoreRecordedSessionInput } from "../src/lib/recordingSession";
contextBridge.exposeInMainWorld("electronAPI", {
hudOverlayHide: () => {
@@ -20,16 +21,22 @@ contextBridge.exposeInMainWorld("electronAPI", {
openSourceSelector: () => {
return ipcRenderer.invoke("open-source-selector");
},
selectSource: (source: any) => {
selectSource: (source: ProcessedDesktopSource) => {
return ipcRenderer.invoke("select-source", source);
},
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");
},
@@ -105,10 +121,14 @@ contextBridge.exposeInMainWorld("electronAPI", {
setHasUnsavedChanges: (hasChanges: boolean) => {
ipcRenderer.send("set-has-unsaved-changes", hasChanges);
},
onRequestSaveBeforeClose: (callback: () => Promise<void>) => {
onRequestSaveBeforeClose: (callback: () => Promise<boolean> | boolean) => {
const listener = async () => {
await callback();
ipcRenderer.send("save-before-close-done");
try {
const shouldClose = await callback();
ipcRenderer.send("save-before-close-done", shouldClose);
} catch {
ipcRenderer.send("save-before-close-done", false);
}
};
ipcRenderer.on("request-save-before-close", listener);
return () => ipcRenderer.removeListener("request-save-before-close", listener);
+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,
+116 -2
View File
@@ -1,12 +1,12 @@
{
"name": "openscreen",
"version": "1.1.3",
"version": "1.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openscreen",
"version": "1.1.3",
"version": "1.2.0",
"dependencies": {
"@fix-webm-duration/fix": "^1.0.1",
"@pixi/filter-drop-shadow": "^5.2.0",
@@ -21,6 +21,7 @@
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@types/gif.js": "^0.2.5",
"@uiw/color-convert": "^2.9.2",
"@uiw/react-color-block": "^2.9.2",
@@ -35,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",
@@ -49,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",
@@ -2940,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",
@@ -3675,6 +3693,40 @@
}
}
},
"node_modules/@radix-ui/react-tooltip": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz",
"integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-dismissable-layer": "1.1.11",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-popper": "1.2.8",
"@radix-ui/react-portal": "1.1.9",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-visually-hidden": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
@@ -4350,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",
@@ -10815,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",
@@ -10838,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",
+5
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": {
@@ -31,6 +33,7 @@
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@types/gif.js": "^0.2.5",
"@uiw/color-convert": "^2.9.2",
"@uiw/react-color-block": "^2.9.2",
@@ -45,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",
@@ -59,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",
});
+24 -19
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from "react";
import { LaunchWindow } from "./components/launch/LaunchWindow";
import { SourceSelector } from "./components/launch/SourceSelector";
import { TooltipProvider } from "./components/ui/tooltip";
import { ShortcutsConfigDialog } from "./components/video-editor/ShortcutsConfigDialog";
import VideoEditor from "./components/video-editor/VideoEditor";
import { ShortcutsProvider } from "./contexts/ShortcutsContext";
@@ -25,23 +26,27 @@ export default function App() {
});
}, []);
switch (windowType) {
case "hud-overlay":
return <LaunchWindow />;
case "source-selector":
return <SourceSelector />;
case "editor":
return (
<ShortcutsProvider>
<VideoEditor />
<ShortcutsConfigDialog />
</ShortcutsProvider>
);
default:
return (
<div className="w-full h-full bg-background text-foreground">
<h1>Openscreen</h1>
</div>
);
}
const content = (() => {
switch (windowType) {
case "hud-overlay":
return <LaunchWindow />;
case "source-selector":
return <SourceSelector />;
case "editor":
return (
<ShortcutsProvider>
<VideoEditor />
<ShortcutsConfigDialog />
</ShortcutsProvider>
);
default:
return (
<div className="w-full h-full bg-background text-foreground">
<h1>Openscreen</h1>
</div>
);
}
})();
return <TooltipProvider>{content}</TooltipProvider>;
}
+1 -126
View File
@@ -1,3 +1,4 @@
/* Electron-specific: no Tailwind equivalent for -webkit-app-region */
.electronDrag {
-webkit-app-region: drag;
}
@@ -5,129 +6,3 @@
.electronNoDrag {
-webkit-app-region: no-drag;
}
.hudBar {
isolation: isolate;
box-shadow:
0 2px 16px rgba(0, 0, 0, 0.25),
0 0 40px rgba(100, 80, 200, 0.08);
}
/* Sub-pill group container */
.hudGroup {
display: flex;
align-items: center;
gap: 2px;
background: rgba(255, 255, 255, 0.05);
border-radius: 9999px;
padding: 4px 8px;
transition: background 0.15s ease;
}
.hudGroup:hover {
background: rgba(255, 255, 255, 0.08);
}
/* Icon button within groups */
.hudIconBtn {
display: flex;
align-items: center;
justify-content: center;
padding: 4px;
border-radius: 9999px;
transition: all 0.15s ease;
cursor: pointer;
background: transparent;
border: none;
color: #fff;
}
.hudIconBtn:hover {
background: rgba(255, 255, 255, 0.1);
transform: scale(1.08);
}
.hudIconBtn:active {
transform: scale(0.95);
}
/* Active icon glow (green) for enabled audio toggles */
.hudIconActive {
filter: drop-shadow(0 0 4px rgba(74, 222, 128, 0.4));
}
/* Recording pulse animation on the record group */
@keyframes recordPulse {
0%,
100% {
box-shadow: 0 0 8px rgba(239, 68, 68, 0.15);
}
50% {
box-shadow: 0 0 16px rgba(239, 68, 68, 0.4);
}
}
.recordingPulse {
animation: recordPulse 1.5s ease-in-out infinite;
background: rgba(239, 68, 68, 0.1) !important;
}
/* Mic panel above the bar */
.micPanel {
background: linear-gradient(135deg, rgba(28, 28, 36, 0.97) 0%, rgba(18, 18, 26, 0.96) 100%);
backdrop-filter: blur(16px) saturate(140%);
-webkit-backdrop-filter: blur(16px) saturate(140%);
border: 1px solid rgba(80, 80, 120, 0.25);
border-radius: 16px;
box-shadow:
0 2px 12px rgba(0, 0, 0, 0.2),
0 0 30px rgba(100, 80, 200, 0.06);
animation: micPanelIn 0.15s ease-out;
}
@keyframes micPanelIn {
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Window control buttons */
.windowBtn {
display: flex;
align-items: center;
justify-content: center;
padding: 3px;
border-radius: 9999px;
transition: all 0.15s ease;
cursor: pointer;
background: transparent;
border: none;
opacity: 0.5;
}
.windowBtn:hover {
opacity: 0.9;
background: rgba(255, 255, 255, 0.08);
}
/* Folder button */
.folderButton {
cursor: pointer;
display: flex;
align-items: center;
gap: 4px;
}
.folderText {
color: #cbd5e1;
transition: text-decoration 0.15s;
}
.folderButton:hover .folderText {
text-decoration: underline;
}
+138 -77
View File
@@ -1,26 +1,78 @@
import { ChevronDown } from "lucide-react";
import { useEffect, useState } from "react";
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,
MdRestartAlt,
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";
import { useScreenRecorder } from "../../hooks/useScreenRecorder";
import { formatTimePadded } from "../../utils/timeUtils";
import { AudioLevelMeter } from "../ui/audio-level-meter";
import { Tooltip } from "../ui/tooltip";
import styles from "./LaunchWindow.module.css";
const ICON_SIZE = 20;
const ICON_CONFIG = {
drag: { icon: RxDragHandleDots2, size: ICON_SIZE },
monitor: { icon: MdMonitor, size: ICON_SIZE },
volumeOn: { icon: MdVolumeUp, size: ICON_SIZE },
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 },
restart: { icon: MdRestartAlt, size: ICON_SIZE },
record: { icon: BsRecordCircle, size: ICON_SIZE },
videoFile: { icon: MdVideoFile, size: ICON_SIZE },
folder: { icon: FaFolderOpen, size: ICON_SIZE },
minimize: { icon: FiMinus, size: ICON_SIZE },
close: { icon: FiX, size: ICON_SIZE },
} as const;
type IconName = keyof typeof ICON_CONFIG;
function getIcon(name: IconName, className?: string) {
const { icon: Icon, size } = ICON_CONFIG[name];
return <Icon size={size} className={className} />;
}
const hudGroupClasses =
"flex items-center gap-0.5 bg-white/5 rounded-full transition-colors duration-150 hover:bg-white/[0.08]";
const hudIconBtnClasses =
"flex items-center justify-center p-2 rounded-full transition-all duration-150 cursor-pointer text-white hover:bg-white/10 hover:scale-[1.08] active:scale-95";
const windowBtnClasses =
"flex items-center justify-center p-2 rounded-full transition-all duration-150 cursor-pointer opacity-50 hover:opacity-90 hover:bg-white/[0.08]";
export function LaunchWindow() {
const {
recording,
toggleRecording,
restartRecording,
microphoneEnabled,
setMicrophoneEnabled,
microphoneDeviceId,
setMicrophoneDeviceId,
systemAudioEnabled,
setSystemAudioEnabled,
webcamEnabled,
setWebcamEnabled,
} = useScreenRecorder();
const [recordingStart, setRecordingStart] = useState<number | null>(null);
const [elapsed, setElapsed] = useState(0);
@@ -58,13 +110,6 @@ export function LaunchWindow() {
};
}, [recording, recordingStart]);
const formatTime = (seconds: number) => {
const m = Math.floor(seconds / 60)
.toString()
.padStart(2, "0");
const s = (seconds % 60).toString().padStart(2, "0");
return `${m}:${s}`;
};
const [selectedSource, setSelectedSource] = useState("Screen");
const [hasSelectedSource, setHasSelectedSource] = useState(false);
@@ -136,133 +181,149 @@ export function LaunchWindow() {
{/* Mic controls panel */}
{showMicControls && (
<div
className={`flex items-center gap-2 px-4 py-2 ${styles.micPanel} ${styles.electronNoDrag}`}
className={`flex items-center gap-2 px-4 py-2 bg-gradient-to-br from-[rgba(28,28,36,0.97)] to-[rgba(18,18,26,0.96)] backdrop-blur-[16px] backdrop-saturate-[140%] border border-[rgba(80,80,120,0.25)] rounded-2xl shadow-mic-panel animate-mic-panel-in ${styles.electronNoDrag}`}
>
<select
value={microphoneDeviceId || selectedDeviceId}
onChange={(e) => {
setSelectedDeviceId(e.target.value);
setMicrophoneDeviceId(e.target.value);
}}
className="flex-1 bg-white/10 text-white text-xs rounded-full px-3 py-1 border border-white/20 outline-none truncate"
style={{ maxWidth: "70%" }}
>
{devices.map((device) => (
<option key={device.deviceId} value={device.deviceId}>
{device.label}
</option>
))}
</select>
<div className="relative flex-1" style={{ maxWidth: "70%" }}>
<select
value={microphoneDeviceId || selectedDeviceId}
onChange={(e) => {
setSelectedDeviceId(e.target.value);
setMicrophoneDeviceId(e.target.value);
}}
className="w-full appearance-none bg-white/10 text-white text-xs rounded-full pl-3 pr-7 py-2 border border-white/20 outline-none truncate"
>
{devices.map((device) => (
<option key={device.deviceId} value={device.deviceId}>
{device.label}
</option>
))}
</select>
<ChevronDown
size={14}
className="absolute right-2 top-1/2 -translate-y-1/2 text-white/60 pointer-events-none"
/>
</div>
<AudioLevelMeter level={level} className="w-24 h-4" />
</div>
)}
{/* Main pill bar */}
<div
className={`flex items-center gap-1.5 px-2 py-1.5 ${styles.hudBar}`}
style={{
borderRadius: 9999,
background: "linear-gradient(135deg, rgba(28,28,36,0.97) 0%, rgba(18,18,26,0.96) 100%)",
backdropFilter: "blur(16px) saturate(140%)",
WebkitBackdropFilter: "blur(16px) saturate(140%)",
border: "1px solid rgba(80,80,120,0.25)",
}}
>
<div className="flex items-center gap-1.5 px-2 py-1.5 isolate rounded-full shadow-hud-bar bg-gradient-to-br from-[rgba(28,28,36,0.97)] to-[rgba(18,18,26,0.96)] backdrop-blur-[16px] backdrop-saturate-[140%] border border-[rgba(80,80,120,0.25)]">
{/* Drag handle */}
<div className={`flex items-center px-1 ${styles.electronDrag}`}>
<RxDragHandleDots2 size={16} className="text-white/30" />
{getIcon("drag", "text-white/30")}
</div>
{/* Source selector */}
<button
className={`${styles.hudGroup} ${styles.electronNoDrag}`}
className={`${hudGroupClasses} p-2 ${styles.electronNoDrag}`}
onClick={openSourceSelector}
disabled={recording}
title={selectedSource}
>
<MdMonitor size={14} className="text-white/80" />
{getIcon("monitor", "text-white/80")}
<span className="text-white/70 text-[11px] max-w-[72px] truncate">
{selectedSource}
</span>
</button>
{/* Audio controls group */}
<div className={`${styles.hudGroup} ${styles.electronNoDrag}`}>
<div className={`${hudGroupClasses} ${styles.electronNoDrag}`}>
<button
className={`${styles.hudIconBtn} ${systemAudioEnabled ? styles.hudIconActive : ""}`}
className={`${hudIconBtnClasses} ${systemAudioEnabled ? "drop-shadow-[0_0_4px_rgba(74,222,128,0.4)]" : ""}`}
onClick={() => !recording && setSystemAudioEnabled(!systemAudioEnabled)}
disabled={recording}
title={systemAudioEnabled ? "Disable system audio" : "Enable system audio"}
>
{systemAudioEnabled ? (
<MdVolumeUp size={15} className="text-green-400" />
) : (
<MdVolumeOff size={15} className="text-white/40" />
)}
{systemAudioEnabled
? getIcon("volumeOn", "text-green-400")
: getIcon("volumeOff", "text-white/40")}
</button>
<button
className={`${styles.hudIconBtn} ${microphoneEnabled ? styles.hudIconActive : ""}`}
className={`${hudIconBtnClasses} ${microphoneEnabled ? "drop-shadow-[0_0_4px_rgba(74,222,128,0.4)]" : ""}`}
onClick={toggleMicrophone}
disabled={recording}
title={microphoneEnabled ? "Disable microphone" : "Enable microphone"}
>
{microphoneEnabled ? (
<MdMic size={15} className="text-green-400" />
) : (
<MdMicOff size={15} className="text-white/40" />
)}
{microphoneEnabled
? 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 */}
<button
className={`${styles.hudGroup} ${styles.electronNoDrag} ${recording ? styles.recordingPulse : ""}`}
className={`flex items-center gap-0.5 rounded-full p-2 transition-colors duration-150 ${styles.electronNoDrag} ${
recording ? "animate-record-pulse bg-red-500/10" : "bg-white/5 hover:bg-white/[0.08]"
}`}
onClick={hasSelectedSource ? toggleRecording : openSourceSelector}
disabled={!hasSelectedSource && !recording}
style={{ flex: "0 0 auto" }}
>
{recording ? (
<>
<FaRegStopCircle size={13} className="text-red-400" />
{getIcon("stop", "text-red-400")}
<span className="text-red-400 text-xs font-semibold tabular-nums">
{formatTime(elapsed)}
{formatTimePadded(elapsed)}
</span>
</>
) : (
<BsRecordCircle
size={14}
className={hasSelectedSource ? "text-white/80" : "text-white/30"}
/>
getIcon("record", hasSelectedSource ? "text-white/80" : "text-white/30")
)}
</button>
{/* Restart recording */}
{recording && (
<Tooltip content="Restart recording">
<button
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
onClick={restartRecording}
>
{getIcon("restart", "text-white/60")}
</button>
</Tooltip>
)}
{/* Open video file */}
<button
className={`${styles.hudIconBtn} ${styles.electronNoDrag}`}
onClick={openVideoFile}
disabled={recording}
title="Open video file"
>
<MdVideoFile size={14} className="text-white/60" />
</button>
<Tooltip content="Open video file">
<button
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
onClick={openVideoFile}
disabled={recording}
>
{getIcon("videoFile", "text-white/60")}
</button>
</Tooltip>
{/* Open project */}
<button
className={`${styles.hudIconBtn} ${styles.electronNoDrag}`}
onClick={openProjectFile}
disabled={recording}
title="Open project"
>
<FaFolderOpen size={14} className="text-white/60" />
</button>
<Tooltip content="Open project">
<button
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
onClick={openProjectFile}
disabled={recording}
>
{getIcon("folder", "text-white/60")}
</button>
</Tooltip>
{/* Window controls */}
<div className={`flex items-center gap-0.5 ${styles.electronNoDrag}`}>
<button className={styles.windowBtn} title="Hide HUD" onClick={sendHudOverlayHide}>
<FiMinus size={14} className="text-white" />
<button className={windowBtnClasses} title="Hide HUD" onClick={sendHudOverlayHide}>
{getIcon("minimize", "text-white")}
</button>
<button className={styles.windowBtn} title="Close App" onClick={sendHudOverlayClose}>
<FiX size={14} className="text-white" />
<button className={windowBtnClasses} title="Close App" onClick={sendHudOverlayClose}>
{getIcon("close", "text-white")}
</button>
</div>
</div>
+6 -3
View File
@@ -104,19 +104,22 @@ export function SourceSelector() {
return (
<div className={`min-h-screen flex flex-col ${styles.glassContainer}`}>
<div className="flex-1 flex flex-col w-full px-4 pt-4">
<Tabs defaultValue="screens" className="flex-1 flex flex-col">
<Tabs
defaultValue={screenSources.length === 0 ? "windows" : "screens"}
className="flex-1 flex flex-col"
>
<TabsList className="grid grid-cols-2 mb-3 bg-white/5 rounded-full">
<TabsTrigger
value="screens"
className="data-[state=active]:bg-white/15 data-[state=active]:text-white text-zinc-400 rounded-full text-xs py-1 transition-all"
>
Screens
Screens ({screenSources.length})
</TabsTrigger>
<TabsTrigger
value="windows"
className="data-[state=active]:bg-white/15 data-[state=active]:text-white text-zinc-400 rounded-full text-xs py-1 transition-all"
>
Windows
Windows ({windowSources.length})
</TabsTrigger>
</TabsList>
<div className="flex-1 min-h-0">
+70
View File
@@ -0,0 +1,70 @@
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import * as React from "react";
import { cn } from "@/lib/utils";
function TooltipProvider({
delayDuration = 200,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
);
}
function TooltipRoot({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />;
}
function TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
}
function TooltipContent({
className,
sideOffset = 6,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"px-2 py-1 text-[11px] leading-none text-white/90 bg-black/85 border border-white/10 rounded-md z-50",
"animate-in fade-in-0 zoom-in-95",
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
className,
)}
{...props}
/>
</TooltipPrimitive.Portal>
);
}
function Tooltip({
children,
content,
side,
className,
}: {
children: React.ReactNode;
content: React.ReactNode;
side?: "top" | "right" | "bottom" | "left";
className?: string;
}) {
return (
<TooltipRoot>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipContent side={side} className={className}>
{content}
</TooltipContent>
</TooltipRoot>
);
}
export { TooltipProvider, TooltipRoot, TooltipTrigger, TooltipContent, Tooltip };
+21 -2
View File
@@ -16,7 +16,7 @@ interface CropControlProps {
aspectRatio: AspectRatio;
}
type DragHandle = "top" | "right" | "bottom" | "left" | null;
type DragHandle = "top" | "right" | "bottom" | "left" | "move" | null;
export function CropControl({ videoElement, cropRegion, onCropChange }: CropControlProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
@@ -99,6 +99,11 @@ export function CropControl({ videoElement, cropRegion, onCropChange }: CropCont
case "right":
newCrop.width = Math.max(0.1, Math.min(initialCrop.width + deltaX, 1 - initialCrop.x));
break;
case "move": {
newCrop.x = Math.max(0, Math.min(initialCrop.x + deltaX, 1 - initialCrop.width));
newCrop.y = Math.max(0, Math.min(initialCrop.y + deltaY, 1 - initialCrop.height));
break;
}
}
onCropChange(newCrop);
@@ -108,7 +113,9 @@ export function CropControl({ videoElement, cropRegion, onCropChange }: CropCont
if (isDragging) {
try {
e.currentTarget.releasePointerCapture(e.pointerId);
} catch {}
} catch {
// Pointer may already be released; ignore.
}
}
setIsDragging(null);
};
@@ -176,6 +183,18 @@ export function CropControl({ videoElement, cropRegion, onCropChange }: CropCont
</svg>
</div>
<div
className="absolute z-10 pointer-events-auto cursor-move"
style={{
left: `${cropPixelX}%`,
top: `${cropPixelY}%`,
width: `${cropPixelWidth}%`,
height: `${cropPixelHeight}%`,
transition: "none",
}}
onPointerDown={(e) => handlePointerDown(e, "move")}
/>
<div
className={cn("absolute h-[3px] cursor-ns-resize z-20 pointer-events-auto bg-[#34B27B]")}
style={{
+13 -21
View File
@@ -1,6 +1,5 @@
import { Download, Loader2, X } from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "sonner"; // Add this import
import { Button } from "@/components/ui/button";
import type { ExportProgress } from "@/lib/exporter";
@@ -13,6 +12,7 @@ interface ExportDialogProps {
onCancel?: () => void;
exportFormat?: "mp4" | "gif";
exportedFilePath?: string;
onShowInFolder?: () => void;
}
export function ExportDialog({
@@ -23,7 +23,8 @@ export function ExportDialog({
error,
onCancel,
exportFormat = "mp4",
exportedFilePath, // Add this line
exportedFilePath,
onShowInFolder,
}: ExportDialogProps) {
const [showSuccess, setShowSuccess] = useState(false);
@@ -66,6 +67,9 @@ export function ExportDialog({
const getStatusMessage = () => {
if (error) return "Please try again";
if (isCompiling || isFinalizing) {
if (exportFormat === "mp4") {
return "Finalizing video export...";
}
if (renderProgress !== undefined && renderProgress > 0) {
return `Compiling GIF... ${renderProgress}%`;
}
@@ -77,27 +81,11 @@ export function ExportDialog({
// Get title based on phase
const getTitle = () => {
if (error) return "Export Failed";
if (isFinalizing && exportFormat === "mp4") return "Finalizing Video";
if (isCompiling || isFinalizing) return "Compiling GIF";
return `Exporting ${formatLabel}`;
};
const handleClickShowInFolder = async () => {
if (exportedFilePath) {
try {
const result = await window.electronAPI.revealInFolder(exportedFilePath);
if (!result.success) {
const errorMessage = result.error || result.message || "Failed to reveal item in folder.";
console.error("Failed to reveal in folder:", errorMessage);
toast.error(errorMessage);
}
} catch (err) {
const errorMessage = String(err);
console.error("Error calling revealInFolder IPC:", errorMessage);
toast.error(`Error revealing in folder: ${errorMessage}`);
}
}
};
return (
<>
<div
@@ -120,7 +108,7 @@ export function ExportDialog({
{exportedFilePath && (
<Button
variant="secondary"
onClick={handleClickShowInFolder}
onClick={onShowInFolder}
className="mt-2 w-fit px-3 py-1 text-sm rounded-md bg-white/10 hover:bg-white/20 text-slate-200"
>
Show in Folder
@@ -233,7 +221,11 @@ export function ExportDialog({
{isCompiling || isFinalizing ? "Status" : "Format"}
</div>
<div className="text-slate-200 font-medium text-sm">
{isCompiling || isFinalizing ? "Compiling..." : formatLabel}
{isFinalizing && exportFormat === "mp4"
? "Finalizing..."
: isCompiling || isFinalizing
? "Compiling..."
: formatLabel}
</div>
</div>
<div className="bg-white/5 rounded-xl p-3 border border-white/5">
@@ -1,24 +1,10 @@
import { HelpCircle, Settings2 } from "lucide-react";
import { useEffect, useState } from "react";
import { useShortcuts } from "@/contexts/ShortcutsContext";
import { formatBinding, SHORTCUT_ACTIONS, SHORTCUT_LABELS } from "@/lib/shortcuts";
import { formatShortcut } from "@/utils/platformUtils";
import { FIXED_SHORTCUTS, formatBinding, SHORTCUT_ACTIONS, SHORTCUT_LABELS } from "@/lib/shortcuts";
export function KeyboardShortcutsHelp() {
const { shortcuts, isMac, openConfig } = useShortcuts();
const [scrollLabels, setScrollLabels] = useState({
pan: "Shift + Ctrl + Scroll",
zoom: "Ctrl + Scroll",
});
useEffect(() => {
Promise.all([
formatShortcut(["shift", "mod", "Scroll"]),
formatShortcut(["mod", "Scroll"]),
]).then(([pan, zoom]) => setScrollLabels({ pan, zoom }));
}, []);
return (
<div className="relative group">
<HelpCircle className="w-4 h-4 text-slate-500 hover:text-[#34B27B] transition-colors cursor-help" />
@@ -47,25 +33,20 @@ export function KeyboardShortcutsHelp() {
</div>
))}
<div className="pt-1 border-t border-white/5 mt-1">
<div className="flex items-center justify-between">
<span className="text-slate-400">Pan Timeline</span>
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">
{scrollLabels.pan}
</kbd>
</div>
<div className="flex items-center justify-between mt-1.5">
<span className="text-slate-400">Zoom Timeline</span>
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">
{scrollLabels.zoom}
</kbd>
</div>
<div className="flex items-center justify-between mt-1.5">
<span className="text-slate-400">Cycle Annotations</span>
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">
Tab
</kbd>
</div>
<div className="pt-1 border-t border-white/5 mt-1 space-y-1.5">
{FIXED_SHORTCUTS.map((fixed) => (
<div key={fixed.label} className="flex items-center justify-between">
<span className="text-slate-400">{fixed.label}</span>
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">
{isMac
? fixed.display
.replace(/Ctrl/g, "⌘")
.replace(/Shift/g, "⇧")
.replace(/Alt/g, "⌥")
: fixed.display}
</kbd>
</div>
))}
</div>
</div>
</div>
+252 -28
View File
@@ -6,15 +6,17 @@ import {
Film,
FolderOpen,
Image,
Lock,
Palette,
Save,
Sparkles,
Star,
Trash2,
Unlock,
Upload,
X,
} from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import {
Accordion,
@@ -31,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";
@@ -38,6 +41,7 @@ import type {
AnnotationRegion,
AnnotationType,
CropRegion,
FigureData,
PlaybackSpeed,
ZoomDepth,
} from "./types";
@@ -86,14 +90,18 @@ interface SettingsPanelProps {
onTrimDelete?: (id: string) => void;
shadowIntensity?: number;
onShadowChange?: (intensity: number) => void;
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;
padding?: number;
onPaddingChange?: (padding: number) => void;
onPaddingCommit?: () => void;
cropRegion?: CropRegion;
onCropChange?: (region: CropRegion) => void;
aspectRatio: AspectRatio;
@@ -118,7 +126,7 @@ interface SettingsPanelProps {
onAnnotationContentChange?: (id: string, content: string) => void;
onAnnotationTypeChange?: (id: string, type: AnnotationType) => void;
onAnnotationStyleChange?: (id: string, style: Partial<AnnotationRegion["style"]>) => void;
onAnnotationFigureDataChange?: (id: string, figureData: any) => void;
onAnnotationFigureDataChange?: (id: string, figureData: FigureData) => void;
onAnnotationDelete?: (id: string) => void;
selectedSpeedId?: string | null;
selectedSpeedValue?: PlaybackSpeed | null;
@@ -148,14 +156,18 @@ export function SettingsPanel({
onTrimDelete,
shadowIntensity = 0,
onShadowChange,
onShadowCommit,
showBlur,
onBlurChange,
motionBlurEnabled = false,
motionBlurAmount = 0,
onMotionBlurChange,
onMotionBlurCommit,
borderRadius = 0,
onBorderRadiusChange,
onBorderRadiusCommit,
padding = 50,
onPaddingChange,
onPaddingCommit,
cropRegion,
onCropChange,
aspectRatio,
@@ -196,7 +208,7 @@ export function SettingsPanel({
try {
const resolved = await Promise.all(WALLPAPER_RELATIVE.map((p) => getAssetPath(p)));
if (mounted) setWallpaperPaths(resolved);
} catch {
} catch (_err) {
if (mounted) setWallpaperPaths(WALLPAPER_RELATIVE.map((p) => `/${p}`));
}
})();
@@ -225,7 +237,107 @@ export function SettingsPanel({
const [selectedColor, setSelectedColor] = useState("#ADADAD");
const [gradient, setGradient] = useState<string>(GRADIENTS[0]);
const [showCropDropdown, setShowCropDropdown] = useState(false);
const [showCropModal, setShowCropModal] = useState(false);
const cropSnapshotRef = useRef<CropRegion | null>(null);
const [cropAspectLocked, setCropAspectLocked] = useState(false);
const [cropAspectRatio, setCropAspectRatio] = useState("");
const videoWidth = videoElement?.videoWidth || 1920;
const videoHeight = videoElement?.videoHeight || 1080;
const handleCropNumericChange = useCallback(
(field: "x" | "y" | "width" | "height", pixelValue: number) => {
if (!cropRegion || !onCropChange) return;
const next = { ...cropRegion };
switch (field) {
case "x":
next.x = Math.max(0, Math.min(pixelValue / videoWidth, 1 - next.width));
break;
case "y":
next.y = Math.max(0, Math.min(pixelValue / videoHeight, 1 - next.height));
break;
case "width": {
const newWidth = Math.max(0.05, Math.min(pixelValue / videoWidth, 1 - next.x));
if (cropAspectLocked && next.width > 0 && next.height > 0) {
const ratio = next.width / next.height;
const newHeight = newWidth / ratio;
if (next.y + newHeight <= 1) {
next.width = newWidth;
next.height = newHeight;
}
} else {
next.width = newWidth;
}
break;
}
case "height": {
const newHeight = Math.max(0.05, Math.min(pixelValue / videoHeight, 1 - next.y));
if (cropAspectLocked && next.width > 0 && next.height > 0) {
const ratio = next.width / next.height;
const newWidth = newHeight * ratio;
if (next.x + newWidth <= 1) {
next.height = newHeight;
next.width = newWidth;
}
} else {
next.height = newHeight;
}
break;
}
}
onCropChange(next);
},
[cropRegion, onCropChange, videoWidth, videoHeight, cropAspectLocked],
);
const applyCropAspectPreset = useCallback(
(preset: string) => {
if (!cropRegion || !onCropChange) return;
setCropAspectRatio(preset);
if (preset === "") {
setCropAspectLocked(false);
return;
}
const [wStr, hStr] = preset.split(":");
const targetRatio = Number(wStr) / Number(hStr);
const next = { ...cropRegion };
const nextHeight = (next.width * videoWidth) / (targetRatio * videoHeight);
if (next.y + nextHeight <= 1 && nextHeight >= 0.05) {
next.height = nextHeight;
} else {
const nextWidth = (next.height * videoHeight * targetRatio) / videoWidth;
if (next.x + nextWidth <= 1 && nextWidth >= 0.05) {
next.width = nextWidth;
}
}
onCropChange(next);
setCropAspectLocked(true);
},
[cropRegion, onCropChange, videoWidth, videoHeight],
);
const getCropPixelValue = useCallback(
(field: "x" | "y" | "width" | "height"): number => {
if (!cropRegion) return 0;
switch (field) {
case "x":
return Math.round(cropRegion.x * videoWidth);
case "y":
return Math.round(cropRegion.y * videoHeight);
case "width":
return Math.round(cropRegion.width * videoWidth);
case "height":
return Math.round(cropRegion.height * videoHeight);
}
},
[cropRegion, videoWidth, videoHeight],
);
const zoomEnabled = Boolean(selectedZoomDepth);
const trimEnabled = Boolean(selectedTrimId);
@@ -289,6 +401,20 @@ export function SettingsPanel({
}
};
const handleCropToggle = () => {
if (!showCropModal && cropRegion) {
cropSnapshotRef.current = { ...cropRegion };
}
setShowCropModal(!showCropModal);
};
const handleCropCancel = () => {
if (cropSnapshotRef.current && onCropChange) {
onCropChange(cropSnapshotRef.current);
}
setShowCropModal(false);
};
// Find selected annotation
const selectedAnnotation = selectedAnnotationId
? annotationRegions.find((a) => a.id === selectedAnnotationId)
@@ -451,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
@@ -470,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>
@@ -480,6 +615,7 @@ export function SettingsPanel({
<Slider
value={[shadowIntensity]}
onValueChange={(values) => onShadowChange?.(values[0])}
onValueCommit={() => onShadowCommit?.()}
min={0}
max={1}
step={0.01}
@@ -494,6 +630,7 @@ export function SettingsPanel({
<Slider
value={[borderRadius]}
onValueChange={(values) => onBorderRadiusChange?.(values[0])}
onValueCommit={() => onBorderRadiusCommit?.()}
min={0}
max={16}
step={0.5}
@@ -508,6 +645,7 @@ export function SettingsPanel({
<Slider
value={[padding]}
onValueChange={(values) => onPaddingChange?.(values[0])}
onValueCommit={() => onPaddingCommit?.()}
min={0}
max={100}
step={1}
@@ -517,7 +655,7 @@ export function SettingsPanel({
</div>
<Button
onClick={() => setShowCropDropdown(!showCropDropdown)}
onClick={handleCropToggle}
variant="outline"
className="w-full mt-2 gap-1.5 bg-white/5 text-slate-200 border-white/10 hover:bg-white/10 hover:border-white/20 hover:text-white text-[10px] h-8 transition-all"
>
@@ -620,7 +758,9 @@ export function SettingsPanel({
s.replace(/^file:\/\//, "").replace(/^\//, "");
if (clean(selected).endsWith(clean(path))) return true;
if (clean(path).endsWith(clean(selected))) return true;
} catch {}
} catch {
// Best-effort comparison; fallback to strict match.
}
return false;
})();
return (
@@ -691,11 +831,11 @@ export function SettingsPanel({
</Accordion>
</div>
{showCropDropdown && cropRegion && onCropChange && (
{showCropModal && cropRegion && onCropChange && (
<>
<div
className="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 animate-in fade-in duration-200"
onClick={() => setShowCropDropdown(false)}
onClick={handleCropCancel}
/>
<div className="fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-[60] bg-[#09090b] rounded-2xl shadow-2xl border border-white/10 p-8 w-[90vw] max-w-5xl max-h-[90vh] overflow-auto animate-in zoom-in-95 duration-200">
<div className="flex items-center justify-between mb-6">
@@ -708,7 +848,7 @@ export function SettingsPanel({
<Button
variant="ghost"
size="icon"
onClick={() => setShowCropDropdown(false)}
onClick={handleCropCancel}
className="hover:bg-white/10 text-slate-400 hover:text-white"
>
<X className="w-5 h-5" />
@@ -720,14 +860,95 @@ export function SettingsPanel({
onCropChange={onCropChange}
aspectRatio={aspectRatio}
/>
<div className="mt-6 flex justify-end">
<Button
onClick={() => setShowCropDropdown(false)}
size="lg"
className="bg-[#34B27B] hover:bg-[#34B27B]/90 text-white"
>
Done
</Button>
<div className="mt-6 space-y-4">
<div className="flex flex-wrap items-end gap-3">
{[
{ label: "X", field: "x" as const, max: videoWidth },
{ label: "Y", field: "y" as const, max: videoHeight },
{ label: "W", field: "width" as const, max: videoWidth },
{ label: "H", field: "height" as const, max: videoHeight },
].map(({ label, field, max }) => (
<div key={field} className="flex flex-col gap-1">
<label className="text-[10px] font-medium text-slate-400 uppercase tracking-wider">
{label}
</label>
<input
type="number"
min={0}
max={max}
value={getCropPixelValue(field)}
onChange={(e) => handleCropNumericChange(field, Number(e.target.value))}
className="w-[90px] h-8 rounded-md border border-white/10 bg-white/5 px-2 text-xs text-slate-200 outline-none focus:border-[#34B27B]/50 focus:ring-1 focus:ring-[#34B27B]/30 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
</div>
))}
<div className="flex flex-col gap-1">
<label className="text-[10px] font-medium text-slate-400 uppercase tracking-wider">
Ratio
</label>
<div className="flex items-center gap-1.5">
<select
value={cropAspectRatio}
onChange={(e) => applyCropAspectPreset(e.target.value)}
className="h-8 rounded-md border border-white/10 bg-[#1a1a1f] px-2 text-xs text-slate-200 outline-none focus:border-[#34B27B]/50 cursor-pointer"
>
<option value="" className="bg-[#1a1a1f] text-slate-200">
Free
</option>
<option value="16:9" className="bg-[#1a1a1f] text-slate-200">
16:9
</option>
<option value="9:16" className="bg-[#1a1a1f] text-slate-200">
9:16
</option>
<option value="4:3" className="bg-[#1a1a1f] text-slate-200">
4:3
</option>
<option value="3:4" className="bg-[#1a1a1f] text-slate-200">
3:4
</option>
<option value="1:1" className="bg-[#1a1a1f] text-slate-200">
1:1
</option>
<option value="21:9" className="bg-[#1a1a1f] text-slate-200">
21:9
</option>
</select>
<button
type="button"
onClick={() => setCropAspectLocked((prev) => !prev)}
className={cn(
"h-8 w-8 flex items-center justify-center rounded-md border transition-all",
cropAspectLocked
? "border-[#34B27B]/50 bg-[#34B27B]/10 text-[#34B27B]"
: "border-white/10 bg-white/5 text-slate-400 hover:text-slate-200",
)}
title={cropAspectLocked ? "Unlock aspect ratio" : "Lock aspect ratio"}
>
{cropAspectLocked ? (
<Lock className="w-3.5 h-3.5" />
) : (
<Unlock className="w-3.5 h-3.5" />
)}
</button>
</div>
</div>
<p className="text-[10px] text-slate-500 self-center ml-2">
{videoWidth} × {videoHeight}px
</p>
</div>
<div className="flex justify-end">
<Button
onClick={() => setShowCropModal(false)}
size="lg"
className="bg-[#34B27B] hover:bg-[#34B27B]/90 text-white"
>
Done
</Button>
</div>
</div>
</div>
</>
@@ -748,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",
@@ -822,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",
@@ -873,6 +1096,7 @@ export function SettingsPanel({
</div>
<Button
data-testid={getTestId("export-button")}
type="button"
size="lg"
onClick={onExport}
@@ -84,7 +84,7 @@ export function ShortcutsConfigDialog() {
window.addEventListener("keydown", handleCapture, { capture: true });
return () => window.removeEventListener("keydown", handleCapture, { capture: true });
}, [captureFor]);
}, [captureFor, draft]);
const handleSwap = useCallback(() => {
if (!conflict || conflict.conflictWith.type !== "configurable") return;
File diff suppressed because it is too large Load Diff
+322 -71
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,7 +19,12 @@ import {
useState,
} from "react";
import { getAssetPath } from "@/lib/assetPath";
import { type AspectRatio, formatAspectRatioForCSS } from "@/utils/aspectRatioUtils";
import { computeWebcamOverlayLayout, type WebcamOverlayLayout } from "@/lib/webcamOverlay";
import {
type AspectRatio,
formatAspectRatioForCSS,
getNativeAspectRatioValue,
} from "@/utils/aspectRatioUtils";
import { AnnotationOverlay } from "./AnnotationOverlay";
import {
type AnnotationRegion,
@@ -29,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;
@@ -50,11 +67,12 @@ interface VideoPlaybackProps {
selectedZoomId: string | null;
onSelectZoom: (id: string | null) => void;
onZoomFocusChange: (id: string, focus: ZoomFocus) => void;
onZoomFocusDragEnd?: () => void;
isPlaying: boolean;
showShadow?: boolean;
shadowIntensity?: number;
showBlur?: boolean;
motionBlurEnabled?: boolean;
motionBlurAmount?: number;
borderRadius?: number;
padding?: number;
cropRegion?: import("./types").CropRegion;
@@ -82,6 +100,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
(
{
videoPath,
webcamVideoPath,
onDurationChange,
onTimeUpdate,
currentTime,
@@ -92,11 +111,12 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
selectedZoomId,
onSelectZoom,
onZoomFocusChange,
onZoomFocusDragEnd,
isPlaying,
showShadow,
shadowIntensity = 0,
showBlur,
motionBlurEnabled = false,
motionBlurAmount = 0,
borderRadius = 0,
padding = 50,
cropRegion,
@@ -112,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);
@@ -122,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);
@@ -129,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 });
@@ -146,7 +178,10 @@ 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);
const clampFocusToStage = useCallback((focus: ZoomFocus, depth: ZoomDepth) => {
@@ -339,7 +374,10 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
isDraggingFocusRef.current = false;
try {
event.currentTarget.releasePointerCapture(event.pointerId);
} catch {}
} catch {
// Pointer may already be released.
}
onZoomFocusDragEnd?.();
};
const handleOverlayPointerUp = (event: React.PointerEvent<HTMLDivElement>) => {
@@ -371,8 +409,16 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
}, [speedRegions]);
useEffect(() => {
motionBlurEnabledRef.current = motionBlurEnabled;
}, [motionBlurEnabled]);
motionBlurAmountRef.current = motionBlurAmount;
}, [motionBlurAmount]);
useEffect(() => {
onTimeUpdateRef.current = onTimeUpdate;
}, [onTimeUpdate]);
useEffect(() => {
onPlayStateChangeRef.current = onPlayStateChange;
}, [onPlayStateChange]);
useEffect(() => {
if (!pixiReady || !videoReady) return;
@@ -397,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;
}
@@ -431,20 +484,22 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
focusY: DEFAULT_FOCUS.cy,
motionIntensity: 0,
isPlaying: false,
motionBlurEnabled: motionBlurEnabledRef.current,
motionBlurAmount: motionBlurAmountRef.current,
});
requestAnimationFrame(() => {
const finalApp = appRef.current;
if (wasPlaying && video) {
video.play().catch(() => {});
video.play().catch(() => {
// Ignore autoplay restoration failures.
});
}
if (tickerWasStarted && finalApp?.ticker) {
finalApp.ticker.start();
}
});
});
}, [pixiReady, videoReady, layoutVideoContent, cropRegion]);
}, [pixiReady, videoReady, layoutVideoContent]);
useEffect(() => {
if (!pixiReady || !videoReady) return;
@@ -538,6 +593,11 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
}, []);
useEffect(() => {
if (!videoPath) {
setVideoReady(false);
return;
}
const video = videoRef.current;
if (!video) return;
video.pause();
@@ -583,16 +643,22 @@ 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;
layoutVideoContent();
layoutVideoContentRef.current?.();
video.pause();
const { handlePlay, handlePause, handleSeeked, handleSeeking } = createVideoEventHandlers({
@@ -602,8 +668,8 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
allowPlaybackRef,
currentTimeRef,
timeUpdateAnimationRef,
onPlayStateChange,
onTimeUpdate,
onPlayStateChange: (playing) => onPlayStateChangeRef.current(playing),
onTimeUpdate: (time) => onTimeUpdateRef.current(time),
trimRegionsRef,
speedRegionsRef,
});
@@ -640,11 +706,15 @@ 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;
};
}, [pixiReady, videoReady, onTimeUpdate, updateOverlayForRegion]);
}, [pixiReady, videoReady]);
useEffect(() => {
if (!pixiReady || !videoReady) return;
@@ -654,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);
@@ -753,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;
@@ -784,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 () => {
@@ -827,7 +1044,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
}
const p = await getAssetPath(wallpaper.replace(/^\//, ""));
if (mounted) setResolvedWallpaper(p);
} catch {
} catch (_err) {
if (mounted) setResolvedWallpaper(wallpaper || "/wallpapers/wallpaper1.jpg");
}
})();
@@ -858,8 +1075,21 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
return (
<div
ref={stageRef}
className="relative rounded-sm overflow-hidden"
style={{ width: "100%", aspectRatio: formatAspectRatioForCSS(aspectRatio) }}
style={{
width: "100%",
aspectRatio: formatAspectRatioForCSS(
aspectRatio,
aspectRatio === "native"
? getNativeAspectRatioValue(
lockedVideoDimensionsRef.current?.width || 1920,
lockedVideoDimensionsRef.current?.height || 1080,
cropRegion,
)
: undefined,
),
}}
>
{/* Background layer - always render as DOM element with blur */}
<div
@@ -879,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 {
@@ -58,24 +61,70 @@ function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
function isFileUrl(value: string): boolean {
return /^file:\/\//i.test(value);
}
function encodePathSegments(pathname: string, keepWindowsDrive = false): string {
return pathname
.split("/")
.map((segment, index) => {
if (!segment) return "";
if (keepWindowsDrive && index === 1 && /^[a-zA-Z]:$/.test(segment)) {
return segment;
}
return encodeURIComponent(segment);
})
.join("/");
}
export function toFileUrl(filePath: string): string {
const normalized = filePath.replace(/\\/g, "/");
if (normalized.match(/^[a-zA-Z]:/)) {
return `file:///${normalized}`;
// Windows drive path: C:/Users/...
if (/^[a-zA-Z]:\//.test(normalized)) {
return `file://${encodePathSegments(`/${normalized}`, true)}`;
}
return `file://${normalized}`;
// UNC path: //server/share/...
if (normalized.startsWith("//")) {
const [host, ...pathParts] = normalized.replace(/^\/+/, "").split("/");
const encodedPath = pathParts.map((part) => encodeURIComponent(part)).join("/");
return encodedPath ? `file://${host}/${encodedPath}` : `file://${host}/`;
}
const absolutePath = normalized.startsWith("/") ? normalized : `/${normalized}`;
return `file://${encodePathSegments(absolutePath)}`;
}
export function fromFileUrl(fileUrl: string): string {
if (!fileUrl.startsWith("file://")) {
const value = fileUrl.trim();
if (!isFileUrl(value)) {
return fileUrl;
}
try {
const url = new URL(fileUrl);
return decodeURIComponent(url.pathname);
const url = new URL(value);
const pathname = decodeURIComponent(url.pathname);
if (url.host && url.host !== "localhost") {
return `//${url.host}${pathname}`;
}
if (/^\/[a-zA-Z]:/.test(pathname)) {
return pathname.slice(1);
}
return pathname;
} catch {
return fileUrl.replace(/^file:\/\//, "");
const rawFallbackPath = value.replace(/^file:\/\//i, "");
let fallbackPath = rawFallbackPath;
try {
fallbackPath = decodeURIComponent(rawFallbackPath);
} catch {
// Keep raw best-effort path if percent decoding fails.
}
return fallbackPath.replace(/^\/([a-zA-Z]:)/, "$1");
}
}
@@ -93,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);
@@ -256,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: {
@@ -295,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,
};
}
@@ -187,12 +187,12 @@ export default function TimelineWrapper({
// Drag/resize tooltip (direct DOM updates, no re-renders)
const tooltipRef = useRef<HTMLDivElement>(null);
const formatTooltipMs = (ms: number) => {
const formatTooltipMs = useCallback((ms: number) => {
const s = ms / 1000;
const min = Math.floor(s / 60);
const sec = s % 60;
return min > 0 ? `${min}:${sec.toFixed(1).padStart(4, "0")}` : `${sec.toFixed(1)}s`;
};
}, []);
const showTooltip = useCallback(
(span: { start: number; end: number } | null, screenX?: number) => {
@@ -213,7 +213,7 @@ export default function TimelineWrapper({
}
}
},
[],
[formatTooltipMs],
);
const onDragStart = useCallback(
@@ -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);
return {
scale,
x: finalX * progress,
y: finalY * progress,
};
}
// 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;
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);
cameraContainer.position.set(cameraX, cameraY);
if (motionBlurState && motionBlurFilter && motionBlurAmount > 0 && isPlaying) {
const now = frameTimeMs ?? performance.now();
if (blurFilter) {
const shouldBlur = motionBlurEnabled && isPlaying && motionIntensity > 0.0005;
const motionBlur = shouldBlur ? Math.min(6, motionIntensity * 120) : 0;
blurFilter.blur = motionBlur;
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,
};
}
+6 -2
View File
@@ -36,7 +36,9 @@ export function ShortcutsProvider({ children }: { children: ReactNode }) {
useEffect(() => {
getIsMac()
.then(setIsMac)
.catch(() => {});
.catch(() => {
// Keep default non-mac fallback if detection fails.
});
window.electronAPI
.getShortcuts?.()
@@ -45,7 +47,9 @@ export function ShortcutsProvider({ children }: { children: ReactNode }) {
setShortcuts(mergeWithDefaults(saved as Partial<ShortcutsConfig>));
}
})
.catch(() => {});
.catch(() => {
// Keep default shortcuts if persisted settings can't be loaded.
});
}, []);
const persistShortcuts = useCallback(
+21 -18
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
export interface AudioLevelMeterOptions {
enabled: boolean;
@@ -13,6 +13,22 @@ export function useAudioLevelMeter(options: AudioLevelMeterOptions) {
const streamRef = useRef<MediaStream | null>(null);
const animationFrameRef = useRef<number | null>(null);
const cleanup = useCallback(() => {
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = null;
}
if (streamRef.current) {
streamRef.current.getTracks().forEach((track) => track.stop());
streamRef.current = null;
}
if (audioContextRef.current) {
audioContextRef.current.close();
audioContextRef.current = null;
}
analyserRef.current = null;
}, []);
useEffect(() => {
if (!options.enabled) {
cleanup();
@@ -39,6 +55,9 @@ export function useAudioLevelMeter(options: AudioLevelMeterOptions) {
streamRef.current = stream;
const audioContext = new AudioContext();
if (audioContext.state === "suspended") {
await audioContext.resume();
}
audioContextRef.current = audioContext;
const analyser = audioContext.createAnalyser();
@@ -82,23 +101,7 @@ export function useAudioLevelMeter(options: AudioLevelMeterOptions) {
mounted = false;
cleanup();
};
}, [options.enabled, options.deviceId, options.smoothingFactor]);
const cleanup = () => {
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = null;
}
if (streamRef.current) {
streamRef.current.getTracks().forEach((track) => track.stop());
streamRef.current = null;
}
if (audioContextRef.current) {
audioContextRef.current.close();
audioContextRef.current = null;
}
analyserRef.current = null;
};
}, [options.enabled, options.deviceId, options.smoothingFactor, cleanup]);
return { level };
}
+124
View File
@@ -0,0 +1,124 @@
import { useCallback, useRef, useState } from "react";
import type {
AnnotationRegion,
CropRegion,
SpeedRegion,
TrimRegion,
ZoomRegion,
} from "@/components/video-editor/types";
import { DEFAULT_CROP_REGION } from "@/components/video-editor/types";
import type { AspectRatio } from "@/utils/aspectRatioUtils";
// Undoable state — selection IDs are intentionally excluded (undoing a
// selection change would feel surprising to the user).
export interface EditorState {
zoomRegions: ZoomRegion[];
trimRegions: TrimRegion[];
speedRegions: SpeedRegion[];
annotationRegions: AnnotationRegion[];
cropRegion: CropRegion;
wallpaper: string;
shadowIntensity: number;
showBlur: boolean;
motionBlurAmount: number;
borderRadius: number;
padding: number;
aspectRatio: AspectRatio;
}
export const INITIAL_EDITOR_STATE: EditorState = {
zoomRegions: [],
trimRegions: [],
speedRegions: [],
annotationRegions: [],
cropRegion: DEFAULT_CROP_REGION,
wallpaper: "/wallpapers/wallpaper1.jpg",
shadowIntensity: 0,
showBlur: false,
motionBlurAmount: 0,
borderRadius: 0,
padding: 50,
aspectRatio: "16:9",
};
type StateUpdate = Partial<EditorState> | ((prev: EditorState) => Partial<EditorState>);
interface History {
past: EditorState[];
present: EditorState;
future: EditorState[];
}
const MAX_HISTORY = 80;
function resolve(present: EditorState, update: StateUpdate): EditorState {
const partial = typeof update === "function" ? update(present) : update;
return { ...present, ...partial };
}
function withCheckpoint(history: History, newPresent: EditorState): History {
return {
past: [...history.past.slice(-(MAX_HISTORY - 1)), history.present],
present: newPresent,
future: [],
};
}
export function useEditorHistory(initial: EditorState = INITIAL_EDITOR_STATE) {
const [history, setHistory] = useState<History>({ past: [], present: initial, future: [] });
// Tracks whether a live-update series (e.g. slider drag) is in progress.
// The first updateState call saves the pre-interaction state as a checkpoint.
const dirtyRef = useRef(false);
const pushState = useCallback((update: StateUpdate) => {
setHistory((prev) => withCheckpoint(prev, resolve(prev.present, update)));
dirtyRef.current = false;
}, []);
const updateState = useCallback((update: StateUpdate) => {
const isFirst = !dirtyRef.current;
dirtyRef.current = true;
setHistory((prev) => {
const next = resolve(prev.present, update);
return isFirst ? withCheckpoint(prev, next) : { ...prev, present: next };
});
}, []);
const commitState = useCallback(() => {
dirtyRef.current = false;
}, []);
const undo = useCallback(() => {
setHistory((prev) => {
if (!prev.past.length) return prev;
const previous = prev.past[prev.past.length - 1];
return {
past: prev.past.slice(0, -1),
present: previous,
future: [prev.present, ...prev.future],
};
});
dirtyRef.current = false;
}, []);
const redo = useCallback(() => {
setHistory((prev) => {
if (!prev.future.length) return prev;
const [next, ...remainingFuture] = prev.future;
return { past: [...prev.past, prev.present], present: next, future: remainingFuture };
});
dirtyRef.current = false;
}, []);
return {
state: history.present,
pushState,
updateState,
commitState,
undo,
redo,
canUndo: history.past.length > 0,
canRedo: history.future.length > 0,
};
}
+1 -1
View File
@@ -69,7 +69,7 @@ export function useMicrophoneDevices(enabled: boolean = true) {
mounted = false;
navigator.mediaDevices.removeEventListener("devicechange", handleDeviceChange);
};
}, [enabled]);
}, [enabled, selectedDeviceId]);
return {
devices,
+345 -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,36 +27,76 @@ 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;
toggleRecording: () => void;
restartRecording: () => void;
microphoneEnabled: boolean;
setMicrophoneEnabled: (enabled: boolean) => void;
microphoneDeviceId: string | undefined;
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 discardRecordingId = useRef<number | null>(null);
const restarting = useRef(false);
const selectMimeType = () => {
const preferred = [
@@ -90,27 +126,180 @@ 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(() => {});
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 (discardRecordingId.current === activeRecordingId) {
return;
}
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;
}
if (discardRecordingId.current === activeRecordingId) {
discardRecordingId.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.
}
}
}
});
@@ -125,28 +314,29 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
return () => {
if (cleanup) cleanup();
allowAutoFinalize.current = false;
restarting.current = false;
discardRecordingId.current = null;
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(() => {});
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 {
@@ -171,7 +361,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
if (systemAudioEnabled) {
try {
screenMediaStream = await (navigator.mediaDevices as any).getUserMedia({
screenMediaStream = await navigator.mediaDevices.getUserMedia({
audio: {
mandatory: {
chromeMediaSource: CHROME_MEDIA_SOURCE,
@@ -179,24 +369,23 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
},
},
video: videoConstraints,
});
} as unknown as MediaStreamConstraints);
} catch (audioErr) {
console.warn("System audio capture failed, falling back to video-only:", audioErr);
toast.error("System audio not available. Recording without system audio.");
screenMediaStream = await (navigator.mediaDevices as any).getUserMedia({
screenMediaStream = await navigator.mediaDevices.getUserMedia({
audio: false,
video: videoConstraints,
});
} as unknown as MediaStreamConstraints);
}
} else {
screenMediaStream = await (navigator.mediaDevices as any).getUserMedia({
screenMediaStream = await navigator.mediaDevices.getUserMedia({
audio: false,
video: videoConstraints,
});
} as unknown as MediaStreamConstraints);
}
screenStream.current = screenMediaStream;
// If microphone is enabled, request mic stream
if (microphoneEnabled) {
try {
microphoneStream.current = await navigator.mediaDevices.getUserMedia({
@@ -221,7 +410,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) {
@@ -233,7 +442,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]));
@@ -249,6 +457,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 },
@@ -268,7 +477,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;
@@ -282,56 +490,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";
@@ -341,22 +547,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(() => {});
mixingContext.current = null;
}
screenRecorder.current = null;
webcamRecorder.current = null;
teardownMedia();
}
};
@@ -364,14 +557,55 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
recording ? stopRecording.current() : startRecording();
};
const restartRecording = async () => {
if (restarting.current) return;
const activeScreenRecorder = screenRecorder.current;
if (!activeScreenRecorder || activeScreenRecorder.recorder.state !== "recording") return;
const activeWebcamRecorder = webcamRecorder.current;
const activeRecordingId = recordingId.current;
restarting.current = true;
discardRecordingId.current = activeRecordingId;
const stopPromises = [
new Promise<void>((resolve) => {
activeScreenRecorder.recorder.addEventListener("stop", () => resolve(), { once: true });
}),
];
if (activeWebcamRecorder?.recorder.state === "recording") {
stopPromises.push(
new Promise<void>((resolve) => {
activeWebcamRecorder.recorder.addEventListener("stop", () => resolve(), {
once: true,
});
}),
);
}
stopRecording.current();
await Promise.all(stopPromises);
try {
await startRecording();
} finally {
restarting.current = false;
}
};
return {
recording,
toggleRecording,
restartRecording,
microphoneEnabled,
setMicrophoneEnabled,
microphoneDeviceId,
setMicrophoneDeviceId,
systemAudioEnabled,
setSystemAudioEnabled,
webcamEnabled,
setWebcamEnabled,
};
}
+20 -9
View File
@@ -1,4 +1,19 @@
function encodeRelativeAssetPath(relativePath: string): string {
return relativePath
.replace(/^\/+/, "")
.split("/")
.filter(Boolean)
.map((part) => encodeURIComponent(part))
.join("/");
}
function ensureTrailingSlash(value: string): string {
return value.endsWith("/") ? value : `${value}/`;
}
export async function getAssetPath(relativePath: string): Promise<string> {
const encodedRelativePath = encodeRelativeAssetPath(relativePath);
try {
if (typeof window !== "undefined") {
// If running in a dev server (http/https), prefer the web-served path
@@ -7,17 +22,13 @@ export async function getAssetPath(relativePath: string): Promise<string> {
window.location.protocol &&
window.location.protocol.startsWith("http")
) {
return `/${relativePath.replace(/^\//, "")}`;
return `/${encodedRelativePath}`;
}
if (
(window as any).electronAPI &&
typeof (window as any).electronAPI.getAssetBasePath === "function"
) {
const base = await (window as any).electronAPI.getAssetBasePath();
if (window.electronAPI && typeof window.electronAPI.getAssetBasePath === "function") {
const base = await window.electronAPI.getAssetBasePath();
if (base) {
const normalized = base.replace(/\\/g, "/");
return `file://${normalized}/${relativePath}`;
return new URL(encodedRelativePath, ensureTrailingSlash(base)).toString();
}
}
}
@@ -26,7 +37,7 @@ export async function getAssetPath(relativePath: string): Promise<string> {
}
// Fallback for web/dev server: public/wallpapers are served at '/wallpapers/...'
return `/${relativePath.replace(/^\//, "")}`;
return `/${encodedRelativePath}`;
}
export default getAssetPath;
+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 = [];
}
}
+358 -22
View File
@@ -1,14 +1,58 @@
import type { WebDemuxer } from "web-demuxer";
import type { TrimRegion } from "@/components/video-editor/types";
import { WebDemuxer } from "web-demuxer";
import type { SpeedRegion, TrimRegion } from "@/components/video-editor/types";
import type { VideoMuxer } from "./muxer";
const AUDIO_BITRATE = 128_000;
const DECODE_BACKPRESSURE_LIMIT = 20;
const MIN_SPEED_REGION_DELTA_MS = 0.0001;
export class AudioProcessor {
private cancelled = false;
async process(demuxer: WebDemuxer, muxer: VideoMuxer, trimRegions?: TrimRegion[]): Promise<void> {
/**
* Audio export has two modes:
* 1) no speed regions -> fast WebCodecs trim-only pipeline
* 2) speed regions present -> pitch-preserving rendered timeline pipeline
*/
async process(
demuxer: WebDemuxer,
muxer: VideoMuxer,
videoUrl: string,
trimRegions?: TrimRegion[],
speedRegions?: SpeedRegion[],
readEndSec?: number,
): Promise<void> {
const sortedTrims = trimRegions ? [...trimRegions].sort((a, b) => a.startMs - b.startMs) : [];
const sortedSpeedRegions = speedRegions
? [...speedRegions]
.filter((region) => region.endMs - region.startMs > MIN_SPEED_REGION_DELTA_MS)
.sort((a, b) => a.startMs - b.startMs)
: [];
// Speed edits must use timeline playback to preserve pitch
if (sortedSpeedRegions.length > 0) {
const renderedAudioBlob = await this.renderPitchPreservedTimelineAudio(
videoUrl,
sortedTrims,
sortedSpeedRegions,
);
if (!this.cancelled) {
await this.muxRenderedAudioBlob(renderedAudioBlob, muxer);
return;
}
}
// No speed edits: keep the original demux/decode/encode path with trim timestamp remap.
await this.processTrimOnlyAudio(demuxer, muxer, sortedTrims, readEndSec);
}
// Legacy trim-only path. This is still used for projects without speed regions.
private async processTrimOnlyAudio(
demuxer: WebDemuxer,
muxer: VideoMuxer,
sortedTrims: TrimRegion[],
readEndSec?: number,
): Promise<void> {
let audioConfig: AudioDecoderConfig;
try {
audioConfig = (await demuxer.getDecoderConfig("audio")) as AudioDecoderConfig;
@@ -23,8 +67,6 @@ export class AudioProcessor {
return;
}
const sortedTrims = trimRegions ? [...trimRegions].sort((a, b) => a.startMs - b.startMs) : [];
// Phase 1: Decode audio from source, skipping trimmed regions
const decodedFrames: AudioData[] = [];
@@ -34,19 +76,36 @@ export class AudioProcessor {
});
decoder.configure(audioConfig);
const reader = (demuxer.read("audio") as ReadableStream<EncodedAudioChunk>).getReader();
const safeReadEndSec =
typeof readEndSec === "number" && Number.isFinite(readEndSec)
? Math.max(0, readEndSec)
: undefined;
const audioStream = (
safeReadEndSec !== undefined
? demuxer.read("audio", 0, safeReadEndSec)
: demuxer.read("audio")
) as ReadableStream<EncodedAudioChunk>;
const reader = audioStream.getReader();
while (!this.cancelled) {
const { done, value: chunk } = await reader.read();
if (done || !chunk) break;
try {
while (!this.cancelled) {
const { done, value: chunk } = await reader.read();
if (done || !chunk) break;
const timestampMs = chunk.timestamp / 1000;
if (this.isInTrimRegion(timestampMs, sortedTrims)) continue;
const timestampMs = chunk.timestamp / 1000;
if (this.isInTrimRegion(timestampMs, sortedTrims)) continue;
decoder.decode(chunk);
decoder.decode(chunk);
while (decoder.decodeQueueSize > DECODE_BACKPRESSURE_LIMIT && !this.cancelled) {
await new Promise((resolve) => setTimeout(resolve, 1));
while (decoder.decodeQueueSize > DECODE_BACKPRESSURE_LIMIT && !this.cancelled) {
await new Promise((resolve) => setTimeout(resolve, 1));
}
}
} finally {
try {
await reader.cancel();
} catch {
/* reader already closed */
}
}
@@ -56,7 +115,7 @@ export class AudioProcessor {
}
if (this.cancelled || decodedFrames.length === 0) {
for (const f of decodedFrames) f.close();
for (const frame of decodedFrames) frame.close();
return;
}
@@ -83,7 +142,7 @@ export class AudioProcessor {
const encodeSupport = await AudioEncoder.isConfigSupported(encodeConfig);
if (!encodeSupport.supported) {
console.warn("[AudioProcessor] Opus encoding not supported, skipping audio");
for (const f of decodedFrames) f.close();
for (const frame of decodedFrames) frame.close();
return;
}
@@ -122,20 +181,297 @@ export class AudioProcessor {
);
}
// Speed-aware path that mirrors preview semantics (trim skipping + playbackRate regions)
// preserve pitch through browser media playback behavior to avoid chipmunk effect.
private async renderPitchPreservedTimelineAudio(
videoUrl: string,
trimRegions: TrimRegion[],
speedRegions: SpeedRegion[],
): Promise<Blob> {
const media = document.createElement("audio");
media.src = videoUrl;
media.preload = "auto";
const pitchMedia = media as HTMLMediaElement & {
preservesPitch?: boolean;
mozPreservesPitch?: boolean;
webkitPreservesPitch?: boolean;
};
pitchMedia.preservesPitch = true;
pitchMedia.mozPreservesPitch = true;
pitchMedia.webkitPreservesPitch = true;
await this.waitForLoadedMetadata(media);
if (this.cancelled) {
throw new Error("Export cancelled");
}
const audioContext = new AudioContext();
const sourceNode = audioContext.createMediaElementSource(media);
const destinationNode = audioContext.createMediaStreamDestination();
sourceNode.connect(destinationNode);
const { recorder, recordedBlobPromise } = this.startAudioRecording(destinationNode.stream);
let rafId: number | null = null;
try {
if (audioContext.state === "suspended") {
await audioContext.resume();
}
await this.seekTo(media, 0);
await media.play();
await new Promise<void>((resolve, reject) => {
const cleanup = () => {
if (rafId !== null) {
cancelAnimationFrame(rafId);
rafId = null;
}
media.removeEventListener("error", onError);
media.removeEventListener("ended", onEnded);
};
const onError = () => {
cleanup();
reject(new Error("Failed while rendering speed-adjusted audio timeline"));
};
const onEnded = () => {
cleanup();
resolve();
};
const tick = () => {
if (this.cancelled) {
cleanup();
resolve();
return;
}
const currentTimeMs = media.currentTime * 1000;
const activeTrimRegion = this.findActiveTrimRegion(currentTimeMs, trimRegions);
if (activeTrimRegion && !media.paused && !media.ended) {
const skipToTime = activeTrimRegion.endMs / 1000;
if (skipToTime >= media.duration) {
media.pause();
cleanup();
resolve();
return;
}
media.currentTime = skipToTime;
} else {
const activeSpeedRegion = this.findActiveSpeedRegion(currentTimeMs, speedRegions);
const playbackRate = activeSpeedRegion ? activeSpeedRegion.speed : 1;
if (Math.abs(media.playbackRate - playbackRate) > 0.0001) {
media.playbackRate = playbackRate;
}
}
if (!media.paused && !media.ended) {
rafId = requestAnimationFrame(tick);
} else {
cleanup();
resolve();
}
};
media.addEventListener("error", onError, { once: true });
media.addEventListener("ended", onEnded, { once: true });
rafId = requestAnimationFrame(tick);
});
} finally {
if (rafId !== null) {
cancelAnimationFrame(rafId);
}
media.pause();
if (recorder.state !== "inactive") {
recorder.stop();
}
destinationNode.stream.getTracks().forEach((track) => track.stop());
sourceNode.disconnect();
destinationNode.disconnect();
await audioContext.close();
media.src = "";
media.load();
}
const recordedBlob = await recordedBlobPromise;
if (this.cancelled) {
throw new Error("Export cancelled");
}
return recordedBlob;
}
// Demuxes the rendered speed-adjusted blob and feeds encoded chunks into the MP4 muxer.
private async muxRenderedAudioBlob(blob: Blob, muxer: VideoMuxer): Promise<void> {
if (this.cancelled) return;
const file = new File([blob], "speed-audio.webm", { type: blob.type || "audio/webm" });
const wasmUrl = new URL("./wasm/web-demuxer.wasm", window.location.href).href;
const demuxer = new WebDemuxer({ wasmFilePath: wasmUrl });
try {
await demuxer.load(file);
const audioConfig = (await demuxer.getDecoderConfig("audio")) as AudioDecoderConfig;
const reader = (demuxer.read("audio") as ReadableStream<EncodedAudioChunk>).getReader();
let isFirstChunk = true;
try {
while (!this.cancelled) {
const { done, value: chunk } = await reader.read();
if (done || !chunk) break;
if (isFirstChunk) {
await muxer.addAudioChunk(chunk, { decoderConfig: audioConfig });
isFirstChunk = false;
} else {
await muxer.addAudioChunk(chunk);
}
}
} finally {
try {
await reader.cancel();
} catch {
/* reader already closed */
}
}
} finally {
try {
demuxer.destroy();
} catch {
/* ignore */
}
}
}
private startAudioRecording(stream: MediaStream): {
recorder: MediaRecorder;
recordedBlobPromise: Promise<Blob>;
} {
const mimeType = this.getSupportedAudioMimeType();
const options: MediaRecorderOptions = {
audioBitsPerSecond: AUDIO_BITRATE,
...(mimeType ? { mimeType } : {}),
};
const recorder = new MediaRecorder(stream, options);
const chunks: Blob[] = [];
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("MediaRecorder failed while capturing speed-adjusted audio"));
};
recorder.onstop = () => {
const type = mimeType || chunks[0]?.type || "audio/webm";
resolve(new Blob(chunks, { type }));
};
});
recorder.start();
return { recorder, recordedBlobPromise };
}
private getSupportedAudioMimeType(): string | undefined {
const candidates = ["audio/webm;codecs=opus", "audio/webm"];
for (const candidate of candidates) {
if (MediaRecorder.isTypeSupported(candidate)) {
return candidate;
}
}
return undefined;
}
private waitForLoadedMetadata(media: HTMLMediaElement): Promise<void> {
if (Number.isFinite(media.duration) && media.readyState >= HTMLMediaElement.HAVE_METADATA) {
return Promise.resolve();
}
return new Promise<void>((resolve, reject) => {
const onLoaded = () => {
cleanup();
resolve();
};
const onError = () => {
cleanup();
reject(new Error("Failed to load media metadata for speed-adjusted audio"));
};
const cleanup = () => {
media.removeEventListener("loadedmetadata", onLoaded);
media.removeEventListener("error", onError);
};
media.addEventListener("loadedmetadata", onLoaded);
media.addEventListener("error", onError, { once: true });
});
}
private seekTo(media: HTMLMediaElement, targetSec: number): Promise<void> {
if (Math.abs(media.currentTime - targetSec) < 0.0001) {
return Promise.resolve();
}
return new Promise<void>((resolve, reject) => {
const onSeeked = () => {
cleanup();
resolve();
};
const onError = () => {
cleanup();
reject(new Error("Failed to seek media for speed-adjusted audio"));
};
const cleanup = () => {
media.removeEventListener("seeked", onSeeked);
media.removeEventListener("error", onError);
};
media.addEventListener("seeked", onSeeked, { once: true });
media.addEventListener("error", onError, { once: true });
media.currentTime = targetSec;
});
}
private findActiveTrimRegion(
currentTimeMs: number,
trimRegions: TrimRegion[],
): TrimRegion | null {
return (
trimRegions.find(
(region) => currentTimeMs >= region.startMs && currentTimeMs < region.endMs,
) || null
);
}
private findActiveSpeedRegion(
currentTimeMs: number,
speedRegions: SpeedRegion[],
): SpeedRegion | null {
return (
speedRegions.find(
(region) => currentTimeMs >= region.startMs && currentTimeMs < region.endMs,
) || null
);
}
private cloneWithTimestamp(src: AudioData, newTimestamp: number): AudioData {
const isPlanar = src.format?.includes("planar") ?? false;
const numPlanes = isPlanar ? src.numberOfChannels : 1;
let totalSize = 0;
for (let p = 0; p < numPlanes; p++) {
totalSize += src.allocationSize({ planeIndex: p });
for (let planeIndex = 0; planeIndex < numPlanes; planeIndex++) {
totalSize += src.allocationSize({ planeIndex });
}
const buffer = new ArrayBuffer(totalSize);
let offset = 0;
for (let p = 0; p < numPlanes; p++) {
const planeSize = src.allocationSize({ planeIndex: p });
src.copyTo(new Uint8Array(buffer, offset, planeSize), { planeIndex: p });
for (let planeIndex = 0; planeIndex < numPlanes; planeIndex++) {
const planeSize = src.allocationSize({ planeIndex });
src.copyTo(new Uint8Array(buffer, offset, planeSize), { planeIndex });
offset += planeSize;
}
@@ -150,7 +486,7 @@ export class AudioProcessor {
}
private isInTrimRegion(timestampMs: number, trims: TrimRegion[]): boolean {
return trims.some((t) => timestampMs >= t.startMs && timestampMs < t.endMs);
return trims.some((trim) => timestampMs >= trim.startMs && timestampMs < trim.endMs);
}
private computeTrimOffset(timestampMs: number, trims: TrimRegion[]): number {
+182 -59
View File
@@ -1,19 +1,36 @@
import { Application, BlurFilter, Container, Graphics, Sprite, Texture } from "pixi.js";
import {
Application,
BlurFilter,
Container,
Graphics,
Sprite,
Texture,
type TextureSourceLike,
} from "pixi.js";
import { MotionBlurFilter } from "pixi-filters/motion-blur";
import type {
AnnotationRegion,
CropRegion,
SpeedRegion,
ZoomDepth,
ZoomRegion,
} from "@/components/video-editor/types";
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 {
applyZoomTransform,
computeFocusFromTransform,
computeZoomTransform,
createMotionBlurState,
type MotionBlurState,
} from "@/components/video-editor/videoPlayback/zoomTransform";
import { computeWebcamOverlayLayout } from "@/lib/webcamOverlay";
import { renderAnnotations } from "./annotationRenderer";
interface FrameRenderConfig {
@@ -24,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;
@@ -40,6 +59,18 @@ interface AnimationState {
scale: number;
focusX: number;
focusY: number;
progress: number;
x: number;
y: number;
appliedScale: number;
}
interface LayoutCache {
stageSize: { width: number; height: number };
videoSize: { width: number; height: number };
baseScale: number;
baseOffset: { x: number; y: number };
maskRect: { x: number; y: number; width: number; height: number };
}
// Renders video frames with all effects (background, zoom, crop, blur, shadow) to an offscreen canvas for export.
@@ -49,17 +80,19 @@ export class FrameRenderer {
private cameraContainer: Container | null = null;
private videoContainer: Container | null = null;
private videoSprite: Sprite | null = null;
private backgroundSprite: Sprite | null = null;
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;
private compositeCtx: CanvasRenderingContext2D | null = null;
private config: FrameRenderConfig;
private animationState: AnimationState;
private layoutCache: any = null;
private layoutCache: LayoutCache | null = null;
private currentVideoTime = 0;
private motionBlurState: MotionBlurState = createMotionBlurState();
constructor(config: FrameRenderConfig) {
this.config = config;
@@ -67,6 +100,10 @@ export class FrameRenderer {
scale: 1,
focusX: DEFAULT_FOCUS.cx,
focusY: DEFAULT_FOCUS.cy,
progress: 0,
x: 0,
y: 0,
appliedScale: 1,
};
}
@@ -112,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");
@@ -263,10 +301,14 @@ export class FrameRenderer {
}
// Store the background canvas for compositing
this.backgroundSprite = bgCanvas as any;
this.backgroundSprite = bgCanvas;
}
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");
}
@@ -275,13 +317,13 @@ export class FrameRenderer {
// Create or update video sprite from VideoFrame
if (!this.videoSprite) {
const texture = Texture.from(videoFrame as any);
const texture = Texture.from(videoFrame as unknown as TextureSourceLike);
this.videoSprite = new Sprite(texture);
this.videoContainer.addChild(this.videoSprite);
} else {
// Destroy old texture to avoid memory leaks, then create new one
const oldTexture = this.videoSprite.texture;
const newTexture = Texture.from(videoFrame as any);
const newTexture = Texture.from(videoFrame as unknown as TextureSourceLike);
this.videoSprite.texture = newTexture;
oldTexture.destroy(true);
}
@@ -298,25 +340,34 @@ export class FrameRenderer {
maxMotionIntensity = Math.max(maxMotionIntensity, motionIntensity);
}
const layoutCache = this.layoutCache;
if (!layoutCache) {
throw new Error("Layout cache not initialized");
}
// Apply transform once with maximum motion intensity from all ticks
applyZoomTransform({
cameraContainer: this.cameraContainer,
blurFilter: this.blurFilter,
stageSize: this.layoutCache.stageSize,
baseMask: this.layoutCache.maskRect,
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 (
@@ -411,76 +462,117 @@ export class FrameRenderer {
private clampFocusToStage(
focus: { cx: number; cy: number },
depth: number,
depth: ZoomDepth,
): { cx: number; cy: number } {
if (!this.layoutCache) return focus;
return clampFocusToStageUtil(focus, depth as any, this.layoutCache.stageSize);
return clampFocusToStageUtil(focus, depth, this.layoutCache.stageSize);
}
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;
@@ -493,7 +585,7 @@ export class FrameRenderer {
// Step 1: Draw background layer (with optional blur, not affected by zoom)
if (this.backgroundSprite) {
const bgCanvas = this.backgroundSprite as any as HTMLCanvasElement;
const bgCanvas = this.backgroundSprite;
if (this.config.showBlur) {
ctx.save();
@@ -535,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 {
@@ -558,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;
+105 -29
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,
@@ -122,9 +133,10 @@ export class GifExporter {
// Initialize GIF encoder
// Loop: 0 = infinite loop, 1 = play once (no loop)
const repeat = this.config.loop ? 0 : 1;
const cores = navigator.hardwareConcurrency || 4;
const WORKER_COUNT = Math.max(1, Math.min(8, cores - 1));
this.gif = new GIF({
workers: 4,
workers: WORKER_COUNT,
quality: 10,
width: this.config.width,
height: this.config.height,
@@ -154,6 +166,42 @@ export class GifExporter {
console.log("[GifExporter] Using streaming decode (web-demuxer + VideoDecoder)");
let frameIndex = 0;
webcamFrameQueue = this.config.webcamVideoUrl ? new AsyncVideoFrameQueue() : null;
let stopWebcamDecode = false;
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 && !stopWebcamDecode) {
await new Promise((resolve) => setTimeout(resolve, 2));
}
if (this.cancelled || stopWebcamDecode) {
webcamFrame.close();
return;
}
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(
@@ -161,32 +209,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();
}
},
);
@@ -195,6 +253,11 @@ export class GifExporter {
return { success: false, error: "Export cancelled" };
}
stopWebcamDecode = true;
webcamFrameQueue?.destroy();
this.webcamDecoder?.cancel();
await webcamDecodePromise;
// Update progress to show we're now in the finalizing phase
if (this.config.onProgress) {
this.config.onProgress({
@@ -238,6 +301,7 @@ export class GifExporter {
error: error instanceof Error ? error.message : String(error),
};
} finally {
webcamFrameQueue?.destroy();
this.cleanup();
}
}
@@ -247,6 +311,9 @@ export class GifExporter {
if (this.streamingDecoder) {
this.streamingDecoder.cancel();
}
if (this.webcamDecoder) {
this.webcamDecoder.cancel();
}
if (this.gif) {
this.gif.abort();
}
@@ -263,6 +330,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();
+183 -79
View File
@@ -5,6 +5,7 @@ export interface DecodedVideoInfo {
width: number;
height: number;
duration: number; // seconds
streamDuration?: number; // seconds
frameRate: number;
codec: string;
hasAudio: boolean;
@@ -23,7 +24,7 @@ type OnFrameCallback = (
* Way faster than seeking an HTMLVideoElement per frame.
*
* Frames in trimmed regions are decoded (needed for P/B-frame state) but discarded.
* Non-trimmed frames get buffered per segment and resampled to the target frame rate.
* Kept frames are resampled to the target frame rate in a streaming pass.
*/
export class StreamingVideoDecoder {
private demuxer: WebDemuxer | null = null;
@@ -31,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;
@@ -61,6 +88,10 @@ export class StreamingVideoDecoder {
width: videoStream?.width || 1920,
height: videoStream?.height || 1080,
duration: mediaInfo.duration,
streamDuration:
typeof videoStream?.duration === "number" && Number.isFinite(videoStream.duration)
? videoStream.duration
: undefined,
frameRate,
codec: videoStream?.codec_string || "unknown",
hasAudio: !!audioStream,
@@ -81,11 +112,17 @@ export class StreamingVideoDecoder {
}
const decoderConfig = await this.demuxer.getDecoderConfig("video");
const codec = this.metadata.codec.toLowerCase();
const shouldPreferSoftwareDecode = codec.includes("av01") || codec.includes("av1");
const segments = this.splitBySpeed(
this.computeSegments(this.metadata.duration, trimRegions),
speedRegions,
);
const segmentOutputFrameCounts = segments.map((segment) =>
Math.ceil(((segment.endSec - segment.startSec) / segment.speed) * targetFrameRate),
);
const frameDurationUs = 1_000_000 / targetFrameRate;
const epsilonSec = 0.001;
// Async frame queue — decoder pushes, consumer pulls
const pendingFrames: VideoFrame[] = [];
@@ -112,7 +149,22 @@ export class StreamingVideoDecoder {
}
},
});
this.decoder.configure(decoderConfig);
const preferredDecoderConfig = shouldPreferSoftwareDecode
? {
...decoderConfig,
hardwareAcceleration: "prefer-software" as const,
}
: decoderConfig;
try {
this.decoder.configure(preferredDecoderConfig);
} catch (error) {
if (!shouldPreferSoftwareDecode) {
throw error;
}
// Fall back to default decoder config if software preference isn't supported.
this.decoder.configure(decoderConfig);
}
const getNextFrame = (): Promise<VideoFrame | null> => {
if (decodeError) throw decodeError;
@@ -123,8 +175,10 @@ export class StreamingVideoDecoder {
});
};
// One forward stream through the whole file
const reader = this.demuxer.read("video").getReader();
// One forward stream through the whole file.
// Pass explicit range because some containers are truncated when no end is provided.
const readEndSec = Math.max(this.metadata.duration, this.metadata.streamDuration ?? 0) + 0.5;
const reader = this.demuxer.read("video", 0, readEndSec).getReader();
// Feed chunks to decoder in background with backpressure
const feedPromise = (async () => {
@@ -133,7 +187,11 @@ export class StreamingVideoDecoder {
const { done, value: chunk } = await reader.read();
if (done || !chunk) break;
while (this.decoder!.decodeQueueSize > 10 && !this.cancelled) {
// Backpressure on both decode queue and decoded frame backlog.
while (
(this.decoder!.decodeQueueSize > 10 || pendingFrames.length > 24) &&
!this.cancelled
) {
await new Promise((resolve) => setTimeout(resolve, 1));
}
if (this.cancelled) break;
@@ -158,65 +216,131 @@ export class StreamingVideoDecoder {
// Route decoded frames into segments by timestamp, then deliver with VFR→CFR resampling
let segmentIdx = 0;
let segmentFrameIndex = 0;
let exportFrameIndex = 0;
let segmentBuffer: VideoFrame[] = [];
let lastDecodedFrameSec: number | null = null;
let heldFrame: VideoFrame | null = null;
let heldFrameSec = 0;
const emitHeldFrameForTarget = async (segment: {
startSec: number;
endSec: number;
speed: number;
}) => {
if (!heldFrame) return false;
const segmentFrameCount = segmentOutputFrameCounts[segmentIdx];
if (segmentFrameIndex >= segmentFrameCount) return false;
const sourceTimeSec =
segment.startSec + (segmentFrameIndex / targetFrameRate) * segment.speed;
if (sourceTimeSec >= segment.endSec - epsilonSec) return false;
const clone = new VideoFrame(heldFrame, { timestamp: heldFrame.timestamp });
await onFrame(clone, exportFrameIndex * frameDurationUs, sourceTimeSec * 1000);
segmentFrameIndex++;
exportFrameIndex++;
return true;
};
while (!this.cancelled && segmentIdx < segments.length) {
const frame = await getNextFrame();
if (!frame) break;
const frameTimeSec = frame.timestamp / 1_000_000;
const currentSegment = segments[segmentIdx];
lastDecodedFrameSec = frameTimeSec;
// Before current segment — trimmed or pre-video
if (frameTimeSec < currentSegment.startSec - 0.001) {
// Finalize completed segments before handling this frame.
while (
segmentIdx < segments.length &&
frameTimeSec >= segments[segmentIdx].endSec - epsilonSec
) {
const segment = segments[segmentIdx];
while (!this.cancelled && (await emitHeldFrameForTarget(segment))) {
// Keep emitting remaining output frames for this segment from the last known frame.
}
segmentIdx++;
segmentFrameIndex = 0;
if (
heldFrame &&
segmentIdx < segments.length &&
heldFrameSec < segments[segmentIdx].startSec - epsilonSec
) {
heldFrame.close();
heldFrame = null;
}
}
if (segmentIdx >= segments.length) {
frame.close();
continue;
}
// Past current segment — flush buffer and advance
if (frameTimeSec >= currentSegment.endSec - 0.001) {
exportFrameIndex = await this.deliverSegment(
segmentBuffer,
currentSegment,
targetFrameRate,
frameDurationUs,
exportFrameIndex,
onFrame,
);
for (const f of segmentBuffer) f.close();
segmentBuffer = [];
const currentSegment = segments[segmentIdx];
segmentIdx++;
while (
segmentIdx < segments.length &&
frameTimeSec >= segments[segmentIdx].endSec - 0.001
) {
segmentIdx++;
}
if (segmentIdx < segments.length && frameTimeSec >= segments[segmentIdx].startSec - 0.001) {
segmentBuffer.push(frame);
} else {
frame.close();
}
// Before current segment (trimmed region or pre-roll).
if (frameTimeSec < currentSegment.startSec - epsilonSec) {
frame.close();
continue;
}
segmentBuffer.push(frame);
if (!heldFrame) {
heldFrame = frame;
heldFrameSec = frameTimeSec;
continue;
}
// Any target timestamp before this midpoint is closer to heldFrame than current frame.
const handoffBoundarySec = (heldFrameSec + frameTimeSec) / 2;
while (!this.cancelled) {
const segmentFrameCount = segmentOutputFrameCounts[segmentIdx];
if (segmentFrameIndex >= segmentFrameCount) {
break;
}
const sourceTimeSec =
currentSegment.startSec + (segmentFrameIndex / targetFrameRate) * currentSegment.speed;
if (sourceTimeSec >= currentSegment.endSec - epsilonSec) {
break;
}
if (sourceTimeSec > handoffBoundarySec) {
break;
}
const clone = new VideoFrame(heldFrame, { timestamp: heldFrame.timestamp });
await onFrame(clone, exportFrameIndex * frameDurationUs, sourceTimeSec * 1000);
segmentFrameIndex++;
exportFrameIndex++;
}
heldFrame.close();
heldFrame = frame;
heldFrameSec = frameTimeSec;
}
// Flush last segment
if (segmentBuffer.length > 0 && segmentIdx < segments.length) {
exportFrameIndex = await this.deliverSegment(
segmentBuffer,
segments[segmentIdx],
targetFrameRate,
frameDurationUs,
exportFrameIndex,
onFrame,
);
for (const f of segmentBuffer) f.close();
// Flush remaining output frames for the last decoded frame.
if (heldFrame && segmentIdx < segments.length) {
while (!this.cancelled && segmentIdx < segments.length) {
const segment = segments[segmentIdx];
if (heldFrameSec < segment.startSec - epsilonSec) {
break;
}
while (!this.cancelled && (await emitHeldFrameForTarget(segment))) {
// Keep emitting output frames for the active segment.
}
segmentIdx++;
segmentFrameIndex = 0;
if (
segmentIdx < segments.length &&
heldFrameSec < segments[segmentIdx].startSec - epsilonSec
) {
break;
}
}
heldFrame.close();
heldFrame = null;
}
// Drain leftover decoded frames
@@ -239,39 +363,17 @@ export class StreamingVideoDecoder {
this.decoder.close();
}
this.decoder = null;
}
/**
* Resample buffered frames to fill the target frame count for this segment.
* Handles VFR sources by duplicating/decimating as needed.
*/
private async deliverSegment(
frames: VideoFrame[],
segment: { startSec: number; endSec: number; speed: number },
targetFrameRate: number,
frameDurationUs: number,
startExportFrameIndex: number,
onFrame: OnFrameCallback,
): Promise<number> {
if (frames.length === 0) return startExportFrameIndex;
const segmentFrameCount = Math.ceil(
((segment.endSec - segment.startSec) / segment.speed) * targetFrameRate,
);
let exportFrameIndex = startExportFrameIndex;
for (let i = 0; i < segmentFrameCount && !this.cancelled; i++) {
const sourceIdx = Math.min(
Math.floor((i * frames.length) / segmentFrameCount),
frames.length - 1,
const requiredEndSec = segments.length > 0 ? segments[segments.length - 1].endSec : 0;
if (
!this.cancelled &&
lastDecodedFrameSec !== null &&
requiredEndSec - lastDecodedFrameSec > 1
) {
throw new Error(
`Video decode ended early at ${lastDecodedFrameSec.toFixed(3)}s (needed ${requiredEndSec.toFixed(3)}s).`,
);
const sourceFrame = frames[sourceIdx];
const clone = new VideoFrame(sourceFrame, { timestamp: sourceFrame.timestamp });
await onFrame(clone, exportFrameIndex * frameDurationUs, sourceFrame.timestamp / 1000);
exportFrameIndex++;
}
return exportFrameIndex;
}
private computeSegments(
@@ -364,7 +466,9 @@ export class StreamingVideoDecoder {
if (this.demuxer) {
try {
this.demuxer.destroy();
} catch {}
} catch {
/* ignore */
}
this.demuxer = null;
}
}
+152 -54
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,
@@ -97,6 +108,7 @@ export class VideoExporter {
this.config.speedRegions,
);
const totalFrames = Math.ceil(effectiveDuration * this.config.frameRate);
const readEndSec = Math.max(videoInfo.duration, videoInfo.streamDuration ?? 0) + 0.5;
console.log("[VideoExporter] Original duration:", videoInfo.duration, "s");
console.log("[VideoExporter] Effective duration:", effectiveDuration, "s");
@@ -105,6 +117,42 @@ export class VideoExporter {
const frameDuration = 1_000_000 / this.config.frameRate; // in microseconds
let frameIndex = 0;
webcamFrameQueue = this.config.webcamVideoUrl ? new AsyncVideoFrameQueue() : null;
let stopWebcamDecode = false;
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 && !stopWebcamDecode) {
await new Promise((resolve) => setTimeout(resolve, 2));
}
if (this.cancelled || stopWebcamDecode) {
webcamFrame.close();
return;
}
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(
@@ -112,61 +160,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();
}
},
);
@@ -175,6 +234,11 @@ export class VideoExporter {
return { success: false, error: "Export cancelled" };
}
stopWebcamDecode = true;
webcamFrameQueue?.destroy();
this.webcamDecoder?.cancel();
await webcamDecodePromise;
// Finalize encoding
if (this.encoder && this.encoder.state === "configured") {
await this.encoder.flush();
@@ -183,13 +247,30 @@ export class VideoExporter {
// Wait for all video muxing operations to complete
await Promise.all(this.muxingPromises);
if (this.config.onProgress) {
this.config.onProgress({
currentFrame: totalFrames,
totalFrames,
percentage: 100,
estimatedTimeRemaining: 0,
phase: "finalizing",
});
}
// Process audio track if present
if (hasAudio && !this.cancelled) {
const demuxer = this.streamingDecoder!.getDemuxer();
if (demuxer) {
console.log("[VideoExporter] Processing audio track...");
this.audioProcessor = new AudioProcessor();
await this.audioProcessor.process(demuxer, this.muxer!, this.config.trimRegions);
await this.audioProcessor.process(
demuxer,
this.muxer!,
this.config.videoUrl,
this.config.trimRegions,
this.config.speedRegions,
readEndSec,
);
}
}
@@ -204,6 +285,7 @@ export class VideoExporter {
error: error instanceof Error ? error.message : String(error),
};
} finally {
webcamFrameQueue?.destroy();
this.cleanup();
}
}
@@ -219,7 +301,11 @@ export class VideoExporter {
// Capture decoder config metadata from encoder output
if (meta?.decoderConfig?.description && !videoDescription) {
const desc = meta.decoderConfig.description;
videoDescription = new Uint8Array(desc instanceof ArrayBuffer ? desc : (desc as any));
if (desc instanceof ArrayBuffer || desc instanceof SharedArrayBuffer) {
videoDescription = new Uint8Array(desc);
} else if (ArrayBuffer.isView(desc)) {
videoDescription = new Uint8Array(desc.buffer, desc.byteOffset, desc.byteLength);
}
this.videoDescription = videoDescription;
}
// Capture colorSpace from encoder metadata if provided
@@ -310,6 +396,9 @@ export class VideoExporter {
if (this.streamingDecoder) {
this.streamingDecoder.cancel();
}
if (this.webcamDecoder) {
this.webcamDecoder.cancel();
}
if (this.audioProcessor) {
this.audioProcessor.cancel();
}
@@ -337,6 +426,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(),
};
}
+9
View File
@@ -27,6 +27,15 @@ export interface FixedShortcut {
}
export const FIXED_SHORTCUTS: FixedShortcut[] = [
{ label: "Undo", display: "Ctrl + Z", bindings: [{ key: "z", ctrl: true }] },
{
label: "Redo",
display: "Ctrl + Shift + Z / Ctrl + Y",
bindings: [
{ key: "z", ctrl: true, shift: true },
{ key: "y", ctrl: true },
],
},
{ label: "Cycle Annotations Forward", display: "Tab", bindings: [{ key: "tab" }] },
{
label: "Cycle Annotations Backward",
+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)),
),
};
}
+27 -5
View File
@@ -1,11 +1,20 @@
export const ASPECT_RATIOS = ["16:9", "9:16", "1:1", "4:3", "4:5", "16:10", "10:16"] as const;
export const ASPECT_RATIOS = [
"16:9",
"9:16",
"1:1",
"4:3",
"4:5",
"16:10",
"10:16",
"native",
] as const;
export type AspectRatio = (typeof ASPECT_RATIOS)[number];
/**
* Returns the numeric value of an aspect ratio.
* Uses exhaustive type checking to ensure all AspectRatio cases are handled.
* If TypeScript errors here, a new ratio was added to the type but not handled.
* For "native", returns a fallback ratio of 16/9.
* Callers with source/crop context should use getNativeAspectRatioValue().
*/
export function getAspectRatioValue(aspectRatio: AspectRatio): number {
switch (aspectRatio) {
@@ -23,14 +32,25 @@ export function getAspectRatioValue(aspectRatio: AspectRatio): number {
return 16 / 10;
case "10:16":
return 10 / 16;
case "native":
return 16 / 9;
default: {
// Ensures all cases are handled - TypeScript errors if missing
const _exhaustiveCheck: never = aspectRatio;
return _exhaustiveCheck;
}
}
}
export function getNativeAspectRatioValue(
videoWidth: number,
videoHeight: number,
cropRegion?: { x: number; y: number; width: number; height: number },
): number {
const cropW = cropRegion?.width ?? 1;
const cropH = cropRegion?.height ?? 1;
return (videoWidth * cropW) / (videoHeight * cropH);
}
export function getAspectRatioDimensions(
aspectRatio: AspectRatio,
baseWidth: number,
@@ -43,9 +63,11 @@ export function getAspectRatioDimensions(
}
export function getAspectRatioLabel(aspectRatio: AspectRatio): string {
if (aspectRatio === "native") return "Native";
return aspectRatio;
}
export function formatAspectRatioForCSS(aspectRatio: AspectRatio): string {
export function formatAspectRatioForCSS(aspectRatio: AspectRatio, nativeRatio?: number): string {
if (aspectRatio === "native") return String(nativeRatio ?? 16 / 9);
return aspectRatio.replace(":", "/");
}
+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}`;
}
+7
View File
@@ -0,0 +1,7 @@
export function formatTimePadded(seconds: number) {
const m = Math.floor(seconds / 60)
.toString()
.padStart(2, "0");
const s = (seconds % 60).toString().padStart(2, "0");
return `${m}:${s}`;
}
+31 -3
View File
@@ -20,15 +20,31 @@ interface Window {
getSources: (opts: Electron.SourcesOptions) => Promise<ProcessedDesktopSource[]>;
switchToEditor: () => Promise<void>;
openSourceSelector: () => Promise<void>;
selectSource: (source: any) => Promise<any>;
getSelectedSource: () => Promise<any>;
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,
@@ -91,5 +117,7 @@ interface Window {
onMenuSaveProject: (callback: () => void) => () => void;
onMenuSaveProjectAs: (callback: () => void) => () => void;
setMicrophoneExpanded: (expanded: boolean) => void;
setHasUnsavedChanges: (hasChanges: boolean) => void;
onRequestSaveBeforeClose: (callback: () => Promise<boolean> | boolean) => () => void;
};
}
+14
View File
@@ -13,10 +13,24 @@ module.exports = {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: "0" },
},
"record-pulse": {
"0%, 100%": { boxShadow: "0 0 8px rgba(239, 68, 68, 0.15)" },
"50%": { boxShadow: "0 0 16px rgba(239, 68, 68, 0.4)" },
},
"mic-panel-in": {
from: { opacity: "0", transform: "translateY(4px)" },
to: { opacity: "1", transform: "translateY(0)" },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
"record-pulse": "record-pulse 1.5s ease-in-out infinite",
"mic-panel-in": "mic-panel-in 0.15s ease-out",
},
boxShadow: {
"hud-bar": "0 2px 16px rgba(0, 0, 0, 0.25), 0 0 40px rgba(100, 80, 200, 0.08)",
"mic-panel": "0 2px 12px rgba(0, 0, 0, 0.2), 0 0 30px rgba(100, 80, 200, 0.06)",
},
borderRadius: {
lg: "var(--radius)",
+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.