mirror of
https://github.com/siddharthvaddem/openscreen.git
synced 2026-11-04 15:21:55 +08:00
Compare commits
75
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69f1b4d20f | ||
|
|
7a6efc5df9 | ||
|
|
4b8c95f04f | ||
|
|
0f123283b3 | ||
|
|
b33ec5e2d7 | ||
|
|
0a0dd088c3 | ||
|
|
2669b380a3 | ||
|
|
0935dac70a | ||
|
|
e2147bec63 | ||
|
|
3d2d0a4dbc | ||
|
|
1591f7dfcb | ||
|
|
c3e4c86b33 | ||
|
|
942a7e599a | ||
|
|
776ed954f2 | ||
|
|
f1a453b9b2 | ||
|
|
e4263d4597 | ||
|
|
2fb5b3b574 | ||
|
|
9a5d94a1c8 | ||
|
|
119c3acb18 | ||
|
|
881acdb26f | ||
|
|
4a308fde12 | ||
|
|
fc8a4db8f1 | ||
|
|
ac4f82484b | ||
|
|
e9f0fda397 | ||
|
|
4655e71ca5 | ||
|
|
e82332647a | ||
|
|
9fb91dd17b | ||
|
|
c8cf052fc9 | ||
|
|
61d89831bb | ||
|
|
9f6ef0f582 | ||
|
|
ea68300634 | ||
|
|
446e3a35fc | ||
|
|
c35a33203b | ||
|
|
dd84edaf41 | ||
|
|
9d71f509b8 | ||
|
|
9687157aba | ||
|
|
e2075f15e9 | ||
|
|
d182854270 | ||
|
|
7a8d0f449a | ||
|
|
0727b61de7 | ||
|
|
56988e86e2 | ||
|
|
965d3e5f4c | ||
|
|
5f6576768c | ||
|
|
6c086be1b6 | ||
|
|
b52d27bf56 | ||
|
|
e3c922d032 | ||
|
|
16dea49fa8 | ||
|
|
575a339550 | ||
|
|
e5fa783b59 | ||
|
|
b5cc7777d7 | ||
|
|
e72fb8252c | ||
|
|
5e8bb99e96 | ||
|
|
1b08618831 | ||
|
|
144e34318e | ||
|
|
6035719252 | ||
|
|
2af33894e9 | ||
|
|
63fd87612e | ||
|
|
4b79909116 | ||
|
|
0a6895e89f | ||
|
|
36a0a304d5 | ||
|
|
4f68df1db8 | ||
|
|
7422e16b1e | ||
|
|
151a3b2902 | ||
|
|
066832a3bd | ||
|
|
118158b8ee | ||
|
|
948e2b1e4a | ||
|
|
c48243360b | ||
|
|
7833dee014 | ||
|
|
dcf35a6ede | ||
|
|
c8ebef026b | ||
|
|
7226632fc4 | ||
|
|
2ccead5fb9 | ||
|
|
6d44dafd96 | ||
|
|
e6e3abb88c | ||
|
|
0e85679b14 |
@@ -42,3 +42,26 @@ jobs:
|
|||||||
cache: npm
|
cache: npm
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
- run: npx vite build
|
- 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
@@ -25,4 +25,8 @@ dist-ssr
|
|||||||
*.sw?
|
*.sw?
|
||||||
release/**
|
release/**
|
||||||
*.kiro/
|
*.kiro/
|
||||||
# npx electron-builder --mac --win
|
# npx electron-builder --mac --win
|
||||||
|
|
||||||
|
# Playwright
|
||||||
|
test-results
|
||||||
|
playwright-report/
|
||||||
+19
-11
@@ -28,6 +28,7 @@
|
|||||||
],
|
],
|
||||||
|
|
||||||
"mac": {
|
"mac": {
|
||||||
|
"hardenedRuntime": false,
|
||||||
"target": [
|
"target": [
|
||||||
{
|
{
|
||||||
"target": "dmg",
|
"target": "dmg",
|
||||||
@@ -36,10 +37,13 @@
|
|||||||
],
|
],
|
||||||
"icon": "icons/icons/mac/icon.icns",
|
"icon": "icons/icons/mac/icon.icns",
|
||||||
"artifactName": "${productName}-Mac-${arch}-${version}-Installer.${ext}",
|
"artifactName": "${productName}-Mac-${arch}-${version}-Installer.${ext}",
|
||||||
"extendInfo": {
|
"extendInfo": {
|
||||||
"NSAudioCaptureUsageDescription": "OpenScreen needs audio capture permission to record system audio.",
|
"NSAudioCaptureUsageDescription": "OpenScreen needs audio capture permission to record system audio.",
|
||||||
"NSCameraUseContinuityCameraDeviceType": true
|
"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": {
|
"linux": {
|
||||||
"target": [
|
"target": [
|
||||||
@@ -49,10 +53,14 @@
|
|||||||
"artifactName": "${productName}-Linux-${version}.${ext}",
|
"artifactName": "${productName}-Linux-${version}.${ext}",
|
||||||
"category": "AudioVideo"
|
"category": "AudioVideo"
|
||||||
},
|
},
|
||||||
"win": {
|
"win": {
|
||||||
"target": [
|
"target": [
|
||||||
"nsis"
|
"nsis"
|
||||||
],
|
],
|
||||||
"icon": "icons/icons/win/icon.ico"
|
"icon": "icons/icons/win/icon.ico"
|
||||||
}
|
},
|
||||||
}
|
"nsis": {
|
||||||
|
"oneClick": false,
|
||||||
|
"allowToChangeInstallationDirectory": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Vendored
+49
-5
@@ -27,13 +27,40 @@ interface Window {
|
|||||||
getSources: (opts: Electron.SourcesOptions) => Promise<ProcessedDesktopSource[]>;
|
getSources: (opts: Electron.SourcesOptions) => Promise<ProcessedDesktopSource[]>;
|
||||||
switchToEditor: () => Promise<void>;
|
switchToEditor: () => Promise<void>;
|
||||||
openSourceSelector: () => Promise<void>;
|
openSourceSelector: () => Promise<void>;
|
||||||
selectSource: (source: any) => Promise<any>;
|
selectSource: (source: ProcessedDesktopSource) => Promise<ProcessedDesktopSource | null>;
|
||||||
getSelectedSource: () => Promise<any>;
|
getSelectedSource: () => Promise<ProcessedDesktopSource | null>;
|
||||||
|
requestCameraAccess: () => Promise<{
|
||||||
|
success: boolean;
|
||||||
|
granted: boolean;
|
||||||
|
status: string;
|
||||||
|
error?: string;
|
||||||
|
}>;
|
||||||
|
getAssetBasePath: () => Promise<string | null>;
|
||||||
storeRecordedVideo: (
|
storeRecordedVideo: (
|
||||||
videoData: ArrayBuffer,
|
videoData: ArrayBuffer,
|
||||||
fileName: string,
|
fileName: string,
|
||||||
) => Promise<{ success: boolean; path?: string; message?: string }>;
|
) => Promise<{
|
||||||
getRecordedVideoPath: () => Promise<{ success: boolean; path?: string; message?: string }>;
|
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>;
|
setRecordingState: (recording: boolean) => Promise<void>;
|
||||||
getCursorTelemetry: (videoPath?: string) => Promise<{
|
getCursorTelemetry: (videoPath?: string) => Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
@@ -49,7 +76,24 @@ interface Window {
|
|||||||
) => Promise<{ success: boolean; path?: string; message?: string; canceled?: boolean }>;
|
) => Promise<{ success: boolean; path?: string; message?: string; canceled?: boolean }>;
|
||||||
openVideoFilePicker: () => Promise<{ success: boolean; path?: string; canceled?: boolean }>;
|
openVideoFilePicker: () => Promise<{ success: boolean; path?: string; canceled?: boolean }>;
|
||||||
setCurrentVideoPath: (path: string) => Promise<{ success: 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 }>;
|
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 }>;
|
clearCurrentVideoPath: () => Promise<{ success: boolean }>;
|
||||||
saveProjectFile: (
|
saveProjectFile: (
|
||||||
projectData: unknown,
|
projectData: unknown,
|
||||||
@@ -91,7 +135,7 @@ interface Window {
|
|||||||
hudOverlayClose: () => void;
|
hudOverlayClose: () => void;
|
||||||
setMicrophoneExpanded: (expanded: boolean) => void;
|
setMicrophoneExpanded: (expanded: boolean) => void;
|
||||||
setHasUnsavedChanges: (hasChanges: boolean) => void;
|
setHasUnsavedChanges: (hasChanges: boolean) => void;
|
||||||
onRequestSaveBeforeClose: (callback: () => Promise<void>) => () => void;
|
onRequestSaveBeforeClose: (callback: () => Promise<boolean> | boolean) => () => void;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+273
-40
@@ -1,10 +1,27 @@
|
|||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import path from "node:path";
|
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";
|
import { RECORDINGS_DIR } from "../main";
|
||||||
|
|
||||||
const PROJECT_FILE_EXTENSION = "openscreen";
|
const PROJECT_FILE_EXTENSION = "openscreen";
|
||||||
const SHORTCUTS_FILE = path.join(app.getPath("userData"), "shortcuts.json");
|
const SHORTCUTS_FILE = path.join(app.getPath("userData"), "shortcuts.json");
|
||||||
|
const RECORDING_SESSION_SUFFIX = ".session.json";
|
||||||
|
|
||||||
type SelectedSource = {
|
type SelectedSource = {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -13,11 +30,33 @@ type SelectedSource = {
|
|||||||
|
|
||||||
let selectedSource: SelectedSource | null = null;
|
let selectedSource: SelectedSource | null = null;
|
||||||
let currentProjectPath: string | null = null;
|
let currentProjectPath: string | null = null;
|
||||||
|
let currentRecordingSession: RecordingSession | null = null;
|
||||||
|
|
||||||
function normalizePath(filePath: string) {
|
function normalizePath(filePath: string) {
|
||||||
return path.resolve(filePath);
|
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) {
|
function isTrustedProjectPath(filePath?: string | null) {
|
||||||
if (!filePath || !currentProjectPath) {
|
if (!filePath || !currentProjectPath) {
|
||||||
return false;
|
return false;
|
||||||
@@ -25,6 +64,101 @@ function isTrustedProjectPath(filePath?: string | null) {
|
|||||||
return normalizePath(filePath) === normalizePath(currentProjectPath);
|
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_TELEMETRY_VERSION = 1;
|
||||||
const CURSOR_SAMPLE_INTERVAL_MS = 100;
|
const CURSOR_SAMPLE_INTERVAL_MS = 100;
|
||||||
const MAX_CURSOR_SAMPLES = 60 * 60 * 10; // 1 hour @ 10Hz
|
const MAX_CURSOR_SAMPLES = 60 * 60 * 10; // 1 hour @ 10Hz
|
||||||
@@ -107,6 +241,38 @@ export function registerIpcHandlers(
|
|||||||
return selectedSource;
|
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", () => {
|
ipcMain.handle("open-source-selector", () => {
|
||||||
const sourceSelectorWin = getSourceSelectorWindow();
|
const sourceSelectorWin = getSourceSelectorWindow();
|
||||||
if (sourceSelectorWin) {
|
if (sourceSelectorWin) {
|
||||||
@@ -124,36 +290,30 @@ export function registerIpcHandlers(
|
|||||||
createEditorWindow();
|
createEditorWindow();
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle("store-recorded-video", async (_, videoData: ArrayBuffer, fileName: string) => {
|
ipcMain.handle("store-recorded-session", async (_, payload: StoreRecordedSessionInput) => {
|
||||||
try {
|
try {
|
||||||
const videoPath = path.join(RECORDINGS_DIR, fileName);
|
return await storeRecordedSessionFiles(payload);
|
||||||
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",
|
|
||||||
};
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to store video:", error);
|
console.error("Failed to store recording session:", error);
|
||||||
return {
|
return {
|
||||||
success: false,
|
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),
|
error: String(error),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -161,8 +321,14 @@ export function registerIpcHandlers(
|
|||||||
|
|
||||||
ipcMain.handle("get-recorded-video-path", async () => {
|
ipcMain.handle("get-recorded-video-path", async () => {
|
||||||
try {
|
try {
|
||||||
|
if (currentRecordingSession?.screenVideoPath) {
|
||||||
|
return { success: true, path: currentRecordingSession.screenVideoPath };
|
||||||
|
}
|
||||||
|
|
||||||
const files = await fs.readdir(RECORDINGS_DIR);
|
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) {
|
if (videoFiles.length === 0) {
|
||||||
return { success: false, message: "No recorded video found" };
|
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) => {
|
ipcMain.handle("set-recording-state", (_, recording: boolean) => {
|
||||||
if (recording) {
|
if (recording) {
|
||||||
stopCursorCapture();
|
stopCursorCapture();
|
||||||
@@ -199,7 +388,9 @@ export function registerIpcHandlers(
|
|||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle("get-cursor-telemetry", async (_, videoPath?: string) => {
|
ipcMain.handle("get-cursor-telemetry", async (_, videoPath?: string) => {
|
||||||
const targetVideoPath = videoPath ?? currentVideoPath;
|
const targetVideoPath = normalizeVideoSourcePath(
|
||||||
|
videoPath ?? currentRecordingSession?.screenVideoPath,
|
||||||
|
);
|
||||||
if (!targetVideoPath) {
|
if (!targetVideoPath) {
|
||||||
return { success: true, samples: [] };
|
return { success: true, samples: [] };
|
||||||
}
|
}
|
||||||
@@ -265,9 +456,11 @@ export function registerIpcHandlers(
|
|||||||
ipcMain.handle("get-asset-base-path", () => {
|
ipcMain.handle("get-asset-base-path", () => {
|
||||||
try {
|
try {
|
||||||
if (app.isPackaged) {
|
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) {
|
} catch (err) {
|
||||||
console.error("Failed to resolve asset base path:", err);
|
console.error("Failed to resolve asset base path:", err);
|
||||||
return null;
|
return null;
|
||||||
@@ -369,7 +562,6 @@ export function registerIpcHandlers(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let currentVideoPath: string | null = null;
|
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
"save-project-file",
|
"save-project-file",
|
||||||
async (_, projectData: unknown, suggestedName?: string, existingProjectPath?: string) => {
|
async (_, projectData: unknown, suggestedName?: string, existingProjectPath?: string) => {
|
||||||
@@ -455,8 +647,17 @@ export function registerIpcHandlers(
|
|||||||
const content = await fs.readFile(filePath, "utf-8");
|
const content = await fs.readFile(filePath, "utf-8");
|
||||||
const project = JSON.parse(content);
|
const project = JSON.parse(content);
|
||||||
currentProjectPath = filePath;
|
currentProjectPath = filePath;
|
||||||
if (project && typeof project === "object" && typeof project.videoPath === "string") {
|
if (project && typeof project === "object") {
|
||||||
currentVideoPath = project.videoPath;
|
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 {
|
return {
|
||||||
@@ -482,8 +683,17 @@ export function registerIpcHandlers(
|
|||||||
|
|
||||||
const content = await fs.readFile(currentProjectPath, "utf-8");
|
const content = await fs.readFile(currentProjectPath, "utf-8");
|
||||||
const project = JSON.parse(content);
|
const project = JSON.parse(content);
|
||||||
if (project && typeof project === "object" && typeof project.videoPath === "string") {
|
if (project && typeof project === "object") {
|
||||||
currentVideoPath = project.videoPath;
|
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 {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -499,18 +709,41 @@ export function registerIpcHandlers(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
ipcMain.handle("set-current-video-path", (_, path: string) => {
|
ipcMain.handle("set-current-recording-session", (_, session: RecordingSession | null) => {
|
||||||
currentVideoPath = path;
|
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;
|
currentProjectPath = null;
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle("get-current-video-path", () => {
|
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", () => {
|
ipcMain.handle("clear-current-video-path", () => {
|
||||||
currentVideoPath = null;
|
setCurrentRecordingSessionState(null);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+74
-15
@@ -1,7 +1,17 @@
|
|||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
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 { registerIpcHandlers } from "./ipc/handlers";
|
||||||
import { createEditorWindow, createHudOverlayWindow, createSourceSelectorWindow } from "./windows";
|
import { createEditorWindow, createHudOverlayWindow, createSourceSelectorWindow } from "./windows";
|
||||||
|
|
||||||
@@ -60,6 +70,19 @@ function createWindow() {
|
|||||||
mainWindow = createHudOverlayWindow();
|
mainWindow = createHudOverlayWindow();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function showMainWindow() {
|
||||||
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||||
|
if (mainWindow.isMinimized()) {
|
||||||
|
mainWindow.restore();
|
||||||
|
}
|
||||||
|
mainWindow.show();
|
||||||
|
mainWindow.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
createWindow();
|
||||||
|
}
|
||||||
|
|
||||||
function isEditorWindow(window: BrowserWindow) {
|
function isEditorWindow(window: BrowserWindow) {
|
||||||
return window.webContents.getURL().includes("windowType=editor");
|
return window.webContents.getURL().includes("windowType=editor");
|
||||||
}
|
}
|
||||||
@@ -167,6 +190,12 @@ function setupApplicationMenu() {
|
|||||||
|
|
||||||
function createTray() {
|
function createTray() {
|
||||||
tray = new Tray(defaultTrayIcon);
|
tray = new Tray(defaultTrayIcon);
|
||||||
|
tray.on("click", () => {
|
||||||
|
showMainWindow();
|
||||||
|
});
|
||||||
|
tray.on("double-click", () => {
|
||||||
|
showMainWindow();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTrayIcon(filename: string) {
|
function getTrayIcon(filename: string) {
|
||||||
@@ -198,11 +227,7 @@ function updateTrayMenu(recording: boolean = false) {
|
|||||||
{
|
{
|
||||||
label: "Open",
|
label: "Open",
|
||||||
click: () => {
|
click: () => {
|
||||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
showMainWindow();
|
||||||
mainWindow.isMinimized() && mainWindow.restore();
|
|
||||||
} else {
|
|
||||||
createWindow();
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -224,6 +249,21 @@ ipcMain.on("set-has-unsaved-changes", (_, hasChanges: boolean) => {
|
|||||||
editorHasUnsavedChanges = hasChanges;
|
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() {
|
function createEditorWindowWrapper() {
|
||||||
if (mainWindow) {
|
if (mainWindow) {
|
||||||
isForceClosing = true;
|
isForceClosing = true;
|
||||||
@@ -249,19 +289,19 @@ function createEditorWindowWrapper() {
|
|||||||
detail: "Do you want to save your project before closing?",
|
detail: "Do you want to save your project before closing?",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const windowToClose = mainWindow;
|
||||||
|
if (!windowToClose || windowToClose.isDestroyed()) return;
|
||||||
|
|
||||||
if (choice === 0) {
|
if (choice === 0) {
|
||||||
// Save & Close — tell renderer to save, then close
|
// Save & Close — tell renderer to save, then close
|
||||||
mainWindow!.webContents.send("request-save-before-close");
|
windowToClose.webContents.send("request-save-before-close");
|
||||||
ipcMain.once("save-before-close-done", () => {
|
ipcMain.once("save-before-close-done", (_, shouldClose: boolean) => {
|
||||||
isForceClosing = true;
|
if (!shouldClose) return;
|
||||||
mainWindow?.close();
|
forceCloseEditorWindow(windowToClose);
|
||||||
isForceClosing = false;
|
|
||||||
});
|
});
|
||||||
} else if (choice === 1) {
|
} else if (choice === 1) {
|
||||||
// Discard & Close
|
// Discard & Close
|
||||||
isForceClosing = true;
|
forceCloseEditorWindow(windowToClose);
|
||||||
mainWindow?.close();
|
|
||||||
isForceClosing = false;
|
|
||||||
}
|
}
|
||||||
// choice === 2: Cancel — do nothing, window stays open
|
// choice === 2: Cancel — do nothing, window stays open
|
||||||
});
|
});
|
||||||
@@ -291,6 +331,25 @@ app.on("activate", () => {
|
|||||||
|
|
||||||
// Register all IPC handlers when app is ready
|
// Register all IPC handlers when app is ready
|
||||||
app.whenReady().then(async () => {
|
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)
|
// Listen for HUD overlay quit event (macOS only)
|
||||||
ipcMain.on("hud-overlay-close", () => {
|
ipcMain.on("hud-overlay-close", () => {
|
||||||
app.quit();
|
app.quit();
|
||||||
@@ -311,7 +370,7 @@ app.whenReady().then(async () => {
|
|||||||
if (!tray) createTray();
|
if (!tray) createTray();
|
||||||
updateTrayMenu(recording);
|
updateTrayMenu(recording);
|
||||||
if (!recording) {
|
if (!recording) {
|
||||||
if (mainWindow) mainWindow.restore();
|
showMainWindow();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
+24
-4
@@ -1,4 +1,5 @@
|
|||||||
import { contextBridge, ipcRenderer } from "electron";
|
import { contextBridge, ipcRenderer } from "electron";
|
||||||
|
import type { RecordingSession, StoreRecordedSessionInput } from "../src/lib/recordingSession";
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld("electronAPI", {
|
contextBridge.exposeInMainWorld("electronAPI", {
|
||||||
hudOverlayHide: () => {
|
hudOverlayHide: () => {
|
||||||
@@ -20,16 +21,22 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
|||||||
openSourceSelector: () => {
|
openSourceSelector: () => {
|
||||||
return ipcRenderer.invoke("open-source-selector");
|
return ipcRenderer.invoke("open-source-selector");
|
||||||
},
|
},
|
||||||
selectSource: (source: any) => {
|
selectSource: (source: ProcessedDesktopSource) => {
|
||||||
return ipcRenderer.invoke("select-source", source);
|
return ipcRenderer.invoke("select-source", source);
|
||||||
},
|
},
|
||||||
getSelectedSource: () => {
|
getSelectedSource: () => {
|
||||||
return ipcRenderer.invoke("get-selected-source");
|
return ipcRenderer.invoke("get-selected-source");
|
||||||
},
|
},
|
||||||
|
requestCameraAccess: () => {
|
||||||
|
return ipcRenderer.invoke("request-camera-access");
|
||||||
|
},
|
||||||
|
|
||||||
storeRecordedVideo: (videoData: ArrayBuffer, fileName: string) => {
|
storeRecordedVideo: (videoData: ArrayBuffer, fileName: string) => {
|
||||||
return ipcRenderer.invoke("store-recorded-video", videoData, fileName);
|
return ipcRenderer.invoke("store-recorded-video", videoData, fileName);
|
||||||
},
|
},
|
||||||
|
storeRecordedSession: (payload: StoreRecordedSessionInput) => {
|
||||||
|
return ipcRenderer.invoke("store-recorded-session", payload);
|
||||||
|
},
|
||||||
|
|
||||||
getRecordedVideoPath: () => {
|
getRecordedVideoPath: () => {
|
||||||
return ipcRenderer.invoke("get-recorded-video-path");
|
return ipcRenderer.invoke("get-recorded-video-path");
|
||||||
@@ -57,9 +64,18 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
|||||||
setCurrentVideoPath: (path: string) => {
|
setCurrentVideoPath: (path: string) => {
|
||||||
return ipcRenderer.invoke("set-current-video-path", path);
|
return ipcRenderer.invoke("set-current-video-path", path);
|
||||||
},
|
},
|
||||||
|
setCurrentRecordingSession: (session: RecordingSession | null) => {
|
||||||
|
return ipcRenderer.invoke("set-current-recording-session", session);
|
||||||
|
},
|
||||||
getCurrentVideoPath: () => {
|
getCurrentVideoPath: () => {
|
||||||
return ipcRenderer.invoke("get-current-video-path");
|
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: () => {
|
clearCurrentVideoPath: () => {
|
||||||
return ipcRenderer.invoke("clear-current-video-path");
|
return ipcRenderer.invoke("clear-current-video-path");
|
||||||
},
|
},
|
||||||
@@ -105,10 +121,14 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
|||||||
setHasUnsavedChanges: (hasChanges: boolean) => {
|
setHasUnsavedChanges: (hasChanges: boolean) => {
|
||||||
ipcRenderer.send("set-has-unsaved-changes", hasChanges);
|
ipcRenderer.send("set-has-unsaved-changes", hasChanges);
|
||||||
},
|
},
|
||||||
onRequestSaveBeforeClose: (callback: () => Promise<void>) => {
|
onRequestSaveBeforeClose: (callback: () => Promise<boolean> | boolean) => {
|
||||||
const listener = async () => {
|
const listener = async () => {
|
||||||
await callback();
|
try {
|
||||||
ipcRenderer.send("save-before-close-done");
|
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);
|
ipcRenderer.on("request-save-before-close", listener);
|
||||||
return () => ipcRenderer.removeListener("request-save-before-close", listener);
|
return () => ipcRenderer.removeListener("request-save-before-close", listener);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|||||||
const APP_ROOT = path.join(__dirname, "..");
|
const APP_ROOT = path.join(__dirname, "..");
|
||||||
const VITE_DEV_SERVER_URL = process.env["VITE_DEV_SERVER_URL"];
|
const VITE_DEV_SERVER_URL = process.env["VITE_DEV_SERVER_URL"];
|
||||||
const RENDERER_DIST = path.join(APP_ROOT, "dist");
|
const RENDERER_DIST = path.join(APP_ROOT, "dist");
|
||||||
|
const HEADLESS = process.env["HEADLESS"] === "true";
|
||||||
|
|
||||||
let hudOverlayWindow: BrowserWindow | null = null;
|
let hudOverlayWindow: BrowserWindow | null = null;
|
||||||
|
|
||||||
@@ -41,6 +42,7 @@ export function createHudOverlayWindow(): BrowserWindow {
|
|||||||
alwaysOnTop: true,
|
alwaysOnTop: true,
|
||||||
skipTaskbar: true,
|
skipTaskbar: true,
|
||||||
hasShadow: false,
|
hasShadow: false,
|
||||||
|
show: !HEADLESS,
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
preload: path.join(__dirname, "preload.mjs"),
|
preload: path.join(__dirname, "preload.mjs"),
|
||||||
nodeIntegration: false,
|
nodeIntegration: false,
|
||||||
@@ -90,6 +92,7 @@ export function createEditorWindow(): BrowserWindow {
|
|||||||
skipTaskbar: false,
|
skipTaskbar: false,
|
||||||
title: "OpenScreen",
|
title: "OpenScreen",
|
||||||
backgroundColor: "#000000",
|
backgroundColor: "#000000",
|
||||||
|
show: !HEADLESS,
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
preload: path.join(__dirname, "preload.mjs"),
|
preload: path.join(__dirname, "preload.mjs"),
|
||||||
nodeIntegration: false,
|
nodeIntegration: false,
|
||||||
|
|||||||
Generated
+116
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "openscreen",
|
"name": "openscreen",
|
||||||
"version": "1.1.3",
|
"version": "1.2.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "openscreen",
|
"name": "openscreen",
|
||||||
"version": "1.1.3",
|
"version": "1.2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fix-webm-duration/fix": "^1.0.1",
|
"@fix-webm-duration/fix": "^1.0.1",
|
||||||
"@pixi/filter-drop-shadow": "^5.2.0",
|
"@pixi/filter-drop-shadow": "^5.2.0",
|
||||||
@@ -21,6 +21,7 @@
|
|||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@radix-ui/react-toggle": "^1.1.10",
|
"@radix-ui/react-toggle": "^1.1.10",
|
||||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||||
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@types/gif.js": "^0.2.5",
|
"@types/gif.js": "^0.2.5",
|
||||||
"@uiw/color-convert": "^2.9.2",
|
"@uiw/color-convert": "^2.9.2",
|
||||||
"@uiw/react-color-block": "^2.9.2",
|
"@uiw/react-color-block": "^2.9.2",
|
||||||
@@ -35,6 +36,7 @@
|
|||||||
"mediabunny": "^1.25.1",
|
"mediabunny": "^1.25.1",
|
||||||
"motion": "^12.23.24",
|
"motion": "^12.23.24",
|
||||||
"mp4box": "^2.2.0",
|
"mp4box": "^2.2.0",
|
||||||
|
"pixi-filters": "^6.1.5",
|
||||||
"pixi.js": "^8.14.0",
|
"pixi.js": "^8.14.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
@@ -49,6 +51,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.3.13",
|
"@biomejs/biome": "^2.3.13",
|
||||||
|
"@playwright/test": "^1.58.2",
|
||||||
"@types/node": "^25.0.3",
|
"@types/node": "^25.0.3",
|
||||||
"@types/react": "^18.2.64",
|
"@types/react": "^18.2.64",
|
||||||
"@types/react-dom": "^18.2.21",
|
"@types/react-dom": "^18.2.21",
|
||||||
@@ -2940,6 +2943,21 @@
|
|||||||
"node": ">=14"
|
"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": {
|
"node_modules/@radix-ui/number": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
|
"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": {
|
"node_modules/@radix-ui/react-use-callback-ref": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
|
"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": "*"
|
"@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": {
|
"node_modules/@types/http-cache-semantics": {
|
||||||
"version": "4.0.4",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz",
|
"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": ">=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": {
|
"node_modules/pixi.js": {
|
||||||
"version": "8.14.0",
|
"version": "8.14.0",
|
||||||
"resolved": "https://registry.npmjs.org/pixi.js/-/pixi.js-8.14.0.tgz",
|
"resolved": "https://registry.npmjs.org/pixi.js/-/pixi.js-8.14.0.tgz",
|
||||||
@@ -10838,6 +10908,50 @@
|
|||||||
"url": "https://opencollective.com/pixijs"
|
"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": {
|
"node_modules/plist": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz",
|
||||||
|
|||||||
@@ -15,6 +15,8 @@
|
|||||||
"build:linux": "tsc && vite build && electron-builder --linux",
|
"build:linux": "tsc && vite build && electron-builder --linux",
|
||||||
"test": "vitest --run",
|
"test": "vitest --run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
|
"build-vite": "tsc && vite build",
|
||||||
|
"test:e2e": "playwright test",
|
||||||
"prepare": "husky"
|
"prepare": "husky"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -31,6 +33,7 @@
|
|||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@radix-ui/react-toggle": "^1.1.10",
|
"@radix-ui/react-toggle": "^1.1.10",
|
||||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||||
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@types/gif.js": "^0.2.5",
|
"@types/gif.js": "^0.2.5",
|
||||||
"@uiw/color-convert": "^2.9.2",
|
"@uiw/color-convert": "^2.9.2",
|
||||||
"@uiw/react-color-block": "^2.9.2",
|
"@uiw/react-color-block": "^2.9.2",
|
||||||
@@ -45,6 +48,7 @@
|
|||||||
"mediabunny": "^1.25.1",
|
"mediabunny": "^1.25.1",
|
||||||
"motion": "^12.23.24",
|
"motion": "^12.23.24",
|
||||||
"mp4box": "^2.2.0",
|
"mp4box": "^2.2.0",
|
||||||
|
"pixi-filters": "^6.1.5",
|
||||||
"pixi.js": "^8.14.0",
|
"pixi.js": "^8.14.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
@@ -59,6 +63,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.3.13",
|
"@biomejs/biome": "^2.3.13",
|
||||||
|
"@playwright/test": "^1.58.2",
|
||||||
"@types/node": "^25.0.3",
|
"@types/node": "^25.0.3",
|
||||||
"@types/react": "^18.2.64",
|
"@types/react": "^18.2.64",
|
||||||
"@types/react-dom": "^18.2.21",
|
"@types/react-dom": "^18.2.21",
|
||||||
|
|||||||
@@ -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
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { LaunchWindow } from "./components/launch/LaunchWindow";
|
import { LaunchWindow } from "./components/launch/LaunchWindow";
|
||||||
import { SourceSelector } from "./components/launch/SourceSelector";
|
import { SourceSelector } from "./components/launch/SourceSelector";
|
||||||
|
import { TooltipProvider } from "./components/ui/tooltip";
|
||||||
import { ShortcutsConfigDialog } from "./components/video-editor/ShortcutsConfigDialog";
|
import { ShortcutsConfigDialog } from "./components/video-editor/ShortcutsConfigDialog";
|
||||||
import VideoEditor from "./components/video-editor/VideoEditor";
|
import VideoEditor from "./components/video-editor/VideoEditor";
|
||||||
import { ShortcutsProvider } from "./contexts/ShortcutsContext";
|
import { ShortcutsProvider } from "./contexts/ShortcutsContext";
|
||||||
@@ -25,23 +26,27 @@ export default function App() {
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
switch (windowType) {
|
const content = (() => {
|
||||||
case "hud-overlay":
|
switch (windowType) {
|
||||||
return <LaunchWindow />;
|
case "hud-overlay":
|
||||||
case "source-selector":
|
return <LaunchWindow />;
|
||||||
return <SourceSelector />;
|
case "source-selector":
|
||||||
case "editor":
|
return <SourceSelector />;
|
||||||
return (
|
case "editor":
|
||||||
<ShortcutsProvider>
|
return (
|
||||||
<VideoEditor />
|
<ShortcutsProvider>
|
||||||
<ShortcutsConfigDialog />
|
<VideoEditor />
|
||||||
</ShortcutsProvider>
|
<ShortcutsConfigDialog />
|
||||||
);
|
</ShortcutsProvider>
|
||||||
default:
|
);
|
||||||
return (
|
default:
|
||||||
<div className="w-full h-full bg-background text-foreground">
|
return (
|
||||||
<h1>Openscreen</h1>
|
<div className="w-full h-full bg-background text-foreground">
|
||||||
</div>
|
<h1>Openscreen</h1>
|
||||||
);
|
</div>
|
||||||
}
|
);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return <TooltipProvider>{content}</TooltipProvider>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/* Electron-specific: no Tailwind equivalent for -webkit-app-region */
|
||||||
.electronDrag {
|
.electronDrag {
|
||||||
-webkit-app-region: drag;
|
-webkit-app-region: drag;
|
||||||
}
|
}
|
||||||
@@ -5,129 +6,3 @@
|
|||||||
.electronNoDrag {
|
.electronNoDrag {
|
||||||
-webkit-app-region: no-drag;
|
-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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,26 +1,78 @@
|
|||||||
|
import { ChevronDown } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { BsRecordCircle } from "react-icons/bs";
|
import { BsRecordCircle } from "react-icons/bs";
|
||||||
import { FaRegStopCircle } from "react-icons/fa";
|
import { FaRegStopCircle } from "react-icons/fa";
|
||||||
import { FaFolderOpen } from "react-icons/fa6";
|
import { FaFolderOpen } from "react-icons/fa6";
|
||||||
import { FiMinus, FiX } from "react-icons/fi";
|
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 { RxDragHandleDots2 } from "react-icons/rx";
|
||||||
import { useAudioLevelMeter } from "../../hooks/useAudioLevelMeter";
|
import { useAudioLevelMeter } from "../../hooks/useAudioLevelMeter";
|
||||||
import { useMicrophoneDevices } from "../../hooks/useMicrophoneDevices";
|
import { useMicrophoneDevices } from "../../hooks/useMicrophoneDevices";
|
||||||
import { useScreenRecorder } from "../../hooks/useScreenRecorder";
|
import { useScreenRecorder } from "../../hooks/useScreenRecorder";
|
||||||
|
import { formatTimePadded } from "../../utils/timeUtils";
|
||||||
import { AudioLevelMeter } from "../ui/audio-level-meter";
|
import { AudioLevelMeter } from "../ui/audio-level-meter";
|
||||||
|
import { Tooltip } from "../ui/tooltip";
|
||||||
import styles from "./LaunchWindow.module.css";
|
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() {
|
export function LaunchWindow() {
|
||||||
const {
|
const {
|
||||||
recording,
|
recording,
|
||||||
toggleRecording,
|
toggleRecording,
|
||||||
|
restartRecording,
|
||||||
microphoneEnabled,
|
microphoneEnabled,
|
||||||
setMicrophoneEnabled,
|
setMicrophoneEnabled,
|
||||||
microphoneDeviceId,
|
microphoneDeviceId,
|
||||||
setMicrophoneDeviceId,
|
setMicrophoneDeviceId,
|
||||||
systemAudioEnabled,
|
systemAudioEnabled,
|
||||||
setSystemAudioEnabled,
|
setSystemAudioEnabled,
|
||||||
|
webcamEnabled,
|
||||||
|
setWebcamEnabled,
|
||||||
} = useScreenRecorder();
|
} = useScreenRecorder();
|
||||||
const [recordingStart, setRecordingStart] = useState<number | null>(null);
|
const [recordingStart, setRecordingStart] = useState<number | null>(null);
|
||||||
const [elapsed, setElapsed] = useState(0);
|
const [elapsed, setElapsed] = useState(0);
|
||||||
@@ -58,13 +110,6 @@ export function LaunchWindow() {
|
|||||||
};
|
};
|
||||||
}, [recording, recordingStart]);
|
}, [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 [selectedSource, setSelectedSource] = useState("Screen");
|
||||||
const [hasSelectedSource, setHasSelectedSource] = useState(false);
|
const [hasSelectedSource, setHasSelectedSource] = useState(false);
|
||||||
|
|
||||||
@@ -136,133 +181,149 @@ export function LaunchWindow() {
|
|||||||
{/* Mic controls panel */}
|
{/* Mic controls panel */}
|
||||||
{showMicControls && (
|
{showMicControls && (
|
||||||
<div
|
<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
|
<div className="relative flex-1" style={{ maxWidth: "70%" }}>
|
||||||
value={microphoneDeviceId || selectedDeviceId}
|
<select
|
||||||
onChange={(e) => {
|
value={microphoneDeviceId || selectedDeviceId}
|
||||||
setSelectedDeviceId(e.target.value);
|
onChange={(e) => {
|
||||||
setMicrophoneDeviceId(e.target.value);
|
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%" }}
|
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) => (
|
{devices.map((device) => (
|
||||||
<option key={device.deviceId} value={device.deviceId}>
|
<option key={device.deviceId} value={device.deviceId}>
|
||||||
{device.label}
|
{device.label}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</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" />
|
<AudioLevelMeter level={level} className="w-24 h-4" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Main pill bar */}
|
{/* Main pill bar */}
|
||||||
<div
|
<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)]">
|
||||||
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)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Drag handle */}
|
{/* Drag handle */}
|
||||||
<div className={`flex items-center px-1 ${styles.electronDrag}`}>
|
<div className={`flex items-center px-1 ${styles.electronDrag}`}>
|
||||||
<RxDragHandleDots2 size={16} className="text-white/30" />
|
{getIcon("drag", "text-white/30")}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Source selector */}
|
{/* Source selector */}
|
||||||
<button
|
<button
|
||||||
className={`${styles.hudGroup} ${styles.electronNoDrag}`}
|
className={`${hudGroupClasses} p-2 ${styles.electronNoDrag}`}
|
||||||
onClick={openSourceSelector}
|
onClick={openSourceSelector}
|
||||||
disabled={recording}
|
disabled={recording}
|
||||||
title={selectedSource}
|
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">
|
<span className="text-white/70 text-[11px] max-w-[72px] truncate">
|
||||||
{selectedSource}
|
{selectedSource}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Audio controls group */}
|
{/* Audio controls group */}
|
||||||
<div className={`${styles.hudGroup} ${styles.electronNoDrag}`}>
|
<div className={`${hudGroupClasses} ${styles.electronNoDrag}`}>
|
||||||
<button
|
<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)}
|
onClick={() => !recording && setSystemAudioEnabled(!systemAudioEnabled)}
|
||||||
disabled={recording}
|
disabled={recording}
|
||||||
title={systemAudioEnabled ? "Disable system audio" : "Enable system audio"}
|
title={systemAudioEnabled ? "Disable system audio" : "Enable system audio"}
|
||||||
>
|
>
|
||||||
{systemAudioEnabled ? (
|
{systemAudioEnabled
|
||||||
<MdVolumeUp size={15} className="text-green-400" />
|
? getIcon("volumeOn", "text-green-400")
|
||||||
) : (
|
: getIcon("volumeOff", "text-white/40")}
|
||||||
<MdVolumeOff size={15} className="text-white/40" />
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`${styles.hudIconBtn} ${microphoneEnabled ? styles.hudIconActive : ""}`}
|
className={`${hudIconBtnClasses} ${microphoneEnabled ? "drop-shadow-[0_0_4px_rgba(74,222,128,0.4)]" : ""}`}
|
||||||
onClick={toggleMicrophone}
|
onClick={toggleMicrophone}
|
||||||
disabled={recording}
|
disabled={recording}
|
||||||
title={microphoneEnabled ? "Disable microphone" : "Enable microphone"}
|
title={microphoneEnabled ? "Disable microphone" : "Enable microphone"}
|
||||||
>
|
>
|
||||||
{microphoneEnabled ? (
|
{microphoneEnabled
|
||||||
<MdMic size={15} className="text-green-400" />
|
? getIcon("micOn", "text-green-400")
|
||||||
) : (
|
: getIcon("micOff", "text-white/40")}
|
||||||
<MdMicOff size={15} className="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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Record/Stop group */}
|
{/* Record/Stop group */}
|
||||||
<button
|
<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}
|
onClick={hasSelectedSource ? toggleRecording : openSourceSelector}
|
||||||
disabled={!hasSelectedSource && !recording}
|
disabled={!hasSelectedSource && !recording}
|
||||||
style={{ flex: "0 0 auto" }}
|
style={{ flex: "0 0 auto" }}
|
||||||
>
|
>
|
||||||
{recording ? (
|
{recording ? (
|
||||||
<>
|
<>
|
||||||
<FaRegStopCircle size={13} className="text-red-400" />
|
{getIcon("stop", "text-red-400")}
|
||||||
<span className="text-red-400 text-xs font-semibold tabular-nums">
|
<span className="text-red-400 text-xs font-semibold tabular-nums">
|
||||||
{formatTime(elapsed)}
|
{formatTimePadded(elapsed)}
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<BsRecordCircle
|
getIcon("record", hasSelectedSource ? "text-white/80" : "text-white/30")
|
||||||
size={14}
|
|
||||||
className={hasSelectedSource ? "text-white/80" : "text-white/30"}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</button>
|
</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 */}
|
{/* Open video file */}
|
||||||
<button
|
<Tooltip content="Open video file">
|
||||||
className={`${styles.hudIconBtn} ${styles.electronNoDrag}`}
|
<button
|
||||||
onClick={openVideoFile}
|
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
|
||||||
disabled={recording}
|
onClick={openVideoFile}
|
||||||
title="Open video file"
|
disabled={recording}
|
||||||
>
|
>
|
||||||
<MdVideoFile size={14} className="text-white/60" />
|
{getIcon("videoFile", "text-white/60")}
|
||||||
</button>
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
{/* Open project */}
|
{/* Open project */}
|
||||||
<button
|
<Tooltip content="Open project">
|
||||||
className={`${styles.hudIconBtn} ${styles.electronNoDrag}`}
|
<button
|
||||||
onClick={openProjectFile}
|
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
|
||||||
disabled={recording}
|
onClick={openProjectFile}
|
||||||
title="Open project"
|
disabled={recording}
|
||||||
>
|
>
|
||||||
<FaFolderOpen size={14} className="text-white/60" />
|
{getIcon("folder", "text-white/60")}
|
||||||
</button>
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
{/* Window controls */}
|
{/* Window controls */}
|
||||||
<div className={`flex items-center gap-0.5 ${styles.electronNoDrag}`}>
|
<div className={`flex items-center gap-0.5 ${styles.electronNoDrag}`}>
|
||||||
<button className={styles.windowBtn} title="Hide HUD" onClick={sendHudOverlayHide}>
|
<button className={windowBtnClasses} title="Hide HUD" onClick={sendHudOverlayHide}>
|
||||||
<FiMinus size={14} className="text-white" />
|
{getIcon("minimize", "text-white")}
|
||||||
</button>
|
</button>
|
||||||
<button className={styles.windowBtn} title="Close App" onClick={sendHudOverlayClose}>
|
<button className={windowBtnClasses} title="Close App" onClick={sendHudOverlayClose}>
|
||||||
<FiX size={14} className="text-white" />
|
{getIcon("close", "text-white")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -104,19 +104,22 @@ export function SourceSelector() {
|
|||||||
return (
|
return (
|
||||||
<div className={`min-h-screen flex flex-col ${styles.glassContainer}`}>
|
<div className={`min-h-screen flex flex-col ${styles.glassContainer}`}>
|
||||||
<div className="flex-1 flex flex-col w-full px-4 pt-4">
|
<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">
|
<TabsList className="grid grid-cols-2 mb-3 bg-white/5 rounded-full">
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="screens"
|
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"
|
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>
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="windows"
|
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"
|
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>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
<div className="flex-1 min-h-0">
|
<div className="flex-1 min-h-0">
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -16,7 +16,7 @@ interface CropControlProps {
|
|||||||
aspectRatio: AspectRatio;
|
aspectRatio: AspectRatio;
|
||||||
}
|
}
|
||||||
|
|
||||||
type DragHandle = "top" | "right" | "bottom" | "left" | null;
|
type DragHandle = "top" | "right" | "bottom" | "left" | "move" | null;
|
||||||
|
|
||||||
export function CropControl({ videoElement, cropRegion, onCropChange }: CropControlProps) {
|
export function CropControl({ videoElement, cropRegion, onCropChange }: CropControlProps) {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
@@ -99,6 +99,11 @@ export function CropControl({ videoElement, cropRegion, onCropChange }: CropCont
|
|||||||
case "right":
|
case "right":
|
||||||
newCrop.width = Math.max(0.1, Math.min(initialCrop.width + deltaX, 1 - initialCrop.x));
|
newCrop.width = Math.max(0.1, Math.min(initialCrop.width + deltaX, 1 - initialCrop.x));
|
||||||
break;
|
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);
|
onCropChange(newCrop);
|
||||||
@@ -108,7 +113,9 @@ export function CropControl({ videoElement, cropRegion, onCropChange }: CropCont
|
|||||||
if (isDragging) {
|
if (isDragging) {
|
||||||
try {
|
try {
|
||||||
e.currentTarget.releasePointerCapture(e.pointerId);
|
e.currentTarget.releasePointerCapture(e.pointerId);
|
||||||
} catch {}
|
} catch {
|
||||||
|
// Pointer may already be released; ignore.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
setIsDragging(null);
|
setIsDragging(null);
|
||||||
};
|
};
|
||||||
@@ -176,6 +183,18 @@ export function CropControl({ videoElement, cropRegion, onCropChange }: CropCont
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</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
|
<div
|
||||||
className={cn("absolute h-[3px] cursor-ns-resize z-20 pointer-events-auto bg-[#34B27B]")}
|
className={cn("absolute h-[3px] cursor-ns-resize z-20 pointer-events-auto bg-[#34B27B]")}
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Download, Loader2, X } from "lucide-react";
|
import { Download, Loader2, X } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { toast } from "sonner"; // Add this import
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import type { ExportProgress } from "@/lib/exporter";
|
import type { ExportProgress } from "@/lib/exporter";
|
||||||
|
|
||||||
@@ -13,6 +12,7 @@ interface ExportDialogProps {
|
|||||||
onCancel?: () => void;
|
onCancel?: () => void;
|
||||||
exportFormat?: "mp4" | "gif";
|
exportFormat?: "mp4" | "gif";
|
||||||
exportedFilePath?: string;
|
exportedFilePath?: string;
|
||||||
|
onShowInFolder?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ExportDialog({
|
export function ExportDialog({
|
||||||
@@ -23,7 +23,8 @@ export function ExportDialog({
|
|||||||
error,
|
error,
|
||||||
onCancel,
|
onCancel,
|
||||||
exportFormat = "mp4",
|
exportFormat = "mp4",
|
||||||
exportedFilePath, // Add this line
|
exportedFilePath,
|
||||||
|
onShowInFolder,
|
||||||
}: ExportDialogProps) {
|
}: ExportDialogProps) {
|
||||||
const [showSuccess, setShowSuccess] = useState(false);
|
const [showSuccess, setShowSuccess] = useState(false);
|
||||||
|
|
||||||
@@ -66,6 +67,9 @@ export function ExportDialog({
|
|||||||
const getStatusMessage = () => {
|
const getStatusMessage = () => {
|
||||||
if (error) return "Please try again";
|
if (error) return "Please try again";
|
||||||
if (isCompiling || isFinalizing) {
|
if (isCompiling || isFinalizing) {
|
||||||
|
if (exportFormat === "mp4") {
|
||||||
|
return "Finalizing video export...";
|
||||||
|
}
|
||||||
if (renderProgress !== undefined && renderProgress > 0) {
|
if (renderProgress !== undefined && renderProgress > 0) {
|
||||||
return `Compiling GIF... ${renderProgress}%`;
|
return `Compiling GIF... ${renderProgress}%`;
|
||||||
}
|
}
|
||||||
@@ -77,27 +81,11 @@ export function ExportDialog({
|
|||||||
// Get title based on phase
|
// Get title based on phase
|
||||||
const getTitle = () => {
|
const getTitle = () => {
|
||||||
if (error) return "Export Failed";
|
if (error) return "Export Failed";
|
||||||
|
if (isFinalizing && exportFormat === "mp4") return "Finalizing Video";
|
||||||
if (isCompiling || isFinalizing) return "Compiling GIF";
|
if (isCompiling || isFinalizing) return "Compiling GIF";
|
||||||
return `Exporting ${formatLabel}`;
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
@@ -120,7 +108,7 @@ export function ExportDialog({
|
|||||||
{exportedFilePath && (
|
{exportedFilePath && (
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
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"
|
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
|
Show in Folder
|
||||||
@@ -233,7 +221,11 @@ export function ExportDialog({
|
|||||||
{isCompiling || isFinalizing ? "Status" : "Format"}
|
{isCompiling || isFinalizing ? "Status" : "Format"}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-slate-200 font-medium text-sm">
|
<div className="text-slate-200 font-medium text-sm">
|
||||||
{isCompiling || isFinalizing ? "Compiling..." : formatLabel}
|
{isFinalizing && exportFormat === "mp4"
|
||||||
|
? "Finalizing..."
|
||||||
|
: isCompiling || isFinalizing
|
||||||
|
? "Compiling..."
|
||||||
|
: formatLabel}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white/5 rounded-xl p-3 border border-white/5">
|
<div className="bg-white/5 rounded-xl p-3 border border-white/5">
|
||||||
|
|||||||
@@ -1,24 +1,10 @@
|
|||||||
import { HelpCircle, Settings2 } from "lucide-react";
|
import { HelpCircle, Settings2 } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useShortcuts } from "@/contexts/ShortcutsContext";
|
import { useShortcuts } from "@/contexts/ShortcutsContext";
|
||||||
import { formatBinding, SHORTCUT_ACTIONS, SHORTCUT_LABELS } from "@/lib/shortcuts";
|
import { FIXED_SHORTCUTS, formatBinding, SHORTCUT_ACTIONS, SHORTCUT_LABELS } from "@/lib/shortcuts";
|
||||||
import { formatShortcut } from "@/utils/platformUtils";
|
|
||||||
|
|
||||||
export function KeyboardShortcutsHelp() {
|
export function KeyboardShortcutsHelp() {
|
||||||
const { shortcuts, isMac, openConfig } = useShortcuts();
|
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 (
|
return (
|
||||||
<div className="relative group">
|
<div className="relative group">
|
||||||
<HelpCircle className="w-4 h-4 text-slate-500 hover:text-[#34B27B] transition-colors cursor-help" />
|
<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>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<div className="pt-1 border-t border-white/5 mt-1">
|
<div className="pt-1 border-t border-white/5 mt-1 space-y-1.5">
|
||||||
<div className="flex items-center justify-between">
|
{FIXED_SHORTCUTS.map((fixed) => (
|
||||||
<span className="text-slate-400">Pan Timeline</span>
|
<div key={fixed.label} className="flex items-center justify-between">
|
||||||
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">
|
<span className="text-slate-400">{fixed.label}</span>
|
||||||
{scrollLabels.pan}
|
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">
|
||||||
</kbd>
|
{isMac
|
||||||
</div>
|
? fixed.display
|
||||||
<div className="flex items-center justify-between mt-1.5">
|
.replace(/Ctrl/g, "⌘")
|
||||||
<span className="text-slate-400">Zoom Timeline</span>
|
.replace(/Shift/g, "⇧")
|
||||||
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">
|
.replace(/Alt/g, "⌥")
|
||||||
{scrollLabels.zoom}
|
: fixed.display}
|
||||||
</kbd>
|
</kbd>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,15 +6,17 @@ import {
|
|||||||
Film,
|
Film,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
Image,
|
Image,
|
||||||
|
Lock,
|
||||||
Palette,
|
Palette,
|
||||||
Save,
|
Save,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Star,
|
Star,
|
||||||
Trash2,
|
Trash2,
|
||||||
|
Unlock,
|
||||||
Upload,
|
Upload,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
Accordion,
|
Accordion,
|
||||||
@@ -31,6 +33,7 @@ import type { ExportFormat, ExportQuality, GifFrameRate, GifSizePreset } from "@
|
|||||||
import { GIF_FRAME_RATES, GIF_SIZE_PRESETS } from "@/lib/exporter";
|
import { GIF_FRAME_RATES, GIF_SIZE_PRESETS } from "@/lib/exporter";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { type AspectRatio } from "@/utils/aspectRatioUtils";
|
import { type AspectRatio } from "@/utils/aspectRatioUtils";
|
||||||
|
import { getTestId } from "@/utils/getTestId";
|
||||||
import { AnnotationSettingsPanel } from "./AnnotationSettingsPanel";
|
import { AnnotationSettingsPanel } from "./AnnotationSettingsPanel";
|
||||||
import { CropControl } from "./CropControl";
|
import { CropControl } from "./CropControl";
|
||||||
import { KeyboardShortcutsHelp } from "./KeyboardShortcutsHelp";
|
import { KeyboardShortcutsHelp } from "./KeyboardShortcutsHelp";
|
||||||
@@ -38,6 +41,7 @@ import type {
|
|||||||
AnnotationRegion,
|
AnnotationRegion,
|
||||||
AnnotationType,
|
AnnotationType,
|
||||||
CropRegion,
|
CropRegion,
|
||||||
|
FigureData,
|
||||||
PlaybackSpeed,
|
PlaybackSpeed,
|
||||||
ZoomDepth,
|
ZoomDepth,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
@@ -86,14 +90,18 @@ interface SettingsPanelProps {
|
|||||||
onTrimDelete?: (id: string) => void;
|
onTrimDelete?: (id: string) => void;
|
||||||
shadowIntensity?: number;
|
shadowIntensity?: number;
|
||||||
onShadowChange?: (intensity: number) => void;
|
onShadowChange?: (intensity: number) => void;
|
||||||
|
onShadowCommit?: () => void;
|
||||||
showBlur?: boolean;
|
showBlur?: boolean;
|
||||||
onBlurChange?: (showBlur: boolean) => void;
|
onBlurChange?: (showBlur: boolean) => void;
|
||||||
motionBlurEnabled?: boolean;
|
motionBlurAmount?: number;
|
||||||
onMotionBlurChange?: (enabled: boolean) => void;
|
onMotionBlurChange?: (amount: number) => void;
|
||||||
|
onMotionBlurCommit?: () => void;
|
||||||
borderRadius?: number;
|
borderRadius?: number;
|
||||||
onBorderRadiusChange?: (radius: number) => void;
|
onBorderRadiusChange?: (radius: number) => void;
|
||||||
|
onBorderRadiusCommit?: () => void;
|
||||||
padding?: number;
|
padding?: number;
|
||||||
onPaddingChange?: (padding: number) => void;
|
onPaddingChange?: (padding: number) => void;
|
||||||
|
onPaddingCommit?: () => void;
|
||||||
cropRegion?: CropRegion;
|
cropRegion?: CropRegion;
|
||||||
onCropChange?: (region: CropRegion) => void;
|
onCropChange?: (region: CropRegion) => void;
|
||||||
aspectRatio: AspectRatio;
|
aspectRatio: AspectRatio;
|
||||||
@@ -118,7 +126,7 @@ interface SettingsPanelProps {
|
|||||||
onAnnotationContentChange?: (id: string, content: string) => void;
|
onAnnotationContentChange?: (id: string, content: string) => void;
|
||||||
onAnnotationTypeChange?: (id: string, type: AnnotationType) => void;
|
onAnnotationTypeChange?: (id: string, type: AnnotationType) => void;
|
||||||
onAnnotationStyleChange?: (id: string, style: Partial<AnnotationRegion["style"]>) => 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;
|
onAnnotationDelete?: (id: string) => void;
|
||||||
selectedSpeedId?: string | null;
|
selectedSpeedId?: string | null;
|
||||||
selectedSpeedValue?: PlaybackSpeed | null;
|
selectedSpeedValue?: PlaybackSpeed | null;
|
||||||
@@ -148,14 +156,18 @@ export function SettingsPanel({
|
|||||||
onTrimDelete,
|
onTrimDelete,
|
||||||
shadowIntensity = 0,
|
shadowIntensity = 0,
|
||||||
onShadowChange,
|
onShadowChange,
|
||||||
|
onShadowCommit,
|
||||||
showBlur,
|
showBlur,
|
||||||
onBlurChange,
|
onBlurChange,
|
||||||
motionBlurEnabled = false,
|
motionBlurAmount = 0,
|
||||||
onMotionBlurChange,
|
onMotionBlurChange,
|
||||||
|
onMotionBlurCommit,
|
||||||
borderRadius = 0,
|
borderRadius = 0,
|
||||||
onBorderRadiusChange,
|
onBorderRadiusChange,
|
||||||
|
onBorderRadiusCommit,
|
||||||
padding = 50,
|
padding = 50,
|
||||||
onPaddingChange,
|
onPaddingChange,
|
||||||
|
onPaddingCommit,
|
||||||
cropRegion,
|
cropRegion,
|
||||||
onCropChange,
|
onCropChange,
|
||||||
aspectRatio,
|
aspectRatio,
|
||||||
@@ -196,7 +208,7 @@ export function SettingsPanel({
|
|||||||
try {
|
try {
|
||||||
const resolved = await Promise.all(WALLPAPER_RELATIVE.map((p) => getAssetPath(p)));
|
const resolved = await Promise.all(WALLPAPER_RELATIVE.map((p) => getAssetPath(p)));
|
||||||
if (mounted) setWallpaperPaths(resolved);
|
if (mounted) setWallpaperPaths(resolved);
|
||||||
} catch {
|
} catch (_err) {
|
||||||
if (mounted) setWallpaperPaths(WALLPAPER_RELATIVE.map((p) => `/${p}`));
|
if (mounted) setWallpaperPaths(WALLPAPER_RELATIVE.map((p) => `/${p}`));
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
@@ -225,7 +237,107 @@ export function SettingsPanel({
|
|||||||
|
|
||||||
const [selectedColor, setSelectedColor] = useState("#ADADAD");
|
const [selectedColor, setSelectedColor] = useState("#ADADAD");
|
||||||
const [gradient, setGradient] = useState<string>(GRADIENTS[0]);
|
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 zoomEnabled = Boolean(selectedZoomDepth);
|
||||||
const trimEnabled = Boolean(selectedTrimId);
|
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
|
// Find selected annotation
|
||||||
const selectedAnnotation = selectedAnnotationId
|
const selectedAnnotation = selectedAnnotationId
|
||||||
? annotationRegions.find((a) => a.id === selectedAnnotationId)
|
? annotationRegions.find((a) => a.id === selectedAnnotationId)
|
||||||
@@ -451,14 +577,6 @@ export function SettingsPanel({
|
|||||||
</AccordionTrigger>
|
</AccordionTrigger>
|
||||||
<AccordionContent className="pb-3">
|
<AccordionContent className="pb-3">
|
||||||
<div className="grid grid-cols-2 gap-2 mb-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="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>
|
<div className="text-[10px] font-medium text-slate-300">Blur BG</div>
|
||||||
<Switch
|
<Switch
|
||||||
@@ -470,6 +588,23 @@ export function SettingsPanel({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<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="p-2 rounded-lg bg-white/5 border border-white/5">
|
||||||
<div className="flex items-center justify-between mb-1">
|
<div className="flex items-center justify-between mb-1">
|
||||||
<div className="text-[10px] font-medium text-slate-300">Shadow</div>
|
<div className="text-[10px] font-medium text-slate-300">Shadow</div>
|
||||||
@@ -480,6 +615,7 @@ export function SettingsPanel({
|
|||||||
<Slider
|
<Slider
|
||||||
value={[shadowIntensity]}
|
value={[shadowIntensity]}
|
||||||
onValueChange={(values) => onShadowChange?.(values[0])}
|
onValueChange={(values) => onShadowChange?.(values[0])}
|
||||||
|
onValueCommit={() => onShadowCommit?.()}
|
||||||
min={0}
|
min={0}
|
||||||
max={1}
|
max={1}
|
||||||
step={0.01}
|
step={0.01}
|
||||||
@@ -494,6 +630,7 @@ export function SettingsPanel({
|
|||||||
<Slider
|
<Slider
|
||||||
value={[borderRadius]}
|
value={[borderRadius]}
|
||||||
onValueChange={(values) => onBorderRadiusChange?.(values[0])}
|
onValueChange={(values) => onBorderRadiusChange?.(values[0])}
|
||||||
|
onValueCommit={() => onBorderRadiusCommit?.()}
|
||||||
min={0}
|
min={0}
|
||||||
max={16}
|
max={16}
|
||||||
step={0.5}
|
step={0.5}
|
||||||
@@ -508,6 +645,7 @@ export function SettingsPanel({
|
|||||||
<Slider
|
<Slider
|
||||||
value={[padding]}
|
value={[padding]}
|
||||||
onValueChange={(values) => onPaddingChange?.(values[0])}
|
onValueChange={(values) => onPaddingChange?.(values[0])}
|
||||||
|
onValueCommit={() => onPaddingCommit?.()}
|
||||||
min={0}
|
min={0}
|
||||||
max={100}
|
max={100}
|
||||||
step={1}
|
step={1}
|
||||||
@@ -517,7 +655,7 @@ export function SettingsPanel({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setShowCropDropdown(!showCropDropdown)}
|
onClick={handleCropToggle}
|
||||||
variant="outline"
|
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"
|
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(/^\//, "");
|
s.replace(/^file:\/\//, "").replace(/^\//, "");
|
||||||
if (clean(selected).endsWith(clean(path))) return true;
|
if (clean(selected).endsWith(clean(path))) return true;
|
||||||
if (clean(path).endsWith(clean(selected))) return true;
|
if (clean(path).endsWith(clean(selected))) return true;
|
||||||
} catch {}
|
} catch {
|
||||||
|
// Best-effort comparison; fallback to strict match.
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
})();
|
})();
|
||||||
return (
|
return (
|
||||||
@@ -691,11 +831,11 @@ export function SettingsPanel({
|
|||||||
</Accordion>
|
</Accordion>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showCropDropdown && cropRegion && onCropChange && (
|
{showCropModal && cropRegion && onCropChange && (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 animate-in fade-in duration-200"
|
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="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">
|
<div className="flex items-center justify-between mb-6">
|
||||||
@@ -708,7 +848,7 @@ export function SettingsPanel({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => setShowCropDropdown(false)}
|
onClick={handleCropCancel}
|
||||||
className="hover:bg-white/10 text-slate-400 hover:text-white"
|
className="hover:bg-white/10 text-slate-400 hover:text-white"
|
||||||
>
|
>
|
||||||
<X className="w-5 h-5" />
|
<X className="w-5 h-5" />
|
||||||
@@ -720,14 +860,95 @@ export function SettingsPanel({
|
|||||||
onCropChange={onCropChange}
|
onCropChange={onCropChange}
|
||||||
aspectRatio={aspectRatio}
|
aspectRatio={aspectRatio}
|
||||||
/>
|
/>
|
||||||
<div className="mt-6 flex justify-end">
|
<div className="mt-6 space-y-4">
|
||||||
<Button
|
<div className="flex flex-wrap items-end gap-3">
|
||||||
onClick={() => setShowCropDropdown(false)}
|
{[
|
||||||
size="lg"
|
{ label: "X", field: "x" as const, max: videoWidth },
|
||||||
className="bg-[#34B27B] hover:bg-[#34B27B]/90 text-white"
|
{ label: "Y", field: "y" as const, max: videoHeight },
|
||||||
>
|
{ label: "W", field: "width" as const, max: videoWidth },
|
||||||
Done
|
{ label: "H", field: "height" as const, max: videoHeight },
|
||||||
</Button>
|
].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>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -748,6 +969,7 @@ export function SettingsPanel({
|
|||||||
MP4
|
MP4
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
data-testid={getTestId("gif-format-button")}
|
||||||
onClick={() => onExportFormatChange?.("gif")}
|
onClick={() => onExportFormatChange?.("gif")}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex-1 flex items-center justify-center gap-1.5 py-2 rounded-lg border transition-all text-xs font-medium",
|
"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]) => (
|
{Object.entries(GIF_SIZE_PRESETS).map(([key, _preset]) => (
|
||||||
<button
|
<button
|
||||||
key={key}
|
key={key}
|
||||||
|
data-testid={getTestId(`gif-size-button-${key}`)}
|
||||||
onClick={() => onGifSizePresetChange?.(key as GifSizePreset)}
|
onClick={() => onGifSizePresetChange?.(key as GifSizePreset)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-md transition-all text-[10px] font-medium",
|
"rounded-md transition-all text-[10px] font-medium",
|
||||||
@@ -873,6 +1096,7 @@ export function SettingsPanel({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
|
data-testid={getTestId("export-button")}
|
||||||
type="button"
|
type="button"
|
||||||
size="lg"
|
size="lg"
|
||||||
onClick={onExport}
|
onClick={onExport}
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ export function ShortcutsConfigDialog() {
|
|||||||
|
|
||||||
window.addEventListener("keydown", handleCapture, { capture: true });
|
window.addEventListener("keydown", handleCapture, { capture: true });
|
||||||
return () => window.removeEventListener("keydown", handleCapture, { capture: true });
|
return () => window.removeEventListener("keydown", handleCapture, { capture: true });
|
||||||
}, [captureFor]);
|
}, [captureFor, draft]);
|
||||||
|
|
||||||
const handleSwap = useCallback(() => {
|
const handleSwap = useCallback(() => {
|
||||||
if (!conflict || conflict.conflictWith.type !== "configurable") return;
|
if (!conflict || conflict.conflictWith.type !== "configurable") return;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,7 @@ import {
|
|||||||
Texture,
|
Texture,
|
||||||
VideoSource,
|
VideoSource,
|
||||||
} from "pixi.js";
|
} from "pixi.js";
|
||||||
|
import { MotionBlurFilter } from "pixi-filters/motion-blur";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import {
|
import {
|
||||||
forwardRef,
|
forwardRef,
|
||||||
@@ -18,7 +19,12 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { getAssetPath } from "@/lib/assetPath";
|
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 { AnnotationOverlay } from "./AnnotationOverlay";
|
||||||
import {
|
import {
|
||||||
type AnnotationRegion,
|
type AnnotationRegion,
|
||||||
@@ -29,17 +35,28 @@ import {
|
|||||||
type ZoomFocus,
|
type ZoomFocus,
|
||||||
type ZoomRegion,
|
type ZoomRegion,
|
||||||
} from "./types";
|
} 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 { clampFocusToStage as clampFocusToStageUtil } from "./videoPlayback/focusUtils";
|
||||||
import { layoutVideoContent as layoutVideoContentUtil } from "./videoPlayback/layoutUtils";
|
import { layoutVideoContent as layoutVideoContentUtil } from "./videoPlayback/layoutUtils";
|
||||||
import { clamp01 } from "./videoPlayback/mathUtils";
|
import { clamp01 } from "./videoPlayback/mathUtils";
|
||||||
import { updateOverlayIndicator } from "./videoPlayback/overlayUtils";
|
import { updateOverlayIndicator } from "./videoPlayback/overlayUtils";
|
||||||
import { createVideoEventHandlers } from "./videoPlayback/videoEventHandlers";
|
import { createVideoEventHandlers } from "./videoPlayback/videoEventHandlers";
|
||||||
import { findDominantRegion } from "./videoPlayback/zoomRegionUtils";
|
import { findDominantRegion } from "./videoPlayback/zoomRegionUtils";
|
||||||
import { applyZoomTransform } from "./videoPlayback/zoomTransform";
|
import {
|
||||||
|
applyZoomTransform,
|
||||||
|
computeFocusFromTransform,
|
||||||
|
computeZoomTransform,
|
||||||
|
createMotionBlurState,
|
||||||
|
type MotionBlurState,
|
||||||
|
} from "./videoPlayback/zoomTransform";
|
||||||
|
|
||||||
interface VideoPlaybackProps {
|
interface VideoPlaybackProps {
|
||||||
videoPath: string;
|
videoPath: string;
|
||||||
|
webcamVideoPath?: string;
|
||||||
onDurationChange: (duration: number) => void;
|
onDurationChange: (duration: number) => void;
|
||||||
onTimeUpdate: (time: number) => void;
|
onTimeUpdate: (time: number) => void;
|
||||||
currentTime: number;
|
currentTime: number;
|
||||||
@@ -50,11 +67,12 @@ interface VideoPlaybackProps {
|
|||||||
selectedZoomId: string | null;
|
selectedZoomId: string | null;
|
||||||
onSelectZoom: (id: string | null) => void;
|
onSelectZoom: (id: string | null) => void;
|
||||||
onZoomFocusChange: (id: string, focus: ZoomFocus) => void;
|
onZoomFocusChange: (id: string, focus: ZoomFocus) => void;
|
||||||
|
onZoomFocusDragEnd?: () => void;
|
||||||
isPlaying: boolean;
|
isPlaying: boolean;
|
||||||
showShadow?: boolean;
|
showShadow?: boolean;
|
||||||
shadowIntensity?: number;
|
shadowIntensity?: number;
|
||||||
showBlur?: boolean;
|
showBlur?: boolean;
|
||||||
motionBlurEnabled?: boolean;
|
motionBlurAmount?: number;
|
||||||
borderRadius?: number;
|
borderRadius?: number;
|
||||||
padding?: number;
|
padding?: number;
|
||||||
cropRegion?: import("./types").CropRegion;
|
cropRegion?: import("./types").CropRegion;
|
||||||
@@ -82,6 +100,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
|||||||
(
|
(
|
||||||
{
|
{
|
||||||
videoPath,
|
videoPath,
|
||||||
|
webcamVideoPath,
|
||||||
onDurationChange,
|
onDurationChange,
|
||||||
onTimeUpdate,
|
onTimeUpdate,
|
||||||
currentTime,
|
currentTime,
|
||||||
@@ -92,11 +111,12 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
|||||||
selectedZoomId,
|
selectedZoomId,
|
||||||
onSelectZoom,
|
onSelectZoom,
|
||||||
onZoomFocusChange,
|
onZoomFocusChange,
|
||||||
|
onZoomFocusDragEnd,
|
||||||
isPlaying,
|
isPlaying,
|
||||||
showShadow,
|
showShadow,
|
||||||
shadowIntensity = 0,
|
shadowIntensity = 0,
|
||||||
showBlur,
|
showBlur,
|
||||||
motionBlurEnabled = false,
|
motionBlurAmount = 0,
|
||||||
borderRadius = 0,
|
borderRadius = 0,
|
||||||
padding = 50,
|
padding = 50,
|
||||||
cropRegion,
|
cropRegion,
|
||||||
@@ -112,7 +132,9 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
|||||||
ref,
|
ref,
|
||||||
) => {
|
) => {
|
||||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||||
|
const webcamVideoRef = useRef<HTMLVideoElement | null>(null);
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const stageRef = useRef<HTMLDivElement | null>(null);
|
||||||
const appRef = useRef<Application | null>(null);
|
const appRef = useRef<Application | null>(null);
|
||||||
const videoSpriteRef = useRef<Sprite | null>(null);
|
const videoSpriteRef = useRef<Sprite | null>(null);
|
||||||
const videoContainerRef = useRef<Container | null>(null);
|
const videoContainerRef = useRef<Container | null>(null);
|
||||||
@@ -122,6 +144,11 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
|||||||
const [videoReady, setVideoReady] = useState(false);
|
const [videoReady, setVideoReady] = useState(false);
|
||||||
const overlayRef = useRef<HTMLDivElement | null>(null);
|
const overlayRef = useRef<HTMLDivElement | null>(null);
|
||||||
const focusIndicatorRef = 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 currentTimeRef = useRef(0);
|
||||||
const zoomRegionsRef = useRef<ZoomRegion[]>([]);
|
const zoomRegionsRef = useRef<ZoomRegion[]>([]);
|
||||||
const selectedZoomIdRef = useRef<string | null>(null);
|
const selectedZoomIdRef = useRef<string | null>(null);
|
||||||
@@ -129,8 +156,13 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
|||||||
scale: 1,
|
scale: 1,
|
||||||
focusX: DEFAULT_FOCUS.cx,
|
focusX: DEFAULT_FOCUS.cx,
|
||||||
focusY: DEFAULT_FOCUS.cy,
|
focusY: DEFAULT_FOCUS.cy,
|
||||||
|
progress: 0,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
appliedScale: 1,
|
||||||
});
|
});
|
||||||
const blurFilterRef = useRef<BlurFilter | null>(null);
|
const blurFilterRef = useRef<BlurFilter | null>(null);
|
||||||
|
const motionBlurFilterRef = useRef<MotionBlurFilter | null>(null);
|
||||||
const isDraggingFocusRef = useRef(false);
|
const isDraggingFocusRef = useRef(false);
|
||||||
const stageSizeRef = useRef({ width: 0, height: 0 });
|
const stageSizeRef = useRef({ width: 0, height: 0 });
|
||||||
const videoSizeRef = 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 layoutVideoContentRef = useRef<(() => void) | null>(null);
|
||||||
const trimRegionsRef = useRef<TrimRegion[]>([]);
|
const trimRegionsRef = useRef<TrimRegion[]>([]);
|
||||||
const speedRegionsRef = useRef<SpeedRegion[]>([]);
|
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 videoReadyRafRef = useRef<number | null>(null);
|
||||||
|
|
||||||
const clampFocusToStage = useCallback((focus: ZoomFocus, depth: ZoomDepth) => {
|
const clampFocusToStage = useCallback((focus: ZoomFocus, depth: ZoomDepth) => {
|
||||||
@@ -339,7 +374,10 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
|||||||
isDraggingFocusRef.current = false;
|
isDraggingFocusRef.current = false;
|
||||||
try {
|
try {
|
||||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||||
} catch {}
|
} catch {
|
||||||
|
// Pointer may already be released.
|
||||||
|
}
|
||||||
|
onZoomFocusDragEnd?.();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOverlayPointerUp = (event: React.PointerEvent<HTMLDivElement>) => {
|
const handleOverlayPointerUp = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||||
@@ -371,8 +409,16 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
|||||||
}, [speedRegions]);
|
}, [speedRegions]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
motionBlurEnabledRef.current = motionBlurEnabled;
|
motionBlurAmountRef.current = motionBlurAmount;
|
||||||
}, [motionBlurEnabled]);
|
}, [motionBlurAmount]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onTimeUpdateRef.current = onTimeUpdate;
|
||||||
|
}, [onTimeUpdate]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onPlayStateChangeRef.current = onPlayStateChange;
|
||||||
|
}, [onPlayStateChange]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!pixiReady || !videoReady) return;
|
if (!pixiReady || !videoReady) return;
|
||||||
@@ -397,8 +443,15 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
|||||||
scale: 1,
|
scale: 1,
|
||||||
focusX: DEFAULT_FOCUS.cx,
|
focusX: DEFAULT_FOCUS.cx,
|
||||||
focusY: DEFAULT_FOCUS.cy,
|
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) {
|
if (blurFilterRef.current) {
|
||||||
blurFilterRef.current.blur = 0;
|
blurFilterRef.current.blur = 0;
|
||||||
}
|
}
|
||||||
@@ -431,20 +484,22 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
|||||||
focusY: DEFAULT_FOCUS.cy,
|
focusY: DEFAULT_FOCUS.cy,
|
||||||
motionIntensity: 0,
|
motionIntensity: 0,
|
||||||
isPlaying: false,
|
isPlaying: false,
|
||||||
motionBlurEnabled: motionBlurEnabledRef.current,
|
motionBlurAmount: motionBlurAmountRef.current,
|
||||||
});
|
});
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
const finalApp = appRef.current;
|
const finalApp = appRef.current;
|
||||||
if (wasPlaying && video) {
|
if (wasPlaying && video) {
|
||||||
video.play().catch(() => {});
|
video.play().catch(() => {
|
||||||
|
// Ignore autoplay restoration failures.
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (tickerWasStarted && finalApp?.ticker) {
|
if (tickerWasStarted && finalApp?.ticker) {
|
||||||
finalApp.ticker.start();
|
finalApp.ticker.start();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}, [pixiReady, videoReady, layoutVideoContent, cropRegion]);
|
}, [pixiReady, videoReady, layoutVideoContent]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!pixiReady || !videoReady) return;
|
if (!pixiReady || !videoReady) return;
|
||||||
@@ -538,6 +593,11 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!videoPath) {
|
||||||
|
setVideoReady(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const video = videoRef.current;
|
const video = videoRef.current;
|
||||||
if (!video) return;
|
if (!video) return;
|
||||||
video.pause();
|
video.pause();
|
||||||
@@ -583,16 +643,22 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
|||||||
scale: 1,
|
scale: 1,
|
||||||
focusX: DEFAULT_FOCUS.cx,
|
focusX: DEFAULT_FOCUS.cx,
|
||||||
focusY: DEFAULT_FOCUS.cy,
|
focusY: DEFAULT_FOCUS.cy,
|
||||||
|
progress: 0,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
appliedScale: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
const blurFilter = new BlurFilter();
|
const blurFilter = new BlurFilter();
|
||||||
blurFilter.quality = 3;
|
blurFilter.quality = 3;
|
||||||
blurFilter.resolution = app.renderer.resolution;
|
blurFilter.resolution = app.renderer.resolution;
|
||||||
blurFilter.blur = 0;
|
blurFilter.blur = 0;
|
||||||
videoContainer.filters = [blurFilter];
|
const motionBlurFilter = new MotionBlurFilter([0, 0], 5, 0);
|
||||||
|
videoContainer.filters = [blurFilter, motionBlurFilter];
|
||||||
blurFilterRef.current = blurFilter;
|
blurFilterRef.current = blurFilter;
|
||||||
|
motionBlurFilterRef.current = motionBlurFilter;
|
||||||
|
|
||||||
layoutVideoContent();
|
layoutVideoContentRef.current?.();
|
||||||
video.pause();
|
video.pause();
|
||||||
|
|
||||||
const { handlePlay, handlePause, handleSeeked, handleSeeking } = createVideoEventHandlers({
|
const { handlePlay, handlePause, handleSeeked, handleSeeking } = createVideoEventHandlers({
|
||||||
@@ -602,8 +668,8 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
|||||||
allowPlaybackRef,
|
allowPlaybackRef,
|
||||||
currentTimeRef,
|
currentTimeRef,
|
||||||
timeUpdateAnimationRef,
|
timeUpdateAnimationRef,
|
||||||
onPlayStateChange,
|
onPlayStateChange: (playing) => onPlayStateChangeRef.current(playing),
|
||||||
onTimeUpdate,
|
onTimeUpdate: (time) => onTimeUpdateRef.current(time),
|
||||||
trimRegionsRef,
|
trimRegionsRef,
|
||||||
speedRegionsRef,
|
speedRegionsRef,
|
||||||
});
|
});
|
||||||
@@ -640,11 +706,15 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
|||||||
blurFilterRef.current.destroy();
|
blurFilterRef.current.destroy();
|
||||||
blurFilterRef.current = null;
|
blurFilterRef.current = null;
|
||||||
}
|
}
|
||||||
|
if (motionBlurFilterRef.current) {
|
||||||
|
motionBlurFilterRef.current.destroy();
|
||||||
|
motionBlurFilterRef.current = null;
|
||||||
|
}
|
||||||
videoTexture.destroy(true);
|
videoTexture.destroy(true);
|
||||||
|
|
||||||
videoSpriteRef.current = null;
|
videoSpriteRef.current = null;
|
||||||
};
|
};
|
||||||
}, [pixiReady, videoReady, onTimeUpdate, updateOverlayForRegion]);
|
}, [pixiReady, videoReady]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!pixiReady || !videoReady) return;
|
if (!pixiReady || !videoReady) return;
|
||||||
@@ -654,97 +724,154 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
|||||||
const videoContainer = videoContainerRef.current;
|
const videoContainer = videoContainerRef.current;
|
||||||
if (!app || !videoSprite || !videoContainer) return;
|
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;
|
const cameraContainer = cameraContainerRef.current;
|
||||||
if (!cameraContainer) return;
|
if (!cameraContainer) return;
|
||||||
|
|
||||||
const state = animationStateRef.current;
|
const state = animationStateRef.current;
|
||||||
|
|
||||||
applyZoomTransform({
|
const appliedTransform = applyZoomTransform({
|
||||||
cameraContainer,
|
cameraContainer,
|
||||||
blurFilter: blurFilterRef.current,
|
blurFilter: blurFilterRef.current,
|
||||||
|
motionBlurFilter: motionBlurFilterRef.current,
|
||||||
stageSize: stageSizeRef.current,
|
stageSize: stageSizeRef.current,
|
||||||
baseMask: baseMaskRef.current,
|
baseMask: baseMaskRef.current,
|
||||||
zoomScale: state.scale,
|
zoomScale: state.scale,
|
||||||
focusX: state.focusX,
|
zoomProgress: state.progress,
|
||||||
focusY: state.focusY,
|
focusX: targetFocus.cx,
|
||||||
|
focusY: targetFocus.cy,
|
||||||
motionIntensity,
|
motionIntensity,
|
||||||
|
motionVector,
|
||||||
isPlaying: isPlayingRef.current,
|
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 ticker = () => {
|
||||||
const { region, strength } = findDominantRegion(
|
const { region, strength, blendedScale, transition } = findDominantRegion(
|
||||||
zoomRegionsRef.current,
|
zoomRegionsRef.current,
|
||||||
currentTimeRef.current,
|
currentTimeRef.current,
|
||||||
|
{ connectZooms: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
const defaultFocus = DEFAULT_FOCUS;
|
const defaultFocus = DEFAULT_FOCUS;
|
||||||
let targetScaleFactor = 1;
|
let targetScaleFactor = 1;
|
||||||
let targetFocus = defaultFocus;
|
let targetFocus = defaultFocus;
|
||||||
|
let targetProgress = 0;
|
||||||
|
|
||||||
// If a zoom is selected but video is not playing, show default unzoomed view
|
// 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 selectedId = selectedZoomIdRef.current;
|
||||||
const hasSelectedZoom = selectedId !== null;
|
const hasSelectedZoom = selectedId !== null;
|
||||||
const shouldShowUnzoomedView = hasSelectedZoom && !isPlayingRef.current;
|
const shouldShowUnzoomedView = hasSelectedZoom && !isPlayingRef.current;
|
||||||
|
|
||||||
if (region && strength > 0 && !shouldShowUnzoomedView) {
|
if (region && strength > 0 && !shouldShowUnzoomedView) {
|
||||||
const zoomScale = ZOOM_DEPTH_SCALES[region.depth];
|
const zoomScale = blendedScale ?? ZOOM_DEPTH_SCALES[region.depth];
|
||||||
const regionFocus = clampFocusToStage(region.focus, region.depth);
|
const regionFocus = region.focus;
|
||||||
|
|
||||||
// Interpolate scale and focus based on region strength
|
targetScaleFactor = zoomScale;
|
||||||
targetScaleFactor = 1 + (zoomScale - 1) * strength;
|
targetFocus = regionFocus;
|
||||||
targetFocus = {
|
targetProgress = strength;
|
||||||
cx: defaultFocus.cx + (regionFocus.cx - defaultFocus.cx) * strength,
|
|
||||||
cy: defaultFocus.cy + (regionFocus.cy - defaultFocus.cy) * 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 state = animationStateRef.current;
|
||||||
|
const prevScale = state.appliedScale;
|
||||||
|
const prevX = state.x;
|
||||||
|
const prevY = state.y;
|
||||||
|
|
||||||
const prevScale = state.scale;
|
state.scale = targetScaleFactor;
|
||||||
const prevFocusX = state.focusX;
|
state.focusX = targetFocus.cx;
|
||||||
const prevFocusY = state.focusY;
|
state.focusY = targetFocus.cy;
|
||||||
|
state.progress = targetProgress;
|
||||||
|
|
||||||
const scaleDelta = targetScaleFactor - state.scale;
|
const projectedTransform = computeZoomTransform({
|
||||||
const focusXDelta = targetFocus.cx - state.focusX;
|
stageSize: stageSizeRef.current,
|
||||||
const focusYDelta = targetFocus.cy - state.focusY;
|
baseMask: baseMaskRef.current,
|
||||||
|
zoomScale: state.scale,
|
||||||
|
zoomProgress: state.progress,
|
||||||
|
focusX: state.focusX,
|
||||||
|
focusY: state.focusY,
|
||||||
|
});
|
||||||
|
|
||||||
let nextScale = prevScale;
|
const appliedScale =
|
||||||
let nextFocusX = prevFocusX;
|
Math.abs(projectedTransform.scale - prevScale) < ZOOM_SCALE_DEADZONE
|
||||||
let nextFocusY = prevFocusY;
|
? projectedTransform.scale
|
||||||
|
: projectedTransform.scale;
|
||||||
if (Math.abs(scaleDelta) > MIN_DELTA) {
|
const appliedX =
|
||||||
nextScale = prevScale + scaleDelta * SMOOTHING_FACTOR;
|
Math.abs(projectedTransform.x - prevX) < ZOOM_TRANSLATION_DEADZONE_PX
|
||||||
} else {
|
? projectedTransform.x
|
||||||
nextScale = targetScaleFactor;
|
: projectedTransform.x;
|
||||||
}
|
const appliedY =
|
||||||
|
Math.abs(projectedTransform.y - prevY) < ZOOM_TRANSLATION_DEADZONE_PX
|
||||||
if (Math.abs(focusXDelta) > MIN_DELTA) {
|
? projectedTransform.y
|
||||||
nextFocusX = prevFocusX + focusXDelta * SMOOTHING_FACTOR;
|
: projectedTransform.y;
|
||||||
} 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 motionIntensity = Math.max(
|
const motionIntensity = Math.max(
|
||||||
Math.abs(nextScale - prevScale),
|
Math.abs(appliedScale - prevScale),
|
||||||
Math.abs(nextFocusX - prevFocusX),
|
Math.abs(appliedX - prevX) / Math.max(1, stageSizeRef.current.width),
|
||||||
Math.abs(nextFocusY - prevFocusY),
|
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);
|
app.ticker.add(ticker);
|
||||||
@@ -753,7 +880,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
|||||||
app.ticker.remove(ticker);
|
app.ticker.remove(ticker);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [pixiReady, videoReady, clampFocusToStage]);
|
}, [pixiReady, videoReady]);
|
||||||
|
|
||||||
const handleLoadedMetadata = (e: React.SyntheticEvent<HTMLVideoElement, Event>) => {
|
const handleLoadedMetadata = (e: React.SyntheticEvent<HTMLVideoElement, Event>) => {
|
||||||
const video = e.currentTarget;
|
const video = e.currentTarget;
|
||||||
@@ -784,6 +911,96 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
|||||||
|
|
||||||
const [resolvedWallpaper, setResolvedWallpaper] = useState<string | null>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
let mounted = true;
|
let mounted = true;
|
||||||
(async () => {
|
(async () => {
|
||||||
@@ -827,7 +1044,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
|||||||
}
|
}
|
||||||
const p = await getAssetPath(wallpaper.replace(/^\//, ""));
|
const p = await getAssetPath(wallpaper.replace(/^\//, ""));
|
||||||
if (mounted) setResolvedWallpaper(p);
|
if (mounted) setResolvedWallpaper(p);
|
||||||
} catch {
|
} catch (_err) {
|
||||||
if (mounted) setResolvedWallpaper(wallpaper || "/wallpapers/wallpaper1.jpg");
|
if (mounted) setResolvedWallpaper(wallpaper || "/wallpapers/wallpaper1.jpg");
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
@@ -858,8 +1075,21 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
ref={stageRef}
|
||||||
className="relative rounded-sm overflow-hidden"
|
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 */}
|
{/* Background layer - always render as DOM element with blur */}
|
||||||
<div
|
<div
|
||||||
@@ -879,12 +1109,33 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
|||||||
: "none",
|
: "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 */}
|
{/* Only render overlay after PIXI and video are fully initialized */}
|
||||||
{pixiReady && videoReady && (
|
{pixiReady && videoReady && (
|
||||||
<div
|
<div
|
||||||
ref={overlayRef}
|
ref={overlayRef}
|
||||||
className="absolute inset-0 select-none"
|
className="absolute inset-0 select-none"
|
||||||
style={{ pointerEvents: "none" }}
|
style={{ pointerEvents: "none", zIndex: 30 }}
|
||||||
onPointerDown={handleOverlayPointerDown}
|
onPointerDown={handleOverlayPointerDown}
|
||||||
onPointerMove={handleOverlayPointerMove}
|
onPointerMove={handleOverlayPointerMove}
|
||||||
onPointerUp={handleOverlayPointerUp}
|
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 { 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 { ASPECT_RATIOS, type AspectRatio } from "@/utils/aspectRatioUtils";
|
||||||
import {
|
import {
|
||||||
type AnnotationRegion,
|
type AnnotationRegion,
|
||||||
@@ -22,13 +24,13 @@ export const WALLPAPER_PATHS = Array.from(
|
|||||||
(_, i) => `/wallpapers/wallpaper${i + 1}.jpg`,
|
(_, i) => `/wallpapers/wallpaper${i + 1}.jpg`,
|
||||||
);
|
);
|
||||||
|
|
||||||
export const PROJECT_VERSION = 1;
|
export const PROJECT_VERSION = 2;
|
||||||
|
|
||||||
export interface ProjectEditorState {
|
export interface ProjectEditorState {
|
||||||
wallpaper: string;
|
wallpaper: string;
|
||||||
shadowIntensity: number;
|
shadowIntensity: number;
|
||||||
showBlur: boolean;
|
showBlur: boolean;
|
||||||
motionBlurEnabled: boolean;
|
motionBlurAmount: number;
|
||||||
borderRadius: number;
|
borderRadius: number;
|
||||||
padding: number;
|
padding: number;
|
||||||
cropRegion: CropRegion;
|
cropRegion: CropRegion;
|
||||||
@@ -46,8 +48,9 @@ export interface ProjectEditorState {
|
|||||||
|
|
||||||
export interface EditorProjectData {
|
export interface EditorProjectData {
|
||||||
version: number;
|
version: number;
|
||||||
videoPath: string;
|
media?: ProjectMedia;
|
||||||
editor: ProjectEditorState;
|
editor: ProjectEditorState;
|
||||||
|
videoPath?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isFiniteNumber(value: unknown): value is number {
|
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));
|
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 {
|
export function toFileUrl(filePath: string): string {
|
||||||
const normalized = filePath.replace(/\\/g, "/");
|
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 {
|
export function fromFileUrl(fileUrl: string): string {
|
||||||
if (!fileUrl.startsWith("file://")) {
|
const value = fileUrl.trim();
|
||||||
|
if (!isFileUrl(value)) {
|
||||||
return fileUrl;
|
return fileUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const url = new URL(fileUrl);
|
const url = new URL(value);
|
||||||
return decodeURIComponent(url.pathname);
|
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 {
|
} 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;
|
if (!candidate || typeof candidate !== "object") return false;
|
||||||
const project = candidate as Partial<EditorProjectData>;
|
const project = candidate as Partial<EditorProjectData>;
|
||||||
if (typeof project.version !== "number") return false;
|
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;
|
if (!project.editor || typeof project.editor !== "object") return false;
|
||||||
return true;
|
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 {
|
export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): ProjectEditorState {
|
||||||
const validAspectRatios = new Set<AspectRatio>(ASPECT_RATIOS);
|
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],
|
wallpaper: typeof editor.wallpaper === "string" ? editor.wallpaper : WALLPAPER_PATHS[0],
|
||||||
shadowIntensity: typeof editor.shadowIntensity === "number" ? editor.shadowIntensity : 0,
|
shadowIntensity: typeof editor.shadowIntensity === "number" ? editor.shadowIntensity : 0,
|
||||||
showBlur: typeof editor.showBlur === "boolean" ? editor.showBlur : false,
|
showBlur: typeof editor.showBlur === "boolean" ? editor.showBlur : false,
|
||||||
motionBlurEnabled:
|
motionBlurAmount: isFiniteNumber(editor.motionBlurAmount)
|
||||||
typeof editor.motionBlurEnabled === "boolean" ? editor.motionBlurEnabled : false,
|
? 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,
|
borderRadius: typeof editor.borderRadius === "number" ? editor.borderRadius : 0,
|
||||||
padding: isFiniteNumber(editor.padding) ? clamp(editor.padding, 0, 100) : 50,
|
padding: isFiniteNumber(editor.padding) ? clamp(editor.padding, 0, 100) : 50,
|
||||||
cropRegion: {
|
cropRegion: {
|
||||||
@@ -295,12 +364,12 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createProjectData(
|
export function createProjectData(
|
||||||
videoPath: string,
|
media: ProjectMedia,
|
||||||
editor: ProjectEditorState,
|
editor: ProjectEditorState,
|
||||||
): EditorProjectData {
|
): EditorProjectData {
|
||||||
return {
|
return {
|
||||||
version: PROJECT_VERSION,
|
version: PROJECT_VERSION,
|
||||||
videoPath,
|
media,
|
||||||
editor,
|
editor,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -187,12 +187,12 @@ export default function TimelineWrapper({
|
|||||||
// Drag/resize tooltip (direct DOM updates, no re-renders)
|
// Drag/resize tooltip (direct DOM updates, no re-renders)
|
||||||
const tooltipRef = useRef<HTMLDivElement>(null);
|
const tooltipRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const formatTooltipMs = (ms: number) => {
|
const formatTooltipMs = useCallback((ms: number) => {
|
||||||
const s = ms / 1000;
|
const s = ms / 1000;
|
||||||
const min = Math.floor(s / 60);
|
const min = Math.floor(s / 60);
|
||||||
const sec = s % 60;
|
const sec = s % 60;
|
||||||
return min > 0 ? `${min}:${sec.toFixed(1).padStart(4, "0")}` : `${sec.toFixed(1)}s`;
|
return min > 0 ? `${min}:${sec.toFixed(1).padStart(4, "0")}` : `${sec.toFixed(1)}s`;
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const showTooltip = useCallback(
|
const showTooltip = useCallback(
|
||||||
(span: { start: number; end: number } | null, screenX?: number) => {
|
(span: { start: number; end: number } | null, screenX?: number) => {
|
||||||
@@ -213,7 +213,7 @@ export default function TimelineWrapper({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[],
|
[formatTooltipMs],
|
||||||
);
|
);
|
||||||
|
|
||||||
const onDragStart = useCallback(
|
const onDragStart = useCallback(
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import type { ZoomFocus } from "../types";
|
import type { ZoomFocus } from "../types";
|
||||||
|
|
||||||
export const DEFAULT_FOCUS: ZoomFocus = { cx: 0.5, cy: 0.5 };
|
export const DEFAULT_FOCUS: ZoomFocus = { cx: 0.5, cy: 0.5 };
|
||||||
export const TRANSITION_WINDOW_MS = 320;
|
export const TRANSITION_WINDOW_MS = 1015.05;
|
||||||
export const SMOOTHING_FACTOR = 0.12;
|
export const ZOOM_IN_TRANSITION_WINDOW_MS = TRANSITION_WINDOW_MS * 1.5;
|
||||||
export const MIN_DELTA = 0.0001;
|
export const MIN_DELTA = 0.0001;
|
||||||
export const VIEWPORT_SCALE = 0.8;
|
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;
|
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(
|
export function clampFocusToStage(
|
||||||
focus: ZoomFocus,
|
focus: ZoomFocus,
|
||||||
depth: ZoomDepth,
|
depth: ZoomDepth,
|
||||||
stageSize: StageSize,
|
_stageSize: StageSize,
|
||||||
): ZoomFocus {
|
): 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 baseFocus = clampFocusToDepth(focus, depth);
|
||||||
|
const bounds = getFocusBounds(depth);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
cx: Math.max(marginX, Math.min(1 - marginX, baseFocus.cx)),
|
cx: clamp(baseFocus.cx, bounds.minX, bounds.maxX),
|
||||||
cy: Math.max(marginY, Math.min(1 - marginY, baseFocus.cy)),
|
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));
|
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) {
|
export function smoothStep(t: number) {
|
||||||
const clamped = clamp01(t);
|
const clamped = clamp01(t);
|
||||||
return clamped * clamped * (3 - 2 * clamped);
|
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 type { ZoomFocus, ZoomRegion } from "../types";
|
||||||
import { TRANSITION_WINDOW_MS } from "./constants";
|
import { ZOOM_DEPTH_SCALES } from "../types";
|
||||||
import { smoothStep } from "./mathUtils";
|
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) {
|
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;
|
const leadOutEnd = region.endMs + TRANSITION_WINDOW_MS;
|
||||||
|
|
||||||
if (timeMs < leadInStart || timeMs > leadOutEnd) {
|
if (timeMs < leadInStart || timeMs > leadOutEnd) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fadeIn = smoothStep((timeMs - leadInStart) / TRANSITION_WINDOW_MS);
|
if (timeMs < zoomInEnd) {
|
||||||
const fadeOut = smoothStep((leadOutEnd - timeMs) / TRANSITION_WINDOW_MS);
|
const progress = (timeMs - leadInStart) / ZOOM_IN_TRANSITION_WINDOW_MS;
|
||||||
return Math.min(fadeIn, fadeOut);
|
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) {
|
function getLinearFocus(start: ZoomFocus, end: ZoomFocus, amount: number): ZoomFocus {
|
||||||
let bestRegion: ZoomRegion | null = null;
|
return {
|
||||||
let bestStrength = 0;
|
cx: lerp(start.cx, end.cx, amount),
|
||||||
|
cy: lerp(start.cy, end.cy, amount),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
for (const region of regions) {
|
function getResolvedFocus(region: ZoomRegion, zoomScale: number): ZoomFocus {
|
||||||
const strength = computeRegionStrength(region, timeMs);
|
return clampFocusToScale(region.focus, zoomScale);
|
||||||
if (strength > bestStrength) {
|
}
|
||||||
bestStrength = strength;
|
|
||||||
bestRegion = region;
|
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 { 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 {
|
interface TransformParams {
|
||||||
cameraContainer: Container;
|
cameraContainer: Container;
|
||||||
blurFilter: BlurFilter | null;
|
blurFilter: BlurFilter | null;
|
||||||
|
motionBlurFilter?: MotionBlurFilter | null;
|
||||||
stageSize: { width: number; height: number };
|
stageSize: { width: number; height: number };
|
||||||
baseMask: { x: number; y: number; width: number; height: number };
|
baseMask: { x: number; y: number; width: number; height: number };
|
||||||
zoomScale: number;
|
zoomScale: number;
|
||||||
|
zoomProgress?: number;
|
||||||
focusX: number;
|
focusX: number;
|
||||||
focusY: number;
|
focusY: number;
|
||||||
motionIntensity: number;
|
motionIntensity: number;
|
||||||
|
motionVector?: { x: number; y: number };
|
||||||
isPlaying: boolean;
|
isPlaying: boolean;
|
||||||
motionBlurEnabled?: boolean;
|
motionBlurAmount?: number;
|
||||||
|
transformOverride?: AppliedTransform;
|
||||||
|
motionBlurState?: MotionBlurState;
|
||||||
|
frameTimeMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function applyZoomTransform({
|
interface AppliedTransform {
|
||||||
cameraContainer,
|
scale: number;
|
||||||
blurFilter,
|
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,
|
stageSize,
|
||||||
baseMask,
|
baseMask,
|
||||||
zoomScale,
|
zoomScale,
|
||||||
|
zoomProgress = 1,
|
||||||
focusX,
|
focusX,
|
||||||
focusY,
|
focusY,
|
||||||
motionIntensity,
|
}: ZoomTransformGeometry): AppliedTransform {
|
||||||
isPlaying,
|
|
||||||
motionBlurEnabled = false,
|
|
||||||
}: TransformParams) {
|
|
||||||
if (
|
if (
|
||||||
stageSize.width <= 0 ||
|
stageSize.width <= 0 ||
|
||||||
stageSize.height <= 0 ||
|
stageSize.height <= 0 ||
|
||||||
baseMask.width <= 0 ||
|
baseMask.width <= 0 ||
|
||||||
baseMask.height <= 0
|
baseMask.height <= 0
|
||||||
) {
|
) {
|
||||||
return;
|
return { scale: 1, x: 0, y: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
// The focus point in stage coordinates (where the user clicked/selected)
|
const progress = Math.min(1, Math.max(0, zoomProgress));
|
||||||
const focusStagePxX = focusX * stageSize.width;
|
const focusStagePxX = baseMask.x + focusX * baseMask.width;
|
||||||
const focusStagePxY = focusY * stageSize.height;
|
const focusStagePxY = baseMask.y + focusY * baseMask.height;
|
||||||
|
|
||||||
// Stage center (where we want the focus to end up after zoom)
|
|
||||||
const stageCenterX = stageSize.width / 2;
|
const stageCenterX = stageSize.width / 2;
|
||||||
const stageCenterY = stageSize.height / 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
|
return {
|
||||||
cameraContainer.scale.set(zoomScale);
|
scale,
|
||||||
|
x: finalX * progress,
|
||||||
|
y: finalY * progress,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Calculate camera position to keep focus point centered
|
export function computeFocusFromTransform({
|
||||||
// After scaling, the focus point moves to (focusX * zoomScale, focusY * zoomScale)
|
stageSize,
|
||||||
// We want it at stage center, so offset = center - (focus * scale)
|
baseMask,
|
||||||
const cameraX = stageCenterX - focusStagePxX * zoomScale;
|
zoomScale,
|
||||||
const cameraY = stageCenterY - focusStagePxY * 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) {
|
if (!motionBlurState.initialized) {
|
||||||
const shouldBlur = motionBlurEnabled && isPlaying && motionIntensity > 0.0005;
|
motionBlurState.prevCamX = transform.x;
|
||||||
const motionBlur = shouldBlur ? Math.min(6, motionIntensity * 120) : 0;
|
motionBlurState.prevCamY = transform.y;
|
||||||
blurFilter.blur = motionBlur;
|
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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,9 @@ export function ShortcutsProvider({ children }: { children: ReactNode }) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getIsMac()
|
getIsMac()
|
||||||
.then(setIsMac)
|
.then(setIsMac)
|
||||||
.catch(() => {});
|
.catch(() => {
|
||||||
|
// Keep default non-mac fallback if detection fails.
|
||||||
|
});
|
||||||
|
|
||||||
window.electronAPI
|
window.electronAPI
|
||||||
.getShortcuts?.()
|
.getShortcuts?.()
|
||||||
@@ -45,7 +47,9 @@ export function ShortcutsProvider({ children }: { children: ReactNode }) {
|
|||||||
setShortcuts(mergeWithDefaults(saved as Partial<ShortcutsConfig>));
|
setShortcuts(mergeWithDefaults(saved as Partial<ShortcutsConfig>));
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {
|
||||||
|
// Keep default shortcuts if persisted settings can't be loaded.
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const persistShortcuts = useCallback(
|
const persistShortcuts = useCallback(
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
export interface AudioLevelMeterOptions {
|
export interface AudioLevelMeterOptions {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@@ -13,6 +13,22 @@ export function useAudioLevelMeter(options: AudioLevelMeterOptions) {
|
|||||||
const streamRef = useRef<MediaStream | null>(null);
|
const streamRef = useRef<MediaStream | null>(null);
|
||||||
const animationFrameRef = useRef<number | 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(() => {
|
useEffect(() => {
|
||||||
if (!options.enabled) {
|
if (!options.enabled) {
|
||||||
cleanup();
|
cleanup();
|
||||||
@@ -39,6 +55,9 @@ export function useAudioLevelMeter(options: AudioLevelMeterOptions) {
|
|||||||
streamRef.current = stream;
|
streamRef.current = stream;
|
||||||
|
|
||||||
const audioContext = new AudioContext();
|
const audioContext = new AudioContext();
|
||||||
|
if (audioContext.state === "suspended") {
|
||||||
|
await audioContext.resume();
|
||||||
|
}
|
||||||
audioContextRef.current = audioContext;
|
audioContextRef.current = audioContext;
|
||||||
|
|
||||||
const analyser = audioContext.createAnalyser();
|
const analyser = audioContext.createAnalyser();
|
||||||
@@ -82,23 +101,7 @@ export function useAudioLevelMeter(options: AudioLevelMeterOptions) {
|
|||||||
mounted = false;
|
mounted = false;
|
||||||
cleanup();
|
cleanup();
|
||||||
};
|
};
|
||||||
}, [options.enabled, options.deviceId, options.smoothingFactor]);
|
}, [options.enabled, options.deviceId, options.smoothingFactor, cleanup]);
|
||||||
|
|
||||||
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;
|
|
||||||
};
|
|
||||||
|
|
||||||
return { level };
|
return { level };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -69,7 +69,7 @@ export function useMicrophoneDevices(enabled: boolean = true) {
|
|||||||
mounted = false;
|
mounted = false;
|
||||||
navigator.mediaDevices.removeEventListener("devicechange", handleDeviceChange);
|
navigator.mediaDevices.removeEventListener("devicechange", handleDeviceChange);
|
||||||
};
|
};
|
||||||
}, [enabled]);
|
}, [enabled, selectedDeviceId]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
devices,
|
devices,
|
||||||
|
|||||||
+345
-111
@@ -1,8 +1,7 @@
|
|||||||
import { fixWebmDuration } from "@fix-webm-duration/fix";
|
import { fixWebmDuration } from "@fix-webm-duration/fix";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
// Target visually lossless 4K @ 60fps; fall back gracefully when hardware cannot keep up
|
|
||||||
const TARGET_FRAME_RATE = 60;
|
const TARGET_FRAME_RATE = 60;
|
||||||
const MIN_FRAME_RATE = 30;
|
const MIN_FRAME_RATE = 30;
|
||||||
const TARGET_WIDTH = 3840;
|
const TARGET_WIDTH = 3840;
|
||||||
@@ -12,18 +11,15 @@ const QHD_WIDTH = 2560;
|
|||||||
const QHD_HEIGHT = 1440;
|
const QHD_HEIGHT = 1440;
|
||||||
const QHD_PIXELS = QHD_WIDTH * QHD_HEIGHT;
|
const QHD_PIXELS = QHD_WIDTH * QHD_HEIGHT;
|
||||||
|
|
||||||
// Bitrates (bits per second) per resolution tier
|
|
||||||
const BITRATE_4K = 45_000_000;
|
const BITRATE_4K = 45_000_000;
|
||||||
const BITRATE_QHD = 28_000_000;
|
const BITRATE_QHD = 28_000_000;
|
||||||
const BITRATE_BASE = 18_000_000;
|
const BITRATE_BASE = 18_000_000;
|
||||||
const HIGH_FRAME_RATE_THRESHOLD = 60;
|
const HIGH_FRAME_RATE_THRESHOLD = 60;
|
||||||
const HIGH_FRAME_RATE_BOOST = 1.7;
|
const HIGH_FRAME_RATE_BOOST = 1.7;
|
||||||
|
|
||||||
// Fallback track settings when the driver reports nothing
|
|
||||||
const DEFAULT_WIDTH = 1920;
|
const DEFAULT_WIDTH = 1920;
|
||||||
const DEFAULT_HEIGHT = 1080;
|
const DEFAULT_HEIGHT = 1080;
|
||||||
|
|
||||||
// Codec alignment: VP9/AV1 require dimensions divisible by 2
|
|
||||||
const CODEC_ALIGNMENT = 2;
|
const CODEC_ALIGNMENT = 2;
|
||||||
|
|
||||||
const RECORDER_TIMESLICE_MS = 1000;
|
const RECORDER_TIMESLICE_MS = 1000;
|
||||||
@@ -31,36 +27,76 @@ const BITS_PER_MEGABIT = 1_000_000;
|
|||||||
const CHROME_MEDIA_SOURCE = "desktop";
|
const CHROME_MEDIA_SOURCE = "desktop";
|
||||||
const RECORDING_FILE_PREFIX = "recording-";
|
const RECORDING_FILE_PREFIX = "recording-";
|
||||||
const VIDEO_FILE_EXTENSION = ".webm";
|
const VIDEO_FILE_EXTENSION = ".webm";
|
||||||
|
const WEBCAM_FILE_SUFFIX = "-webcam";
|
||||||
|
|
||||||
const AUDIO_BITRATE_VOICE = 128_000;
|
const AUDIO_BITRATE_VOICE = 128_000;
|
||||||
const AUDIO_BITRATE_SYSTEM = 192_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 MIC_GAIN_BOOST = 1.4;
|
||||||
|
const WEBCAM_TARGET_WIDTH = 1280;
|
||||||
|
const WEBCAM_TARGET_HEIGHT = 720;
|
||||||
|
const WEBCAM_TARGET_FRAME_RATE = 30;
|
||||||
|
|
||||||
type UseScreenRecorderReturn = {
|
type UseScreenRecorderReturn = {
|
||||||
recording: boolean;
|
recording: boolean;
|
||||||
toggleRecording: () => void;
|
toggleRecording: () => void;
|
||||||
|
restartRecording: () => void;
|
||||||
microphoneEnabled: boolean;
|
microphoneEnabled: boolean;
|
||||||
setMicrophoneEnabled: (enabled: boolean) => void;
|
setMicrophoneEnabled: (enabled: boolean) => void;
|
||||||
microphoneDeviceId: string | undefined;
|
microphoneDeviceId: string | undefined;
|
||||||
setMicrophoneDeviceId: (deviceId: string | undefined) => void;
|
setMicrophoneDeviceId: (deviceId: string | undefined) => void;
|
||||||
systemAudioEnabled: boolean;
|
systemAudioEnabled: boolean;
|
||||||
setSystemAudioEnabled: (enabled: boolean) => void;
|
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 {
|
export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||||
const [recording, setRecording] = useState(false);
|
const [recording, setRecording] = useState(false);
|
||||||
const [microphoneEnabled, setMicrophoneEnabled] = useState(false);
|
const [microphoneEnabled, setMicrophoneEnabled] = useState(false);
|
||||||
const [microphoneDeviceId, setMicrophoneDeviceId] = useState<string | undefined>(undefined);
|
const [microphoneDeviceId, setMicrophoneDeviceId] = useState<string | undefined>(undefined);
|
||||||
const [systemAudioEnabled, setSystemAudioEnabled] = useState(false);
|
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 stream = useRef<MediaStream | null>(null);
|
||||||
const screenStream = useRef<MediaStream | null>(null);
|
const screenStream = useRef<MediaStream | null>(null);
|
||||||
const microphoneStream = useRef<MediaStream | null>(null);
|
const microphoneStream = useRef<MediaStream | null>(null);
|
||||||
|
const webcamStream = useRef<MediaStream | null>(null);
|
||||||
const mixingContext = useRef<AudioContext | null>(null);
|
const mixingContext = useRef<AudioContext | null>(null);
|
||||||
const chunks = useRef<Blob[]>([]);
|
|
||||||
const startTime = useRef<number>(0);
|
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 selectMimeType = () => {
|
||||||
const preferred = [
|
const preferred = [
|
||||||
@@ -90,27 +126,180 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
|||||||
return Math.round(BITRATE_BASE * highFrameRateBoost);
|
return Math.round(BITRATE_BASE * highFrameRateBoost);
|
||||||
};
|
};
|
||||||
|
|
||||||
const stopRecording = useRef(() => {
|
const teardownMedia = useCallback(() => {
|
||||||
if (mediaRecorder.current?.state === "recording") {
|
if (stream.current) {
|
||||||
if (stream.current) {
|
stream.current.getTracks().forEach((track) => track.stop());
|
||||||
stream.current.getTracks().forEach((track) => track.stop());
|
stream.current = null;
|
||||||
}
|
}
|
||||||
if (screenStream.current) {
|
if (screenStream.current) {
|
||||||
screenStream.current.getTracks().forEach((track) => track.stop());
|
screenStream.current.getTracks().forEach((track) => track.stop());
|
||||||
screenStream.current = null;
|
screenStream.current = null;
|
||||||
}
|
}
|
||||||
if (microphoneStream.current) {
|
if (microphoneStream.current) {
|
||||||
microphoneStream.current.getTracks().forEach((track) => track.stop());
|
microphoneStream.current.getTracks().forEach((track) => track.stop());
|
||||||
microphoneStream.current = null;
|
microphoneStream.current = null;
|
||||||
}
|
}
|
||||||
if (mixingContext.current) {
|
if (webcamStream.current) {
|
||||||
mixingContext.current.close().catch(() => {});
|
webcamStream.current.getTracks().forEach((track) => track.stop());
|
||||||
mixingContext.current = null;
|
webcamStream.current = null;
|
||||||
}
|
}
|
||||||
mediaRecorder.current.stop();
|
if (mixingContext.current) {
|
||||||
setRecording(false);
|
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);
|
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 () => {
|
return () => {
|
||||||
if (cleanup) cleanup();
|
if (cleanup) cleanup();
|
||||||
|
allowAutoFinalize.current = false;
|
||||||
|
restarting.current = false;
|
||||||
|
discardRecordingId.current = null;
|
||||||
|
|
||||||
if (mediaRecorder.current?.state === "recording") {
|
if (screenRecorder.current?.recorder.state === "recording") {
|
||||||
mediaRecorder.current.stop();
|
try {
|
||||||
|
screenRecorder.current.recorder.stop();
|
||||||
|
} catch {
|
||||||
|
// Ignore recorder teardown errors during cleanup.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (stream.current) {
|
if (webcamRecorder.current?.recorder.state === "recording") {
|
||||||
stream.current.getTracks().forEach((track) => track.stop());
|
try {
|
||||||
stream.current = null;
|
webcamRecorder.current.recorder.stop();
|
||||||
}
|
} catch {
|
||||||
if (screenStream.current) {
|
// Ignore recorder teardown errors during cleanup.
|
||||||
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();
|
||||||
};
|
};
|
||||||
}, []);
|
}, [teardownMedia]);
|
||||||
|
|
||||||
const startRecording = async () => {
|
const startRecording = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -171,7 +361,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
|||||||
|
|
||||||
if (systemAudioEnabled) {
|
if (systemAudioEnabled) {
|
||||||
try {
|
try {
|
||||||
screenMediaStream = await (navigator.mediaDevices as any).getUserMedia({
|
screenMediaStream = await navigator.mediaDevices.getUserMedia({
|
||||||
audio: {
|
audio: {
|
||||||
mandatory: {
|
mandatory: {
|
||||||
chromeMediaSource: CHROME_MEDIA_SOURCE,
|
chromeMediaSource: CHROME_MEDIA_SOURCE,
|
||||||
@@ -179,24 +369,23 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
video: videoConstraints,
|
video: videoConstraints,
|
||||||
});
|
} as unknown as MediaStreamConstraints);
|
||||||
} catch (audioErr) {
|
} catch (audioErr) {
|
||||||
console.warn("System audio capture failed, falling back to video-only:", audioErr);
|
console.warn("System audio capture failed, falling back to video-only:", audioErr);
|
||||||
toast.error("System audio not available. Recording without system audio.");
|
toast.error("System audio not available. Recording without system audio.");
|
||||||
screenMediaStream = await (navigator.mediaDevices as any).getUserMedia({
|
screenMediaStream = await navigator.mediaDevices.getUserMedia({
|
||||||
audio: false,
|
audio: false,
|
||||||
video: videoConstraints,
|
video: videoConstraints,
|
||||||
});
|
} as unknown as MediaStreamConstraints);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
screenMediaStream = await (navigator.mediaDevices as any).getUserMedia({
|
screenMediaStream = await navigator.mediaDevices.getUserMedia({
|
||||||
audio: false,
|
audio: false,
|
||||||
video: videoConstraints,
|
video: videoConstraints,
|
||||||
});
|
} as unknown as MediaStreamConstraints);
|
||||||
}
|
}
|
||||||
screenStream.current = screenMediaStream;
|
screenStream.current = screenMediaStream;
|
||||||
|
|
||||||
// If microphone is enabled, request mic stream
|
|
||||||
if (microphoneEnabled) {
|
if (microphoneEnabled) {
|
||||||
try {
|
try {
|
||||||
microphoneStream.current = await navigator.mediaDevices.getUserMedia({
|
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();
|
stream.current = new MediaStream();
|
||||||
const videoTrack = screenMediaStream.getVideoTracks()[0];
|
const videoTrack = screenMediaStream.getVideoTracks()[0];
|
||||||
if (!videoTrack) {
|
if (!videoTrack) {
|
||||||
@@ -233,7 +442,6 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
|||||||
const micAudioTrack = microphoneStream.current?.getAudioTracks()[0];
|
const micAudioTrack = microphoneStream.current?.getAudioTracks()[0];
|
||||||
|
|
||||||
if (systemAudioTrack && micAudioTrack) {
|
if (systemAudioTrack && micAudioTrack) {
|
||||||
// Mix system audio + mic using Web Audio API
|
|
||||||
const ctx = new AudioContext();
|
const ctx = new AudioContext();
|
||||||
mixingContext.current = ctx;
|
mixingContext.current = ctx;
|
||||||
const systemSource = ctx.createMediaStreamSource(new MediaStream([systemAudioTrack]));
|
const systemSource = ctx.createMediaStreamSource(new MediaStream([systemAudioTrack]));
|
||||||
@@ -249,6 +457,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
|||||||
} else if (micAudioTrack) {
|
} else if (micAudioTrack) {
|
||||||
stream.current.addTrack(micAudioTrack);
|
stream.current.addTrack(micAudioTrack);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await videoTrack.applyConstraints({
|
await videoTrack.applyConstraints({
|
||||||
frameRate: { ideal: TARGET_FRAME_RATE, max: TARGET_FRAME_RATE },
|
frameRate: { ideal: TARGET_FRAME_RATE, max: TARGET_FRAME_RATE },
|
||||||
@@ -268,7 +477,6 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
|||||||
frameRate = TARGET_FRAME_RATE,
|
frameRate = TARGET_FRAME_RATE,
|
||||||
} = videoTrack.getSettings();
|
} = videoTrack.getSettings();
|
||||||
|
|
||||||
// Ensure dimensions are divisible by 2 for VP9/AV1 codec compatibility
|
|
||||||
width = Math.floor(width / CODEC_ALIGNMENT) * CODEC_ALIGNMENT;
|
width = Math.floor(width / CODEC_ALIGNMENT) * CODEC_ALIGNMENT;
|
||||||
height = Math.floor(height / 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;
|
const hasAudio = stream.current.getAudioTracks().length > 0;
|
||||||
|
screenRecorder.current = createRecorderHandle(stream.current, {
|
||||||
chunks.current = [];
|
|
||||||
const recorder = new MediaRecorder(stream.current, {
|
|
||||||
mimeType,
|
mimeType,
|
||||||
videoBitsPerSecond,
|
videoBitsPerSecond,
|
||||||
...(hasAudio
|
...(hasAudio
|
||||||
? { audioBitsPerSecond: systemAudioTrack ? AUDIO_BITRATE_SYSTEM : AUDIO_BITRATE_VOICE }
|
? { audioBitsPerSecond: systemAudioTrack ? AUDIO_BITRATE_SYSTEM : AUDIO_BITRATE_VOICE }
|
||||||
: {}),
|
: {}),
|
||||||
});
|
});
|
||||||
mediaRecorder.current = recorder;
|
screenRecorder.current.recorder.addEventListener(
|
||||||
recorder.ondataavailable = (e) => {
|
"error",
|
||||||
if (e.data && e.data.size > 0) chunks.current.push(e.data);
|
() => {
|
||||||
};
|
setRecording(false);
|
||||||
recorder.onstop = async () => {
|
},
|
||||||
stream.current = null;
|
{ once: true },
|
||||||
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}`;
|
|
||||||
|
|
||||||
try {
|
if (webcamStream.current) {
|
||||||
const videoBlob = await fixWebmDuration(buggyBlob, duration);
|
webcamRecorder.current = createRecorderHandle(webcamStream.current, {
|
||||||
const arrayBuffer = await videoBlob.arrayBuffer();
|
mimeType,
|
||||||
const videoResult = await window.electronAPI.storeRecordedVideo(
|
videoBitsPerSecond: Math.min(videoBitsPerSecond, BITRATE_BASE),
|
||||||
arrayBuffer,
|
});
|
||||||
videoFileName,
|
}
|
||||||
);
|
|
||||||
if (!videoResult.success) {
|
|
||||||
console.error("Failed to store video:", videoResult.message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (videoResult.path) {
|
recordingId.current = Date.now();
|
||||||
await window.electronAPI.setCurrentVideoPath(videoResult.path);
|
startTime.current = recordingId.current;
|
||||||
}
|
allowAutoFinalize.current = true;
|
||||||
|
|
||||||
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();
|
|
||||||
setRecording(true);
|
setRecording(true);
|
||||||
window.electronAPI?.setRecordingState(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) {
|
} catch (error) {
|
||||||
console.error("Failed to start recording:", error);
|
console.error("Failed to start recording:", error);
|
||||||
const errorMsg = error instanceof Error ? error.message : "Failed to start recording";
|
const errorMsg = error instanceof Error ? error.message : "Failed to start recording";
|
||||||
@@ -341,22 +547,9 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
|||||||
toast.error(errorMsg);
|
toast.error(errorMsg);
|
||||||
}
|
}
|
||||||
setRecording(false);
|
setRecording(false);
|
||||||
if (stream.current) {
|
screenRecorder.current = null;
|
||||||
stream.current.getTracks().forEach((track) => track.stop());
|
webcamRecorder.current = null;
|
||||||
stream.current = null;
|
teardownMedia();
|
||||||
}
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -364,14 +557,55 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
|||||||
recording ? stopRecording.current() : startRecording();
|
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 {
|
return {
|
||||||
recording,
|
recording,
|
||||||
toggleRecording,
|
toggleRecording,
|
||||||
|
restartRecording,
|
||||||
microphoneEnabled,
|
microphoneEnabled,
|
||||||
setMicrophoneEnabled,
|
setMicrophoneEnabled,
|
||||||
microphoneDeviceId,
|
microphoneDeviceId,
|
||||||
setMicrophoneDeviceId,
|
setMicrophoneDeviceId,
|
||||||
systemAudioEnabled,
|
systemAudioEnabled,
|
||||||
setSystemAudioEnabled,
|
setSystemAudioEnabled,
|
||||||
|
webcamEnabled,
|
||||||
|
setWebcamEnabled,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-9
@@ -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> {
|
export async function getAssetPath(relativePath: string): Promise<string> {
|
||||||
|
const encodedRelativePath = encodeRelativeAssetPath(relativePath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
// If running in a dev server (http/https), prefer the web-served path
|
// 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 &&
|
||||||
window.location.protocol.startsWith("http")
|
window.location.protocol.startsWith("http")
|
||||||
) {
|
) {
|
||||||
return `/${relativePath.replace(/^\//, "")}`;
|
return `/${encodedRelativePath}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (window.electronAPI && typeof window.electronAPI.getAssetBasePath === "function") {
|
||||||
(window as any).electronAPI &&
|
const base = await window.electronAPI.getAssetBasePath();
|
||||||
typeof (window as any).electronAPI.getAssetBasePath === "function"
|
|
||||||
) {
|
|
||||||
const base = await (window as any).electronAPI.getAssetBasePath();
|
|
||||||
if (base) {
|
if (base) {
|
||||||
const normalized = base.replace(/\\/g, "/");
|
return new URL(encodedRelativePath, ensureTrailingSlash(base)).toString();
|
||||||
return `file://${normalized}/${relativePath}`;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -26,7 +37,7 @@ export async function getAssetPath(relativePath: string): Promise<string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fallback for web/dev server: public/wallpapers are served at '/wallpapers/...'
|
// Fallback for web/dev server: public/wallpapers are served at '/wallpapers/...'
|
||||||
return `/${relativePath.replace(/^\//, "")}`;
|
return `/${encodedRelativePath}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default getAssetPath;
|
export default getAssetPath;
|
||||||
|
|||||||
@@ -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 = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +1,58 @@
|
|||||||
import type { WebDemuxer } from "web-demuxer";
|
import { WebDemuxer } from "web-demuxer";
|
||||||
import type { TrimRegion } from "@/components/video-editor/types";
|
import type { SpeedRegion, TrimRegion } from "@/components/video-editor/types";
|
||||||
import type { VideoMuxer } from "./muxer";
|
import type { VideoMuxer } from "./muxer";
|
||||||
|
|
||||||
const AUDIO_BITRATE = 128_000;
|
const AUDIO_BITRATE = 128_000;
|
||||||
const DECODE_BACKPRESSURE_LIMIT = 20;
|
const DECODE_BACKPRESSURE_LIMIT = 20;
|
||||||
|
const MIN_SPEED_REGION_DELTA_MS = 0.0001;
|
||||||
|
|
||||||
export class AudioProcessor {
|
export class AudioProcessor {
|
||||||
private cancelled = false;
|
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;
|
let audioConfig: AudioDecoderConfig;
|
||||||
try {
|
try {
|
||||||
audioConfig = (await demuxer.getDecoderConfig("audio")) as AudioDecoderConfig;
|
audioConfig = (await demuxer.getDecoderConfig("audio")) as AudioDecoderConfig;
|
||||||
@@ -23,8 +67,6 @@ export class AudioProcessor {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sortedTrims = trimRegions ? [...trimRegions].sort((a, b) => a.startMs - b.startMs) : [];
|
|
||||||
|
|
||||||
// Phase 1: Decode audio from source, skipping trimmed regions
|
// Phase 1: Decode audio from source, skipping trimmed regions
|
||||||
const decodedFrames: AudioData[] = [];
|
const decodedFrames: AudioData[] = [];
|
||||||
|
|
||||||
@@ -34,19 +76,36 @@ export class AudioProcessor {
|
|||||||
});
|
});
|
||||||
decoder.configure(audioConfig);
|
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) {
|
try {
|
||||||
const { done, value: chunk } = await reader.read();
|
while (!this.cancelled) {
|
||||||
if (done || !chunk) break;
|
const { done, value: chunk } = await reader.read();
|
||||||
|
if (done || !chunk) break;
|
||||||
|
|
||||||
const timestampMs = chunk.timestamp / 1000;
|
const timestampMs = chunk.timestamp / 1000;
|
||||||
if (this.isInTrimRegion(timestampMs, sortedTrims)) continue;
|
if (this.isInTrimRegion(timestampMs, sortedTrims)) continue;
|
||||||
|
|
||||||
decoder.decode(chunk);
|
decoder.decode(chunk);
|
||||||
|
|
||||||
while (decoder.decodeQueueSize > DECODE_BACKPRESSURE_LIMIT && !this.cancelled) {
|
while (decoder.decodeQueueSize > DECODE_BACKPRESSURE_LIMIT && !this.cancelled) {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
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) {
|
if (this.cancelled || decodedFrames.length === 0) {
|
||||||
for (const f of decodedFrames) f.close();
|
for (const frame of decodedFrames) frame.close();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,7 +142,7 @@ export class AudioProcessor {
|
|||||||
const encodeSupport = await AudioEncoder.isConfigSupported(encodeConfig);
|
const encodeSupport = await AudioEncoder.isConfigSupported(encodeConfig);
|
||||||
if (!encodeSupport.supported) {
|
if (!encodeSupport.supported) {
|
||||||
console.warn("[AudioProcessor] Opus encoding not supported, skipping audio");
|
console.warn("[AudioProcessor] Opus encoding not supported, skipping audio");
|
||||||
for (const f of decodedFrames) f.close();
|
for (const frame of decodedFrames) frame.close();
|
||||||
return;
|
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 {
|
private cloneWithTimestamp(src: AudioData, newTimestamp: number): AudioData {
|
||||||
const isPlanar = src.format?.includes("planar") ?? false;
|
const isPlanar = src.format?.includes("planar") ?? false;
|
||||||
const numPlanes = isPlanar ? src.numberOfChannels : 1;
|
const numPlanes = isPlanar ? src.numberOfChannels : 1;
|
||||||
|
|
||||||
let totalSize = 0;
|
let totalSize = 0;
|
||||||
for (let p = 0; p < numPlanes; p++) {
|
for (let planeIndex = 0; planeIndex < numPlanes; planeIndex++) {
|
||||||
totalSize += src.allocationSize({ planeIndex: p });
|
totalSize += src.allocationSize({ planeIndex });
|
||||||
}
|
}
|
||||||
|
|
||||||
const buffer = new ArrayBuffer(totalSize);
|
const buffer = new ArrayBuffer(totalSize);
|
||||||
let offset = 0;
|
let offset = 0;
|
||||||
for (let p = 0; p < numPlanes; p++) {
|
for (let planeIndex = 0; planeIndex < numPlanes; planeIndex++) {
|
||||||
const planeSize = src.allocationSize({ planeIndex: p });
|
const planeSize = src.allocationSize({ planeIndex });
|
||||||
src.copyTo(new Uint8Array(buffer, offset, planeSize), { planeIndex: p });
|
src.copyTo(new Uint8Array(buffer, offset, planeSize), { planeIndex });
|
||||||
offset += planeSize;
|
offset += planeSize;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,7 +486,7 @@ export class AudioProcessor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private isInTrimRegion(timestampMs: number, trims: TrimRegion[]): boolean {
|
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 {
|
private computeTrimOffset(timestampMs: number, trims: TrimRegion[]): number {
|
||||||
|
|||||||
@@ -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 {
|
import type {
|
||||||
AnnotationRegion,
|
AnnotationRegion,
|
||||||
CropRegion,
|
CropRegion,
|
||||||
SpeedRegion,
|
SpeedRegion,
|
||||||
|
ZoomDepth,
|
||||||
ZoomRegion,
|
ZoomRegion,
|
||||||
} from "@/components/video-editor/types";
|
} from "@/components/video-editor/types";
|
||||||
import { ZOOM_DEPTH_SCALES } from "@/components/video-editor/types";
|
import { ZOOM_DEPTH_SCALES } from "@/components/video-editor/types";
|
||||||
import {
|
import {
|
||||||
DEFAULT_FOCUS,
|
DEFAULT_FOCUS,
|
||||||
MIN_DELTA,
|
ZOOM_SCALE_DEADZONE,
|
||||||
SMOOTHING_FACTOR,
|
ZOOM_TRANSLATION_DEADZONE_PX,
|
||||||
} from "@/components/video-editor/videoPlayback/constants";
|
} from "@/components/video-editor/videoPlayback/constants";
|
||||||
import { clampFocusToStage as clampFocusToStageUtil } from "@/components/video-editor/videoPlayback/focusUtils";
|
import { clampFocusToStage as clampFocusToStageUtil } from "@/components/video-editor/videoPlayback/focusUtils";
|
||||||
import { findDominantRegion } from "@/components/video-editor/videoPlayback/zoomRegionUtils";
|
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";
|
import { renderAnnotations } from "./annotationRenderer";
|
||||||
|
|
||||||
interface FrameRenderConfig {
|
interface FrameRenderConfig {
|
||||||
@@ -24,12 +41,14 @@ interface FrameRenderConfig {
|
|||||||
showShadow: boolean;
|
showShadow: boolean;
|
||||||
shadowIntensity: number;
|
shadowIntensity: number;
|
||||||
showBlur: boolean;
|
showBlur: boolean;
|
||||||
motionBlurEnabled?: boolean;
|
motionBlurAmount?: number;
|
||||||
borderRadius?: number;
|
borderRadius?: number;
|
||||||
padding?: number;
|
padding?: number;
|
||||||
cropRegion: CropRegion;
|
cropRegion: CropRegion;
|
||||||
videoWidth: number;
|
videoWidth: number;
|
||||||
videoHeight: number;
|
videoHeight: number;
|
||||||
|
webcamWidth?: number;
|
||||||
|
webcamHeight?: number;
|
||||||
annotationRegions?: AnnotationRegion[];
|
annotationRegions?: AnnotationRegion[];
|
||||||
speedRegions?: SpeedRegion[];
|
speedRegions?: SpeedRegion[];
|
||||||
previewWidth?: number;
|
previewWidth?: number;
|
||||||
@@ -40,6 +59,18 @@ interface AnimationState {
|
|||||||
scale: number;
|
scale: number;
|
||||||
focusX: number;
|
focusX: number;
|
||||||
focusY: 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.
|
// 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 cameraContainer: Container | null = null;
|
||||||
private videoContainer: Container | null = null;
|
private videoContainer: Container | null = null;
|
||||||
private videoSprite: Sprite | null = null;
|
private videoSprite: Sprite | null = null;
|
||||||
private backgroundSprite: Sprite | null = null;
|
private backgroundSprite: HTMLCanvasElement | null = null;
|
||||||
private maskGraphics: Graphics | null = null;
|
private maskGraphics: Graphics | null = null;
|
||||||
private blurFilter: BlurFilter | null = null;
|
private blurFilter: BlurFilter | null = null;
|
||||||
|
private motionBlurFilter: MotionBlurFilter | null = null;
|
||||||
private shadowCanvas: HTMLCanvasElement | null = null;
|
private shadowCanvas: HTMLCanvasElement | null = null;
|
||||||
private shadowCtx: CanvasRenderingContext2D | null = null;
|
private shadowCtx: CanvasRenderingContext2D | null = null;
|
||||||
private compositeCanvas: HTMLCanvasElement | null = null;
|
private compositeCanvas: HTMLCanvasElement | null = null;
|
||||||
private compositeCtx: CanvasRenderingContext2D | null = null;
|
private compositeCtx: CanvasRenderingContext2D | null = null;
|
||||||
private config: FrameRenderConfig;
|
private config: FrameRenderConfig;
|
||||||
private animationState: AnimationState;
|
private animationState: AnimationState;
|
||||||
private layoutCache: any = null;
|
private layoutCache: LayoutCache | null = null;
|
||||||
private currentVideoTime = 0;
|
private currentVideoTime = 0;
|
||||||
|
private motionBlurState: MotionBlurState = createMotionBlurState();
|
||||||
|
|
||||||
constructor(config: FrameRenderConfig) {
|
constructor(config: FrameRenderConfig) {
|
||||||
this.config = config;
|
this.config = config;
|
||||||
@@ -67,6 +100,10 @@ export class FrameRenderer {
|
|||||||
scale: 1,
|
scale: 1,
|
||||||
focusX: DEFAULT_FOCUS.cx,
|
focusX: DEFAULT_FOCUS.cx,
|
||||||
focusY: DEFAULT_FOCUS.cy,
|
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.quality = 5;
|
||||||
this.blurFilter.resolution = this.app.renderer.resolution;
|
this.blurFilter.resolution = this.app.renderer.resolution;
|
||||||
this.blurFilter.blur = 0;
|
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
|
// Setup composite canvas for final output with shadows
|
||||||
this.compositeCanvas = document.createElement("canvas");
|
this.compositeCanvas = document.createElement("canvas");
|
||||||
@@ -263,10 +301,14 @@ export class FrameRenderer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Store the background canvas for compositing
|
// 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) {
|
if (!this.app || !this.videoContainer || !this.cameraContainer) {
|
||||||
throw new Error("Renderer not initialized");
|
throw new Error("Renderer not initialized");
|
||||||
}
|
}
|
||||||
@@ -275,13 +317,13 @@ export class FrameRenderer {
|
|||||||
|
|
||||||
// Create or update video sprite from VideoFrame
|
// Create or update video sprite from VideoFrame
|
||||||
if (!this.videoSprite) {
|
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.videoSprite = new Sprite(texture);
|
||||||
this.videoContainer.addChild(this.videoSprite);
|
this.videoContainer.addChild(this.videoSprite);
|
||||||
} else {
|
} else {
|
||||||
// Destroy old texture to avoid memory leaks, then create new one
|
// Destroy old texture to avoid memory leaks, then create new one
|
||||||
const oldTexture = this.videoSprite.texture;
|
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;
|
this.videoSprite.texture = newTexture;
|
||||||
oldTexture.destroy(true);
|
oldTexture.destroy(true);
|
||||||
}
|
}
|
||||||
@@ -298,25 +340,34 @@ export class FrameRenderer {
|
|||||||
maxMotionIntensity = Math.max(maxMotionIntensity, motionIntensity);
|
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
|
// Apply transform once with maximum motion intensity from all ticks
|
||||||
applyZoomTransform({
|
applyZoomTransform({
|
||||||
cameraContainer: this.cameraContainer,
|
cameraContainer: this.cameraContainer,
|
||||||
blurFilter: this.blurFilter,
|
blurFilter: this.blurFilter,
|
||||||
stageSize: this.layoutCache.stageSize,
|
motionBlurFilter: this.motionBlurFilter,
|
||||||
baseMask: this.layoutCache.maskRect,
|
stageSize: layoutCache.stageSize,
|
||||||
|
baseMask: layoutCache.maskRect,
|
||||||
zoomScale: this.animationState.scale,
|
zoomScale: this.animationState.scale,
|
||||||
|
zoomProgress: this.animationState.progress,
|
||||||
focusX: this.animationState.focusX,
|
focusX: this.animationState.focusX,
|
||||||
focusY: this.animationState.focusY,
|
focusY: this.animationState.focusY,
|
||||||
motionIntensity: maxMotionIntensity,
|
motionIntensity: maxMotionIntensity,
|
||||||
isPlaying: true,
|
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)
|
// Render the PixiJS stage to its canvas (video only, transparent background)
|
||||||
this.app.renderer.render(this.app.stage);
|
this.app.renderer.render(this.app.stage);
|
||||||
|
|
||||||
// Composite with shadows to final output canvas
|
// Composite with shadows to final output canvas
|
||||||
this.compositeWithShadows();
|
this.compositeWithShadows(webcamFrame);
|
||||||
|
|
||||||
// Render annotations on top if present
|
// Render annotations on top if present
|
||||||
if (
|
if (
|
||||||
@@ -411,76 +462,117 @@ export class FrameRenderer {
|
|||||||
|
|
||||||
private clampFocusToStage(
|
private clampFocusToStage(
|
||||||
focus: { cx: number; cy: number },
|
focus: { cx: number; cy: number },
|
||||||
depth: number,
|
depth: ZoomDepth,
|
||||||
): { cx: number; cy: number } {
|
): { cx: number; cy: number } {
|
||||||
if (!this.layoutCache) return focus;
|
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 {
|
private updateAnimationState(timeMs: number): number {
|
||||||
if (!this.cameraContainer || !this.layoutCache) return 0;
|
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;
|
const defaultFocus = DEFAULT_FOCUS;
|
||||||
let targetScaleFactor = 1;
|
let targetScaleFactor = 1;
|
||||||
let targetFocus = { ...defaultFocus };
|
let targetFocus = { ...defaultFocus };
|
||||||
|
let targetProgress = 0;
|
||||||
|
|
||||||
if (region && strength > 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);
|
const regionFocus = this.clampFocusToStage(region.focus, region.depth);
|
||||||
|
|
||||||
targetScaleFactor = 1 + (zoomScale - 1) * strength;
|
targetScaleFactor = zoomScale;
|
||||||
targetFocus = {
|
targetFocus = regionFocus;
|
||||||
cx: defaultFocus.cx + (regionFocus.cx - defaultFocus.cx) * strength,
|
targetProgress = strength;
|
||||||
cy: defaultFocus.cy + (regionFocus.cy - defaultFocus.cy) * 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 state = this.animationState;
|
||||||
|
|
||||||
const prevScale = state.scale;
|
const prevScale = state.appliedScale;
|
||||||
const prevFocusX = state.focusX;
|
const prevX = state.x;
|
||||||
const prevFocusY = state.focusY;
|
const prevY = state.y;
|
||||||
|
|
||||||
const scaleDelta = targetScaleFactor - state.scale;
|
state.scale = targetScaleFactor;
|
||||||
const focusXDelta = targetFocus.cx - state.focusX;
|
state.focusX = targetFocus.cx;
|
||||||
const focusYDelta = targetFocus.cy - state.focusY;
|
state.focusY = targetFocus.cy;
|
||||||
|
state.progress = targetProgress;
|
||||||
|
|
||||||
let nextScale = prevScale;
|
const projectedTransform = computeZoomTransform({
|
||||||
let nextFocusX = prevFocusX;
|
stageSize: this.layoutCache.stageSize,
|
||||||
let nextFocusY = prevFocusY;
|
baseMask: this.layoutCache.maskRect,
|
||||||
|
zoomScale: state.scale,
|
||||||
|
zoomProgress: state.progress,
|
||||||
|
focusX: state.focusX,
|
||||||
|
focusY: state.focusY,
|
||||||
|
});
|
||||||
|
|
||||||
if (Math.abs(scaleDelta) > MIN_DELTA) {
|
const appliedScale =
|
||||||
nextScale = prevScale + scaleDelta * SMOOTHING_FACTOR;
|
Math.abs(projectedTransform.scale - prevScale) < ZOOM_SCALE_DEADZONE
|
||||||
} else {
|
? projectedTransform.scale
|
||||||
nextScale = targetScaleFactor;
|
: 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) {
|
state.x = appliedX;
|
||||||
nextFocusX = prevFocusX + focusXDelta * SMOOTHING_FACTOR;
|
state.y = appliedY;
|
||||||
} else {
|
state.appliedScale = appliedScale;
|
||||||
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;
|
|
||||||
|
|
||||||
return Math.max(
|
return Math.max(
|
||||||
Math.abs(nextScale - prevScale),
|
Math.abs(appliedScale - prevScale),
|
||||||
Math.abs(nextFocusX - prevFocusX),
|
Math.abs(appliedX - prevX) / Math.max(1, this.layoutCache.stageSize.width),
|
||||||
Math.abs(nextFocusY - prevFocusY),
|
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;
|
if (!this.compositeCanvas || !this.compositeCtx || !this.app) return;
|
||||||
|
|
||||||
const videoCanvas = this.app.canvas as HTMLCanvasElement;
|
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)
|
// Step 1: Draw background layer (with optional blur, not affected by zoom)
|
||||||
if (this.backgroundSprite) {
|
if (this.backgroundSprite) {
|
||||||
const bgCanvas = this.backgroundSprite as any as HTMLCanvasElement;
|
const bgCanvas = this.backgroundSprite;
|
||||||
|
|
||||||
if (this.config.showBlur) {
|
if (this.config.showBlur) {
|
||||||
ctx.save();
|
ctx.save();
|
||||||
@@ -535,6 +627,36 @@ export class FrameRenderer {
|
|||||||
} else {
|
} else {
|
||||||
ctx.drawImage(videoCanvas, 0, 0, w, h);
|
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 {
|
getCanvas(): HTMLCanvasElement {
|
||||||
@@ -558,6 +680,7 @@ export class FrameRenderer {
|
|||||||
this.videoContainer = null;
|
this.videoContainer = null;
|
||||||
this.maskGraphics = null;
|
this.maskGraphics = null;
|
||||||
this.blurFilter = null;
|
this.blurFilter = null;
|
||||||
|
this.motionBlurFilter = null;
|
||||||
this.shadowCanvas = null;
|
this.shadowCanvas = null;
|
||||||
this.shadowCtx = null;
|
this.shadowCtx = null;
|
||||||
this.compositeCanvas = null;
|
this.compositeCanvas = null;
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { calculateOutputDimensions } from "./gifExporter";
|
||||||
|
import { GIF_SIZE_PRESETS } from "./types";
|
||||||
|
|
||||||
|
describe("calculateOutputDimensions", () => {
|
||||||
|
it("uses the selected aspect ratio for scaled GIF exports", () => {
|
||||||
|
expect(calculateOutputDimensions(1080, 1920, "medium", GIF_SIZE_PRESETS, 16 / 9)).toEqual({
|
||||||
|
width: 1280,
|
||||||
|
height: 720,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fits original-size GIF exports within the source bounds at the selected aspect ratio", () => {
|
||||||
|
expect(calculateOutputDimensions(1080, 1920, "original", GIF_SIZE_PRESETS, 16 / 9)).toEqual({
|
||||||
|
width: 1080,
|
||||||
|
height: 606,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
+130
-39
@@ -6,6 +6,7 @@ import type {
|
|||||||
TrimRegion,
|
TrimRegion,
|
||||||
ZoomRegion,
|
ZoomRegion,
|
||||||
} from "@/components/video-editor/types";
|
} from "@/components/video-editor/types";
|
||||||
|
import { AsyncVideoFrameQueue } from "./asyncVideoFrameQueue";
|
||||||
import { FrameRenderer } from "./frameRenderer";
|
import { FrameRenderer } from "./frameRenderer";
|
||||||
import { StreamingVideoDecoder } from "./streamingDecoder";
|
import { StreamingVideoDecoder } from "./streamingDecoder";
|
||||||
import type {
|
import type {
|
||||||
@@ -20,6 +21,7 @@ const GIF_WORKER_URL = new URL("gif.js/dist/gif.worker.js", import.meta.url).toS
|
|||||||
|
|
||||||
interface GifExporterConfig {
|
interface GifExporterConfig {
|
||||||
videoUrl: string;
|
videoUrl: string;
|
||||||
|
webcamVideoUrl?: string;
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
frameRate: GifFrameRate;
|
frameRate: GifFrameRate;
|
||||||
@@ -32,7 +34,7 @@ interface GifExporterConfig {
|
|||||||
showShadow: boolean;
|
showShadow: boolean;
|
||||||
shadowIntensity: number;
|
shadowIntensity: number;
|
||||||
showBlur: boolean;
|
showBlur: boolean;
|
||||||
motionBlurEnabled?: boolean;
|
motionBlurAmount?: number;
|
||||||
borderRadius?: number;
|
borderRadius?: number;
|
||||||
padding?: number;
|
padding?: number;
|
||||||
videoPadding?: number;
|
videoPadding?: number;
|
||||||
@@ -56,30 +58,46 @@ export function calculateOutputDimensions(
|
|||||||
sourceHeight: number,
|
sourceHeight: number,
|
||||||
sizePreset: GifSizePreset,
|
sizePreset: GifSizePreset,
|
||||||
sizePresets: typeof GIF_SIZE_PRESETS,
|
sizePresets: typeof GIF_SIZE_PRESETS,
|
||||||
|
targetAspectRatio = sourceWidth / sourceHeight,
|
||||||
): { width: number; height: number } {
|
): { width: number; height: number } {
|
||||||
const preset = sizePresets[sizePreset];
|
const preset = sizePresets[sizePreset];
|
||||||
const maxHeight = preset.maxHeight;
|
const maxHeight = preset.maxHeight;
|
||||||
|
const aspectRatio =
|
||||||
|
Number.isFinite(targetAspectRatio) && targetAspectRatio > 0
|
||||||
|
? targetAspectRatio
|
||||||
|
: sourceWidth / sourceHeight;
|
||||||
|
|
||||||
// If original is smaller than max height or preset is 'original', use source dimensions
|
const toEven = (value: number) => {
|
||||||
if (sourceHeight <= maxHeight || sizePreset === "original") {
|
const evenValue = Math.max(2, Math.floor(value / 2) * 2);
|
||||||
return { width: sourceWidth, height: sourceHeight };
|
return evenValue;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (sizePreset === "original") {
|
||||||
|
const sourceAspect = sourceWidth / sourceHeight;
|
||||||
|
if (aspectRatio >= sourceAspect) {
|
||||||
|
const width = toEven(sourceWidth);
|
||||||
|
const height = toEven(width / aspectRatio);
|
||||||
|
return { width, height };
|
||||||
|
}
|
||||||
|
|
||||||
|
const height = toEven(sourceHeight);
|
||||||
|
const width = toEven(height * aspectRatio);
|
||||||
|
return { width, height };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate scaled dimensions preserving aspect ratio
|
const targetHeight = maxHeight;
|
||||||
const aspectRatio = sourceWidth / sourceHeight;
|
const targetWidth = Math.round(targetHeight * aspectRatio);
|
||||||
const newHeight = maxHeight;
|
|
||||||
const newWidth = Math.round(newHeight * aspectRatio);
|
|
||||||
|
|
||||||
// Ensure dimensions are even (required for some encoders)
|
|
||||||
return {
|
return {
|
||||||
width: newWidth % 2 === 0 ? newWidth : newWidth + 1,
|
width: toEven(targetWidth),
|
||||||
height: newHeight % 2 === 0 ? newHeight : newHeight + 1,
|
height: toEven(targetHeight),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export class GifExporter {
|
export class GifExporter {
|
||||||
private config: GifExporterConfig;
|
private config: GifExporterConfig;
|
||||||
private streamingDecoder: StreamingVideoDecoder | null = null;
|
private streamingDecoder: StreamingVideoDecoder | null = null;
|
||||||
|
private webcamDecoder: StreamingVideoDecoder | null = null;
|
||||||
private renderer: FrameRenderer | null = null;
|
private renderer: FrameRenderer | null = null;
|
||||||
private gif: GIF | null = null;
|
private gif: GIF | null = null;
|
||||||
private cancelled = false;
|
private cancelled = false;
|
||||||
@@ -89,6 +107,7 @@ export class GifExporter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async export(): Promise<ExportResult> {
|
async export(): Promise<ExportResult> {
|
||||||
|
let webcamFrameQueue: AsyncVideoFrameQueue | null = null;
|
||||||
try {
|
try {
|
||||||
this.cleanup();
|
this.cleanup();
|
||||||
this.cancelled = false;
|
this.cancelled = false;
|
||||||
@@ -96,6 +115,11 @@ export class GifExporter {
|
|||||||
// Initialize streaming decoder and load video metadata
|
// Initialize streaming decoder and load video metadata
|
||||||
this.streamingDecoder = new StreamingVideoDecoder();
|
this.streamingDecoder = new StreamingVideoDecoder();
|
||||||
const videoInfo = await this.streamingDecoder.loadMetadata(this.config.videoUrl);
|
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
|
// Initialize frame renderer
|
||||||
this.renderer = new FrameRenderer({
|
this.renderer = new FrameRenderer({
|
||||||
@@ -106,12 +130,14 @@ export class GifExporter {
|
|||||||
showShadow: this.config.showShadow,
|
showShadow: this.config.showShadow,
|
||||||
shadowIntensity: this.config.shadowIntensity,
|
shadowIntensity: this.config.shadowIntensity,
|
||||||
showBlur: this.config.showBlur,
|
showBlur: this.config.showBlur,
|
||||||
motionBlurEnabled: this.config.motionBlurEnabled,
|
motionBlurAmount: this.config.motionBlurAmount,
|
||||||
borderRadius: this.config.borderRadius,
|
borderRadius: this.config.borderRadius,
|
||||||
padding: this.config.padding,
|
padding: this.config.padding,
|
||||||
cropRegion: this.config.cropRegion,
|
cropRegion: this.config.cropRegion,
|
||||||
videoWidth: videoInfo.width,
|
videoWidth: videoInfo.width,
|
||||||
videoHeight: videoInfo.height,
|
videoHeight: videoInfo.height,
|
||||||
|
webcamWidth: webcamInfo?.width,
|
||||||
|
webcamHeight: webcamInfo?.height,
|
||||||
annotationRegions: this.config.annotationRegions,
|
annotationRegions: this.config.annotationRegions,
|
||||||
speedRegions: this.config.speedRegions,
|
speedRegions: this.config.speedRegions,
|
||||||
previewWidth: this.config.previewWidth,
|
previewWidth: this.config.previewWidth,
|
||||||
@@ -122,9 +148,10 @@ export class GifExporter {
|
|||||||
// Initialize GIF encoder
|
// Initialize GIF encoder
|
||||||
// Loop: 0 = infinite loop, 1 = play once (no loop)
|
// Loop: 0 = infinite loop, 1 = play once (no loop)
|
||||||
const repeat = this.config.loop ? 0 : 1;
|
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({
|
this.gif = new GIF({
|
||||||
workers: 4,
|
workers: WORKER_COUNT,
|
||||||
quality: 10,
|
quality: 10,
|
||||||
width: this.config.width,
|
width: this.config.width,
|
||||||
height: this.config.height,
|
height: this.config.height,
|
||||||
@@ -154,6 +181,42 @@ export class GifExporter {
|
|||||||
console.log("[GifExporter] Using streaming decode (web-demuxer + VideoDecoder)");
|
console.log("[GifExporter] Using streaming decode (web-demuxer + VideoDecoder)");
|
||||||
|
|
||||||
let frameIndex = 0;
|
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!
|
// Stream decode and process frames — no seeking!
|
||||||
await this.streamingDecoder.decodeAll(
|
await this.streamingDecoder.decodeAll(
|
||||||
@@ -161,32 +224,42 @@ export class GifExporter {
|
|||||||
this.config.trimRegions,
|
this.config.trimRegions,
|
||||||
this.config.speedRegions,
|
this.config.speedRegions,
|
||||||
async (videoFrame, _exportTimestampUs, sourceTimestampMs) => {
|
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();
|
videoFrame.close();
|
||||||
return;
|
webcamFrame?.close();
|
||||||
}
|
|
||||||
|
|
||||||
// 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,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -195,6 +268,11 @@ export class GifExporter {
|
|||||||
return { success: false, error: "Export cancelled" };
|
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
|
// Update progress to show we're now in the finalizing phase
|
||||||
if (this.config.onProgress) {
|
if (this.config.onProgress) {
|
||||||
this.config.onProgress({
|
this.config.onProgress({
|
||||||
@@ -238,6 +316,7 @@ export class GifExporter {
|
|||||||
error: error instanceof Error ? error.message : String(error),
|
error: error instanceof Error ? error.message : String(error),
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
|
webcamFrameQueue?.destroy();
|
||||||
this.cleanup();
|
this.cleanup();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -247,6 +326,9 @@ export class GifExporter {
|
|||||||
if (this.streamingDecoder) {
|
if (this.streamingDecoder) {
|
||||||
this.streamingDecoder.cancel();
|
this.streamingDecoder.cancel();
|
||||||
}
|
}
|
||||||
|
if (this.webcamDecoder) {
|
||||||
|
this.webcamDecoder.cancel();
|
||||||
|
}
|
||||||
if (this.gif) {
|
if (this.gif) {
|
||||||
this.gif.abort();
|
this.gif.abort();
|
||||||
}
|
}
|
||||||
@@ -263,6 +345,15 @@ export class GifExporter {
|
|||||||
this.streamingDecoder = null;
|
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) {
|
if (this.renderer) {
|
||||||
try {
|
try {
|
||||||
this.renderer.destroy();
|
this.renderer.destroy();
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export interface DecodedVideoInfo {
|
|||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
duration: number; // seconds
|
duration: number; // seconds
|
||||||
|
streamDuration?: number; // seconds
|
||||||
frameRate: number;
|
frameRate: number;
|
||||||
codec: string;
|
codec: string;
|
||||||
hasAudio: boolean;
|
hasAudio: boolean;
|
||||||
@@ -23,7 +24,7 @@ type OnFrameCallback = (
|
|||||||
* Way faster than seeking an HTMLVideoElement per frame.
|
* Way faster than seeking an HTMLVideoElement per frame.
|
||||||
*
|
*
|
||||||
* Frames in trimmed regions are decoded (needed for P/B-frame state) but discarded.
|
* 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 {
|
export class StreamingVideoDecoder {
|
||||||
private demuxer: WebDemuxer | null = null;
|
private demuxer: WebDemuxer | null = null;
|
||||||
@@ -31,11 +32,37 @@ export class StreamingVideoDecoder {
|
|||||||
private cancelled = false;
|
private cancelled = false;
|
||||||
private metadata: DecodedVideoInfo | null = null;
|
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);
|
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 blob = await response.blob();
|
||||||
const filename = videoUrl.split("/").pop() || "video";
|
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
|
// 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;
|
const wasmUrl = new URL("./wasm/web-demuxer.wasm", window.location.href).href;
|
||||||
@@ -61,6 +88,10 @@ export class StreamingVideoDecoder {
|
|||||||
width: videoStream?.width || 1920,
|
width: videoStream?.width || 1920,
|
||||||
height: videoStream?.height || 1080,
|
height: videoStream?.height || 1080,
|
||||||
duration: mediaInfo.duration,
|
duration: mediaInfo.duration,
|
||||||
|
streamDuration:
|
||||||
|
typeof videoStream?.duration === "number" && Number.isFinite(videoStream.duration)
|
||||||
|
? videoStream.duration
|
||||||
|
: undefined,
|
||||||
frameRate,
|
frameRate,
|
||||||
codec: videoStream?.codec_string || "unknown",
|
codec: videoStream?.codec_string || "unknown",
|
||||||
hasAudio: !!audioStream,
|
hasAudio: !!audioStream,
|
||||||
@@ -81,11 +112,17 @@ export class StreamingVideoDecoder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const decoderConfig = await this.demuxer.getDecoderConfig("video");
|
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(
|
const segments = this.splitBySpeed(
|
||||||
this.computeSegments(this.metadata.duration, trimRegions),
|
this.computeSegments(this.metadata.duration, trimRegions),
|
||||||
speedRegions,
|
speedRegions,
|
||||||
);
|
);
|
||||||
|
const segmentOutputFrameCounts = segments.map((segment) =>
|
||||||
|
Math.ceil(((segment.endSec - segment.startSec) / segment.speed) * targetFrameRate),
|
||||||
|
);
|
||||||
const frameDurationUs = 1_000_000 / targetFrameRate;
|
const frameDurationUs = 1_000_000 / targetFrameRate;
|
||||||
|
const epsilonSec = 0.001;
|
||||||
|
|
||||||
// Async frame queue — decoder pushes, consumer pulls
|
// Async frame queue — decoder pushes, consumer pulls
|
||||||
const pendingFrames: VideoFrame[] = [];
|
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> => {
|
const getNextFrame = (): Promise<VideoFrame | null> => {
|
||||||
if (decodeError) throw decodeError;
|
if (decodeError) throw decodeError;
|
||||||
@@ -123,8 +175,10 @@ export class StreamingVideoDecoder {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// One forward stream through the whole file
|
// One forward stream through the whole file.
|
||||||
const reader = this.demuxer.read("video").getReader();
|
// 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
|
// Feed chunks to decoder in background with backpressure
|
||||||
const feedPromise = (async () => {
|
const feedPromise = (async () => {
|
||||||
@@ -133,7 +187,11 @@ export class StreamingVideoDecoder {
|
|||||||
const { done, value: chunk } = await reader.read();
|
const { done, value: chunk } = await reader.read();
|
||||||
if (done || !chunk) break;
|
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));
|
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||||
}
|
}
|
||||||
if (this.cancelled) break;
|
if (this.cancelled) break;
|
||||||
@@ -158,65 +216,131 @@ export class StreamingVideoDecoder {
|
|||||||
|
|
||||||
// Route decoded frames into segments by timestamp, then deliver with VFR→CFR resampling
|
// Route decoded frames into segments by timestamp, then deliver with VFR→CFR resampling
|
||||||
let segmentIdx = 0;
|
let segmentIdx = 0;
|
||||||
|
let segmentFrameIndex = 0;
|
||||||
let exportFrameIndex = 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) {
|
while (!this.cancelled && segmentIdx < segments.length) {
|
||||||
const frame = await getNextFrame();
|
const frame = await getNextFrame();
|
||||||
if (!frame) break;
|
if (!frame) break;
|
||||||
|
|
||||||
const frameTimeSec = frame.timestamp / 1_000_000;
|
const frameTimeSec = frame.timestamp / 1_000_000;
|
||||||
const currentSegment = segments[segmentIdx];
|
lastDecodedFrameSec = frameTimeSec;
|
||||||
|
|
||||||
// Before current segment — trimmed or pre-video
|
// Finalize completed segments before handling this frame.
|
||||||
if (frameTimeSec < currentSegment.startSec - 0.001) {
|
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();
|
frame.close();
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Past current segment — flush buffer and advance
|
const currentSegment = segments[segmentIdx];
|
||||||
if (frameTimeSec >= currentSegment.endSec - 0.001) {
|
|
||||||
exportFrameIndex = await this.deliverSegment(
|
|
||||||
segmentBuffer,
|
|
||||||
currentSegment,
|
|
||||||
targetFrameRate,
|
|
||||||
frameDurationUs,
|
|
||||||
exportFrameIndex,
|
|
||||||
onFrame,
|
|
||||||
);
|
|
||||||
for (const f of segmentBuffer) f.close();
|
|
||||||
segmentBuffer = [];
|
|
||||||
|
|
||||||
segmentIdx++;
|
// Before current segment (trimmed region or pre-roll).
|
||||||
while (
|
if (frameTimeSec < currentSegment.startSec - epsilonSec) {
|
||||||
segmentIdx < segments.length &&
|
frame.close();
|
||||||
frameTimeSec >= segments[segmentIdx].endSec - 0.001
|
|
||||||
) {
|
|
||||||
segmentIdx++;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (segmentIdx < segments.length && frameTimeSec >= segments[segmentIdx].startSec - 0.001) {
|
|
||||||
segmentBuffer.push(frame);
|
|
||||||
} else {
|
|
||||||
frame.close();
|
|
||||||
}
|
|
||||||
continue;
|
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
|
// Flush remaining output frames for the last decoded frame.
|
||||||
if (segmentBuffer.length > 0 && segmentIdx < segments.length) {
|
if (heldFrame && segmentIdx < segments.length) {
|
||||||
exportFrameIndex = await this.deliverSegment(
|
while (!this.cancelled && segmentIdx < segments.length) {
|
||||||
segmentBuffer,
|
const segment = segments[segmentIdx];
|
||||||
segments[segmentIdx],
|
if (heldFrameSec < segment.startSec - epsilonSec) {
|
||||||
targetFrameRate,
|
break;
|
||||||
frameDurationUs,
|
}
|
||||||
exportFrameIndex,
|
|
||||||
onFrame,
|
while (!this.cancelled && (await emitHeldFrameForTarget(segment))) {
|
||||||
);
|
// Keep emitting output frames for the active segment.
|
||||||
for (const f of segmentBuffer) f.close();
|
}
|
||||||
|
|
||||||
|
segmentIdx++;
|
||||||
|
segmentFrameIndex = 0;
|
||||||
|
if (
|
||||||
|
segmentIdx < segments.length &&
|
||||||
|
heldFrameSec < segments[segmentIdx].startSec - epsilonSec
|
||||||
|
) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
heldFrame.close();
|
||||||
|
heldFrame = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drain leftover decoded frames
|
// Drain leftover decoded frames
|
||||||
@@ -239,39 +363,17 @@ export class StreamingVideoDecoder {
|
|||||||
this.decoder.close();
|
this.decoder.close();
|
||||||
}
|
}
|
||||||
this.decoder = null;
|
this.decoder = null;
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
const requiredEndSec = segments.length > 0 ? segments[segments.length - 1].endSec : 0;
|
||||||
* Resample buffered frames to fill the target frame count for this segment.
|
if (
|
||||||
* Handles VFR sources by duplicating/decimating as needed.
|
!this.cancelled &&
|
||||||
*/
|
lastDecodedFrameSec !== null &&
|
||||||
private async deliverSegment(
|
requiredEndSec - lastDecodedFrameSec > 1
|
||||||
frames: VideoFrame[],
|
) {
|
||||||
segment: { startSec: number; endSec: number; speed: number },
|
throw new Error(
|
||||||
targetFrameRate: number,
|
`Video decode ended early at ${lastDecodedFrameSec.toFixed(3)}s (needed ${requiredEndSec.toFixed(3)}s).`,
|
||||||
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 sourceFrame = frames[sourceIdx];
|
|
||||||
const clone = new VideoFrame(sourceFrame, { timestamp: sourceFrame.timestamp });
|
|
||||||
await onFrame(clone, exportFrameIndex * frameDurationUs, sourceFrame.timestamp / 1000);
|
|
||||||
exportFrameIndex++;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return exportFrameIndex;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private computeSegments(
|
private computeSegments(
|
||||||
@@ -364,7 +466,9 @@ export class StreamingVideoDecoder {
|
|||||||
if (this.demuxer) {
|
if (this.demuxer) {
|
||||||
try {
|
try {
|
||||||
this.demuxer.destroy();
|
this.demuxer.destroy();
|
||||||
} catch {}
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
this.demuxer = null;
|
this.demuxer = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
TrimRegion,
|
TrimRegion,
|
||||||
ZoomRegion,
|
ZoomRegion,
|
||||||
} from "@/components/video-editor/types";
|
} from "@/components/video-editor/types";
|
||||||
|
import { AsyncVideoFrameQueue } from "./asyncVideoFrameQueue";
|
||||||
import { AudioProcessor } from "./audioEncoder";
|
import { AudioProcessor } from "./audioEncoder";
|
||||||
import { FrameRenderer } from "./frameRenderer";
|
import { FrameRenderer } from "./frameRenderer";
|
||||||
import { VideoMuxer } from "./muxer";
|
import { VideoMuxer } from "./muxer";
|
||||||
@@ -13,6 +14,7 @@ import type { ExportConfig, ExportProgress, ExportResult } from "./types";
|
|||||||
|
|
||||||
interface VideoExporterConfig extends ExportConfig {
|
interface VideoExporterConfig extends ExportConfig {
|
||||||
videoUrl: string;
|
videoUrl: string;
|
||||||
|
webcamVideoUrl?: string;
|
||||||
wallpaper: string;
|
wallpaper: string;
|
||||||
zoomRegions: ZoomRegion[];
|
zoomRegions: ZoomRegion[];
|
||||||
trimRegions?: TrimRegion[];
|
trimRegions?: TrimRegion[];
|
||||||
@@ -20,7 +22,7 @@ interface VideoExporterConfig extends ExportConfig {
|
|||||||
showShadow: boolean;
|
showShadow: boolean;
|
||||||
shadowIntensity: number;
|
shadowIntensity: number;
|
||||||
showBlur: boolean;
|
showBlur: boolean;
|
||||||
motionBlurEnabled?: boolean;
|
motionBlurAmount?: number;
|
||||||
borderRadius?: number;
|
borderRadius?: number;
|
||||||
padding?: number;
|
padding?: number;
|
||||||
videoPadding?: number;
|
videoPadding?: number;
|
||||||
@@ -38,6 +40,7 @@ export class VideoExporter {
|
|||||||
private encoder: VideoEncoder | null = null;
|
private encoder: VideoEncoder | null = null;
|
||||||
private muxer: VideoMuxer | null = null;
|
private muxer: VideoMuxer | null = null;
|
||||||
private audioProcessor: AudioProcessor | null = null;
|
private audioProcessor: AudioProcessor | null = null;
|
||||||
|
private webcamDecoder: StreamingVideoDecoder | null = null;
|
||||||
private cancelled = false;
|
private cancelled = false;
|
||||||
private encodeQueue = 0;
|
private encodeQueue = 0;
|
||||||
// Increased queue size for better throughput with hardware encoding
|
// Increased queue size for better throughput with hardware encoding
|
||||||
@@ -53,6 +56,7 @@ export class VideoExporter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async export(): Promise<ExportResult> {
|
async export(): Promise<ExportResult> {
|
||||||
|
let webcamFrameQueue: AsyncVideoFrameQueue | null = null;
|
||||||
try {
|
try {
|
||||||
this.cleanup();
|
this.cleanup();
|
||||||
this.cancelled = false;
|
this.cancelled = false;
|
||||||
@@ -60,6 +64,11 @@ export class VideoExporter {
|
|||||||
// Initialize streaming decoder and load video metadata
|
// Initialize streaming decoder and load video metadata
|
||||||
this.streamingDecoder = new StreamingVideoDecoder();
|
this.streamingDecoder = new StreamingVideoDecoder();
|
||||||
const videoInfo = await this.streamingDecoder.loadMetadata(this.config.videoUrl);
|
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
|
// Initialize frame renderer
|
||||||
this.renderer = new FrameRenderer({
|
this.renderer = new FrameRenderer({
|
||||||
@@ -70,12 +79,14 @@ export class VideoExporter {
|
|||||||
showShadow: this.config.showShadow,
|
showShadow: this.config.showShadow,
|
||||||
shadowIntensity: this.config.shadowIntensity,
|
shadowIntensity: this.config.shadowIntensity,
|
||||||
showBlur: this.config.showBlur,
|
showBlur: this.config.showBlur,
|
||||||
motionBlurEnabled: this.config.motionBlurEnabled,
|
motionBlurAmount: this.config.motionBlurAmount,
|
||||||
borderRadius: this.config.borderRadius,
|
borderRadius: this.config.borderRadius,
|
||||||
padding: this.config.padding,
|
padding: this.config.padding,
|
||||||
cropRegion: this.config.cropRegion,
|
cropRegion: this.config.cropRegion,
|
||||||
videoWidth: videoInfo.width,
|
videoWidth: videoInfo.width,
|
||||||
videoHeight: videoInfo.height,
|
videoHeight: videoInfo.height,
|
||||||
|
webcamWidth: webcamInfo?.width,
|
||||||
|
webcamHeight: webcamInfo?.height,
|
||||||
annotationRegions: this.config.annotationRegions,
|
annotationRegions: this.config.annotationRegions,
|
||||||
speedRegions: this.config.speedRegions,
|
speedRegions: this.config.speedRegions,
|
||||||
previewWidth: this.config.previewWidth,
|
previewWidth: this.config.previewWidth,
|
||||||
@@ -97,6 +108,7 @@ export class VideoExporter {
|
|||||||
this.config.speedRegions,
|
this.config.speedRegions,
|
||||||
);
|
);
|
||||||
const totalFrames = Math.ceil(effectiveDuration * this.config.frameRate);
|
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] Original duration:", videoInfo.duration, "s");
|
||||||
console.log("[VideoExporter] Effective duration:", effectiveDuration, "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
|
const frameDuration = 1_000_000 / this.config.frameRate; // in microseconds
|
||||||
let frameIndex = 0;
|
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!
|
// Stream decode and process frames — no seeking!
|
||||||
await this.streamingDecoder.decodeAll(
|
await this.streamingDecoder.decodeAll(
|
||||||
@@ -112,61 +160,72 @@ export class VideoExporter {
|
|||||||
this.config.trimRegions,
|
this.config.trimRegions,
|
||||||
this.config.speedRegions,
|
this.config.speedRegions,
|
||||||
async (videoFrame, _exportTimestampUs, sourceTimestampMs) => {
|
async (videoFrame, _exportTimestampUs, sourceTimestampMs) => {
|
||||||
if (this.cancelled) {
|
let webcamFrame: VideoFrame | null = null;
|
||||||
videoFrame.close();
|
try {
|
||||||
return;
|
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
|
// Render the frame with all effects using source timestamp
|
||||||
const sourceTimestampUs = sourceTimestampMs * 1000; // Convert to microseconds
|
const sourceTimestampUs = sourceTimestampMs * 1000; // Convert to microseconds
|
||||||
await this.renderer!.renderFrame(videoFrame, sourceTimestampUs);
|
await renderer.renderFrame(videoFrame, sourceTimestampUs, webcamFrame);
|
||||||
videoFrame.close();
|
|
||||||
|
|
||||||
const canvas = this.renderer!.getCanvas();
|
const canvas = renderer.getCanvas();
|
||||||
|
|
||||||
// Create VideoFrame from canvas on GPU without reading pixels
|
// Create VideoFrame from canvas on GPU without reading pixels
|
||||||
// @ts-expect-error - colorSpace not in TypeScript definitions but works at runtime
|
// @ts-expect-error - colorSpace not in TypeScript definitions but works at runtime
|
||||||
const exportFrame = new VideoFrame(canvas, {
|
const exportFrame = new VideoFrame(canvas, {
|
||||||
timestamp,
|
timestamp,
|
||||||
duration: frameDuration,
|
duration: frameDuration,
|
||||||
colorSpace: {
|
colorSpace: {
|
||||||
primaries: "bt709",
|
primaries: "bt709",
|
||||||
transfer: "iec61966-2-1",
|
transfer: "iec61966-2-1",
|
||||||
matrix: "rgb",
|
matrix: "rgb",
|
||||||
fullRange: true,
|
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,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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" };
|
return { success: false, error: "Export cancelled" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
stopWebcamDecode = true;
|
||||||
|
webcamFrameQueue?.destroy();
|
||||||
|
this.webcamDecoder?.cancel();
|
||||||
|
await webcamDecodePromise;
|
||||||
|
|
||||||
// Finalize encoding
|
// Finalize encoding
|
||||||
if (this.encoder && this.encoder.state === "configured") {
|
if (this.encoder && this.encoder.state === "configured") {
|
||||||
await this.encoder.flush();
|
await this.encoder.flush();
|
||||||
@@ -183,13 +247,30 @@ export class VideoExporter {
|
|||||||
// Wait for all video muxing operations to complete
|
// Wait for all video muxing operations to complete
|
||||||
await Promise.all(this.muxingPromises);
|
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
|
// Process audio track if present
|
||||||
if (hasAudio && !this.cancelled) {
|
if (hasAudio && !this.cancelled) {
|
||||||
const demuxer = this.streamingDecoder!.getDemuxer();
|
const demuxer = this.streamingDecoder!.getDemuxer();
|
||||||
if (demuxer) {
|
if (demuxer) {
|
||||||
console.log("[VideoExporter] Processing audio track...");
|
console.log("[VideoExporter] Processing audio track...");
|
||||||
this.audioProcessor = new AudioProcessor();
|
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),
|
error: error instanceof Error ? error.message : String(error),
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
|
webcamFrameQueue?.destroy();
|
||||||
this.cleanup();
|
this.cleanup();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -219,7 +301,11 @@ export class VideoExporter {
|
|||||||
// Capture decoder config metadata from encoder output
|
// Capture decoder config metadata from encoder output
|
||||||
if (meta?.decoderConfig?.description && !videoDescription) {
|
if (meta?.decoderConfig?.description && !videoDescription) {
|
||||||
const desc = meta.decoderConfig.description;
|
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;
|
this.videoDescription = videoDescription;
|
||||||
}
|
}
|
||||||
// Capture colorSpace from encoder metadata if provided
|
// Capture colorSpace from encoder metadata if provided
|
||||||
@@ -310,6 +396,9 @@ export class VideoExporter {
|
|||||||
if (this.streamingDecoder) {
|
if (this.streamingDecoder) {
|
||||||
this.streamingDecoder.cancel();
|
this.streamingDecoder.cancel();
|
||||||
}
|
}
|
||||||
|
if (this.webcamDecoder) {
|
||||||
|
this.webcamDecoder.cancel();
|
||||||
|
}
|
||||||
if (this.audioProcessor) {
|
if (this.audioProcessor) {
|
||||||
this.audioProcessor.cancel();
|
this.audioProcessor.cancel();
|
||||||
}
|
}
|
||||||
@@ -337,6 +426,15 @@ export class VideoExporter {
|
|||||||
this.streamingDecoder = null;
|
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) {
|
if (this.renderer) {
|
||||||
try {
|
try {
|
||||||
this.renderer.destroy();
|
this.renderer.destroy();
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -27,6 +27,15 @@ export interface FixedShortcut {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const FIXED_SHORTCUTS: 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 Forward", display: "Tab", bindings: [{ key: "tab" }] },
|
||||||
{
|
{
|
||||||
label: "Cycle Annotations Backward",
|
label: "Cycle Annotations Backward",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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)),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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];
|
export type AspectRatio = (typeof ASPECT_RATIOS)[number];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the numeric value of an aspect ratio.
|
* Returns the numeric value of an aspect ratio.
|
||||||
* Uses exhaustive type checking to ensure all AspectRatio cases are handled.
|
* For "native", returns a fallback ratio of 16/9.
|
||||||
* If TypeScript errors here, a new ratio was added to the type but not handled.
|
* Callers with source/crop context should use getNativeAspectRatioValue().
|
||||||
*/
|
*/
|
||||||
export function getAspectRatioValue(aspectRatio: AspectRatio): number {
|
export function getAspectRatioValue(aspectRatio: AspectRatio): number {
|
||||||
switch (aspectRatio) {
|
switch (aspectRatio) {
|
||||||
@@ -23,14 +32,25 @@ export function getAspectRatioValue(aspectRatio: AspectRatio): number {
|
|||||||
return 16 / 10;
|
return 16 / 10;
|
||||||
case "10:16":
|
case "10:16":
|
||||||
return 10 / 16;
|
return 10 / 16;
|
||||||
|
case "native":
|
||||||
|
return 16 / 9;
|
||||||
default: {
|
default: {
|
||||||
// Ensures all cases are handled - TypeScript errors if missing
|
|
||||||
const _exhaustiveCheck: never = aspectRatio;
|
const _exhaustiveCheck: never = aspectRatio;
|
||||||
return _exhaustiveCheck;
|
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(
|
export function getAspectRatioDimensions(
|
||||||
aspectRatio: AspectRatio,
|
aspectRatio: AspectRatio,
|
||||||
baseWidth: number,
|
baseWidth: number,
|
||||||
@@ -43,9 +63,11 @@ export function getAspectRatioDimensions(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getAspectRatioLabel(aspectRatio: AspectRatio): string {
|
export function getAspectRatioLabel(aspectRatio: AspectRatio): string {
|
||||||
|
if (aspectRatio === "native") return "Native";
|
||||||
return aspectRatio;
|
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(":", "/");
|
return aspectRatio.replace(":", "/");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export type TestId = `gif-size-button-${string}` | "export-button" | `gif-format-button`;
|
||||||
|
|
||||||
|
export function getTestId(testId: TestId) {
|
||||||
|
return `testId-${testId}`;
|
||||||
|
}
|
||||||
@@ -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}`;
|
||||||
|
}
|
||||||
Vendored
+31
-3
@@ -20,15 +20,31 @@ interface Window {
|
|||||||
getSources: (opts: Electron.SourcesOptions) => Promise<ProcessedDesktopSource[]>;
|
getSources: (opts: Electron.SourcesOptions) => Promise<ProcessedDesktopSource[]>;
|
||||||
switchToEditor: () => Promise<void>;
|
switchToEditor: () => Promise<void>;
|
||||||
openSourceSelector: () => Promise<void>;
|
openSourceSelector: () => Promise<void>;
|
||||||
selectSource: (source: any) => Promise<any>;
|
selectSource: (source: ProcessedDesktopSource) => Promise<ProcessedDesktopSource | null>;
|
||||||
getSelectedSource: () => Promise<any>;
|
getSelectedSource: () => Promise<ProcessedDesktopSource | null>;
|
||||||
|
requestCameraAccess: () => Promise<{
|
||||||
|
success: boolean;
|
||||||
|
granted: boolean;
|
||||||
|
status: string;
|
||||||
|
error?: string;
|
||||||
|
}>;
|
||||||
storeRecordedVideo: (
|
storeRecordedVideo: (
|
||||||
videoData: ArrayBuffer,
|
videoData: ArrayBuffer,
|
||||||
fileName: string,
|
fileName: string,
|
||||||
) => Promise<{
|
) => Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
path?: string;
|
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;
|
error?: string;
|
||||||
}>;
|
}>;
|
||||||
getRecordedVideoPath: () => Promise<{
|
getRecordedVideoPath: () => Promise<{
|
||||||
@@ -58,7 +74,17 @@ interface Window {
|
|||||||
}>;
|
}>;
|
||||||
openVideoFilePicker: () => Promise<{ success: boolean; path?: string; canceled?: boolean }>;
|
openVideoFilePicker: () => Promise<{ success: boolean; path?: string; canceled?: boolean }>;
|
||||||
setCurrentVideoPath: (path: string) => Promise<{ success: 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 }>;
|
getCurrentVideoPath: () => Promise<{ success: boolean; path?: string }>;
|
||||||
|
getCurrentRecordingSession: () => Promise<{
|
||||||
|
success: boolean;
|
||||||
|
session?: import("./lib/recordingSession").RecordingSession;
|
||||||
|
}>;
|
||||||
clearCurrentVideoPath: () => Promise<{ success: boolean }>;
|
clearCurrentVideoPath: () => Promise<{ success: boolean }>;
|
||||||
saveProjectFile: (
|
saveProjectFile: (
|
||||||
projectData: unknown,
|
projectData: unknown,
|
||||||
@@ -91,5 +117,7 @@ interface Window {
|
|||||||
onMenuSaveProject: (callback: () => void) => () => void;
|
onMenuSaveProject: (callback: () => void) => () => void;
|
||||||
onMenuSaveProjectAs: (callback: () => void) => () => void;
|
onMenuSaveProjectAs: (callback: () => void) => () => void;
|
||||||
setMicrophoneExpanded: (expanded: boolean) => void;
|
setMicrophoneExpanded: (expanded: boolean) => void;
|
||||||
|
setHasUnsavedChanges: (hasChanges: boolean) => void;
|
||||||
|
onRequestSaveBeforeClose: (callback: () => Promise<boolean> | boolean) => () => void;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,10 +13,24 @@ module.exports = {
|
|||||||
from: { height: "var(--radix-accordion-content-height)" },
|
from: { height: "var(--radix-accordion-content-height)" },
|
||||||
to: { height: "0" },
|
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: {
|
animation: {
|
||||||
"accordion-down": "accordion-down 0.2s ease-out",
|
"accordion-down": "accordion-down 0.2s ease-out",
|
||||||
"accordion-up": "accordion-up 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: {
|
borderRadius: {
|
||||||
lg: "var(--radius)",
|
lg: "var(--radius)",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
Vendored
BIN
Binary file not shown.
Reference in New Issue
Block a user