Merge branch 'siddharthvaddem:main' into main
This commit is contained in:
@@ -97,4 +97,5 @@ jobs:
|
||||
path: |
|
||||
release/**/*.AppImage
|
||||
release/**/*.zsync
|
||||
release/**/*.deb
|
||||
retention-days: 30
|
||||
|
||||
@@ -42,26 +42,3 @@ jobs:
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npx vite build
|
||||
|
||||
e2e:
|
||||
name: E2E Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npx playwright install --with-deps chromium
|
||||
# Install Electron system dependencies not covered by Playwright's chromium deps
|
||||
- run: npx electron . --version || sudo apt-get install -y libgbm-dev
|
||||
- run: npm run build-vite
|
||||
# xvfb provides a virtual display; Electron needs one on Linux even with show:false
|
||||
- run: xvfb-run --auto-servernum npm run test:e2e
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: failure()
|
||||
with:
|
||||
name: playwright-report
|
||||
path: playwright-report/
|
||||
retention-days: 7
|
||||
|
||||
@@ -8,6 +8,10 @@
|
||||
<a href="https://deepwiki.com/siddharthvaddem/openscreen">
|
||||
<img src="https://deepwiki.com/badge.svg" alt="Ask DeepWiki" />
|
||||
</a>
|
||||
|
||||
<a href="https://discord.gg/yAQQhRaEeg">
|
||||
<img src="https://img.shields.io/discord/pHAUbcqNd?logo=discord&label=Discord&color=5865F2" alt="Join Discord" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
# <p align="center">OpenScreen</p>
|
||||
@@ -21,21 +25,20 @@ Screen Studio is an awesome product and this is definitely not a 1:1 clone. Open
|
||||
OpenScreen is 100% free for personal and commercial use. Use it, modify it, distribute it. (Just be cool 😁 and give a shoutout if you feel like it !)
|
||||
|
||||
<p align="center">
|
||||
<img src="public/preview3.png" alt="OpenScreen App Preview 3" style="height: 320px; margin-right: 12px;" />
|
||||
<img src="public/preview4.png" alt="OpenScreen App Preview 4" style="height: 320px; margin-right: 12px;" />
|
||||
<img src="public/preview3.png" alt="OpenScreen App Preview 3" style="height: 0.2467; margin-right: 12px;" />
|
||||
<img src="public/preview4.png" alt="OpenScreen App Preview 4" style="height: 0.1678; margin-right: 12px;" />
|
||||
</p>
|
||||
|
||||
## Core Features
|
||||
- Record your whole screen or specific windows.
|
||||
- Add Automatic zooms or manual zooms (customizable depth levels).
|
||||
- Record microphone audio and system audio capture.
|
||||
- Customize the duration and position of zooms however you please.
|
||||
- Record specific windows or your whole screen.
|
||||
- Add automatic or manual zooms (adjustable depth levels) and customize their durarion and position.
|
||||
- Record microphone and system audio.
|
||||
- Crop video recordings to hide parts.
|
||||
- Choose between wallpapers, solid colors, gradients or a custom background.
|
||||
- Motion blur for smoother pan and zoom effects.
|
||||
- Add annotations (text, arrows, images).
|
||||
- Trim sections of the clip.
|
||||
- Customize speed at different segments.
|
||||
- Customize the speed of different segments.
|
||||
- Export in different aspect ratios and resolutions.
|
||||
|
||||
## Installation
|
||||
@@ -74,9 +77,9 @@ You may need to grant screen recording permissions depending on your desktop env
|
||||
|
||||
System audio capture relies on Electron's [desktopCapturer](https://www.electronjs.org/docs/latest/api/desktop-capturer) and has some platform-specific quirks:
|
||||
|
||||
- **macOS**: Requires macOS 13+. On macOS 14.2+ you'll be prompted to grant audio capture permission. macOS 12 and below does not support system audio (mic still work).
|
||||
- **macOS**: Requires macOS 13+. On macOS 14.2+ you'll be prompted to grant audio capture permission. macOS 12 and below does not support system audio (mic still works).
|
||||
- **Windows**: Works out of the box.
|
||||
- **Linux**: Needs PipeWire (default on Ubuntu 22.04+, Fedora 34+). Older PulseAudio-only setups may not support system audio (mic should still works).
|
||||
- **Linux**: Needs PipeWire (default on Ubuntu 22.04+, Fedora 34+). Older PulseAudio-only setups may not support system audio (mic should still work).
|
||||
|
||||
## Built with
|
||||
- Electron
|
||||
|
||||
Vendored
+2
@@ -26,6 +26,8 @@ interface Window {
|
||||
electronAPI: {
|
||||
getSources: (opts: Electron.SourcesOptions) => Promise<ProcessedDesktopSource[]>;
|
||||
switchToEditor: () => Promise<void>;
|
||||
switchToHud: () => Promise<void>;
|
||||
startNewRecording: () => Promise<{ success: boolean; error?: string }>;
|
||||
openSourceSelector: () => Promise<void>;
|
||||
selectSource: (source: ProcessedDesktopSource) => Promise<ProcessedDesktopSource | null>;
|
||||
getSelectedSource: () => Promise<ProcessedDesktopSource | null>;
|
||||
|
||||
+197
-29
@@ -14,6 +14,7 @@ import {
|
||||
import {
|
||||
normalizeProjectMedia,
|
||||
normalizeRecordingSession,
|
||||
type ProjectMedia,
|
||||
type RecordingSession,
|
||||
type StoreRecordedSessionInput,
|
||||
} from "../../src/lib/recordingSession";
|
||||
@@ -23,6 +24,143 @@ import { RECORDINGS_DIR } from "../main";
|
||||
const PROJECT_FILE_EXTENSION = "openscreen";
|
||||
const SHORTCUTS_FILE = path.join(app.getPath("userData"), "shortcuts.json");
|
||||
const RECORDING_SESSION_SUFFIX = ".session.json";
|
||||
const ALLOWED_IMPORT_VIDEO_EXTENSIONS = new Set([".webm", ".mp4", ".mov", ".avi", ".mkv"]);
|
||||
|
||||
/**
|
||||
* Paths explicitly approved by the user via file picker dialogs or project loads.
|
||||
* These are added at runtime when the user selects files from outside the default directories.
|
||||
*/
|
||||
const approvedPaths = new Set<string>();
|
||||
|
||||
function approveFilePath(filePath: string): void {
|
||||
approvedPaths.add(path.resolve(filePath));
|
||||
}
|
||||
|
||||
function getAllowedReadDirs(): string[] {
|
||||
return [RECORDINGS_DIR];
|
||||
}
|
||||
|
||||
function isPathWithinDir(filePath: string, dirPath: string): boolean {
|
||||
const resolved = path.resolve(filePath);
|
||||
const resolvedDir = path.resolve(dirPath);
|
||||
return resolved === resolvedDir || resolved.startsWith(resolvedDir + path.sep);
|
||||
}
|
||||
|
||||
function isPathAllowed(filePath: string): boolean {
|
||||
const resolved = path.resolve(filePath);
|
||||
if (approvedPaths.has(resolved)) return true;
|
||||
return getAllowedReadDirs().some((dir) => isPathWithinDir(resolved, dir));
|
||||
}
|
||||
|
||||
function hasAllowedImportVideoExtension(filePath: string): boolean {
|
||||
return ALLOWED_IMPORT_VIDEO_EXTENSIONS.has(path.extname(filePath).toLowerCase());
|
||||
}
|
||||
|
||||
async function approveReadableVideoPath(
|
||||
filePath?: string | null,
|
||||
trustedDirs?: string[],
|
||||
): Promise<string | null> {
|
||||
const normalizedPath = normalizeVideoSourcePath(filePath);
|
||||
if (!normalizedPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isPathAllowed(normalizedPath)) {
|
||||
return normalizedPath;
|
||||
}
|
||||
|
||||
if (!hasAllowedImportVideoExtension(normalizedPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// When called with trustedDirs (e.g. from project load), only auto-approve
|
||||
// paths within those directories. This prevents malicious project files from
|
||||
// approving reads to arbitrary filesystem locations.
|
||||
if (trustedDirs) {
|
||||
const resolved = path.resolve(normalizedPath);
|
||||
const withinTrusted = trustedDirs.some((dir) => isPathWithinDir(resolved, dir));
|
||||
if (!withinTrusted) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const stats = await fs.stat(normalizedPath);
|
||||
if (!stats.isFile()) {
|
||||
return null;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
approveFilePath(normalizedPath);
|
||||
return normalizedPath;
|
||||
}
|
||||
|
||||
function resolveRecordingOutputPath(fileName: string): string {
|
||||
const trimmed = fileName.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error("Invalid recording file name");
|
||||
}
|
||||
|
||||
const parsedPath = path.parse(trimmed);
|
||||
const hasTraversalSegments = trimmed.split(/[\\/]+/).some((segment) => segment === "..");
|
||||
const isNestedPath =
|
||||
parsedPath.dir !== "" ||
|
||||
path.isAbsolute(trimmed) ||
|
||||
trimmed.includes("/") ||
|
||||
trimmed.includes("\\");
|
||||
if (hasTraversalSegments || isNestedPath || parsedPath.base !== trimmed) {
|
||||
throw new Error("Recording file name must not contain path segments");
|
||||
}
|
||||
|
||||
return path.join(RECORDINGS_DIR, parsedPath.base);
|
||||
}
|
||||
|
||||
async function getApprovedProjectSession(
|
||||
project: unknown,
|
||||
projectFilePath?: string,
|
||||
): Promise<RecordingSession | null> {
|
||||
if (!project || typeof project !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rawProject = project as { media?: unknown; videoPath?: unknown };
|
||||
const media: ProjectMedia | null =
|
||||
normalizeProjectMedia(rawProject.media) ??
|
||||
(typeof rawProject.videoPath === "string"
|
||||
? {
|
||||
screenVideoPath: normalizeVideoSourcePath(rawProject.videoPath) ?? rawProject.videoPath,
|
||||
}
|
||||
: null);
|
||||
|
||||
if (!media) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Only auto-approve media paths within the project's directory or RECORDINGS_DIR.
|
||||
// This prevents crafted project files from approving reads to arbitrary locations.
|
||||
const trustedDirs = [RECORDINGS_DIR];
|
||||
if (projectFilePath) {
|
||||
trustedDirs.push(path.dirname(path.resolve(projectFilePath)));
|
||||
}
|
||||
|
||||
const screenVideoPath = await approveReadableVideoPath(media.screenVideoPath, trustedDirs);
|
||||
if (!screenVideoPath) {
|
||||
throw new Error("Project references an invalid or unsupported screen video path");
|
||||
}
|
||||
|
||||
const webcamVideoPath = media.webcamVideoPath
|
||||
? await approveReadableVideoPath(media.webcamVideoPath, trustedDirs)
|
||||
: undefined;
|
||||
if (media.webcamVideoPath && !webcamVideoPath) {
|
||||
throw new Error("Project references an invalid or unsupported webcam video path");
|
||||
}
|
||||
|
||||
return webcamVideoPath
|
||||
? { screenVideoPath, webcamVideoPath, createdAt: Date.now() }
|
||||
: { screenVideoPath, createdAt: Date.now() };
|
||||
}
|
||||
|
||||
type SelectedSource = {
|
||||
name: string;
|
||||
@@ -121,12 +259,12 @@ async function storeRecordedSessionFiles(payload: StoreRecordedSessionInput) {
|
||||
typeof payload.createdAt === "number" && Number.isFinite(payload.createdAt)
|
||||
? payload.createdAt
|
||||
: Date.now();
|
||||
const screenVideoPath = path.join(RECORDINGS_DIR, payload.screen.fileName);
|
||||
const screenVideoPath = resolveRecordingOutputPath(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);
|
||||
webcamVideoPath = resolveRecordingOutputPath(payload.webcam.fileName);
|
||||
await fs.writeFile(webcamVideoPath, Buffer.from(payload.webcam.videoData));
|
||||
}
|
||||
|
||||
@@ -217,7 +355,24 @@ export function registerIpcHandlers(
|
||||
getMainWindow: () => BrowserWindow | null,
|
||||
getSourceSelectorWindow: () => BrowserWindow | null,
|
||||
onRecordingStateChange?: (recording: boolean, sourceName: string) => void,
|
||||
switchToHud?: () => void,
|
||||
) {
|
||||
ipcMain.handle("switch-to-hud", () => {
|
||||
if (switchToHud) switchToHud();
|
||||
});
|
||||
ipcMain.handle("start-new-recording", async () => {
|
||||
try {
|
||||
setCurrentRecordingSessionState(null);
|
||||
if (switchToHud) {
|
||||
switchToHud();
|
||||
}
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("Failed to start new recording:", error);
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("get-sources", async (_, opts) => {
|
||||
const sources = await desktopCapturer.getSources(opts);
|
||||
return sources.map((source) => ({
|
||||
@@ -352,6 +507,14 @@ export function registerIpcHandlers(
|
||||
return { success: false, message: "Invalid file path" };
|
||||
}
|
||||
|
||||
if (!isPathAllowed(normalizedPath)) {
|
||||
console.warn(
|
||||
"[read-binary-file] Rejected path outside allowed directories:",
|
||||
normalizedPath,
|
||||
);
|
||||
return { success: false, message: "Access denied: path outside allowed directories" };
|
||||
}
|
||||
|
||||
const data = await fs.readFile(normalizedPath);
|
||||
return {
|
||||
success: true,
|
||||
@@ -396,6 +559,14 @@ export function registerIpcHandlers(
|
||||
return { success: true, samples: [] };
|
||||
}
|
||||
|
||||
if (!isPathAllowed(targetVideoPath)) {
|
||||
console.warn(
|
||||
"[get-cursor-telemetry] Rejected path outside allowed directories:",
|
||||
targetVideoPath,
|
||||
);
|
||||
return { success: true, samples: [] };
|
||||
}
|
||||
|
||||
const telemetryPath = `${targetVideoPath}.cursor.json`;
|
||||
try {
|
||||
const content = await fs.readFile(telemetryPath, "utf-8");
|
||||
@@ -529,10 +700,17 @@ export function registerIpcHandlers(
|
||||
return { success: false, canceled: true };
|
||||
}
|
||||
|
||||
const approvedPath = await approveReadableVideoPath(result.filePaths[0]);
|
||||
if (!approvedPath) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Selected file is not a supported video",
|
||||
};
|
||||
}
|
||||
currentProjectPath = null;
|
||||
return {
|
||||
success: true,
|
||||
path: result.filePaths[0],
|
||||
path: approvedPath,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Failed to open file picker:", error);
|
||||
@@ -658,19 +836,9 @@ export function registerIpcHandlers(
|
||||
const filePath = result.filePaths[0];
|
||||
const content = await fs.readFile(filePath, "utf-8");
|
||||
const project = JSON.parse(content);
|
||||
const session = await getApprovedProjectSession(project, filePath);
|
||||
currentProjectPath = filePath;
|
||||
if (project && typeof project === "object") {
|
||||
const rawProject = project as { media?: unknown; videoPath?: unknown };
|
||||
const media =
|
||||
normalizeProjectMedia(rawProject.media) ??
|
||||
(typeof rawProject.videoPath === "string"
|
||||
? {
|
||||
screenVideoPath:
|
||||
normalizeVideoSourcePath(rawProject.videoPath) ?? rawProject.videoPath,
|
||||
}
|
||||
: null);
|
||||
setCurrentRecordingSessionState(media ? { ...media, createdAt: Date.now() } : null);
|
||||
}
|
||||
setCurrentRecordingSessionState(session);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -695,18 +863,8 @@ export function registerIpcHandlers(
|
||||
|
||||
const content = await fs.readFile(currentProjectPath, "utf-8");
|
||||
const project = JSON.parse(content);
|
||||
if (project && typeof project === "object") {
|
||||
const rawProject = project as { media?: unknown; videoPath?: unknown };
|
||||
const media =
|
||||
normalizeProjectMedia(rawProject.media) ??
|
||||
(typeof rawProject.videoPath === "string"
|
||||
? {
|
||||
screenVideoPath:
|
||||
normalizeVideoSourcePath(rawProject.videoPath) ?? rawProject.videoPath,
|
||||
}
|
||||
: null);
|
||||
setCurrentRecordingSessionState(media ? { ...media, createdAt: Date.now() } : null);
|
||||
}
|
||||
const session = await getApprovedProjectSession(project, currentProjectPath);
|
||||
setCurrentRecordingSessionState(session);
|
||||
return {
|
||||
success: true,
|
||||
path: currentProjectPath,
|
||||
@@ -735,12 +893,22 @@ export function registerIpcHandlers(
|
||||
});
|
||||
|
||||
ipcMain.handle("set-current-video-path", async (_, path: string) => {
|
||||
const restoredSession = await loadRecordedSessionForVideoPath(path);
|
||||
const normalizedPath = normalizeVideoSourcePath(path);
|
||||
if (!normalizedPath || !isPathAllowed(normalizedPath)) {
|
||||
return { success: false, message: "Video path has not been approved" };
|
||||
}
|
||||
|
||||
const restoredSession = await loadRecordedSessionForVideoPath(normalizedPath);
|
||||
if (restoredSession) {
|
||||
// Approve all media paths from the restored session so they can be read later
|
||||
approveFilePath(restoredSession.screenVideoPath);
|
||||
if (restoredSession.webcamVideoPath) {
|
||||
approveFilePath(restoredSession.webcamVideoPath);
|
||||
}
|
||||
setCurrentRecordingSessionState(restoredSession);
|
||||
} else {
|
||||
setCurrentRecordingSessionState({
|
||||
screenVideoPath: normalizeVideoSourcePath(path) ?? path,
|
||||
screenVideoPath: normalizedPath,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -371,6 +371,16 @@ app.whenReady().then(async () => {
|
||||
// Ensure recordings directory exists
|
||||
await ensureRecordingsDir();
|
||||
|
||||
function switchToHudWrapper() {
|
||||
if (mainWindow) {
|
||||
isForceClosing = true;
|
||||
mainWindow.close();
|
||||
isForceClosing = false;
|
||||
mainWindow = null;
|
||||
}
|
||||
showMainWindow();
|
||||
}
|
||||
|
||||
registerIpcHandlers(
|
||||
createEditorWindowWrapper,
|
||||
createSourceSelectorWindowWrapper,
|
||||
@@ -384,6 +394,7 @@ app.whenReady().then(async () => {
|
||||
showMainWindow();
|
||||
}
|
||||
},
|
||||
switchToHudWrapper,
|
||||
);
|
||||
createWindow();
|
||||
});
|
||||
|
||||
@@ -18,6 +18,12 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
||||
switchToEditor: () => {
|
||||
return ipcRenderer.invoke("switch-to-editor");
|
||||
},
|
||||
switchToHud: () => {
|
||||
return ipcRenderer.invoke("switch-to-hud");
|
||||
},
|
||||
startNewRecording: () => {
|
||||
return ipcRenderer.invoke("start-new-recording");
|
||||
},
|
||||
openSourceSelector: () => {
|
||||
return ipcRenderer.invoke("open-source-selector");
|
||||
},
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "openscreen",
|
||||
"version": "1.2.0",
|
||||
"version": "1.3.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "openscreen",
|
||||
"version": "1.2.0",
|
||||
"version": "1.3.0",
|
||||
"dependencies": {
|
||||
"@fix-webm-duration/fix": "^1.0.1",
|
||||
"@pixi/filter-drop-shadow": "^5.2.0",
|
||||
|
||||
+5
-1
@@ -8,6 +8,10 @@
|
||||
"node": "22.22.1",
|
||||
"npm": "10.9.4"
|
||||
},
|
||||
"author": {
|
||||
"name": "Sid",
|
||||
"email": "svaddem@asu.edu"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build && electron-builder",
|
||||
@@ -18,7 +22,7 @@
|
||||
"preview": "vite preview",
|
||||
"build:mac": "tsc && vite build && electron-builder --mac",
|
||||
"build:win": "tsc && vite build && electron-builder --win",
|
||||
"build:linux": "tsc && vite build && electron-builder --linux",
|
||||
"build:linux": "tsc && vite build && electron-builder --linux AppImage deb",
|
||||
"test": "vitest --run",
|
||||
"test:watch": "vitest",
|
||||
"build-vite": "tsc && vite build",
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { ChevronDown, Languages } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { BsRecordCircle } from "react-icons/bs";
|
||||
import { BsPauseCircle, BsPlayCircle, BsRecordCircle } from "react-icons/bs";
|
||||
import { FaRegStopCircle } from "react-icons/fa";
|
||||
import { FaFolderOpen } from "react-icons/fa6";
|
||||
import { FiMinus, FiX } from "react-icons/fi";
|
||||
import {
|
||||
MdCancel,
|
||||
MdMic,
|
||||
MdMicOff,
|
||||
MdMonitor,
|
||||
@@ -41,8 +42,11 @@ const ICON_CONFIG = {
|
||||
micOff: { icon: MdMicOff, size: ICON_SIZE },
|
||||
webcamOn: { icon: MdVideocam, size: ICON_SIZE },
|
||||
webcamOff: { icon: MdVideocamOff, size: ICON_SIZE },
|
||||
pause: { icon: BsPauseCircle, size: ICON_SIZE },
|
||||
resume: { icon: BsPlayCircle, size: ICON_SIZE },
|
||||
stop: { icon: FaRegStopCircle, size: ICON_SIZE },
|
||||
restart: { icon: MdRestartAlt, size: ICON_SIZE },
|
||||
cancel: { icon: MdCancel, size: ICON_SIZE },
|
||||
record: { icon: BsRecordCircle, size: ICON_SIZE },
|
||||
videoFile: { icon: MdVideoFile, size: ICON_SIZE },
|
||||
folder: { icon: FaFolderOpen, size: ICON_SIZE },
|
||||
@@ -77,8 +81,12 @@ export function LaunchWindow() {
|
||||
|
||||
const {
|
||||
recording,
|
||||
paused,
|
||||
elapsedSeconds,
|
||||
toggleRecording,
|
||||
togglePaused,
|
||||
restartRecording,
|
||||
cancelRecording,
|
||||
microphoneEnabled,
|
||||
setMicrophoneEnabled,
|
||||
microphoneDeviceId,
|
||||
@@ -90,8 +98,6 @@ export function LaunchWindow() {
|
||||
webcamDeviceId,
|
||||
setWebcamDeviceId,
|
||||
} = useScreenRecorder();
|
||||
const [recordingStart, setRecordingStart] = useState<number | null>(null);
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
|
||||
const showMicControls = microphoneEnabled && !recording;
|
||||
const showWebcamControls = webcamEnabled && !recording;
|
||||
@@ -146,25 +152,6 @@ export function LaunchWindow() {
|
||||
}
|
||||
}, [selectedCameraId, setWebcamDeviceId]);
|
||||
|
||||
useEffect(() => {
|
||||
let timer: NodeJS.Timeout | null = null;
|
||||
if (recording) {
|
||||
if (!recordingStart) setRecordingStart(Date.now());
|
||||
timer = setInterval(() => {
|
||||
if (recordingStart) {
|
||||
setElapsed(Math.floor((Date.now() - recordingStart) / 1000));
|
||||
}
|
||||
}, 1000);
|
||||
} else {
|
||||
setRecordingStart(null);
|
||||
setElapsed(0);
|
||||
if (timer) clearInterval(timer);
|
||||
}
|
||||
return () => {
|
||||
if (timer) clearInterval(timer);
|
||||
};
|
||||
}, [recording, recordingStart]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!import.meta.env.DEV) {
|
||||
return;
|
||||
@@ -447,7 +434,11 @@ export function LaunchWindow() {
|
||||
{/* Record/Stop group */}
|
||||
<button
|
||||
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]"
|
||||
recording
|
||||
? paused
|
||||
? "bg-amber-500/10 hover:bg-amber-500/15"
|
||||
: "animate-record-pulse bg-red-500/10"
|
||||
: "bg-white/5 hover:bg-white/[0.08]"
|
||||
}`}
|
||||
onClick={toggleRecording}
|
||||
disabled={!hasSelectedSource && !recording}
|
||||
@@ -455,9 +446,11 @@ export function LaunchWindow() {
|
||||
>
|
||||
{recording ? (
|
||||
<>
|
||||
{getIcon("stop", "text-red-400")}
|
||||
<span className="text-red-400 text-xs font-semibold tabular-nums">
|
||||
{formatTimePadded(elapsed)}
|
||||
{getIcon("stop", paused ? "text-amber-400" : "text-red-400")}
|
||||
<span
|
||||
className={`${paused ? "text-amber-400" : "text-red-400"} text-xs font-semibold tabular-nums`}
|
||||
>
|
||||
{formatTimePadded(elapsedSeconds)}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
@@ -465,6 +458,17 @@ export function LaunchWindow() {
|
||||
)}
|
||||
</button>
|
||||
|
||||
{recording && (
|
||||
<Tooltip content={paused ? t("tooltips.resumeRecording") : t("tooltips.pauseRecording")}>
|
||||
<button
|
||||
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
|
||||
onClick={togglePaused}
|
||||
>
|
||||
{getIcon(paused ? "resume" : "pause", paused ? "text-amber-400" : "text-white/60")}
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Restart recording */}
|
||||
{recording && (
|
||||
<Tooltip content={t("tooltips.restartRecording")}>
|
||||
@@ -477,6 +481,18 @@ export function LaunchWindow() {
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Cancel recording */}
|
||||
{recording && (
|
||||
<Tooltip content={t("tooltips.cancelRecording")}>
|
||||
<button
|
||||
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
|
||||
onClick={cancelRecording}
|
||||
>
|
||||
{getIcon("cancel", "text-white/60")}
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Open video file */}
|
||||
<Tooltip content={t("tooltips.openVideoFile")}>
|
||||
<button
|
||||
|
||||
@@ -37,8 +37,10 @@ export function KeyboardShortcutsHelp() {
|
||||
|
||||
<div className="pt-1 border-t border-white/5 mt-1 space-y-1.5">
|
||||
{FIXED_SHORTCUTS.map((fixed) => (
|
||||
<div key={fixed.label} className="flex items-center justify-between">
|
||||
<span className="text-slate-400">{fixed.label}</span>
|
||||
<div key={fixed.i18nKey} className="flex items-center justify-between">
|
||||
<span className="text-slate-400">
|
||||
{t(`fixedActions.${fixed.i18nKey}`, { defaultValue: fixed.label })}
|
||||
</span>
|
||||
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">
|
||||
{isMac
|
||||
? fixed.display
|
||||
|
||||
@@ -55,7 +55,70 @@ import type {
|
||||
ZoomDepth,
|
||||
ZoomFocusMode,
|
||||
} from "./types";
|
||||
import { SPEED_OPTIONS } from "./types";
|
||||
import { MAX_PLAYBACK_SPEED, SPEED_OPTIONS } from "./types";
|
||||
|
||||
function CustomSpeedInput({
|
||||
value,
|
||||
onChange,
|
||||
onError,
|
||||
}: {
|
||||
value: number;
|
||||
onChange: (val: number) => void;
|
||||
onError: () => void;
|
||||
}) {
|
||||
const isPreset = SPEED_OPTIONS.some((o) => o.speed === value);
|
||||
const [draft, setDraft] = useState(isPreset ? "" : String(Math.round(value)));
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
|
||||
const prevValue = useRef(value);
|
||||
if (!isFocused && prevValue.current !== value) {
|
||||
prevValue.current = value;
|
||||
setDraft(isPreset ? "" : String(Math.round(value)));
|
||||
}
|
||||
|
||||
const handleChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const digits = e.target.value.replace(/\D/g, "");
|
||||
if (digits === "") {
|
||||
setDraft("");
|
||||
return;
|
||||
}
|
||||
const num = Number(digits);
|
||||
if (num > MAX_PLAYBACK_SPEED) {
|
||||
onError();
|
||||
return;
|
||||
}
|
||||
setDraft(digits);
|
||||
if (num >= 1) onChange(num);
|
||||
},
|
||||
[onChange, onError],
|
||||
);
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
setIsFocused(false);
|
||||
if (!draft || Number(draft) < 1) {
|
||||
setDraft(isPreset ? "" : String(Math.round(value)));
|
||||
}
|
||||
}, [draft, isPreset, value]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
placeholder="--"
|
||||
value={draft}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={(e) => e.key === "Enter" && (e.target as HTMLInputElement).blur()}
|
||||
className="w-12 bg-white/5 border border-white/10 rounded-md px-1 py-0.5 text-[11px] font-semibold text-[#d97706] text-center focus:outline-none focus:border-[#d97706]/40"
|
||||
/>
|
||||
<span className="text-[11px] font-semibold text-slate-500">×</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const WALLPAPER_COUNT = 18;
|
||||
const WALLPAPER_RELATIVE = Array.from(
|
||||
@@ -584,7 +647,7 @@ export function SettingsPanel({
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-7 gap-1.5">
|
||||
<div className="grid grid-cols-5 gap-1.5">
|
||||
{SPEED_OPTIONS.map((option) => {
|
||||
const isActive = selectedSpeedValue === option.speed;
|
||||
return (
|
||||
@@ -609,6 +672,29 @@ export function SettingsPanel({
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span
|
||||
className={cn("text-[11px]", selectedSpeedId ? "text-slate-500" : "text-slate-600")}
|
||||
>
|
||||
{t("speed.customPlaybackSpeed")}
|
||||
</span>
|
||||
{selectedSpeedId ? (
|
||||
<CustomSpeedInput
|
||||
value={selectedSpeedValue ?? 1}
|
||||
onChange={(val) => onSpeedChange?.(val)}
|
||||
onError={() => toast.error(t("speed.maxSpeedError"))}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center gap-1 opacity-40">
|
||||
<div className="w-12 bg-white/5 border border-white/10 rounded-md px-1 py-0.5 text-[11px] font-semibold text-slate-600 text-center">
|
||||
--
|
||||
</div>
|
||||
<span className="text-[11px] font-semibold text-slate-600">×</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!selectedSpeedId && (
|
||||
<p className="text-[10px] text-slate-500 mt-2 text-center">{t("speed.selectRegion")}</p>
|
||||
)}
|
||||
|
||||
@@ -197,12 +197,14 @@ export function ShortcutsConfigDialog() {
|
||||
<p className="text-[10px] text-slate-500 mb-2 uppercase tracking-wide font-semibold">
|
||||
{t("fixed")}
|
||||
</p>
|
||||
{FIXED_SHORTCUTS.map(({ label, display }) => (
|
||||
{FIXED_SHORTCUTS.map(({ i18nKey, label, display }) => (
|
||||
<div
|
||||
key={label}
|
||||
key={i18nKey}
|
||||
className="flex items-center justify-between py-1.5 px-1 border-b border-white/5 last:border-0"
|
||||
>
|
||||
<span className="text-sm text-slate-400">{label}</span>
|
||||
<span className="text-sm text-slate-400">
|
||||
{t(`fixedActions.${i18nKey}`, { defaultValue: label })}
|
||||
</span>
|
||||
<kbd className="px-2 py-1 bg-white/5 border border-white/10 rounded text-xs font-mono text-slate-400 min-w-[90px] text-center">
|
||||
{display}
|
||||
</kbd>
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import type { Span } from "dnd-timeline";
|
||||
import { FolderOpen, Languages, Save } from "lucide-react";
|
||||
import { FolderOpen, Languages, Save, Video } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useI18n, useScopedT } from "@/contexts/I18nContext";
|
||||
import { useShortcuts } from "@/contexts/ShortcutsContext";
|
||||
import { INITIAL_EDITOR_STATE, useEditorHistory } from "@/hooks/useEditorHistory";
|
||||
@@ -20,8 +28,10 @@ import {
|
||||
type GifSizePreset,
|
||||
VideoExporter,
|
||||
} from "@/lib/exporter";
|
||||
import { computeFrameStepTime } from "@/lib/frameStep";
|
||||
import type { ProjectMedia } from "@/lib/recordingSession";
|
||||
import { matchesShortcut } from "@/lib/shortcuts";
|
||||
import { loadUserPreferences, saveUserPreferences } from "@/lib/userPreferences";
|
||||
import {
|
||||
getAspectRatioValue,
|
||||
getNativeAspectRatioValue,
|
||||
@@ -31,8 +41,10 @@ import { ExportDialog } from "./ExportDialog";
|
||||
import PlaybackControls from "./PlaybackControls";
|
||||
import {
|
||||
createProjectData,
|
||||
createProjectSnapshot,
|
||||
deriveNextId,
|
||||
fromFileUrl,
|
||||
hasProjectUnsavedChanges,
|
||||
normalizeProjectEditor,
|
||||
resolveProjectMedia,
|
||||
toFileUrl,
|
||||
@@ -100,6 +112,10 @@ export default function VideoEditor() {
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const currentTimeRef = useRef(currentTime);
|
||||
currentTimeRef.current = currentTime;
|
||||
const durationRef = useRef(duration);
|
||||
durationRef.current = duration;
|
||||
const [cursorTelemetry, setCursorTelemetry] = useState<CursorTelemetryPoint[]>([]);
|
||||
const [selectedZoomId, setSelectedZoomId] = useState<string | null>(null);
|
||||
const [selectedTrimId, setSelectedTrimId] = useState<string | null>(null);
|
||||
@@ -109,6 +125,7 @@ export default function VideoEditor() {
|
||||
const [exportProgress, setExportProgress] = useState<ExportProgress | null>(null);
|
||||
const [exportError, setExportError] = useState<string | null>(null);
|
||||
const [showExportDialog, setShowExportDialog] = useState(false);
|
||||
const [showNewRecordingDialog, setShowNewRecordingDialog] = useState(false);
|
||||
const [exportQuality, setExportQuality] = useState<ExportQuality>("good");
|
||||
const [exportFormat, setExportFormat] = useState<ExportFormat>("mp4");
|
||||
const [gifFrameRate, setGifFrameRate] = useState<GifFrameRate>(15);
|
||||
@@ -234,13 +251,11 @@ export default function VideoEditor() {
|
||||
) + 1;
|
||||
|
||||
setLastSavedSnapshot(
|
||||
JSON.stringify(
|
||||
createProjectData(
|
||||
webcamSourcePath
|
||||
? { screenVideoPath: sourcePath, webcamVideoPath: webcamSourcePath }
|
||||
: { screenVideoPath: sourcePath },
|
||||
normalizedEditor,
|
||||
),
|
||||
createProjectSnapshot(
|
||||
webcamSourcePath
|
||||
? { screenVideoPath: sourcePath, webcamVideoPath: webcamSourcePath }
|
||||
: { screenVideoPath: sourcePath },
|
||||
normalizedEditor,
|
||||
),
|
||||
);
|
||||
return true;
|
||||
@@ -252,30 +267,28 @@ export default function VideoEditor() {
|
||||
if (!currentProjectMedia) {
|
||||
return null;
|
||||
}
|
||||
return JSON.stringify(
|
||||
createProjectData(currentProjectMedia, {
|
||||
wallpaper,
|
||||
shadowIntensity,
|
||||
showBlur,
|
||||
motionBlurAmount,
|
||||
borderRadius,
|
||||
padding,
|
||||
cropRegion,
|
||||
zoomRegions,
|
||||
trimRegions,
|
||||
speedRegions,
|
||||
annotationRegions,
|
||||
aspectRatio,
|
||||
webcamLayoutPreset,
|
||||
webcamMaskShape,
|
||||
webcamPosition,
|
||||
exportQuality,
|
||||
exportFormat,
|
||||
gifFrameRate,
|
||||
gifLoop,
|
||||
gifSizePreset,
|
||||
}),
|
||||
);
|
||||
return createProjectSnapshot(currentProjectMedia, {
|
||||
wallpaper,
|
||||
shadowIntensity,
|
||||
showBlur,
|
||||
motionBlurAmount,
|
||||
borderRadius,
|
||||
padding,
|
||||
cropRegion,
|
||||
zoomRegions,
|
||||
trimRegions,
|
||||
speedRegions,
|
||||
annotationRegions,
|
||||
aspectRatio,
|
||||
webcamLayoutPreset,
|
||||
webcamMaskShape,
|
||||
webcamPosition,
|
||||
exportQuality,
|
||||
exportFormat,
|
||||
gifFrameRate,
|
||||
gifLoop,
|
||||
gifSizePreset,
|
||||
});
|
||||
}, [
|
||||
currentProjectMedia,
|
||||
wallpaper,
|
||||
@@ -300,12 +313,7 @@ export default function VideoEditor() {
|
||||
gifSizePreset,
|
||||
]);
|
||||
|
||||
const hasUnsavedChanges = Boolean(
|
||||
currentProjectPath &&
|
||||
currentProjectSnapshot &&
|
||||
lastSavedSnapshot &&
|
||||
currentProjectSnapshot !== lastSavedSnapshot,
|
||||
);
|
||||
const hasUnsavedChanges = hasProjectUnsavedChanges(currentProjectSnapshot, lastSavedSnapshot);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadInitialData() {
|
||||
@@ -333,7 +341,14 @@ export default function VideoEditor() {
|
||||
setWebcamVideoSourcePath(webcamSourcePath);
|
||||
setWebcamVideoPath(webcamSourcePath ? toFileUrl(webcamSourcePath) : null);
|
||||
setCurrentProjectPath(null);
|
||||
setLastSavedSnapshot(null);
|
||||
setLastSavedSnapshot(
|
||||
createProjectSnapshot(
|
||||
webcamSourcePath
|
||||
? { screenVideoPath: sourcePath, webcamVideoPath: webcamSourcePath }
|
||||
: { screenVideoPath: sourcePath },
|
||||
INITIAL_EDITOR_STATE,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -345,7 +360,9 @@ export default function VideoEditor() {
|
||||
setWebcamVideoSourcePath(null);
|
||||
setWebcamVideoPath(null);
|
||||
setCurrentProjectPath(null);
|
||||
setLastSavedSnapshot(null);
|
||||
setLastSavedSnapshot(
|
||||
createProjectSnapshot({ screenVideoPath: sourcePath }, INITIAL_EDITOR_STATE),
|
||||
);
|
||||
} else {
|
||||
setError("No video to load. Please record or select a video.");
|
||||
}
|
||||
@@ -359,6 +376,28 @@ export default function VideoEditor() {
|
||||
loadInitialData();
|
||||
}, [applyLoadedProject]);
|
||||
|
||||
// Track whether user preferences have been loaded to avoid
|
||||
// overwriting saved prefs with defaults on the first render
|
||||
const [prefsHydrated, setPrefsHydrated] = useState(false);
|
||||
|
||||
// Load persisted user preferences on mount (intentionally runs once)
|
||||
useEffect(() => {
|
||||
const prefs = loadUserPreferences();
|
||||
updateState({
|
||||
padding: prefs.padding,
|
||||
aspectRatio: prefs.aspectRatio,
|
||||
});
|
||||
setExportQuality(prefs.exportQuality);
|
||||
setExportFormat(prefs.exportFormat);
|
||||
setPrefsHydrated(true);
|
||||
}, [updateState]);
|
||||
|
||||
// Auto-save user preferences when settings change
|
||||
useEffect(() => {
|
||||
if (!prefsHydrated) return;
|
||||
saveUserPreferences({ padding, aspectRatio, exportQuality, exportFormat });
|
||||
}, [prefsHydrated, padding, aspectRatio, exportQuality, exportFormat]);
|
||||
|
||||
const saveProject = useCallback(
|
||||
async (forceSaveAs: boolean) => {
|
||||
if (!videoPath) {
|
||||
@@ -471,6 +510,16 @@ export default function VideoEditor() {
|
||||
await saveProject(true);
|
||||
}, [saveProject]);
|
||||
|
||||
const handleNewRecordingConfirm = useCallback(async () => {
|
||||
const result = await window.electronAPI.startNewRecording();
|
||||
if (result.success) {
|
||||
setShowNewRecordingDialog(false);
|
||||
} else {
|
||||
console.error("Failed to start new recording:", result.error);
|
||||
setError("Failed to start new recording: " + (result.error || "Unknown error"));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleLoadProject = useCallback(async () => {
|
||||
const result = await window.electronAPI.loadProjectFile();
|
||||
|
||||
@@ -945,6 +994,40 @@ export default function VideoEditor() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Frame-step navigation (arrow keys, no modifiers)
|
||||
if (
|
||||
(e.key === "ArrowLeft" || e.key === "ArrowRight") &&
|
||||
!e.ctrlKey &&
|
||||
!e.metaKey &&
|
||||
!e.shiftKey &&
|
||||
!e.altKey
|
||||
) {
|
||||
const target = e.target;
|
||||
if (
|
||||
target instanceof HTMLInputElement ||
|
||||
target instanceof HTMLTextAreaElement ||
|
||||
target instanceof HTMLSelectElement ||
|
||||
(target instanceof HTMLElement &&
|
||||
(target.isContentEditable ||
|
||||
target.closest('[role="separator"], [role="slider"], [role="spinbutton"]')))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
const video = videoPlaybackRef.current?.video;
|
||||
if (!video) {
|
||||
return;
|
||||
}
|
||||
const direction = e.key === "ArrowLeft" ? "backward" : "forward";
|
||||
const newTime = computeFrameStepTime(
|
||||
video.currentTime,
|
||||
Number.isFinite(video.duration) ? video.duration : durationRef.current,
|
||||
direction,
|
||||
);
|
||||
video.currentTime = newTime;
|
||||
return;
|
||||
}
|
||||
|
||||
const isInput =
|
||||
e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement;
|
||||
|
||||
@@ -1418,6 +1501,34 @@ export default function VideoEditor() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-[#09090b] text-slate-200 overflow-hidden selection:bg-[#34B27B]/30">
|
||||
<Dialog open={showNewRecordingDialog} onOpenChange={setShowNewRecordingDialog}>
|
||||
<DialogContent
|
||||
className="sm:max-w-[425px]"
|
||||
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("newRecording.title")}</DialogTitle>
|
||||
<DialogDescription>{t("newRecording.description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowNewRecordingDialog(false)}
|
||||
className="px-4 py-2 rounded-md bg-white/10 text-white hover:bg-white/20 text-sm font-medium transition-colors"
|
||||
>
|
||||
{t("newRecording.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNewRecordingConfirm}
|
||||
className="px-4 py-2 rounded-md bg-[#34B27B] text-white hover:bg-[#34B27B]/90 text-sm font-medium transition-colors"
|
||||
>
|
||||
{t("newRecording.confirm")}
|
||||
</button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<div
|
||||
className="h-10 flex-shrink-0 bg-[#09090b]/80 backdrop-blur-md border-b border-white/5 flex items-center justify-between px-6 z-50"
|
||||
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
|
||||
@@ -1443,6 +1554,14 @@ export default function VideoEditor() {
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowNewRecordingDialog(true)}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded-md text-white/50 hover:text-white/90 hover:bg-white/10 transition-all duration-150 text-[11px] font-medium"
|
||||
>
|
||||
<Video size={14} />
|
||||
{t("newRecording.title")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLoadProject}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createProjectData,
|
||||
createProjectSnapshot,
|
||||
hasProjectUnsavedChanges,
|
||||
normalizeProjectEditor,
|
||||
PROJECT_VERSION,
|
||||
resolveProjectMedia,
|
||||
@@ -65,3 +67,39 @@ describe("projectPersistence media compatibility", () => {
|
||||
).toBe("rectangle");
|
||||
});
|
||||
});
|
||||
|
||||
it("creates stable snapshots for identical project state", () => {
|
||||
const media = {
|
||||
screenVideoPath: "/tmp/screen.webm",
|
||||
webcamVideoPath: "/tmp/webcam.webm",
|
||||
};
|
||||
const editor = normalizeProjectEditor({
|
||||
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",
|
||||
webcamLayoutPreset: "picture-in-picture",
|
||||
webcamMaskShape: "circle",
|
||||
exportQuality: "good",
|
||||
exportFormat: "mp4",
|
||||
gifFrameRate: 15,
|
||||
gifLoop: true,
|
||||
gifSizePreset: "medium",
|
||||
});
|
||||
|
||||
expect(createProjectSnapshot(media, editor)).toBe(createProjectSnapshot(media, editor));
|
||||
});
|
||||
|
||||
it("detects unsaved changes from differing snapshots", () => {
|
||||
expect(hasProjectUnsavedChanges(null, null)).toBe(false);
|
||||
expect(hasProjectUnsavedChanges("same", "same")).toBe(false);
|
||||
expect(hasProjectUnsavedChanges("current", "baseline")).toBe(true);
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ASPECT_RATIOS, type AspectRatio } from "@/utils/aspectRatioUtils";
|
||||
import {
|
||||
type AnnotationRegion,
|
||||
type CropRegion,
|
||||
clampPlaybackSpeed,
|
||||
DEFAULT_ANNOTATION_POSITION,
|
||||
DEFAULT_ANNOTATION_SIZE,
|
||||
DEFAULT_ANNOTATION_STYLE,
|
||||
@@ -15,6 +16,8 @@ import {
|
||||
DEFAULT_WEBCAM_MASK_SHAPE,
|
||||
DEFAULT_WEBCAM_POSITION,
|
||||
DEFAULT_ZOOM_DEPTH,
|
||||
MAX_PLAYBACK_SPEED,
|
||||
MIN_PLAYBACK_SPEED,
|
||||
type SpeedRegion,
|
||||
type TrimRegion,
|
||||
type WebcamLayoutPreset,
|
||||
@@ -223,14 +226,10 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
|
||||
const endMs = Math.max(startMs + 1, rawEnd);
|
||||
|
||||
const speed =
|
||||
region.speed === 0.25 ||
|
||||
region.speed === 0.5 ||
|
||||
region.speed === 0.75 ||
|
||||
region.speed === 1.25 ||
|
||||
region.speed === 1.5 ||
|
||||
region.speed === 1.75 ||
|
||||
region.speed === 2
|
||||
? region.speed
|
||||
isFiniteNumber(region.speed) &&
|
||||
region.speed >= MIN_PLAYBACK_SPEED &&
|
||||
region.speed <= MAX_PLAYBACK_SPEED
|
||||
? clampPlaybackSpeed(region.speed)
|
||||
: DEFAULT_PLAYBACK_SPEED;
|
||||
|
||||
return {
|
||||
@@ -405,3 +404,19 @@ export function createProjectData(
|
||||
editor,
|
||||
};
|
||||
}
|
||||
|
||||
export function createProjectSnapshot(
|
||||
media: ProjectMedia,
|
||||
editor: Partial<ProjectEditorState>,
|
||||
): string {
|
||||
return JSON.stringify(createProjectData(media, normalizeProjectEditor(editor)));
|
||||
}
|
||||
|
||||
export function hasProjectUnsavedChanges(
|
||||
currentSnapshot: string | null,
|
||||
baselineSnapshot: string | null,
|
||||
): boolean {
|
||||
return Boolean(
|
||||
currentSnapshot !== null && baselineSnapshot !== null && currentSnapshot !== baselineSnapshot,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -138,7 +138,16 @@ export const DEFAULT_CROP_REGION: CropRegion = {
|
||||
height: 1,
|
||||
};
|
||||
|
||||
export type PlaybackSpeed = 0.25 | 0.5 | 0.75 | 1.25 | 1.5 | 1.75 | 2;
|
||||
export type PlaybackSpeed = number;
|
||||
|
||||
export const MIN_PLAYBACK_SPEED = 0.1;
|
||||
// Anything above 16x causes the playhead to stall during preview
|
||||
// due to the video decoder not being able to keep up.
|
||||
export const MAX_PLAYBACK_SPEED = 16;
|
||||
|
||||
export function clampPlaybackSpeed(speed: number): PlaybackSpeed {
|
||||
return Math.round(Math.min(MAX_PLAYBACK_SPEED, Math.max(MIN_PLAYBACK_SPEED, speed)) * 100) / 100;
|
||||
}
|
||||
|
||||
export interface SpeedRegion {
|
||||
id: string;
|
||||
@@ -155,6 +164,9 @@ export const SPEED_OPTIONS: Array<{ speed: PlaybackSpeed; label: string }> = [
|
||||
{ speed: 1.5, label: "1.5×" },
|
||||
{ speed: 1.75, label: "1.75×" },
|
||||
{ speed: 2, label: "2×" },
|
||||
{ speed: 3, label: "3×" },
|
||||
{ speed: 4, label: "4×" },
|
||||
{ speed: 5, label: "5×" },
|
||||
];
|
||||
|
||||
export const DEFAULT_PLAYBACK_SPEED: PlaybackSpeed = 1.5;
|
||||
|
||||
+122
-10
@@ -41,8 +41,12 @@ const WEBCAM_TARGET_FRAME_RATE = 30;
|
||||
|
||||
type UseScreenRecorderReturn = {
|
||||
recording: boolean;
|
||||
paused: boolean;
|
||||
elapsedSeconds: number;
|
||||
toggleRecording: () => void;
|
||||
togglePaused: () => void;
|
||||
restartRecording: () => void;
|
||||
cancelRecording: () => void;
|
||||
microphoneEnabled: boolean;
|
||||
setMicrophoneEnabled: (enabled: boolean) => void;
|
||||
microphoneDeviceId: string | undefined;
|
||||
@@ -85,6 +89,8 @@ function createRecorderHandle(stream: MediaStream, options: MediaRecorderOptions
|
||||
export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
const t = useScopedT("editor");
|
||||
const [recording, setRecording] = useState(false);
|
||||
const [paused, setPaused] = useState(false);
|
||||
const [elapsedSeconds, setElapsedSeconds] = useState(0);
|
||||
const [microphoneEnabled, setMicrophoneEnabled] = useState(false);
|
||||
const [microphoneDeviceId, setMicrophoneDeviceId] = useState<string | undefined>(undefined);
|
||||
const [webcamDeviceId, setWebcamDeviceId] = useState<string | undefined>(undefined);
|
||||
@@ -97,13 +103,20 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
const microphoneStream = useRef<MediaStream | null>(null);
|
||||
const webcamStream = useRef<MediaStream | null>(null);
|
||||
const mixingContext = useRef<AudioContext | null>(null);
|
||||
const startTime = useRef<number>(0);
|
||||
const recordingId = useRef<number>(0);
|
||||
const accumulatedDurationMs = useRef(0);
|
||||
const segmentStartedAt = useRef<number | null>(null);
|
||||
const finalizingRecordingId = useRef<number | null>(null);
|
||||
const allowAutoFinalize = useRef(false);
|
||||
const discardRecordingId = useRef<number | null>(null);
|
||||
const restarting = useRef(false);
|
||||
|
||||
const getRecordingDurationMs = useCallback(() => {
|
||||
const segmentDuration =
|
||||
segmentStartedAt.current === null ? 0 : Date.now() - segmentStartedAt.current;
|
||||
return accumulatedDurationMs.current + segmentDuration;
|
||||
}, []);
|
||||
|
||||
const selectMimeType = () => {
|
||||
const preferred = [
|
||||
"video/webm;codecs=av1",
|
||||
@@ -202,6 +215,10 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
|
||||
teardownMedia();
|
||||
setRecording(false);
|
||||
setPaused(false);
|
||||
setElapsedSeconds(0);
|
||||
accumulatedDurationMs.current = 0;
|
||||
segmentStartedAt.current = null;
|
||||
window.electronAPI?.setRecordingState(false);
|
||||
|
||||
void (async () => {
|
||||
@@ -273,7 +290,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
}
|
||||
|
||||
const activeWebcamRecorder = webcamRecorder.current;
|
||||
const duration = Date.now() - startTime.current;
|
||||
const duration = getRecordingDurationMs();
|
||||
const activeRecordingId = recordingId.current;
|
||||
|
||||
finalizeRecording(
|
||||
@@ -283,7 +300,10 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
activeRecordingId,
|
||||
);
|
||||
|
||||
if (activeScreenRecorder.recorder.state === "recording") {
|
||||
if (
|
||||
activeScreenRecorder.recorder.state === "recording" ||
|
||||
activeScreenRecorder.recorder.state === "paused"
|
||||
) {
|
||||
try {
|
||||
activeScreenRecorder.recorder.stop();
|
||||
} catch {
|
||||
@@ -291,7 +311,10 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
}
|
||||
}
|
||||
if (activeWebcamRecorder) {
|
||||
if (activeWebcamRecorder.recorder.state === "recording") {
|
||||
if (
|
||||
activeWebcamRecorder.recorder.state === "recording" ||
|
||||
activeWebcamRecorder.recorder.state === "paused"
|
||||
) {
|
||||
try {
|
||||
activeWebcamRecorder.recorder.stop();
|
||||
} catch {
|
||||
@@ -316,14 +339,20 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
restarting.current = false;
|
||||
discardRecordingId.current = null;
|
||||
|
||||
if (screenRecorder.current?.recorder.state === "recording") {
|
||||
if (
|
||||
screenRecorder.current?.recorder.state === "recording" ||
|
||||
screenRecorder.current?.recorder.state === "paused"
|
||||
) {
|
||||
try {
|
||||
screenRecorder.current.recorder.stop();
|
||||
} catch {
|
||||
// Ignore recorder teardown errors during cleanup.
|
||||
}
|
||||
}
|
||||
if (webcamRecorder.current?.recorder.state === "recording") {
|
||||
if (
|
||||
webcamRecorder.current?.recorder.state === "recording" ||
|
||||
webcamRecorder.current?.recorder.state === "paused"
|
||||
) {
|
||||
try {
|
||||
webcamRecorder.current.recorder.stop();
|
||||
} catch {
|
||||
@@ -518,9 +547,12 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
}
|
||||
|
||||
recordingId.current = Date.now();
|
||||
startTime.current = recordingId.current;
|
||||
accumulatedDurationMs.current = 0;
|
||||
segmentStartedAt.current = Date.now();
|
||||
allowAutoFinalize.current = true;
|
||||
setRecording(true);
|
||||
setPaused(false);
|
||||
setElapsedSeconds(0);
|
||||
window.electronAPI?.setRecordingState(true);
|
||||
|
||||
const activeScreenRecorder = screenRecorder.current;
|
||||
@@ -536,7 +568,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
finalizeRecording(
|
||||
activeScreenRecorder,
|
||||
activeWebcamRecorder ?? null,
|
||||
Math.max(0, Date.now() - startTime.current),
|
||||
Math.max(0, getRecordingDurationMs()),
|
||||
activeRecordingId,
|
||||
);
|
||||
},
|
||||
@@ -552,12 +584,56 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
toast.error(errorMsg);
|
||||
}
|
||||
setRecording(false);
|
||||
setPaused(false);
|
||||
setElapsedSeconds(0);
|
||||
accumulatedDurationMs.current = 0;
|
||||
segmentStartedAt.current = null;
|
||||
screenRecorder.current = null;
|
||||
webcamRecorder.current = null;
|
||||
teardownMedia();
|
||||
}
|
||||
};
|
||||
|
||||
const togglePaused = () => {
|
||||
const activeScreenRecorder = screenRecorder.current?.recorder;
|
||||
if (!activeScreenRecorder || activeScreenRecorder.state === "inactive") {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeWebcamRecorder = webcamRecorder.current?.recorder;
|
||||
|
||||
if (activeScreenRecorder.state === "paused") {
|
||||
try {
|
||||
activeScreenRecorder.resume();
|
||||
if (activeWebcamRecorder?.state === "paused") {
|
||||
activeWebcamRecorder.resume();
|
||||
}
|
||||
segmentStartedAt.current = Date.now();
|
||||
setPaused(false);
|
||||
} catch (error) {
|
||||
console.error("Failed to resume recording:", error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeScreenRecorder.state !== "recording") {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
accumulatedDurationMs.current = getRecordingDurationMs();
|
||||
segmentStartedAt.current = null;
|
||||
setElapsedSeconds(Math.floor(accumulatedDurationMs.current / 1000));
|
||||
activeScreenRecorder.pause();
|
||||
if (activeWebcamRecorder?.state === "recording") {
|
||||
activeWebcamRecorder.pause();
|
||||
}
|
||||
setPaused(true);
|
||||
} catch (error) {
|
||||
console.error("Failed to pause recording:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleRecording = () => {
|
||||
recording ? stopRecording.current() : startRecording();
|
||||
};
|
||||
@@ -566,7 +642,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
if (restarting.current) return;
|
||||
|
||||
const activeScreenRecorder = screenRecorder.current;
|
||||
if (!activeScreenRecorder || activeScreenRecorder.recorder.state !== "recording") return;
|
||||
if (!activeScreenRecorder || activeScreenRecorder.recorder.state === "inactive") return;
|
||||
|
||||
const activeWebcamRecorder = webcamRecorder.current;
|
||||
const activeRecordingId = recordingId.current;
|
||||
@@ -581,7 +657,10 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
}),
|
||||
];
|
||||
|
||||
if (activeWebcamRecorder?.recorder.state === "recording") {
|
||||
if (
|
||||
activeWebcamRecorder?.recorder.state === "recording" ||
|
||||
activeWebcamRecorder?.recorder.state === "paused"
|
||||
) {
|
||||
stopPromises.push(
|
||||
new Promise<void>((resolve) => {
|
||||
activeWebcamRecorder.recorder.addEventListener("stop", () => resolve(), {
|
||||
@@ -601,10 +680,43 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!recording) {
|
||||
setElapsedSeconds(0);
|
||||
return;
|
||||
}
|
||||
|
||||
setElapsedSeconds(Math.floor(getRecordingDurationMs() / 1000));
|
||||
if (paused) {
|
||||
return;
|
||||
}
|
||||
|
||||
const interval = window.setInterval(() => {
|
||||
setElapsedSeconds(Math.floor(getRecordingDurationMs() / 1000));
|
||||
}, 250);
|
||||
|
||||
return () => window.clearInterval(interval);
|
||||
}, [getRecordingDurationMs, paused, recording]);
|
||||
|
||||
const cancelRecording = () => {
|
||||
const activeScreenRecorder = screenRecorder.current;
|
||||
if (!activeScreenRecorder || activeScreenRecorder.recorder.state !== "recording") return;
|
||||
|
||||
const activeRecordingId = recordingId.current;
|
||||
discardRecordingId.current = activeRecordingId;
|
||||
allowAutoFinalize.current = false;
|
||||
|
||||
stopRecording.current();
|
||||
};
|
||||
|
||||
return {
|
||||
recording,
|
||||
paused,
|
||||
elapsedSeconds,
|
||||
toggleRecording,
|
||||
togglePaused,
|
||||
restartRecording,
|
||||
cancelRecording,
|
||||
microphoneEnabled,
|
||||
setMicrophoneEnabled,
|
||||
microphoneDeviceId,
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
{
|
||||
"newRecording": {
|
||||
"title": "Return to Recorder",
|
||||
"description": "Your current session has been saved.",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Confirm"
|
||||
},
|
||||
"errors": {
|
||||
"noVideoLoaded": "No video loaded",
|
||||
"videoNotReady": "Video not ready",
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
"hideHUD": "Hide HUD",
|
||||
"closeApp": "Close App",
|
||||
"restartRecording": "Restart recording",
|
||||
"cancelRecording": "Cancel recording",
|
||||
"pauseRecording": "Pause recording",
|
||||
"resumeRecording": "Resume recording",
|
||||
"openVideoFile": "Open video file",
|
||||
"openProject": "Open project"
|
||||
},
|
||||
|
||||
@@ -13,7 +13,9 @@
|
||||
"speed": {
|
||||
"playbackSpeed": "Playback Speed",
|
||||
"selectRegion": "Select a speed region to adjust",
|
||||
"deleteRegion": "Delete Speed Region"
|
||||
"deleteRegion": "Delete Speed Region",
|
||||
"customPlaybackSpeed": "Custom Playback Speed",
|
||||
"maxSpeedError": "Speed can't go higher than 16×"
|
||||
},
|
||||
"trim": {
|
||||
"deleteRegion": "Delete Trim Region"
|
||||
|
||||
@@ -29,6 +29,8 @@
|
||||
"cycleAnnotationsBackward": "Cycle Annotations Backward",
|
||||
"deleteSelectedAlt": "Delete Selected (alt)",
|
||||
"panTimeline": "Pan Timeline",
|
||||
"zoomTimeline": "Zoom Timeline"
|
||||
"zoomTimeline": "Zoom Timeline",
|
||||
"frameBack": "Frame Back",
|
||||
"frameForward": "Frame Forward"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
"hideHUD": "Ocultar HUD",
|
||||
"closeApp": "Cerrar aplicación",
|
||||
"restartRecording": "Reiniciar grabación",
|
||||
"cancelRecording": "Cancelar grabación",
|
||||
"pauseRecording": "Pausar grabación",
|
||||
"resumeRecording": "Reanudar grabación",
|
||||
"openVideoFile": "Abrir archivo de video",
|
||||
"openProject": "Abrir proyecto"
|
||||
},
|
||||
|
||||
@@ -13,7 +13,9 @@
|
||||
"speed": {
|
||||
"playbackSpeed": "Velocidad de reproducción",
|
||||
"selectRegion": "Selecciona una región de velocidad para ajustar",
|
||||
"deleteRegion": "Eliminar región de velocidad"
|
||||
"deleteRegion": "Eliminar región de velocidad",
|
||||
"customPlaybackSpeed": "Velocidad personalizada",
|
||||
"maxSpeedError": "La velocidad no puede superar 16×"
|
||||
},
|
||||
"trim": {
|
||||
"deleteRegion": "Eliminar región de recorte"
|
||||
|
||||
@@ -29,6 +29,8 @@
|
||||
"cycleAnnotationsBackward": "Recorrer anotaciones hacia atrás",
|
||||
"deleteSelectedAlt": "Eliminar seleccionado (alt)",
|
||||
"panTimeline": "Desplazar línea de tiempo",
|
||||
"zoomTimeline": "Zoom en línea de tiempo"
|
||||
"zoomTimeline": "Zoom en línea de tiempo",
|
||||
"frameBack": "Fotograma anterior",
|
||||
"frameForward": "Fotograma siguiente"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
"hideHUD": "隐藏控制面板",
|
||||
"closeApp": "关闭应用",
|
||||
"restartRecording": "重新开始录制",
|
||||
"cancelRecording": "取消录制",
|
||||
"pauseRecording": "暂停录制",
|
||||
"resumeRecording": "继续录制",
|
||||
"openVideoFile": "打开视频文件",
|
||||
"openProject": "打开项目"
|
||||
},
|
||||
|
||||
@@ -13,7 +13,9 @@
|
||||
"speed": {
|
||||
"playbackSpeed": "播放速度",
|
||||
"selectRegion": "选择要调整的速度区域",
|
||||
"deleteRegion": "删除速度区域"
|
||||
"deleteRegion": "删除速度区域",
|
||||
"customPlaybackSpeed": "自定义播放速度",
|
||||
"maxSpeedError": "速度不能超过 16×"
|
||||
},
|
||||
"trim": {
|
||||
"deleteRegion": "删除剪辑区域"
|
||||
|
||||
@@ -29,6 +29,8 @@
|
||||
"cycleAnnotationsBackward": "向后切换标注",
|
||||
"deleteSelectedAlt": "删除所选(替代)",
|
||||
"panTimeline": "平移时间轴",
|
||||
"zoomTimeline": "缩放时间轴"
|
||||
"zoomTimeline": "缩放时间轴",
|
||||
"frameBack": "上一帧",
|
||||
"frameForward": "下一帧"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { computeFrameStepTime, FRAME_DURATION_SEC } from "@/lib/frameStep";
|
||||
|
||||
describe("computeFrameStepTime", () => {
|
||||
const duration = 10;
|
||||
|
||||
it("moves forward by one frame from the middle", () => {
|
||||
const result = computeFrameStepTime(5, duration, "forward");
|
||||
expect(result).toBeCloseTo(5 + FRAME_DURATION_SEC, 10);
|
||||
});
|
||||
|
||||
it("moves backward by one frame from the middle", () => {
|
||||
const result = computeFrameStepTime(5, duration, "backward");
|
||||
expect(result).toBeCloseTo(5 - FRAME_DURATION_SEC, 10);
|
||||
});
|
||||
|
||||
it("clamps to 0 when stepping backward at the beginning", () => {
|
||||
const result = computeFrameStepTime(0, duration, "backward");
|
||||
expect(result).toBe(0);
|
||||
});
|
||||
|
||||
it("clamps to 0 when stepping backward near the beginning", () => {
|
||||
const result = computeFrameStepTime(FRAME_DURATION_SEC / 2, duration, "backward");
|
||||
expect(result).toBe(0);
|
||||
});
|
||||
|
||||
it("clamps to duration when stepping forward at the end", () => {
|
||||
const result = computeFrameStepTime(duration, duration, "forward");
|
||||
expect(result).toBe(duration);
|
||||
});
|
||||
|
||||
it("clamps to duration when stepping forward near the end", () => {
|
||||
const result = computeFrameStepTime(duration - FRAME_DURATION_SEC / 2, duration, "forward");
|
||||
expect(result).toBe(duration);
|
||||
});
|
||||
|
||||
it("handles duration of 0 gracefully", () => {
|
||||
expect(computeFrameStepTime(0, 0, "forward")).toBe(0);
|
||||
expect(computeFrameStepTime(0, 0, "backward")).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,52 @@ import type { SpeedRegion, TrimRegion } from "@/components/video-editor/types";
|
||||
|
||||
const SOURCE_LOAD_TIMEOUT_MS = 60_000;
|
||||
|
||||
/**
|
||||
* Build a full WebCodecs-compatible AV1 codec string from the AV1CodecConfigurationRecord.
|
||||
* web-demuxer may return a bare "av01" when the WASM-side parser fails to read
|
||||
* the extradata (e.g. raw OBU sequence header from WebM instead of ISOBMFF av1C box).
|
||||
* This function parses the record if present, otherwise returns a safe default.
|
||||
*
|
||||
* @see https://aomediacodec.github.io/av1-isobmff/#av1codecconfigurationbox-section
|
||||
*/
|
||||
function buildAV1CodecString(description?: BufferSource): string {
|
||||
const fallback = "av01.0.01M.08";
|
||||
|
||||
if (!description) return fallback;
|
||||
|
||||
const bytes =
|
||||
description instanceof ArrayBuffer
|
||||
? new Uint8Array(description)
|
||||
: new Uint8Array(description.buffer, description.byteOffset, description.byteLength);
|
||||
|
||||
// AV1CodecConfigurationRecord layout (4+ bytes):
|
||||
// Byte 0: marker (1) | version (7)
|
||||
// Byte 1: seq_profile (3) | seq_level_idx_0 (5)
|
||||
// Byte 2: seq_tier_0 (1) | high_bitdepth (1) | twelve_bit (1) | ...
|
||||
// The spec says version should be 1, but Chrome/Electron's MediaRecorder
|
||||
// may write version 127 (0xFF first byte). We accept any version as long
|
||||
// as the marker bit is set and the record is long enough.
|
||||
if (bytes.length < 4) return fallback;
|
||||
if (!(bytes[0] & 0x80)) return fallback; // marker bit must be 1
|
||||
|
||||
// Byte 1: seq_profile (3) | seq_level_idx_0 (5)
|
||||
const profile = (bytes[1] >> 5) & 0x07;
|
||||
const level = bytes[1] & 0x1f;
|
||||
|
||||
// Byte 2: seq_tier_0 (1) | high_bitdepth (1) | twelve_bit (1) | monochrome (1) | ...
|
||||
const tier = (bytes[2] >> 7) & 0x01;
|
||||
const highBitdepth = (bytes[2] >> 6) & 0x01;
|
||||
const twelveBit = (bytes[2] >> 5) & 0x01;
|
||||
let bitdepth = 8;
|
||||
if (highBitdepth) bitdepth = twelveBit ? 12 : 10;
|
||||
|
||||
const tierChar = tier ? "H" : "M";
|
||||
const levelStr = level.toString().padStart(2, "0");
|
||||
const bitdepthStr = bitdepth.toString().padStart(2, "0");
|
||||
|
||||
return `av01.${profile}.${levelStr}${tierChar}.${bitdepthStr}`;
|
||||
}
|
||||
|
||||
export interface DecodedVideoInfo {
|
||||
width: number;
|
||||
height: number;
|
||||
@@ -183,7 +229,17 @@ export class StreamingVideoDecoder {
|
||||
}
|
||||
|
||||
const decoderConfig = await this.demuxer.getDecoderConfig("video");
|
||||
const codec = this.metadata.codec.toLowerCase();
|
||||
|
||||
// web-demuxer may return a bare "av01" for AV1 in WebM containers when the
|
||||
// extradata isn't in the expected ISOBMFF format. WebCodecs requires the
|
||||
// full parametrized form (e.g. "av01.0.05M.08").
|
||||
if (/^av01$/i.test(decoderConfig.codec)) {
|
||||
decoderConfig.codec = buildAV1CodecString(
|
||||
decoderConfig.description as BufferSource | undefined,
|
||||
);
|
||||
}
|
||||
|
||||
const codec = decoderConfig.codec.toLowerCase();
|
||||
const shouldPreferSoftwareDecode = codec.includes("av01") || codec.includes("av1");
|
||||
const segments = this.splitBySpeed(
|
||||
this.computeSegments(this.metadata.duration, trimRegions),
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
/** Duration of a single frame in seconds at 60 FPS (~16.67ms). */
|
||||
export const FRAME_DURATION_SEC = 1 / 60;
|
||||
|
||||
/**
|
||||
* Compute the new playhead time after stepping one frame forward or backward.
|
||||
* The result is clamped to the range [0, duration].
|
||||
*/
|
||||
export function computeFrameStepTime(
|
||||
currentTime: number,
|
||||
duration: number,
|
||||
direction: "forward" | "backward",
|
||||
): number {
|
||||
const delta = direction === "forward" ? FRAME_DURATION_SEC : -FRAME_DURATION_SEC;
|
||||
return Math.min(duration, Math.max(0, currentTime + delta));
|
||||
}
|
||||
+25
-4
@@ -21,14 +21,16 @@ export interface ShortcutBinding {
|
||||
export type ShortcutsConfig = Record<ShortcutAction, ShortcutBinding>;
|
||||
|
||||
export interface FixedShortcut {
|
||||
i18nKey: string;
|
||||
label: string;
|
||||
display: string;
|
||||
bindings: ShortcutBinding[];
|
||||
}
|
||||
|
||||
export const FIXED_SHORTCUTS: FixedShortcut[] = [
|
||||
{ label: "Undo", display: "Ctrl + Z", bindings: [{ key: "z", ctrl: true }] },
|
||||
{ i18nKey: "undo", label: "Undo", display: "Ctrl + Z", bindings: [{ key: "z", ctrl: true }] },
|
||||
{
|
||||
i18nKey: "redo",
|
||||
label: "Redo",
|
||||
display: "Ctrl + Shift + Z / Ctrl + Y",
|
||||
bindings: [
|
||||
@@ -36,19 +38,38 @@ export const FIXED_SHORTCUTS: FixedShortcut[] = [
|
||||
{ key: "y", ctrl: true },
|
||||
],
|
||||
},
|
||||
{ label: "Cycle Annotations Forward", display: "Tab", bindings: [{ key: "tab" }] },
|
||||
{
|
||||
i18nKey: "cycleAnnotationsForward",
|
||||
label: "Cycle Annotations Forward",
|
||||
display: "Tab",
|
||||
bindings: [{ key: "tab" }],
|
||||
},
|
||||
{
|
||||
i18nKey: "cycleAnnotationsBackward",
|
||||
label: "Cycle Annotations Backward",
|
||||
display: "Shift + Tab",
|
||||
bindings: [{ key: "tab", shift: true }],
|
||||
},
|
||||
{
|
||||
i18nKey: "deleteSelectedAlt",
|
||||
label: "Delete Selected (alt)",
|
||||
display: "Del / ⌫",
|
||||
bindings: [{ key: "delete" }, { key: "backspace" }],
|
||||
},
|
||||
{ label: "Pan Timeline", display: "Shift + Ctrl + Scroll", bindings: [] },
|
||||
{ label: "Zoom Timeline", display: "Ctrl + Scroll", bindings: [] },
|
||||
{
|
||||
i18nKey: "panTimeline",
|
||||
label: "Pan Timeline",
|
||||
display: "Shift + Ctrl + Scroll",
|
||||
bindings: [],
|
||||
},
|
||||
{ i18nKey: "zoomTimeline", label: "Zoom Timeline", display: "Ctrl + Scroll", bindings: [] },
|
||||
{ i18nKey: "frameBack", label: "Frame Back", display: "←", bindings: [{ key: "arrowleft" }] },
|
||||
{
|
||||
i18nKey: "frameForward",
|
||||
label: "Frame Forward",
|
||||
display: "→",
|
||||
bindings: [{ key: "arrowright" }],
|
||||
},
|
||||
];
|
||||
|
||||
export type ShortcutConflict =
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { ExportFormat, ExportQuality } from "@/lib/exporter";
|
||||
import type { AspectRatio } from "@/utils/aspectRatioUtils";
|
||||
|
||||
const PREFS_KEY = "openscreen_user_preferences";
|
||||
|
||||
const VALID_ASPECT_RATIOS: readonly string[] = [
|
||||
"16:9",
|
||||
"9:16",
|
||||
"1:1",
|
||||
"4:3",
|
||||
"4:5",
|
||||
"16:10",
|
||||
"10:16",
|
||||
"native",
|
||||
];
|
||||
|
||||
export interface UserPreferences {
|
||||
/** Default padding % */
|
||||
padding: number;
|
||||
/** Default aspect ratio */
|
||||
aspectRatio: AspectRatio;
|
||||
/** Default export quality */
|
||||
exportQuality: ExportQuality;
|
||||
/** Default export format */
|
||||
exportFormat: ExportFormat;
|
||||
}
|
||||
|
||||
const DEFAULT_PREFS: UserPreferences = {
|
||||
padding: 50,
|
||||
aspectRatio: "16:9",
|
||||
exportQuality: "good",
|
||||
exportFormat: "mp4",
|
||||
};
|
||||
|
||||
function safeJsonParse(text: string | null): Record<string, unknown> | null {
|
||||
if (!text) return null;
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load persisted user preferences from localStorage.
|
||||
* Returns defaults for any missing or invalid fields.
|
||||
*/
|
||||
export function loadUserPreferences(): UserPreferences {
|
||||
let raw: Record<string, unknown> | null = null;
|
||||
try {
|
||||
raw = safeJsonParse(localStorage.getItem(PREFS_KEY));
|
||||
} catch {
|
||||
return { ...DEFAULT_PREFS };
|
||||
}
|
||||
if (!raw || typeof raw !== "object") return { ...DEFAULT_PREFS };
|
||||
|
||||
return {
|
||||
padding:
|
||||
typeof raw.padding === "number" &&
|
||||
Number.isFinite(raw.padding) &&
|
||||
raw.padding >= 0 &&
|
||||
raw.padding <= 100
|
||||
? raw.padding
|
||||
: DEFAULT_PREFS.padding,
|
||||
aspectRatio:
|
||||
typeof raw.aspectRatio === "string" && VALID_ASPECT_RATIOS.includes(raw.aspectRatio)
|
||||
? (raw.aspectRatio as AspectRatio)
|
||||
: DEFAULT_PREFS.aspectRatio,
|
||||
exportQuality:
|
||||
raw.exportQuality === "medium" ||
|
||||
raw.exportQuality === "good" ||
|
||||
raw.exportQuality === "source"
|
||||
? (raw.exportQuality as ExportQuality)
|
||||
: DEFAULT_PREFS.exportQuality,
|
||||
exportFormat:
|
||||
raw.exportFormat === "gif" || raw.exportFormat === "mp4"
|
||||
? (raw.exportFormat as ExportFormat)
|
||||
: DEFAULT_PREFS.exportFormat,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist user preferences to localStorage.
|
||||
* Only the explicitly provided fields are updated.
|
||||
*/
|
||||
export function saveUserPreferences(partial: Partial<UserPreferences>): void {
|
||||
const current = loadUserPreferences();
|
||||
const merged = { ...current, ...partial };
|
||||
try {
|
||||
localStorage.setItem(PREFS_KEY, JSON.stringify(merged));
|
||||
} catch {
|
||||
// localStorage may be unavailable (e.g. private browsing quota exceeded)
|
||||
}
|
||||
}
|
||||
Vendored
+2
@@ -19,6 +19,8 @@ interface Window {
|
||||
electronAPI: {
|
||||
getSources: (opts: Electron.SourcesOptions) => Promise<ProcessedDesktopSource[]>;
|
||||
switchToEditor: () => Promise<void>;
|
||||
switchToHud: () => Promise<void>;
|
||||
startNewRecording: () => Promise<{ success: boolean; error?: string }>;
|
||||
openSourceSelector: () => Promise<void>;
|
||||
selectSource: (source: ProcessedDesktopSource) => Promise<ProcessedDesktopSource | null>;
|
||||
getSelectedSource: () => Promise<ProcessedDesktopSource | null>;
|
||||
|
||||
@@ -11,12 +11,15 @@ 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`);
|
||||
let testVideoInRecordings = "";
|
||||
|
||||
const app = await electron.launch({
|
||||
args: [
|
||||
MAIN_JS,
|
||||
// Required in CI sandbox environments (GitHub Actions, Docker, etc.)
|
||||
"--no-sandbox",
|
||||
// Force software WebGL in headless CI to avoid GPU framebuffer errors.
|
||||
"--enable-unsafe-swiftshader",
|
||||
],
|
||||
env: {
|
||||
...process.env,
|
||||
@@ -58,14 +61,25 @@ test("exports a GIF from a loaded video", async () => {
|
||||
);
|
||||
});
|
||||
|
||||
await hudWindow.evaluate((videoPath: string) => {
|
||||
window.electronAPI.setCurrentVideoPath(videoPath);
|
||||
try {
|
||||
// Copy the test fixture into the app's recordings directory so it passes
|
||||
// the path security check in set-current-video-path.
|
||||
const userDataDir = await app.evaluate(({ app: electronApp }) => {
|
||||
return electronApp.getPath("userData");
|
||||
});
|
||||
const recordingsDir = path.join(userDataDir, "recordings");
|
||||
testVideoInRecordings = path.join(recordingsDir, "test-sample.webm");
|
||||
fs.mkdirSync(recordingsDir, { recursive: true });
|
||||
fs.copyFileSync(TEST_VIDEO, testVideoInRecordings);
|
||||
|
||||
try {
|
||||
await hudWindow.evaluate((videoPath: string) => {
|
||||
window.electronAPI.setCurrentVideoPath(videoPath);
|
||||
window.electronAPI.switchToEditor();
|
||||
} catch {
|
||||
// Expected: HUD window closes during this call, killing the context.
|
||||
}
|
||||
}, TEST_VIDEO);
|
||||
}, testVideoInRecordings);
|
||||
} catch {
|
||||
// Expected: switchToEditor() closes the HUD window, terminating
|
||||
// the Playwright page context before evaluate() can resolve.
|
||||
}
|
||||
|
||||
// ── 3. Switch to the editor window. This closes the HUD and opens
|
||||
// a new BrowserWindow with ?windowType=editor.
|
||||
@@ -116,5 +130,8 @@ test("exports a GIF from a loaded video", async () => {
|
||||
if (fs.existsSync(outputPath)) {
|
||||
fs.unlinkSync(outputPath);
|
||||
}
|
||||
if (testVideoInRecordings && fs.existsSync(testVideoInRecordings)) {
|
||||
fs.unlinkSync(testVideoInRecordings);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user