mirror of
https://github.com/siddharthvaddem/openscreen.git
synced 2026-11-03 06:04:11 +08:00
Compare commits
129
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7169e583c7 | ||
|
|
de7518549c | ||
|
|
b1a1f45e93 | ||
|
|
bce1957505 | ||
|
|
24b4b4254a | ||
|
|
16cba73cb2 | ||
|
|
c55f462f1c | ||
|
|
68295b21ec | ||
|
|
3232918197 | ||
|
|
38d72217c2 | ||
|
|
f6b7c463f0 | ||
|
|
f8232d9c76 | ||
|
|
5a9c85c345 | ||
|
|
a4f1c6a2ee | ||
|
|
e7d5f51740 | ||
|
|
9e6b05815f | ||
|
|
7bd993a97b | ||
|
|
558379702a | ||
|
|
09b99563f5 | ||
|
|
b34961f6af | ||
|
|
5a36179454 | ||
|
|
3482be9864 | ||
|
|
b8fe1a1ec8 | ||
|
|
33609432e1 | ||
|
|
b65c68d139 | ||
|
|
6bff2a2a2c | ||
|
|
1f56bb42c3 | ||
|
|
7a8fb807e6 | ||
|
|
0e1a69a7b2 | ||
|
|
e739653b3f | ||
|
|
9024eaae61 | ||
|
|
c5882b06b1 | ||
|
|
306b61a902 | ||
|
|
c36349d950 | ||
|
|
4e2a53b200 | ||
|
|
90ba713323 | ||
|
|
24928164ca | ||
|
|
5320f76aae | ||
|
|
e571ecbf8d | ||
|
|
d4c50c9a5e | ||
|
|
3e6dff9c34 | ||
|
|
1b6f4cce46 | ||
|
|
ec946f0807 | ||
|
|
37e1a82d05 | ||
|
|
2ce4dc49b3 | ||
|
|
db815e362a | ||
|
|
ed9b8689f7 | ||
|
|
dc0856282f | ||
|
|
da79dab756 | ||
|
|
1dc2c06ee4 | ||
|
|
b6803eb6e3 | ||
|
|
8013cc97bb | ||
|
|
e45611ade4 | ||
|
|
b986148d5d | ||
|
|
fe0c2829a7 | ||
|
|
e4672811de | ||
|
|
3e28e5860d | ||
|
|
925a7e532d | ||
|
|
f3d761b28d | ||
|
|
c9861dbef8 | ||
|
|
1591edbeca | ||
|
|
a3c2ed8ed1 | ||
|
|
ae971bc480 | ||
|
|
213637967e | ||
|
|
a8427b950e | ||
|
|
c868469be5 | ||
|
|
e90bba82ef | ||
|
|
475cbcd76c | ||
|
|
08f66c7c25 | ||
|
|
013312be1f | ||
|
|
735dd2a191 | ||
|
|
7072c05edd | ||
|
|
0bc3bbca6b | ||
|
|
5340272530 | ||
|
|
2ee7ccd89c | ||
|
|
79201569c5 | ||
|
|
ca962ff16b | ||
|
|
da16872809 | ||
|
|
11788ad703 | ||
|
|
2712d8a41b | ||
|
|
21361d9bf8 | ||
|
|
66f9172a35 | ||
|
|
bd604cb658 | ||
|
|
3b5ad5064e | ||
|
|
4f48ecd4bc | ||
|
|
7d746196d2 | ||
|
|
d5f59a7b8e | ||
|
|
478fe316dc | ||
|
|
b451bdc03d | ||
|
|
43ec6ee9cd | ||
|
|
98da431da0 | ||
|
|
21893f07af | ||
|
|
763c187f87 | ||
|
|
20567db245 | ||
|
|
7a1113827c | ||
|
|
7e298d3bbf | ||
|
|
97c9a73578 | ||
|
|
3bfcd8576b | ||
|
|
b002f2a485 | ||
|
|
cd0f2ab318 | ||
|
|
54df597160 | ||
|
|
e5430eed39 | ||
|
|
baa30a9d6a | ||
|
|
b709d0d240 | ||
|
|
11bad60eb2 | ||
|
|
5259ae5d87 | ||
|
|
14cd045e65 | ||
|
|
27853cc2c3 | ||
|
|
d6933813bd | ||
|
|
2b471783c0 | ||
|
|
b101820ab8 | ||
|
|
3061c141c6 | ||
|
|
846cf71e09 | ||
|
|
3895ca985f | ||
|
|
9d0ccf3bde | ||
|
|
914a3c7f7b | ||
|
|
05a87a8ab1 | ||
|
|
5c6621293a | ||
|
|
163b12d6fc | ||
|
|
3be195cc15 | ||
|
|
baec9a7585 | ||
|
|
9817c85acf | ||
|
|
9762448929 | ||
|
|
eade28079d | ||
|
|
fed5a44b5a | ||
|
|
a9222c9484 | ||
|
|
e72851d4ef | ||
|
|
317089d57f | ||
|
|
0a5e57ce76 |
@@ -94,6 +94,8 @@ jobs:
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: linux-installer
|
||||
path: release/**/*.AppImage
|
||||
path: |
|
||||
release/**/*.AppImage
|
||||
release/**/*.zsync
|
||||
release/**/*.deb
|
||||
retention-days: 30
|
||||
|
||||
|
||||
+13
-23
@@ -31,6 +31,19 @@ jobs:
|
||||
- run: npm ci
|
||||
- run: npx tsc --noEmit
|
||||
|
||||
test:
|
||||
name: Test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run test:browser:install
|
||||
- run: npm run test:browser
|
||||
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
@@ -42,26 +55,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
|
||||
|
||||
+4
-1
@@ -29,4 +29,7 @@ release/**
|
||||
|
||||
# Playwright
|
||||
test-results
|
||||
playwright-report/
|
||||
playwright-report/
|
||||
|
||||
# Vitest browser mode screenshots
|
||||
__screenshots__/
|
||||
@@ -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
|
||||
|
||||
@@ -20,14 +20,15 @@
|
||||
"!CONTRIBUTING.md",
|
||||
"!LICENSE"
|
||||
],
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "public/wallpapers",
|
||||
"to": "assets/wallpapers"
|
||||
}
|
||||
],
|
||||
|
||||
"mac": {
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "public/wallpapers",
|
||||
"to": "assets/wallpapers"
|
||||
}
|
||||
],
|
||||
"publish": [{"provider": "github"}],
|
||||
|
||||
"mac": {
|
||||
"hardenedRuntime": false,
|
||||
"target": [
|
||||
{
|
||||
|
||||
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>;
|
||||
|
||||
+5
-2
@@ -5,10 +5,12 @@ import commonEn from "../src/i18n/locales/en/common.json";
|
||||
import dialogsEn from "../src/i18n/locales/en/dialogs.json";
|
||||
import commonEs from "../src/i18n/locales/es/common.json";
|
||||
import dialogsEs from "../src/i18n/locales/es/dialogs.json";
|
||||
import commonFr from "../src/i18n/locales/fr/common.json";
|
||||
import dialogsFr from "../src/i18n/locales/fr/dialogs.json";
|
||||
import commonZh from "../src/i18n/locales/zh-CN/common.json";
|
||||
import dialogsZh from "../src/i18n/locales/zh-CN/dialogs.json";
|
||||
|
||||
type Locale = "en" | "zh-CN" | "es";
|
||||
type Locale = "en" | "zh-CN" | "es" | "fr";
|
||||
type Namespace = "common" | "dialogs";
|
||||
type MessageMap = Record<string, unknown>;
|
||||
|
||||
@@ -16,12 +18,13 @@ const messages: Record<Locale, Record<Namespace, MessageMap>> = {
|
||||
en: { common: commonEn, dialogs: dialogsEn },
|
||||
"zh-CN": { common: commonZh, dialogs: dialogsZh },
|
||||
es: { common: commonEs, dialogs: dialogsEs },
|
||||
fr: { common: commonFr, dialogs: dialogsFr },
|
||||
};
|
||||
|
||||
let currentLocale: Locale = "en";
|
||||
|
||||
export function setMainLocale(locale: string) {
|
||||
if (locale === "en" || locale === "zh-CN" || locale === "es") {
|
||||
if (locale === "en" || locale === "zh-CN" || locale === "es" || locale === "fr") {
|
||||
currentLocale = locale;
|
||||
}
|
||||
}
|
||||
|
||||
+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");
|
||||
},
|
||||
|
||||
+6
-6
@@ -21,8 +21,8 @@ export function createHudOverlayWindow(): BrowserWindow {
|
||||
const primaryDisplay = screen.getPrimaryDisplay();
|
||||
const { workArea } = primaryDisplay;
|
||||
|
||||
const windowWidth = 500;
|
||||
const windowHeight = 155;
|
||||
const windowWidth = 600;
|
||||
const windowHeight = 160;
|
||||
|
||||
const x = Math.floor(workArea.x + (workArea.width - windowWidth) / 2);
|
||||
const y = Math.floor(workArea.y + workArea.height - windowHeight - 5);
|
||||
@@ -30,10 +30,10 @@ export function createHudOverlayWindow(): BrowserWindow {
|
||||
const win = new BrowserWindow({
|
||||
width: windowWidth,
|
||||
height: windowHeight,
|
||||
minWidth: 500,
|
||||
maxWidth: 500,
|
||||
minHeight: 155,
|
||||
maxHeight: 155,
|
||||
minWidth: 600,
|
||||
maxWidth: 600,
|
||||
minHeight: 160,
|
||||
maxHeight: 160,
|
||||
x: x,
|
||||
y: y,
|
||||
frame: false,
|
||||
|
||||
Generated
+2343
-203
File diff suppressed because it is too large
Load Diff
+14
-2
@@ -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,10 +22,12 @@
|
||||
"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",
|
||||
"test:browser": "vitest --config vitest.browser.config.ts --run",
|
||||
"test:browser:install": "playwright install --with-deps chromium-headless-shell",
|
||||
"test:e2e": "playwright test",
|
||||
"prepare": "husky"
|
||||
},
|
||||
@@ -69,12 +75,17 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.3.13",
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@playwright/test": "^1.59.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^25.0.3",
|
||||
"@types/react": "^18.2.64",
|
||||
"@types/react-dom": "^18.2.21",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"@vitest/browser": "^4.0.16",
|
||||
"@vitest/browser-playwright": "^4.0.16",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"electron": "^39.2.7",
|
||||
"electron-builder": "^26.7.0",
|
||||
@@ -82,6 +93,7 @@
|
||||
"electron-rebuild": "^3.2.9",
|
||||
"fast-check": "^4.5.2",
|
||||
"husky": "^9.1.7",
|
||||
"jsdom": "^29.0.1",
|
||||
"lint-staged": "^16.3.2",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.18",
|
||||
|
||||
@@ -11,7 +11,7 @@ import path from "node:path";
|
||||
|
||||
const LOCALES_DIR = path.resolve("src/i18n/locales");
|
||||
const BASE_LOCALE = "en";
|
||||
const COMPARE_LOCALES = ["zh-CN", "es"];
|
||||
const COMPARE_LOCALES = ["zh-CN", "es", "tr"];
|
||||
|
||||
function getKeys(obj, prefix = "") {
|
||||
const keys = [];
|
||||
|
||||
@@ -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,
|
||||
@@ -21,6 +22,7 @@ import { type Locale, SUPPORTED_LOCALES } from "@/i18n/config";
|
||||
import { getLocaleName } from "@/i18n/loader";
|
||||
import { isMac as getIsMac } from "@/utils/platformUtils";
|
||||
import { useAudioLevelMeter } from "../../hooks/useAudioLevelMeter";
|
||||
import { useCameraDevices } from "../../hooks/useCameraDevices";
|
||||
import { useMicrophoneDevices } from "../../hooks/useMicrophoneDevices";
|
||||
import { useScreenRecorder } from "../../hooks/useScreenRecorder";
|
||||
import { requestCameraAccess } from "../../lib/requestCameraAccess";
|
||||
@@ -40,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 },
|
||||
@@ -76,8 +81,12 @@ export function LaunchWindow() {
|
||||
|
||||
const {
|
||||
recording,
|
||||
paused,
|
||||
elapsedSeconds,
|
||||
toggleRecording,
|
||||
togglePaused,
|
||||
restartRecording,
|
||||
cancelRecording,
|
||||
microphoneEnabled,
|
||||
setMicrophoneEnabled,
|
||||
microphoneDeviceId,
|
||||
@@ -86,42 +95,62 @@ export function LaunchWindow() {
|
||||
setSystemAudioEnabled,
|
||||
webcamEnabled,
|
||||
setWebcamEnabled,
|
||||
webcamDeviceId,
|
||||
setWebcamDeviceId,
|
||||
} = useScreenRecorder();
|
||||
const [recordingStart, setRecordingStart] = useState<number | null>(null);
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
|
||||
const showMicControls = microphoneEnabled && !recording;
|
||||
const { devices, selectedDeviceId, setSelectedDeviceId } =
|
||||
useMicrophoneDevices(microphoneEnabled);
|
||||
const showWebcamControls = webcamEnabled && !recording;
|
||||
|
||||
const [isMicHovered, setIsMicHovered] = useState(false);
|
||||
const [isMicFocused, setIsMicFocused] = useState(false);
|
||||
const micExpanded = isMicHovered || isMicFocused;
|
||||
|
||||
const [isWebcamHovered, setIsWebcamHovered] = useState(false);
|
||||
const [isWebcamFocused, setIsWebcamFocused] = useState(false);
|
||||
const webcamExpanded = isWebcamHovered || isWebcamFocused;
|
||||
|
||||
const {
|
||||
devices: micDevices,
|
||||
selectedDeviceId: selectedMicId,
|
||||
setSelectedDeviceId: setSelectedMicId,
|
||||
} = useMicrophoneDevices(microphoneEnabled);
|
||||
const {
|
||||
devices: cameraDevices,
|
||||
selectedDeviceId: selectedCameraId,
|
||||
setSelectedDeviceId: setSelectedCameraId,
|
||||
isLoading: isCameraDevicesLoading,
|
||||
error: cameraDevicesError,
|
||||
} = useCameraDevices(webcamEnabled);
|
||||
|
||||
const selectedMicLabel =
|
||||
micDevices.find((d) => d.deviceId === (microphoneDeviceId || selectedMicId))?.label ||
|
||||
t("audio.defaultMicrophone");
|
||||
const selectedCameraLabel = isCameraDevicesLoading
|
||||
? t("webcam.searching")
|
||||
: cameraDevicesError
|
||||
? t("webcam.unavailable")
|
||||
: cameraDevices.length === 0
|
||||
? t("webcam.noneFound")
|
||||
: cameraDevices.find((d) => d.deviceId === (webcamDeviceId || selectedCameraId))?.label ||
|
||||
t("webcam.defaultCamera");
|
||||
|
||||
const { level } = useAudioLevelMeter({
|
||||
enabled: showMicControls,
|
||||
deviceId: microphoneDeviceId,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedDeviceId && selectedDeviceId !== "default") {
|
||||
setMicrophoneDeviceId(selectedDeviceId);
|
||||
if (selectedMicId && selectedMicId !== "default") {
|
||||
setMicrophoneDeviceId(selectedMicId);
|
||||
}
|
||||
}, [selectedDeviceId, setMicrophoneDeviceId]);
|
||||
}, [selectedMicId, setMicrophoneDeviceId]);
|
||||
|
||||
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);
|
||||
if (selectedCameraId) {
|
||||
setWebcamDeviceId(selectedCameraId);
|
||||
}
|
||||
return () => {
|
||||
if (timer) clearInterval(timer);
|
||||
};
|
||||
}, [recording, recordingStart]);
|
||||
}, [selectedCameraId, setWebcamDeviceId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!import.meta.env.DEV) {
|
||||
@@ -199,10 +228,10 @@ export function LaunchWindow() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex items-end justify-center bg-transparent relative">
|
||||
<div className={`w-screen h-screen bg-transparent ${styles.electronDrag}`}>
|
||||
{/* Language switcher — top-left, beside traffic lights */}
|
||||
<div
|
||||
className={`absolute top-2 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 ${isMac ? "left-[72px]" : "left-2"} ${styles.electronNoDrag}`}
|
||||
className={`fixed top-2 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 ${isMac ? "left-[72px]" : "left-2"} ${styles.electronNoDrag}`}
|
||||
>
|
||||
<Languages size={14} />
|
||||
<select
|
||||
@@ -219,165 +248,289 @@ export function LaunchWindow() {
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className={`flex flex-col items-center gap-2 mx-auto ${styles.electronDrag}`}>
|
||||
{/* Mic controls panel */}
|
||||
{showMicControls && (
|
||||
<div
|
||||
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}`}
|
||||
>
|
||||
<div className="relative flex-1" style={{ maxWidth: "70%" }}>
|
||||
<select
|
||||
value={microphoneDeviceId || selectedDeviceId}
|
||||
onChange={(e) => {
|
||||
setSelectedDeviceId(e.target.value);
|
||||
setMicrophoneDeviceId(e.target.value);
|
||||
}}
|
||||
className="w-full appearance-none bg-white/10 text-white text-xs rounded-full pl-3 pr-7 py-2 border border-white/20 outline-none truncate"
|
||||
>
|
||||
{devices.map((device) => (
|
||||
<option key={device.deviceId} value={device.deviceId}>
|
||||
{device.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-white/60 pointer-events-none"
|
||||
{/* Device selectors — fixed above HUD bar, viewport-relative, never clipped */}
|
||||
{(showMicControls || showWebcamControls) && (
|
||||
<div
|
||||
className={`fixed bottom-[60px] left-1/2 -translate-x-1/2 flex items-center gap-2 animate-mic-panel-in ${styles.electronNoDrag}`}
|
||||
>
|
||||
{/* Mic selector */}
|
||||
{showMicControls && (
|
||||
<div
|
||||
className={`flex items-center gap-2 px-3 py-1.5 h-[36px] bg-gradient-to-br from-[rgba(28,28,36,0.97)] to-[rgba(18,18,26,0.96)] backdrop-blur-[24px] border border-white/10 rounded-xl shadow-2xl transition-all duration-300 overflow-hidden ${!micExpanded ? "opacity-60 grayscale-[0.5]" : "opacity-100"}`}
|
||||
onMouseEnter={() => setIsMicHovered(true)}
|
||||
onMouseLeave={() => setIsMicHovered(false)}
|
||||
onFocus={() => setIsMicFocused(true)}
|
||||
onBlur={() => setIsMicFocused(false)}
|
||||
style={{ width: micExpanded ? "240px" : "140px", transition: "width 300ms ease" }}
|
||||
>
|
||||
<div className="relative flex-1 min-w-0">
|
||||
{!micExpanded && (
|
||||
<div className="text-white/60 text-[10px] font-medium truncate">
|
||||
{selectedMicLabel}
|
||||
</div>
|
||||
)}
|
||||
<select
|
||||
value={microphoneDeviceId || selectedMicId}
|
||||
onChange={(e) => {
|
||||
setSelectedMicId(e.target.value);
|
||||
setMicrophoneDeviceId(e.target.value);
|
||||
}}
|
||||
className={`w-full appearance-none bg-white/5 text-white text-[11px] rounded-lg pl-2 pr-6 py-1 border border-white/10 outline-none hover:bg-white/10 transition-colors cursor-pointer ${!micExpanded ? "sr-only" : ""}`}
|
||||
>
|
||||
{micDevices.map((device) => (
|
||||
<option key={device.deviceId} value={device.deviceId} className="bg-[#1c1c24]">
|
||||
{device.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{micExpanded && (
|
||||
<ChevronDown
|
||||
size={12}
|
||||
className="absolute right-1.5 top-1/2 -translate-y-1/2 text-white/40 pointer-events-none"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<AudioLevelMeter
|
||||
level={level}
|
||||
className={`${micExpanded ? "w-16" : "w-8"} h-2 transition-all duration-300`}
|
||||
/>
|
||||
</div>
|
||||
<AudioLevelMeter level={level} className="w-24 h-4" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main pill bar */}
|
||||
<div className="flex items-center gap-1.5 px-2 py-1.5 isolate rounded-full shadow-hud-bar bg-gradient-to-br from-[rgba(28,28,36,0.97)] to-[rgba(18,18,26,0.96)] backdrop-blur-[16px] backdrop-saturate-[140%] border border-[rgba(80,80,120,0.25)]">
|
||||
{/* Drag handle */}
|
||||
<div className={`flex items-center px-1 ${styles.electronDrag}`}>
|
||||
{getIcon("drag", "text-white/30")}
|
||||
</div>
|
||||
|
||||
{/* Source selector */}
|
||||
<button
|
||||
className={`${hudGroupClasses} p-2 ${styles.electronNoDrag}`}
|
||||
onClick={openSourceSelector}
|
||||
disabled={recording}
|
||||
title={selectedSource}
|
||||
>
|
||||
{getIcon("monitor", "text-white/80")}
|
||||
<span className="text-white/70 text-[11px] max-w-[72px] truncate">
|
||||
{selectedSource}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Audio controls group */}
|
||||
<div className={`${hudGroupClasses} ${styles.electronNoDrag}`}>
|
||||
<button
|
||||
className={`${hudIconBtnClasses} ${systemAudioEnabled ? "drop-shadow-[0_0_4px_rgba(74,222,128,0.4)]" : ""}`}
|
||||
onClick={() => !recording && setSystemAudioEnabled(!systemAudioEnabled)}
|
||||
disabled={recording}
|
||||
title={
|
||||
systemAudioEnabled ? t("audio.disableSystemAudio") : t("audio.enableSystemAudio")
|
||||
}
|
||||
>
|
||||
{systemAudioEnabled
|
||||
? getIcon("volumeOn", "text-green-400")
|
||||
: getIcon("volumeOff", "text-white/40")}
|
||||
</button>
|
||||
<button
|
||||
className={`${hudIconBtnClasses} ${microphoneEnabled ? "drop-shadow-[0_0_4px_rgba(74,222,128,0.4)]" : ""}`}
|
||||
onClick={toggleMicrophone}
|
||||
disabled={recording}
|
||||
title={microphoneEnabled ? t("audio.disableMicrophone") : t("audio.enableMicrophone")}
|
||||
>
|
||||
{microphoneEnabled
|
||||
? getIcon("micOn", "text-green-400")
|
||||
: getIcon("micOff", "text-white/40")}
|
||||
</button>
|
||||
<button
|
||||
className={`${hudIconBtnClasses} ${webcamEnabled ? "drop-shadow-[0_0_4px_rgba(74,222,128,0.4)]" : ""}`}
|
||||
onClick={async () => {
|
||||
await setWebcamEnabled(!webcamEnabled);
|
||||
}}
|
||||
title={webcamEnabled ? t("webcam.disableWebcam") : t("webcam.enableWebcam")}
|
||||
>
|
||||
{webcamEnabled
|
||||
? getIcon("webcamOn", "text-green-400")
|
||||
: getIcon("webcamOff", "text-white/40")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 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]"
|
||||
}`}
|
||||
onClick={hasSelectedSource ? toggleRecording : openSourceSelector}
|
||||
disabled={!hasSelectedSource && !recording}
|
||||
style={{ flex: "0 0 auto" }}
|
||||
>
|
||||
{recording ? (
|
||||
<>
|
||||
{getIcon("stop", "text-red-400")}
|
||||
<span className="text-red-400 text-xs font-semibold tabular-nums">
|
||||
{formatTimePadded(elapsed)}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
getIcon("record", hasSelectedSource ? "text-white/80" : "text-white/30")
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Restart recording */}
|
||||
{recording && (
|
||||
<Tooltip content={t("tooltips.restartRecording")}>
|
||||
<button
|
||||
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
|
||||
onClick={restartRecording}
|
||||
>
|
||||
{getIcon("restart", "text-white/60")}
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Open video file */}
|
||||
<Tooltip content={t("tooltips.openVideoFile")}>
|
||||
{/* Webcam selector */}
|
||||
{showWebcamControls && (
|
||||
<div
|
||||
className={`flex items-center gap-2 px-3 py-1.5 h-[36px] bg-gradient-to-br from-[rgba(28,28,36,0.97)] to-[rgba(18,18,26,0.96)] backdrop-blur-[24px] border border-white/10 rounded-xl shadow-2xl transition-all duration-300 overflow-hidden ${!webcamExpanded ? "opacity-60 grayscale-[0.5]" : "opacity-100"}`}
|
||||
onMouseEnter={() => setIsWebcamHovered(true)}
|
||||
onMouseLeave={() => setIsWebcamHovered(false)}
|
||||
onFocus={() => setIsWebcamFocused(true)}
|
||||
onBlur={() => setIsWebcamFocused(false)}
|
||||
style={{ width: webcamExpanded ? "240px" : "140px", transition: "width 300ms ease" }}
|
||||
>
|
||||
<div className="relative flex-1 min-w-0">
|
||||
{!webcamExpanded && (
|
||||
<div className="text-white/60 text-[10px] font-medium truncate">
|
||||
{selectedCameraLabel}
|
||||
</div>
|
||||
)}
|
||||
{webcamExpanded &&
|
||||
(isCameraDevicesLoading ? (
|
||||
<span className="text-white/40 text-[10px] italic">
|
||||
{t("webcam.searching")}
|
||||
</span>
|
||||
) : cameraDevicesError ? (
|
||||
<span className="text-white/40 text-[10px] italic">
|
||||
{t("webcam.unavailable")}
|
||||
</span>
|
||||
) : cameraDevices.length === 0 ? (
|
||||
<span className="text-white/40 text-[10px] italic">
|
||||
{t("webcam.noneFound")}
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<select
|
||||
value={webcamDeviceId || selectedCameraId}
|
||||
onChange={(e) => {
|
||||
setSelectedCameraId(e.target.value);
|
||||
setWebcamDeviceId(e.target.value);
|
||||
}}
|
||||
className="w-full appearance-none bg-white/5 text-white text-[11px] rounded-lg pl-2 pr-6 py-1 border border-white/10 outline-none hover:bg-white/10 transition-colors cursor-pointer"
|
||||
>
|
||||
{cameraDevices.map((device) => (
|
||||
<option
|
||||
key={device.deviceId}
|
||||
value={device.deviceId}
|
||||
className="bg-[#1c1c24]"
|
||||
>
|
||||
{device.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown
|
||||
size={12}
|
||||
className="absolute right-1.5 top-1/2 -translate-y-1/2 text-white/40 pointer-events-none"
|
||||
/>
|
||||
</>
|
||||
))}
|
||||
{(!webcamExpanded || cameraDevices.length === 0) && (
|
||||
<select
|
||||
value={webcamDeviceId || selectedCameraId}
|
||||
onChange={(e) => {
|
||||
setSelectedCameraId(e.target.value);
|
||||
setWebcamDeviceId(e.target.value);
|
||||
}}
|
||||
className="sr-only"
|
||||
>
|
||||
{cameraDevices.map((device) => (
|
||||
<option key={device.deviceId} value={device.deviceId}>
|
||||
{device.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* HUD bar — fixed at bottom center, viewport-relative, never moves */}
|
||||
<div
|
||||
className={`fixed bottom-4 left-1/2 -translate-x-1/2 flex items-center gap-1.5 px-2 py-1.5 rounded-full shadow-hud-bar bg-gradient-to-br from-[rgba(28,28,36,0.97)] to-[rgba(18,18,26,0.96)] backdrop-blur-[16px] backdrop-saturate-[140%] border border-[rgba(80,80,120,0.25)]`}
|
||||
>
|
||||
{/* Drag handle */}
|
||||
<div className={`flex items-center px-1 ${styles.electronDrag}`}>
|
||||
{getIcon("drag", "text-white/30")}
|
||||
</div>
|
||||
|
||||
{/* Source selector */}
|
||||
<button
|
||||
className={`${hudGroupClasses} p-2 ${styles.electronNoDrag}`}
|
||||
onClick={openSourceSelector}
|
||||
disabled={recording}
|
||||
title={selectedSource}
|
||||
>
|
||||
{getIcon("monitor", "text-white/80")}
|
||||
<span className="text-white/70 text-[11px] max-w-[72px] truncate">{selectedSource}</span>
|
||||
</button>
|
||||
|
||||
{/* Audio controls group */}
|
||||
<div className={`${hudGroupClasses} ${styles.electronNoDrag}`}>
|
||||
<button
|
||||
className={`${hudIconBtnClasses} ${systemAudioEnabled ? "drop-shadow-[0_0_4px_rgba(74,222,128,0.4)]" : ""}`}
|
||||
onClick={() => !recording && setSystemAudioEnabled(!systemAudioEnabled)}
|
||||
disabled={recording}
|
||||
title={
|
||||
systemAudioEnabled ? t("audio.disableSystemAudio") : t("audio.enableSystemAudio")
|
||||
}
|
||||
>
|
||||
{systemAudioEnabled
|
||||
? getIcon("volumeOn", "text-green-400")
|
||||
: getIcon("volumeOff", "text-white/40")}
|
||||
</button>
|
||||
<button
|
||||
className={`${hudIconBtnClasses} ${microphoneEnabled ? "drop-shadow-[0_0_4px_rgba(74,222,128,0.4)]" : ""}`}
|
||||
onClick={toggleMicrophone}
|
||||
disabled={recording}
|
||||
title={microphoneEnabled ? t("audio.disableMicrophone") : t("audio.enableMicrophone")}
|
||||
>
|
||||
{microphoneEnabled
|
||||
? getIcon("micOn", "text-green-400")
|
||||
: getIcon("micOff", "text-white/40")}
|
||||
</button>
|
||||
<button
|
||||
className={`${hudIconBtnClasses} ${webcamEnabled ? "drop-shadow-[0_0_4px_rgba(74,222,128,0.4)]" : ""}`}
|
||||
onClick={async () => {
|
||||
await setWebcamEnabled(!webcamEnabled);
|
||||
}}
|
||||
title={webcamEnabled ? t("webcam.disableWebcam") : t("webcam.enableWebcam")}
|
||||
>
|
||||
{webcamEnabled
|
||||
? getIcon("webcamOn", "text-green-400")
|
||||
: getIcon("webcamOff", "text-white/40")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Record/Stop group */}
|
||||
<button
|
||||
className={`flex items-center gap-0.5 rounded-full p-2 transition-colors duration-150 ${styles.electronNoDrag} ${
|
||||
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}
|
||||
style={{ flex: "0 0 auto" }}
|
||||
>
|
||||
{recording ? (
|
||||
<>
|
||||
{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>
|
||||
</>
|
||||
) : (
|
||||
getIcon("record", hasSelectedSource ? "text-white/80" : "text-white/30")
|
||||
)}
|
||||
</button>
|
||||
|
||||
{recording && (
|
||||
<Tooltip content={paused ? t("tooltips.resumeRecording") : t("tooltips.pauseRecording")}>
|
||||
<button
|
||||
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
|
||||
onClick={openVideoFile}
|
||||
disabled={recording}
|
||||
onClick={togglePaused}
|
||||
>
|
||||
{getIcon("videoFile", "text-white/60")}
|
||||
{getIcon(paused ? "resume" : "pause", paused ? "text-amber-400" : "text-white/60")}
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Open project */}
|
||||
<Tooltip content={t("tooltips.openProject")}>
|
||||
{/* Restart recording */}
|
||||
{recording && (
|
||||
<Tooltip content={t("tooltips.restartRecording")}>
|
||||
<button
|
||||
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
|
||||
onClick={openProjectFile}
|
||||
disabled={recording}
|
||||
onClick={restartRecording}
|
||||
>
|
||||
{getIcon("folder", "text-white/60")}
|
||||
{getIcon("restart", "text-white/60")}
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Window controls */}
|
||||
<div className={`flex items-center gap-0.5 ${styles.electronNoDrag}`}>
|
||||
{/* Cancel recording */}
|
||||
{recording && (
|
||||
<Tooltip content={t("tooltips.cancelRecording")}>
|
||||
<button
|
||||
className={windowBtnClasses}
|
||||
title={t("tooltips.hideHUD")}
|
||||
onClick={sendHudOverlayHide}
|
||||
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
|
||||
onClick={cancelRecording}
|
||||
>
|
||||
{getIcon("minimize", "text-white")}
|
||||
{getIcon("cancel", "text-white/60")}
|
||||
</button>
|
||||
<button
|
||||
className={windowBtnClasses}
|
||||
title={t("tooltips.closeApp")}
|
||||
onClick={sendHudOverlayClose}
|
||||
>
|
||||
{getIcon("close", "text-white")}
|
||||
</button>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Open video file */}
|
||||
<Tooltip content={t("tooltips.openVideoFile")}>
|
||||
<button
|
||||
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
|
||||
onClick={openVideoFile}
|
||||
disabled={recording}
|
||||
>
|
||||
{getIcon("videoFile", "text-white/60")}
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
{/* Open project */}
|
||||
<Tooltip content={t("tooltips.openProject")}>
|
||||
<button
|
||||
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
|
||||
onClick={openProjectFile}
|
||||
disabled={recording}
|
||||
>
|
||||
{getIcon("folder", "text-white/60")}
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
{/* Window controls */}
|
||||
<div className={`flex items-center gap-0.5 ${styles.electronNoDrag}`}>
|
||||
<button
|
||||
className={windowBtnClasses}
|
||||
title={t("tooltips.hideHUD")}
|
||||
onClick={sendHudOverlayHide}
|
||||
>
|
||||
{getIcon("minimize", "text-white")}
|
||||
</button>
|
||||
<button
|
||||
className={windowBtnClasses}
|
||||
title={t("tooltips.closeApp")}
|
||||
onClick={sendHudOverlayClose}
|
||||
>
|
||||
{getIcon("close", "text-white")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,28 @@
|
||||
import { useRef } from "react";
|
||||
import { type CSSProperties, type PointerEvent, useRef, useState } from "react";
|
||||
import { Rnd } from "react-rnd";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getArrowComponent } from "./ArrowSvgs";
|
||||
import type { AnnotationRegion } from "./types";
|
||||
import {
|
||||
type AnnotationRegion,
|
||||
type BlurData,
|
||||
DEFAULT_BLUR_DATA,
|
||||
DEFAULT_BLUR_INTENSITY,
|
||||
} from "./types";
|
||||
|
||||
const FREEHAND_POINT_THRESHOLD = 1;
|
||||
|
||||
function buildBlurPolygonClipPath(points: Array<{ x: number; y: number }>) {
|
||||
if (points.length < 3) return undefined;
|
||||
const polygon = points.map((point) => `${point.x}% ${point.y}%`).join(", ");
|
||||
return `polygon(${polygon})`;
|
||||
}
|
||||
|
||||
function buildBlurFreehandPath(points: Array<{ x: number; y: number }>, closed = true) {
|
||||
if (closed ? points.length < 3 : points.length < 2) return null;
|
||||
const [firstPoint, ...rest] = points;
|
||||
const path = `M ${firstPoint.x} ${firstPoint.y} ${rest.map((point) => `L ${point.x} ${point.y}`).join(" ")}`;
|
||||
return closed ? `${path} Z` : path;
|
||||
}
|
||||
|
||||
interface AnnotationOverlayProps {
|
||||
annotation: AnnotationRegion;
|
||||
@@ -11,6 +31,8 @@ interface AnnotationOverlayProps {
|
||||
containerHeight: number;
|
||||
onPositionChange: (id: string, position: { x: number; y: number }) => void;
|
||||
onSizeChange: (id: string, size: { width: number; height: number }) => void;
|
||||
onBlurDataChange?: (id: string, blurData: BlurData) => void;
|
||||
onBlurDataCommit?: () => void;
|
||||
onClick: (id: string) => void;
|
||||
zIndex: number;
|
||||
isSelectedBoost: boolean; // Boost z-index when selected for easy editing
|
||||
@@ -23,6 +45,8 @@ export function AnnotationOverlay({
|
||||
containerHeight,
|
||||
onPositionChange,
|
||||
onSizeChange,
|
||||
onBlurDataChange,
|
||||
onBlurDataCommit,
|
||||
onClick,
|
||||
zIndex,
|
||||
isSelectedBoost,
|
||||
@@ -31,8 +55,16 @@ export function AnnotationOverlay({
|
||||
const y = (annotation.position.y / 100) * containerHeight;
|
||||
const width = (annotation.size.width / 100) * containerWidth;
|
||||
const height = (annotation.size.height / 100) * containerHeight;
|
||||
|
||||
const blurShape = annotation.type === "blur" ? (annotation.blurData?.shape ?? "rectangle") : null;
|
||||
const isSelectedFreehandBlur = isSelected && blurShape === "freehand";
|
||||
const isDraggingRef = useRef(false);
|
||||
const isDrawingFreehandRef = useRef(false);
|
||||
const freehandPointsRef = useRef<Array<{ x: number; y: number }>>([]);
|
||||
const [isFreehandDrawing, setIsFreehandDrawing] = useState(false);
|
||||
const [draftFreehandPoints, setDraftFreehandPoints] = useState<Array<{ x: number; y: number }>>(
|
||||
[],
|
||||
);
|
||||
const [livePointerPoint, setLivePointerPoint] = useState<{ x: number; y: number } | null>(null);
|
||||
|
||||
const renderArrow = () => {
|
||||
const direction = annotation.figureData?.arrowDirection || "right";
|
||||
@@ -43,6 +75,95 @@ export function AnnotationOverlay({
|
||||
return <ArrowComponent color={color} strokeWidth={strokeWidth} />;
|
||||
};
|
||||
|
||||
const normalizePoint = (event: PointerEvent<HTMLDivElement>) => {
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
const x = ((event.clientX - rect.left) / rect.width) * 100;
|
||||
const y = ((event.clientY - rect.top) / rect.height) * 100;
|
||||
return {
|
||||
x: Math.max(0, Math.min(100, x)),
|
||||
y: Math.max(0, Math.min(100, y)),
|
||||
};
|
||||
};
|
||||
|
||||
const appendFreehandPoint = (point: { x: number; y: number }) => {
|
||||
const points = freehandPointsRef.current;
|
||||
const lastPoint = points[points.length - 1];
|
||||
if (!lastPoint) {
|
||||
points.push(point);
|
||||
return;
|
||||
}
|
||||
const dx = point.x - lastPoint.x;
|
||||
const dy = point.y - lastPoint.y;
|
||||
// Sample freehand points in annotation-space percent units to avoid overly dense paths.
|
||||
if (Math.hypot(dx, dy) >= FREEHAND_POINT_THRESHOLD) {
|
||||
points.push(point);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFreehandPointerDown = (event: PointerEvent<HTMLDivElement>) => {
|
||||
if (
|
||||
!isSelected ||
|
||||
annotation.type !== "blur" ||
|
||||
annotation.blurData?.shape !== "freehand" ||
|
||||
!onBlurDataChange
|
||||
) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
isDrawingFreehandRef.current = true;
|
||||
setIsFreehandDrawing(true);
|
||||
const point = normalizePoint(event);
|
||||
freehandPointsRef.current = [point];
|
||||
setDraftFreehandPoints([point]);
|
||||
setLivePointerPoint(point);
|
||||
};
|
||||
|
||||
const handleFreehandPointerMove = (event: PointerEvent<HTMLDivElement>) => {
|
||||
if (!isDrawingFreehandRef.current) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const point = normalizePoint(event);
|
||||
setLivePointerPoint(point);
|
||||
appendFreehandPoint(point);
|
||||
setDraftFreehandPoints([...freehandPointsRef.current]);
|
||||
};
|
||||
|
||||
const finishFreehandPointer = (event: PointerEvent<HTMLDivElement>) => {
|
||||
if (!isDrawingFreehandRef.current || !onBlurDataChange) return;
|
||||
isDrawingFreehandRef.current = false;
|
||||
setIsFreehandDrawing(false);
|
||||
try {
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
} catch {
|
||||
// no-op if already released
|
||||
}
|
||||
const points = [...freehandPointsRef.current];
|
||||
if (livePointerPoint) {
|
||||
const last = points[points.length - 1];
|
||||
if (!last || Math.hypot(last.x - livePointerPoint.x, last.y - livePointerPoint.y) > 0.001) {
|
||||
points.push(livePointerPoint);
|
||||
}
|
||||
}
|
||||
if (points.length >= 3) {
|
||||
const closedPoints = [...points];
|
||||
const first = closedPoints[0];
|
||||
const last = closedPoints[closedPoints.length - 1];
|
||||
if (Math.hypot(last.x - first.x, last.y - first.y) > 0.001) {
|
||||
closedPoints.push({ ...first });
|
||||
}
|
||||
onBlurDataChange(annotation.id, {
|
||||
...(annotation.blurData || { ...DEFAULT_BLUR_DATA, shape: "freehand" }),
|
||||
shape: "freehand",
|
||||
freehandPoints: closedPoints,
|
||||
});
|
||||
setDraftFreehandPoints(closedPoints);
|
||||
onBlurDataCommit?.();
|
||||
}
|
||||
setLivePointerPoint(null);
|
||||
};
|
||||
|
||||
const renderContent = () => {
|
||||
switch (annotation.type) {
|
||||
case "text":
|
||||
@@ -113,6 +234,114 @@ export function AnnotationOverlay({
|
||||
<div className="w-full h-full flex items-center justify-center p-2">{renderArrow()}</div>
|
||||
);
|
||||
|
||||
case "blur": {
|
||||
const shape = annotation.blurData?.shape ?? "rectangle";
|
||||
const blurIntensity = Math.max(
|
||||
1,
|
||||
Math.round(annotation.blurData?.intensity ?? DEFAULT_BLUR_INTENSITY),
|
||||
);
|
||||
const activeFreehandPoints =
|
||||
shape === "freehand"
|
||||
? isFreehandDrawing
|
||||
? draftFreehandPoints
|
||||
: (annotation.blurData?.freehandPoints ?? [])
|
||||
: [];
|
||||
const drawingPoints =
|
||||
isFreehandDrawing && livePointerPoint
|
||||
? (() => {
|
||||
const last = activeFreehandPoints[activeFreehandPoints.length - 1];
|
||||
if (!last) return [livePointerPoint];
|
||||
const dx = livePointerPoint.x - last.x;
|
||||
const dy = livePointerPoint.y - last.y;
|
||||
return Math.hypot(dx, dy) > 0.01
|
||||
? [...activeFreehandPoints, livePointerPoint]
|
||||
: activeFreehandPoints;
|
||||
})()
|
||||
: activeFreehandPoints;
|
||||
const clipPath =
|
||||
shape === "freehand" ? buildBlurPolygonClipPath(activeFreehandPoints) : undefined;
|
||||
const freehandPath =
|
||||
shape === "freehand"
|
||||
? buildBlurFreehandPath(
|
||||
isFreehandDrawing ? drawingPoints : activeFreehandPoints,
|
||||
!isFreehandDrawing,
|
||||
)
|
||||
: null;
|
||||
const currentPointerPoint = isFreehandDrawing
|
||||
? livePointerPoint || drawingPoints[drawingPoints.length - 1] || null
|
||||
: null;
|
||||
const shapeBorderRadius = shape === "oval" ? "50%" : shape === "rectangle" ? "8px" : "0";
|
||||
const shouldShowFreehandBlurFill =
|
||||
shape !== "freehand" || (!!clipPath && !isFreehandDrawing);
|
||||
const shapeMaskStyle: CSSProperties = {
|
||||
borderRadius: shapeBorderRadius,
|
||||
clipPath: isFreehandDrawing ? undefined : clipPath,
|
||||
WebkitClipPath: isFreehandDrawing ? undefined : clipPath,
|
||||
};
|
||||
const isFreehandSelected = isSelectedFreehandBlur;
|
||||
return (
|
||||
<div className="w-full h-full relative">
|
||||
<div
|
||||
className="absolute inset-0 overflow-hidden"
|
||||
style={{
|
||||
...shapeMaskStyle,
|
||||
isolation: "isolate",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
...shapeMaskStyle,
|
||||
backdropFilter: `blur(${blurIntensity}px)`,
|
||||
WebkitBackdropFilter: `blur(${blurIntensity}px)`,
|
||||
backgroundColor: "rgba(255, 255, 255, 0.02)",
|
||||
opacity: shouldShowFreehandBlurFill ? 1 : 0,
|
||||
}}
|
||||
/>
|
||||
{isSelected && shape !== "freehand" && (
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none border-2 border-[#34B27B]/80"
|
||||
style={{ borderRadius: shapeBorderRadius }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{isSelected && shape === "freehand" && freehandPath && (
|
||||
<svg
|
||||
viewBox="0 0 100 100"
|
||||
preserveAspectRatio="none"
|
||||
className="absolute inset-0 pointer-events-none"
|
||||
>
|
||||
<path
|
||||
d={freehandPath}
|
||||
fill="none"
|
||||
stroke="#34B27B"
|
||||
strokeWidth="0.55"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
{currentPointerPoint && (
|
||||
<circle
|
||||
cx={currentPointerPoint.x}
|
||||
cy={currentPointerPoint.y}
|
||||
r="0.6"
|
||||
fill="#34B27B"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
)}
|
||||
{isFreehandSelected && (
|
||||
<div
|
||||
className="absolute inset-0 cursor-crosshair"
|
||||
onPointerDown={handleFreehandPointerDown}
|
||||
onPointerMove={handleFreehandPointerMove}
|
||||
onPointerUp={finishFreehandPointer}
|
||||
onPointerCancel={finishFreehandPointer}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@@ -149,18 +378,23 @@ export function AnnotationOverlay({
|
||||
}}
|
||||
bounds="parent"
|
||||
className={cn(
|
||||
"cursor-move transition-all",
|
||||
isSelected && "ring-2 ring-[#34B27B] ring-offset-2 ring-offset-transparent",
|
||||
"cursor-move",
|
||||
isSelected &&
|
||||
annotation.type !== "blur" &&
|
||||
"ring-2 ring-[#34B27B] ring-offset-2 ring-offset-transparent",
|
||||
)}
|
||||
style={{
|
||||
zIndex: isSelectedBoost ? zIndex + 1000 : zIndex, // Boost selected annotation to ensure it's on top
|
||||
pointerEvents: isSelected ? "auto" : "none",
|
||||
border: isSelected ? "2px solid rgba(52, 178, 123, 0.8)" : "none",
|
||||
backgroundColor: isSelected ? "rgba(52, 178, 123, 0.1)" : "transparent",
|
||||
boxShadow: isSelected ? "0 0 0 1px rgba(52, 178, 123, 0.35)" : "none",
|
||||
border:
|
||||
isSelected && annotation.type !== "blur" ? "2px solid rgba(52, 178, 123, 0.8)" : "none",
|
||||
backgroundColor:
|
||||
isSelected && annotation.type !== "blur" ? "rgba(52, 178, 123, 0.1)" : "transparent",
|
||||
boxShadow:
|
||||
isSelected && annotation.type !== "blur" ? "0 0 0 1px rgba(52, 178, 123, 0.35)" : "none",
|
||||
}}
|
||||
enableResizing={isSelected}
|
||||
disableDragging={!isSelected}
|
||||
enableResizing={isSelected && !isSelectedFreehandBlur}
|
||||
disableDragging={!isSelected || isSelectedFreehandBlur}
|
||||
resizeHandleStyles={{
|
||||
topLeft: {
|
||||
width: "12px",
|
||||
@@ -206,11 +440,13 @@ export function AnnotationOverlay({
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"w-full h-full rounded-lg",
|
||||
"w-full h-full",
|
||||
annotation.type !== "blur" && "rounded-lg",
|
||||
annotation.type === "text" && "bg-transparent",
|
||||
annotation.type === "image" && "bg-transparent",
|
||||
annotation.type === "figure" && "bg-transparent",
|
||||
isSelected && "shadow-lg",
|
||||
annotation.type === "blur" && "bg-transparent",
|
||||
isSelected && annotation.type !== "blur" && "shadow-lg",
|
||||
)}
|
||||
>
|
||||
{renderContent()}
|
||||
|
||||
@@ -32,7 +32,12 @@ import { type CustomFont, getCustomFonts } from "@/lib/customFonts";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AddCustomFontDialog } from "./AddCustomFontDialog";
|
||||
import { getArrowComponent } from "./ArrowSvgs";
|
||||
import type { AnnotationRegion, AnnotationType, ArrowDirection, FigureData } from "./types";
|
||||
import {
|
||||
type AnnotationRegion,
|
||||
type AnnotationType,
|
||||
type ArrowDirection,
|
||||
type FigureData,
|
||||
} from "./types";
|
||||
|
||||
interface AnnotationSettingsPanelProps {
|
||||
annotation: AnnotationRegion;
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { Info, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { useScopedT } from "@/contexts/I18nContext";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
type AnnotationRegion,
|
||||
type BlurData,
|
||||
type BlurShape,
|
||||
DEFAULT_BLUR_DATA,
|
||||
MAX_BLUR_INTENSITY,
|
||||
MIN_BLUR_INTENSITY,
|
||||
} from "./types";
|
||||
|
||||
interface BlurSettingsPanelProps {
|
||||
blurRegion: AnnotationRegion;
|
||||
onBlurDataChange: (blurData: BlurData) => void;
|
||||
onBlurDataCommit?: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
export function BlurSettingsPanel({
|
||||
blurRegion,
|
||||
onBlurDataChange,
|
||||
onBlurDataCommit,
|
||||
onDelete,
|
||||
}: BlurSettingsPanelProps) {
|
||||
const t = useScopedT("settings");
|
||||
|
||||
const blurShapeOptions: Array<{ value: BlurShape; labelKey: string }> = [
|
||||
{ value: "rectangle", labelKey: "blurShapeRectangle" },
|
||||
{ value: "oval", labelKey: "blurShapeOval" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex-[2] min-w-0 bg-[#09090b] border border-white/5 rounded-2xl p-4 flex flex-col shadow-xl h-full overflow-y-auto custom-scrollbar">
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<span className="text-sm font-medium text-slate-200">{t("annotation.blurShape")}</span>
|
||||
<span className="text-[10px] uppercase tracking-wider font-medium text-[#34B27B] bg-[#34B27B]/10 px-2 py-1 rounded-full">
|
||||
{t("annotation.active")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{blurShapeOptions.map((shape) => {
|
||||
const activeShape = blurRegion.blurData?.shape || DEFAULT_BLUR_DATA.shape;
|
||||
const isActive = activeShape === shape.value;
|
||||
return (
|
||||
<button
|
||||
key={shape.value}
|
||||
onClick={() => {
|
||||
const nextBlurData: BlurData = {
|
||||
...DEFAULT_BLUR_DATA,
|
||||
...blurRegion.blurData,
|
||||
shape: shape.value,
|
||||
};
|
||||
onBlurDataChange(nextBlurData);
|
||||
requestAnimationFrame(() => {
|
||||
onBlurDataCommit?.();
|
||||
});
|
||||
}}
|
||||
className={cn(
|
||||
"h-16 rounded-lg border flex flex-col items-center justify-center transition-all p-2 gap-1",
|
||||
isActive
|
||||
? "bg-[#34B27B] border-[#34B27B]"
|
||||
: "bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20",
|
||||
)}
|
||||
>
|
||||
{shape.value === "rectangle" && (
|
||||
<div
|
||||
className={cn(
|
||||
"w-8 h-5 border-2 rounded-sm",
|
||||
isActive ? "border-white" : "border-slate-400",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{shape.value === "oval" && (
|
||||
<div
|
||||
className={cn(
|
||||
"w-8 h-5 border-2 rounded-full",
|
||||
isActive ? "border-white" : "border-slate-400",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<span className="text-[10px] leading-none">
|
||||
{t(`annotation.${shape.labelKey}`)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 p-3 rounded-lg bg-white/5 border border-white/10">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-medium text-slate-300">
|
||||
{t("annotation.blurIntensity")}
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-400 font-mono">
|
||||
{Math.round(blurRegion.blurData?.intensity ?? DEFAULT_BLUR_DATA.intensity)}px
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[blurRegion.blurData?.intensity ?? DEFAULT_BLUR_DATA.intensity]}
|
||||
onValueChange={(values) => {
|
||||
onBlurDataChange({
|
||||
...DEFAULT_BLUR_DATA,
|
||||
...blurRegion.blurData,
|
||||
intensity: values[0],
|
||||
});
|
||||
}}
|
||||
onValueCommit={() => onBlurDataCommit?.()}
|
||||
min={MIN_BLUR_INTENSITY}
|
||||
max={MAX_BLUR_INTENSITY}
|
||||
step={1}
|
||||
className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={onDelete}
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="w-full gap-2 bg-red-500/10 text-red-400 border border-red-500/20 hover:bg-red-500/20 hover:border-red-500/30 transition-all mt-4"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
{t("annotation.deleteAnnotation")}
|
||||
</Button>
|
||||
|
||||
<div className="mt-6 p-3 bg-white/5 rounded-lg border border-white/5">
|
||||
<div className="flex items-center gap-2 mb-2 text-slate-300">
|
||||
<Info className="w-3.5 h-3.5" />
|
||||
<span className="text-xs font-medium">{t("annotation.shortcutsAndTips")}</span>
|
||||
</div>
|
||||
<ul className="text-[10px] text-slate-400 space-y-1.5 list-disc pl-3 leading-relaxed">
|
||||
<li>{t("annotation.tipMovePlayhead")}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -42,18 +42,86 @@ import { cn } from "@/lib/utils";
|
||||
import { type AspectRatio, isPortraitAspectRatio } from "@/utils/aspectRatioUtils";
|
||||
import { getTestId } from "@/utils/getTestId";
|
||||
import { AnnotationSettingsPanel } from "./AnnotationSettingsPanel";
|
||||
import { BlurSettingsPanel } from "./BlurSettingsPanel";
|
||||
import { CropControl } from "./CropControl";
|
||||
import { KeyboardShortcutsHelp } from "./KeyboardShortcutsHelp";
|
||||
import type {
|
||||
AnnotationRegion,
|
||||
AnnotationType,
|
||||
BlurData,
|
||||
CropRegion,
|
||||
FigureData,
|
||||
PlaybackSpeed,
|
||||
WebcamLayoutPreset,
|
||||
WebcamMaskShape,
|
||||
WebcamSizePreset,
|
||||
ZoomDepth,
|
||||
ZoomFocusMode,
|
||||
} from "./types";
|
||||
import { SPEED_OPTIONS } from "./types";
|
||||
import { DEFAULT_WEBCAM_SIZE_PRESET, 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(
|
||||
@@ -92,6 +160,9 @@ interface SettingsPanelProps {
|
||||
onWallpaperChange: (path: string) => void;
|
||||
selectedZoomDepth?: ZoomDepth | null;
|
||||
onZoomDepthChange?: (depth: ZoomDepth) => void;
|
||||
selectedZoomFocusMode?: ZoomFocusMode | null;
|
||||
onZoomFocusModeChange?: (mode: ZoomFocusMode) => void;
|
||||
hasCursorTelemetry?: boolean;
|
||||
selectedZoomId?: string | null;
|
||||
onZoomDelete?: (id: string) => void;
|
||||
selectedTrimId?: string | null;
|
||||
@@ -127,7 +198,11 @@ interface SettingsPanelProps {
|
||||
onGifSizePresetChange?: (preset: GifSizePreset) => void;
|
||||
gifOutputDimensions?: { width: number; height: number };
|
||||
onExport?: () => void;
|
||||
unsavedExport?: { arrayBuffer: ArrayBuffer; fileName: string; format: string } | null;
|
||||
unsavedExport?: {
|
||||
arrayBuffer: ArrayBuffer;
|
||||
fileName: string;
|
||||
format: string;
|
||||
} | null;
|
||||
onSaveUnsavedExport?: () => void;
|
||||
selectedAnnotationId?: string | null;
|
||||
annotationRegions?: AnnotationRegion[];
|
||||
@@ -136,6 +211,11 @@ interface SettingsPanelProps {
|
||||
onAnnotationStyleChange?: (id: string, style: Partial<AnnotationRegion["style"]>) => void;
|
||||
onAnnotationFigureDataChange?: (id: string, figureData: FigureData) => void;
|
||||
onAnnotationDelete?: (id: string) => void;
|
||||
selectedBlurId?: string | null;
|
||||
blurRegions?: AnnotationRegion[];
|
||||
onBlurDataChange?: (id: string, blurData: BlurData) => void;
|
||||
onBlurDataCommit?: () => void;
|
||||
onBlurDelete?: (id: string) => void;
|
||||
selectedSpeedId?: string | null;
|
||||
selectedSpeedValue?: PlaybackSpeed | null;
|
||||
onSpeedChange?: (speed: PlaybackSpeed) => void;
|
||||
@@ -143,6 +223,11 @@ interface SettingsPanelProps {
|
||||
hasWebcam?: boolean;
|
||||
webcamLayoutPreset?: WebcamLayoutPreset;
|
||||
onWebcamLayoutPresetChange?: (preset: WebcamLayoutPreset) => void;
|
||||
webcamMaskShape?: import("./types").WebcamMaskShape;
|
||||
onWebcamMaskShapeChange?: (shape: import("./types").WebcamMaskShape) => void;
|
||||
webcamSizePreset?: WebcamSizePreset;
|
||||
onWebcamSizePresetChange?: (size: WebcamSizePreset) => void;
|
||||
onWebcamSizePresetCommit?: () => void;
|
||||
}
|
||||
|
||||
export default SettingsPanel;
|
||||
@@ -161,6 +246,9 @@ export function SettingsPanel({
|
||||
onWallpaperChange,
|
||||
selectedZoomDepth,
|
||||
onZoomDepthChange,
|
||||
selectedZoomFocusMode,
|
||||
onZoomFocusModeChange,
|
||||
hasCursorTelemetry = false,
|
||||
selectedZoomId,
|
||||
onZoomDelete,
|
||||
selectedTrimId,
|
||||
@@ -204,6 +292,11 @@ export function SettingsPanel({
|
||||
onAnnotationStyleChange,
|
||||
onAnnotationFigureDataChange,
|
||||
onAnnotationDelete,
|
||||
selectedBlurId,
|
||||
blurRegions = [],
|
||||
onBlurDataChange,
|
||||
onBlurDataCommit,
|
||||
onBlurDelete,
|
||||
selectedSpeedId,
|
||||
selectedSpeedValue,
|
||||
onSpeedChange,
|
||||
@@ -211,6 +304,11 @@ export function SettingsPanel({
|
||||
hasWebcam = false,
|
||||
webcamLayoutPreset = "picture-in-picture",
|
||||
onWebcamLayoutPresetChange,
|
||||
webcamMaskShape = "rectangle",
|
||||
onWebcamMaskShapeChange,
|
||||
webcamSizePreset = DEFAULT_WEBCAM_SIZE_PRESET,
|
||||
onWebcamSizePresetChange,
|
||||
onWebcamSizePresetCommit,
|
||||
}: SettingsPanelProps) {
|
||||
const t = useScopedT("settings");
|
||||
const [wallpaperPaths, setWallpaperPaths] = useState<string[]>([]);
|
||||
@@ -434,6 +532,9 @@ export function SettingsPanel({
|
||||
const selectedAnnotation = selectedAnnotationId
|
||||
? annotationRegions.find((a) => a.id === selectedAnnotationId)
|
||||
: null;
|
||||
const selectedBlur = selectedBlurId
|
||||
? blurRegions.find((region) => region.id === selectedBlurId)
|
||||
: null;
|
||||
|
||||
// If an annotation is selected, show annotation settings instead
|
||||
if (
|
||||
@@ -459,6 +560,17 @@ export function SettingsPanel({
|
||||
);
|
||||
}
|
||||
|
||||
if (selectedBlur && onBlurDataChange && onBlurDelete) {
|
||||
return (
|
||||
<BlurSettingsPanel
|
||||
blurRegion={selectedBlur}
|
||||
onBlurDataChange={(blurData) => onBlurDataChange(selectedBlur.id, blurData)}
|
||||
onBlurDataCommit={onBlurDataCommit}
|
||||
onDelete={() => onBlurDelete(selectedBlur.id)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-[2] min-w-0 bg-[#09090b] border border-white/5 rounded-2xl flex flex-col shadow-xl h-full overflow-hidden">
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar p-4 pb-0">
|
||||
@@ -500,6 +612,41 @@ export function SettingsPanel({
|
||||
{!zoomEnabled && (
|
||||
<p className="text-[10px] text-slate-500 mt-2 text-center">{t("zoom.selectRegion")}</p>
|
||||
)}
|
||||
{zoomEnabled && hasCursorTelemetry && (
|
||||
<div className="mt-3">
|
||||
<span className="text-sm font-medium text-slate-200 mb-2 block">
|
||||
{t("zoom.focusMode.title")}
|
||||
</span>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{(["manual", "auto"] as const).map((mode) => {
|
||||
const isActive = selectedZoomFocusMode === mode;
|
||||
return (
|
||||
<Button
|
||||
key={mode}
|
||||
type="button"
|
||||
onClick={() => onZoomFocusModeChange?.(mode)}
|
||||
className={cn(
|
||||
"h-auto w-full rounded-lg border px-2 py-2 text-center shadow-sm transition-all",
|
||||
"duration-200 ease-out cursor-pointer",
|
||||
isActive
|
||||
? "border-[#34B27B] bg-[#34B27B] text-white shadow-[#34B27B]/20"
|
||||
: "border-white/5 bg-white/5 text-slate-400 hover:bg-white/10 hover:border-white/10 hover:text-slate-200",
|
||||
)}
|
||||
>
|
||||
<span className="text-xs font-semibold capitalize">
|
||||
{t(`zoom.focusMode.${mode}`)}
|
||||
</span>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{selectedZoomFocusMode === "auto" && (
|
||||
<p className="text-[10px] text-slate-500 mt-1.5">
|
||||
{t("zoom.focusMode.autoDescription")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{zoomEnabled && (
|
||||
<Button
|
||||
onClick={handleDeleteClick}
|
||||
@@ -537,7 +684,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 (
|
||||
@@ -562,6 +709,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>
|
||||
)}
|
||||
@@ -623,6 +793,108 @@ export function SettingsPanel({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{webcamLayoutPreset === "picture-in-picture" && (
|
||||
<div className="mt-2 p-2 rounded-lg bg-white/5 border border-white/5">
|
||||
<div className="text-[10px] font-medium text-slate-300 mb-1.5">
|
||||
{t("layout.webcamShape")}
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-1.5">
|
||||
{(
|
||||
[
|
||||
{ value: "rectangle", label: "Rect" },
|
||||
{ value: "circle", label: "Circle" },
|
||||
{ value: "square", label: "Square" },
|
||||
{ value: "rounded", label: "Rounded" },
|
||||
] as Array<{ value: WebcamMaskShape; label: string }>
|
||||
).map((shape) => (
|
||||
<button
|
||||
key={shape.value}
|
||||
type="button"
|
||||
onClick={() => onWebcamMaskShapeChange?.(shape.value)}
|
||||
className={cn(
|
||||
"h-10 rounded-lg border flex flex-col items-center justify-center gap-0.5 transition-all",
|
||||
webcamMaskShape === shape.value
|
||||
? "bg-[#34B27B] border-[#34B27B] text-white"
|
||||
: "bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20 text-slate-400",
|
||||
)}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
{shape.value === "rectangle" && (
|
||||
<rect
|
||||
x="1"
|
||||
y="3"
|
||||
width="14"
|
||||
height="10"
|
||||
rx="2"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
)}
|
||||
{shape.value === "circle" && (
|
||||
<circle
|
||||
cx="8"
|
||||
cy="8"
|
||||
r="6.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
)}
|
||||
{shape.value === "square" && (
|
||||
<rect
|
||||
x="2"
|
||||
y="2"
|
||||
width="12"
|
||||
height="12"
|
||||
rx="1"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
)}
|
||||
{shape.value === "rounded" && (
|
||||
<rect
|
||||
x="1"
|
||||
y="3"
|
||||
width="14"
|
||||
height="10"
|
||||
rx="5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
<span className="text-[8px] leading-none">{shape.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{webcamLayoutPreset === "picture-in-picture" && (
|
||||
<div className="p-2 rounded-lg bg-white/5 border border-white/5 mt-2">
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<div className="text-[10px] font-medium text-slate-300">
|
||||
{t("layout.webcamSize")}
|
||||
</div>
|
||||
<div className="text-[10px] font-medium text-slate-400">
|
||||
{webcamSizePreset}%
|
||||
</div>
|
||||
</div>
|
||||
<Slider
|
||||
value={[webcamSizePreset]}
|
||||
onValueChange={(values) => onWebcamSizePresetChange?.(values[0])}
|
||||
onValueCommit={() => onWebcamSizePresetCommit?.()}
|
||||
min={10}
|
||||
max={50}
|
||||
step={1}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
)}
|
||||
@@ -888,7 +1160,9 @@ export function SettingsPanel({
|
||||
: "border-white/10 hover:border-[#34B27B]/40 opacity-80 hover:opacity-100 bg-white/5",
|
||||
)}
|
||||
style={{ background: g }}
|
||||
aria-label={t("background.gradientLabel", { index: idx + 1 })}
|
||||
aria-label={t("background.gradientLabel", {
|
||||
index: idx + 1,
|
||||
})}
|
||||
onClick={() => {
|
||||
setGradient(g);
|
||||
onWallpaperChange(g);
|
||||
|
||||
@@ -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,
|
||||
@@ -42,11 +54,13 @@ import { SettingsPanel } from "./SettingsPanel";
|
||||
import TimelineEditor from "./timeline/TimelineEditor";
|
||||
import {
|
||||
type AnnotationRegion,
|
||||
type BlurData,
|
||||
type CursorTelemetryPoint,
|
||||
clampFocusToDepth,
|
||||
DEFAULT_ANNOTATION_POSITION,
|
||||
DEFAULT_ANNOTATION_SIZE,
|
||||
DEFAULT_ANNOTATION_STYLE,
|
||||
DEFAULT_BLUR_DATA,
|
||||
DEFAULT_FIGURE_DATA,
|
||||
DEFAULT_PLAYBACK_SPEED,
|
||||
DEFAULT_ZOOM_DEPTH,
|
||||
@@ -56,6 +70,7 @@ import {
|
||||
type TrimRegion,
|
||||
type ZoomDepth,
|
||||
type ZoomFocus,
|
||||
type ZoomFocusMode,
|
||||
type ZoomRegion,
|
||||
} from "./types";
|
||||
import VideoPlayback, { VideoPlaybackRef } from "./VideoPlayback";
|
||||
@@ -84,6 +99,8 @@ export default function VideoEditor() {
|
||||
padding,
|
||||
aspectRatio,
|
||||
webcamLayoutPreset,
|
||||
webcamMaskShape,
|
||||
webcamSizePreset,
|
||||
webcamPosition,
|
||||
} = editorState;
|
||||
|
||||
@@ -98,15 +115,21 @@ 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);
|
||||
const [selectedSpeedId, setSelectedSpeedId] = useState<string | null>(null);
|
||||
const [selectedAnnotationId, setSelectedAnnotationId] = useState<string | null>(null);
|
||||
const [selectedBlurId, setSelectedBlurId] = useState<string | null>(null);
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
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);
|
||||
@@ -137,6 +160,15 @@ export default function VideoEditor() {
|
||||
const nextAnnotationZIndexRef = useRef(1);
|
||||
const exporterRef = useRef<VideoExporter | null>(null);
|
||||
|
||||
const annotationOnlyRegions = useMemo(
|
||||
() => annotationRegions.filter((region) => region.type !== "blur"),
|
||||
[annotationRegions],
|
||||
);
|
||||
const blurRegions = useMemo(
|
||||
() => annotationRegions.filter((region) => region.type === "blur"),
|
||||
[annotationRegions],
|
||||
);
|
||||
|
||||
const currentProjectMedia = useMemo<ProjectMedia | null>(() => {
|
||||
const screenVideoPath = videoSourcePath ?? (videoPath ? fromFileUrl(videoPath) : null);
|
||||
if (!screenVideoPath) {
|
||||
@@ -195,6 +227,8 @@ export default function VideoEditor() {
|
||||
annotationRegions: normalizedEditor.annotationRegions,
|
||||
aspectRatio: normalizedEditor.aspectRatio,
|
||||
webcamLayoutPreset: normalizedEditor.webcamLayoutPreset,
|
||||
webcamMaskShape: normalizedEditor.webcamMaskShape,
|
||||
webcamSizePreset: normalizedEditor.webcamSizePreset,
|
||||
webcamPosition: normalizedEditor.webcamPosition,
|
||||
});
|
||||
setExportQuality(normalizedEditor.exportQuality);
|
||||
@@ -207,6 +241,7 @@ export default function VideoEditor() {
|
||||
setSelectedTrimId(null);
|
||||
setSelectedSpeedId(null);
|
||||
setSelectedAnnotationId(null);
|
||||
setSelectedBlurId(null);
|
||||
|
||||
nextZoomIdRef.current = deriveNextId(
|
||||
"zoom",
|
||||
@@ -231,13 +266,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;
|
||||
@@ -249,29 +282,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,
|
||||
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,
|
||||
@@ -287,6 +319,8 @@ export default function VideoEditor() {
|
||||
annotationRegions,
|
||||
aspectRatio,
|
||||
webcamLayoutPreset,
|
||||
webcamMaskShape,
|
||||
webcamSizePreset,
|
||||
webcamPosition,
|
||||
exportQuality,
|
||||
exportFormat,
|
||||
@@ -295,12 +329,7 @@ export default function VideoEditor() {
|
||||
gifSizePreset,
|
||||
]);
|
||||
|
||||
const hasUnsavedChanges = Boolean(
|
||||
currentProjectPath &&
|
||||
currentProjectSnapshot &&
|
||||
lastSavedSnapshot &&
|
||||
currentProjectSnapshot !== lastSavedSnapshot,
|
||||
);
|
||||
const hasUnsavedChanges = hasProjectUnsavedChanges(currentProjectSnapshot, lastSavedSnapshot);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadInitialData() {
|
||||
@@ -328,7 +357,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;
|
||||
}
|
||||
|
||||
@@ -340,7 +376,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.");
|
||||
}
|
||||
@@ -354,6 +392,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) {
|
||||
@@ -380,6 +440,8 @@ export default function VideoEditor() {
|
||||
annotationRegions,
|
||||
aspectRatio,
|
||||
webcamLayoutPreset,
|
||||
webcamMaskShape,
|
||||
webcamSizePreset,
|
||||
webcamPosition,
|
||||
exportQuality,
|
||||
exportFormat,
|
||||
@@ -434,6 +496,8 @@ export default function VideoEditor() {
|
||||
annotationRegions,
|
||||
aspectRatio,
|
||||
webcamLayoutPreset,
|
||||
webcamMaskShape,
|
||||
webcamSizePreset,
|
||||
webcamPosition,
|
||||
exportQuality,
|
||||
exportFormat,
|
||||
@@ -464,6 +528,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();
|
||||
|
||||
@@ -565,7 +639,11 @@ export default function VideoEditor() {
|
||||
|
||||
const handleSelectZoom = useCallback((id: string | null) => {
|
||||
setSelectedZoomId(id);
|
||||
if (id) setSelectedTrimId(null);
|
||||
if (id) {
|
||||
setSelectedTrimId(null);
|
||||
setSelectedAnnotationId(null);
|
||||
setSelectedBlurId(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSelectTrim = useCallback((id: string | null) => {
|
||||
@@ -573,6 +651,7 @@ export default function VideoEditor() {
|
||||
if (id) {
|
||||
setSelectedZoomId(null);
|
||||
setSelectedAnnotationId(null);
|
||||
setSelectedBlurId(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -581,6 +660,17 @@ export default function VideoEditor() {
|
||||
if (id) {
|
||||
setSelectedZoomId(null);
|
||||
setSelectedTrimId(null);
|
||||
setSelectedBlurId(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSelectBlur = useCallback((id: string | null) => {
|
||||
setSelectedBlurId(id);
|
||||
if (id) {
|
||||
setSelectedZoomId(null);
|
||||
setSelectedTrimId(null);
|
||||
setSelectedAnnotationId(null);
|
||||
setSelectedSpeedId(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -598,6 +688,7 @@ export default function VideoEditor() {
|
||||
setSelectedZoomId(id);
|
||||
setSelectedTrimId(null);
|
||||
setSelectedAnnotationId(null);
|
||||
setSelectedBlurId(null);
|
||||
},
|
||||
[pushState],
|
||||
);
|
||||
@@ -616,6 +707,7 @@ export default function VideoEditor() {
|
||||
setSelectedZoomId(id);
|
||||
setSelectedTrimId(null);
|
||||
setSelectedAnnotationId(null);
|
||||
setSelectedBlurId(null);
|
||||
},
|
||||
[pushState],
|
||||
);
|
||||
@@ -632,6 +724,7 @@ export default function VideoEditor() {
|
||||
setSelectedTrimId(id);
|
||||
setSelectedZoomId(null);
|
||||
setSelectedAnnotationId(null);
|
||||
setSelectedBlurId(null);
|
||||
},
|
||||
[pushState],
|
||||
);
|
||||
@@ -641,7 +734,11 @@ export default function VideoEditor() {
|
||||
pushState((prev) => ({
|
||||
zoomRegions: prev.zoomRegions.map((region) =>
|
||||
region.id === id
|
||||
? { ...region, startMs: Math.round(span.start), endMs: Math.round(span.end) }
|
||||
? {
|
||||
...region,
|
||||
startMs: Math.round(span.start),
|
||||
endMs: Math.round(span.end),
|
||||
}
|
||||
: region,
|
||||
),
|
||||
}));
|
||||
@@ -654,7 +751,11 @@ export default function VideoEditor() {
|
||||
pushState((prev) => ({
|
||||
trimRegions: prev.trimRegions.map((region) =>
|
||||
region.id === id
|
||||
? { ...region, startMs: Math.round(span.start), endMs: Math.round(span.end) }
|
||||
? {
|
||||
...region,
|
||||
startMs: Math.round(span.start),
|
||||
endMs: Math.round(span.end),
|
||||
}
|
||||
: region,
|
||||
),
|
||||
}));
|
||||
@@ -680,7 +781,11 @@ export default function VideoEditor() {
|
||||
pushState((prev) => ({
|
||||
zoomRegions: prev.zoomRegions.map((region) =>
|
||||
region.id === selectedZoomId
|
||||
? { ...region, depth, focus: clampFocusToDepth(region.focus, depth) }
|
||||
? {
|
||||
...region,
|
||||
depth,
|
||||
focus: clampFocusToDepth(region.focus, depth),
|
||||
}
|
||||
: region,
|
||||
),
|
||||
}));
|
||||
@@ -688,9 +793,23 @@ export default function VideoEditor() {
|
||||
[selectedZoomId, pushState],
|
||||
);
|
||||
|
||||
const handleZoomFocusModeChange = useCallback(
|
||||
(focusMode: ZoomFocusMode) => {
|
||||
if (!selectedZoomId) return;
|
||||
pushState((prev) => ({
|
||||
zoomRegions: prev.zoomRegions.map((region) =>
|
||||
region.id === selectedZoomId ? { ...region, focusMode } : region,
|
||||
),
|
||||
}));
|
||||
},
|
||||
[selectedZoomId, pushState],
|
||||
);
|
||||
|
||||
const handleZoomDelete = useCallback(
|
||||
(id: string) => {
|
||||
pushState((prev) => ({ zoomRegions: prev.zoomRegions.filter((r) => r.id !== id) }));
|
||||
pushState((prev) => ({
|
||||
zoomRegions: prev.zoomRegions.filter((r) => r.id !== id),
|
||||
}));
|
||||
if (selectedZoomId === id) {
|
||||
setSelectedZoomId(null);
|
||||
}
|
||||
@@ -700,7 +819,9 @@ export default function VideoEditor() {
|
||||
|
||||
const handleTrimDelete = useCallback(
|
||||
(id: string) => {
|
||||
pushState((prev) => ({ trimRegions: prev.trimRegions.filter((r) => r.id !== id) }));
|
||||
pushState((prev) => ({
|
||||
trimRegions: prev.trimRegions.filter((r) => r.id !== id),
|
||||
}));
|
||||
if (selectedTrimId === id) {
|
||||
setSelectedTrimId(null);
|
||||
}
|
||||
@@ -714,6 +835,7 @@ export default function VideoEditor() {
|
||||
setSelectedZoomId(null);
|
||||
setSelectedTrimId(null);
|
||||
setSelectedAnnotationId(null);
|
||||
setSelectedBlurId(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -726,11 +848,14 @@ export default function VideoEditor() {
|
||||
endMs: Math.round(span.end),
|
||||
speed: DEFAULT_PLAYBACK_SPEED,
|
||||
};
|
||||
pushState((prev) => ({ speedRegions: [...prev.speedRegions, newRegion] }));
|
||||
pushState((prev) => ({
|
||||
speedRegions: [...prev.speedRegions, newRegion],
|
||||
}));
|
||||
setSelectedSpeedId(id);
|
||||
setSelectedZoomId(null);
|
||||
setSelectedTrimId(null);
|
||||
setSelectedAnnotationId(null);
|
||||
setSelectedBlurId(null);
|
||||
},
|
||||
[pushState],
|
||||
);
|
||||
@@ -791,10 +916,41 @@ export default function VideoEditor() {
|
||||
style: { ...DEFAULT_ANNOTATION_STYLE },
|
||||
zIndex,
|
||||
};
|
||||
pushState((prev) => ({ annotationRegions: [...prev.annotationRegions, newRegion] }));
|
||||
pushState((prev) => ({
|
||||
annotationRegions: [...prev.annotationRegions, newRegion],
|
||||
}));
|
||||
setSelectedAnnotationId(id);
|
||||
setSelectedZoomId(null);
|
||||
setSelectedTrimId(null);
|
||||
setSelectedBlurId(null);
|
||||
},
|
||||
[pushState],
|
||||
);
|
||||
|
||||
const handleBlurAdded = useCallback(
|
||||
(span: Span) => {
|
||||
const id = `annotation-${nextAnnotationIdRef.current++}`;
|
||||
const zIndex = nextAnnotationZIndexRef.current++;
|
||||
const newRegion: AnnotationRegion = {
|
||||
id,
|
||||
startMs: Math.round(span.start),
|
||||
endMs: Math.round(span.end),
|
||||
type: "blur",
|
||||
content: "",
|
||||
position: { ...DEFAULT_ANNOTATION_POSITION },
|
||||
size: { ...DEFAULT_ANNOTATION_SIZE },
|
||||
style: { ...DEFAULT_ANNOTATION_STYLE },
|
||||
zIndex,
|
||||
blurData: { ...DEFAULT_BLUR_DATA },
|
||||
};
|
||||
pushState((prev) => ({
|
||||
annotationRegions: [...prev.annotationRegions, newRegion],
|
||||
}));
|
||||
setSelectedBlurId(id);
|
||||
setSelectedAnnotationId(null);
|
||||
setSelectedZoomId(null);
|
||||
setSelectedTrimId(null);
|
||||
setSelectedSpeedId(null);
|
||||
},
|
||||
[pushState],
|
||||
);
|
||||
@@ -804,7 +960,11 @@ export default function VideoEditor() {
|
||||
pushState((prev) => ({
|
||||
annotationRegions: prev.annotationRegions.map((region) =>
|
||||
region.id === id
|
||||
? { ...region, startMs: Math.round(span.start), endMs: Math.round(span.end) }
|
||||
? {
|
||||
...region,
|
||||
startMs: Math.round(span.start),
|
||||
endMs: Math.round(span.end),
|
||||
}
|
||||
: region,
|
||||
),
|
||||
}));
|
||||
@@ -820,8 +980,11 @@ export default function VideoEditor() {
|
||||
if (selectedAnnotationId === id) {
|
||||
setSelectedAnnotationId(null);
|
||||
}
|
||||
if (selectedBlurId === id) {
|
||||
setSelectedBlurId(null);
|
||||
}
|
||||
},
|
||||
[selectedAnnotationId, pushState],
|
||||
[selectedAnnotationId, selectedBlurId, pushState],
|
||||
);
|
||||
|
||||
const handleAnnotationContentChange = useCallback(
|
||||
@@ -856,12 +1019,26 @@ export default function VideoEditor() {
|
||||
if (!region.figureData) {
|
||||
updatedRegion.figureData = { ...DEFAULT_FIGURE_DATA };
|
||||
}
|
||||
} else if (type === "blur") {
|
||||
updatedRegion.content = "";
|
||||
if (!region.blurData) {
|
||||
updatedRegion.blurData = { ...DEFAULT_BLUR_DATA };
|
||||
}
|
||||
}
|
||||
return updatedRegion;
|
||||
}),
|
||||
}));
|
||||
|
||||
if (type === "blur" && selectedAnnotationId === id) {
|
||||
setSelectedAnnotationId(null);
|
||||
setSelectedBlurId(id);
|
||||
setSelectedSpeedId(null);
|
||||
} else if (type !== "blur" && selectedBlurId === id) {
|
||||
setSelectedBlurId(null);
|
||||
setSelectedAnnotationId(id);
|
||||
}
|
||||
},
|
||||
[pushState],
|
||||
[pushState, selectedAnnotationId, selectedBlurId],
|
||||
);
|
||||
|
||||
const handleAnnotationStyleChange = useCallback(
|
||||
@@ -886,6 +1063,51 @@ export default function VideoEditor() {
|
||||
[pushState],
|
||||
);
|
||||
|
||||
const handleBlurDataPreviewChange = useCallback(
|
||||
(id: string, blurData: BlurData) => {
|
||||
updateState((prev) => ({
|
||||
annotationRegions: prev.annotationRegions.map((region) =>
|
||||
region.id === id
|
||||
? {
|
||||
...region,
|
||||
blurData,
|
||||
// Freehand drawing area is the full video surface.
|
||||
...(blurData.shape === "freehand"
|
||||
? {
|
||||
position: { x: 0, y: 0 },
|
||||
size: { width: 100, height: 100 },
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
: region,
|
||||
),
|
||||
}));
|
||||
},
|
||||
[updateState],
|
||||
);
|
||||
|
||||
const handleBlurDataPanelChange = useCallback(
|
||||
(id: string, blurData: BlurData) => {
|
||||
pushState((prev) => ({
|
||||
annotationRegions: prev.annotationRegions.map((region) =>
|
||||
region.id === id
|
||||
? {
|
||||
...region,
|
||||
blurData,
|
||||
...(blurData.shape === "freehand"
|
||||
? {
|
||||
position: { x: 0, y: 0 },
|
||||
size: { width: 100, height: 100 },
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
: region,
|
||||
),
|
||||
}));
|
||||
},
|
||||
[pushState],
|
||||
);
|
||||
|
||||
const handleAnnotationPositionChange = useCallback(
|
||||
(id: string, position: { x: number; y: number }) => {
|
||||
pushState((prev) => ({
|
||||
@@ -926,6 +1148,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;
|
||||
|
||||
@@ -965,11 +1221,14 @@ export default function VideoEditor() {
|
||||
useEffect(() => {
|
||||
if (
|
||||
selectedAnnotationId &&
|
||||
!annotationRegions.some((region) => region.id === selectedAnnotationId)
|
||||
!annotationOnlyRegions.some((region) => region.id === selectedAnnotationId)
|
||||
) {
|
||||
setSelectedAnnotationId(null);
|
||||
}
|
||||
}, [selectedAnnotationId, annotationRegions]);
|
||||
if (selectedBlurId && !blurRegions.some((region) => region.id === selectedBlurId)) {
|
||||
setSelectedBlurId(null);
|
||||
}
|
||||
}, [selectedAnnotationId, selectedBlurId, annotationOnlyRegions, blurRegions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedSpeedId && !speedRegions.some((region) => region.id === selectedSpeedId)) {
|
||||
@@ -1090,9 +1349,12 @@ export default function VideoEditor() {
|
||||
cropRegion,
|
||||
annotationRegions,
|
||||
webcamLayoutPreset,
|
||||
webcamMaskShape,
|
||||
webcamSizePreset,
|
||||
webcamPosition,
|
||||
previewWidth,
|
||||
previewHeight,
|
||||
cursorTelemetry,
|
||||
onProgress: (progress: ExportProgress) => {
|
||||
setExportProgress(progress);
|
||||
},
|
||||
@@ -1221,9 +1483,12 @@ export default function VideoEditor() {
|
||||
cropRegion,
|
||||
annotationRegions,
|
||||
webcamLayoutPreset,
|
||||
webcamMaskShape,
|
||||
webcamSizePreset,
|
||||
webcamPosition,
|
||||
previewWidth,
|
||||
previewHeight,
|
||||
cursorTelemetry,
|
||||
onProgress: (progress: ExportProgress) => {
|
||||
setExportProgress(progress);
|
||||
},
|
||||
@@ -1289,9 +1554,12 @@ export default function VideoEditor() {
|
||||
isPlaying,
|
||||
aspectRatio,
|
||||
webcamLayoutPreset,
|
||||
webcamMaskShape,
|
||||
webcamSizePreset,
|
||||
webcamPosition,
|
||||
exportQuality,
|
||||
handleExportSaved,
|
||||
cursorTelemetry,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1393,6 +1661,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}
|
||||
@@ -1418,6 +1714,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}
|
||||
@@ -1473,6 +1777,8 @@ export default function VideoEditor() {
|
||||
videoPath={videoPath || ""}
|
||||
webcamVideoPath={webcamVideoPath || undefined}
|
||||
webcamLayoutPreset={webcamLayoutPreset}
|
||||
webcamMaskShape={webcamMaskShape}
|
||||
webcamSizePreset={webcamSizePreset}
|
||||
webcamPosition={webcamPosition}
|
||||
onWebcamPositionChange={(pos) => updateState({ webcamPosition: pos })}
|
||||
onWebcamPositionDragEnd={commitState}
|
||||
@@ -1497,11 +1803,19 @@ export default function VideoEditor() {
|
||||
cropRegion={cropRegion}
|
||||
trimRegions={trimRegions}
|
||||
speedRegions={speedRegions}
|
||||
annotationRegions={annotationRegions}
|
||||
annotationRegions={annotationOnlyRegions}
|
||||
selectedAnnotationId={selectedAnnotationId}
|
||||
onSelectAnnotation={handleSelectAnnotation}
|
||||
onAnnotationPositionChange={handleAnnotationPositionChange}
|
||||
onAnnotationSizeChange={handleAnnotationSizeChange}
|
||||
blurRegions={blurRegions}
|
||||
selectedBlurId={selectedBlurId}
|
||||
onSelectBlur={handleSelectBlur}
|
||||
onBlurPositionChange={handleAnnotationPositionChange}
|
||||
onBlurSizeChange={handleAnnotationSizeChange}
|
||||
onBlurDataChange={handleBlurDataPreviewChange}
|
||||
onBlurDataCommit={commitState}
|
||||
cursorTelemetry={cursorTelemetry}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1553,12 +1867,18 @@ export default function VideoEditor() {
|
||||
onSpeedDelete={handleSpeedDelete}
|
||||
selectedSpeedId={selectedSpeedId}
|
||||
onSelectSpeed={handleSelectSpeed}
|
||||
annotationRegions={annotationRegions}
|
||||
annotationRegions={annotationOnlyRegions}
|
||||
onAnnotationAdded={handleAnnotationAdded}
|
||||
onAnnotationSpanChange={handleAnnotationSpanChange}
|
||||
onAnnotationDelete={handleAnnotationDelete}
|
||||
selectedAnnotationId={selectedAnnotationId}
|
||||
onSelectAnnotation={handleSelectAnnotation}
|
||||
blurRegions={blurRegions}
|
||||
onBlurAdded={handleBlurAdded}
|
||||
onBlurSpanChange={handleAnnotationSpanChange}
|
||||
onBlurDelete={handleAnnotationDelete}
|
||||
selectedBlurId={selectedBlurId}
|
||||
onSelectBlur={handleSelectBlur}
|
||||
aspectRatio={aspectRatio}
|
||||
onAspectRatioChange={(ar) =>
|
||||
pushState({
|
||||
@@ -1584,6 +1904,13 @@ export default function VideoEditor() {
|
||||
selectedZoomId ? zoomRegions.find((z) => z.id === selectedZoomId)?.depth : null
|
||||
}
|
||||
onZoomDepthChange={(depth) => selectedZoomId && handleZoomDepthChange(depth)}
|
||||
selectedZoomFocusMode={
|
||||
selectedZoomId
|
||||
? (zoomRegions.find((z) => z.id === selectedZoomId)?.focusMode ?? "manual")
|
||||
: null
|
||||
}
|
||||
onZoomFocusModeChange={(mode) => selectedZoomId && handleZoomFocusModeChange(mode)}
|
||||
hasCursorTelemetry={cursorTelemetry.length > 0}
|
||||
selectedZoomId={selectedZoomId}
|
||||
onZoomDelete={handleZoomDelete}
|
||||
selectedTrimId={selectedTrimId}
|
||||
@@ -1613,6 +1940,11 @@ export default function VideoEditor() {
|
||||
webcamPosition: preset === "vertical-stack" ? null : webcamPosition,
|
||||
})
|
||||
}
|
||||
webcamMaskShape={webcamMaskShape}
|
||||
onWebcamMaskShapeChange={(shape) => pushState({ webcamMaskShape: shape })}
|
||||
webcamSizePreset={webcamSizePreset}
|
||||
onWebcamSizePresetChange={(v) => updateState({ webcamSizePreset: v })}
|
||||
onWebcamSizePresetCommit={commitState}
|
||||
videoElement={videoPlaybackRef.current?.video || null}
|
||||
exportQuality={exportQuality}
|
||||
onExportQualityChange={setExportQuality}
|
||||
@@ -1639,12 +1971,17 @@ export default function VideoEditor() {
|
||||
)}
|
||||
onExport={handleOpenExportDialog}
|
||||
selectedAnnotationId={selectedAnnotationId}
|
||||
annotationRegions={annotationRegions}
|
||||
annotationRegions={annotationOnlyRegions}
|
||||
onAnnotationContentChange={handleAnnotationContentChange}
|
||||
onAnnotationTypeChange={handleAnnotationTypeChange}
|
||||
onAnnotationStyleChange={handleAnnotationStyleChange}
|
||||
onAnnotationFigureDataChange={handleAnnotationFigureDataChange}
|
||||
onAnnotationDelete={handleAnnotationDelete}
|
||||
selectedBlurId={selectedBlurId}
|
||||
blurRegions={blurRegions}
|
||||
onBlurDataChange={handleBlurDataPanelChange}
|
||||
onBlurDataCommit={commitState}
|
||||
onBlurDelete={handleAnnotationDelete}
|
||||
selectedSpeedId={selectedSpeedId}
|
||||
selectedSpeedValue={
|
||||
selectedSpeedId
|
||||
|
||||
@@ -24,7 +24,9 @@ import {
|
||||
type Size,
|
||||
type StyledRenderRect,
|
||||
type WebcamLayoutPreset,
|
||||
type WebcamSizePreset,
|
||||
} from "@/lib/compositeLayout";
|
||||
import { getCssClipPath } from "@/lib/webcamMaskShapes";
|
||||
import {
|
||||
type AspectRatio,
|
||||
formatAspectRatioForCSS,
|
||||
@@ -33,6 +35,7 @@ import {
|
||||
import { AnnotationOverlay } from "./AnnotationOverlay";
|
||||
import {
|
||||
type AnnotationRegion,
|
||||
type BlurData,
|
||||
type SpeedRegion,
|
||||
type TrimRegion,
|
||||
ZOOM_DEPTH_SCALES,
|
||||
@@ -41,10 +44,14 @@ import {
|
||||
type ZoomRegion,
|
||||
} from "./types";
|
||||
import {
|
||||
AUTO_FOLLOW_RAMP_DISTANCE,
|
||||
AUTO_FOLLOW_SMOOTHING_FACTOR,
|
||||
AUTO_FOLLOW_SMOOTHING_FACTOR_MAX,
|
||||
DEFAULT_FOCUS,
|
||||
ZOOM_SCALE_DEADZONE,
|
||||
ZOOM_TRANSLATION_DEADZONE_PX,
|
||||
} from "./videoPlayback/constants";
|
||||
import { adaptiveSmoothFactor, smoothCursorFocus } from "./videoPlayback/cursorFollowUtils";
|
||||
import { clampFocusToStage as clampFocusToStageUtil } from "./videoPlayback/focusUtils";
|
||||
import { layoutVideoContent as layoutVideoContentUtil } from "./videoPlayback/layoutUtils";
|
||||
import { clamp01 } from "./videoPlayback/mathUtils";
|
||||
@@ -63,6 +70,8 @@ interface VideoPlaybackProps {
|
||||
videoPath: string;
|
||||
webcamVideoPath?: string;
|
||||
webcamLayoutPreset: WebcamLayoutPreset;
|
||||
webcamMaskShape?: import("./types").WebcamMaskShape;
|
||||
webcamSizePreset?: WebcamSizePreset;
|
||||
webcamPosition?: { cx: number; cy: number } | null;
|
||||
onWebcamPositionChange?: (position: { cx: number; cy: number }) => void;
|
||||
onWebcamPositionDragEnd?: () => void;
|
||||
@@ -93,6 +102,14 @@ interface VideoPlaybackProps {
|
||||
onSelectAnnotation?: (id: string | null) => void;
|
||||
onAnnotationPositionChange?: (id: string, position: { x: number; y: number }) => void;
|
||||
onAnnotationSizeChange?: (id: string, size: { width: number; height: number }) => void;
|
||||
blurRegions?: AnnotationRegion[];
|
||||
selectedBlurId?: string | null;
|
||||
onSelectBlur?: (id: string | null) => void;
|
||||
onBlurPositionChange?: (id: string, position: { x: number; y: number }) => void;
|
||||
onBlurSizeChange?: (id: string, size: { width: number; height: number }) => void;
|
||||
onBlurDataChange?: (id: string, blurData: BlurData) => void;
|
||||
onBlurDataCommit?: () => void;
|
||||
cursorTelemetry?: import("./types").CursorTelemetryPoint[];
|
||||
}
|
||||
|
||||
export interface VideoPlaybackRef {
|
||||
@@ -111,6 +128,8 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
videoPath,
|
||||
webcamVideoPath,
|
||||
webcamLayoutPreset,
|
||||
webcamMaskShape,
|
||||
webcamSizePreset,
|
||||
webcamPosition,
|
||||
onWebcamPositionChange,
|
||||
onWebcamPositionDragEnd,
|
||||
@@ -141,6 +160,14 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
onSelectAnnotation,
|
||||
onAnnotationPositionChange,
|
||||
onAnnotationSizeChange,
|
||||
blurRegions = [],
|
||||
selectedBlurId,
|
||||
onSelectBlur,
|
||||
onBlurPositionChange,
|
||||
onBlurSizeChange,
|
||||
onBlurDataChange,
|
||||
onBlurDataCommit,
|
||||
cursorTelemetry = [],
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
@@ -154,12 +181,15 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
const timeUpdateAnimationRef = useRef<number | null>(null);
|
||||
const [pixiReady, setPixiReady] = useState(false);
|
||||
const [videoReady, setVideoReady] = useState(false);
|
||||
const [overlaySize, setOverlaySize] = useState({ width: 800, height: 600 });
|
||||
const [overlayElement, setOverlayElement] = useState<HTMLDivElement | null>(null);
|
||||
const overlayRef = useRef<HTMLDivElement | null>(null);
|
||||
const focusIndicatorRef = useRef<HTMLDivElement | null>(null);
|
||||
const [webcamLayout, setWebcamLayout] = useState<StyledRenderRect | null>(null);
|
||||
const [webcamDimensions, setWebcamDimensions] = useState<Size | null>(null);
|
||||
const currentTimeRef = useRef(0);
|
||||
const zoomRegionsRef = useRef<ZoomRegion[]>([]);
|
||||
const cursorTelemetryRef = useRef<import("./types").CursorTelemetryPoint[]>([]);
|
||||
const selectedZoomIdRef = useRef<string | null>(null);
|
||||
const animationStateRef = useRef({
|
||||
scale: 1,
|
||||
@@ -185,7 +215,10 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
const isPlayingRef = useRef(isPlaying);
|
||||
const isSeekingRef = useRef(false);
|
||||
const allowPlaybackRef = useRef(false);
|
||||
const lockedVideoDimensionsRef = useRef<{ width: number; height: number } | null>(null);
|
||||
const lockedVideoDimensionsRef = useRef<{
|
||||
width: number;
|
||||
height: number;
|
||||
} | null>(null);
|
||||
const layoutVideoContentRef = useRef<(() => void) | null>(null);
|
||||
const trimRegionsRef = useRef<TrimRegion[]>([]);
|
||||
const speedRegionsRef = useRef<SpeedRegion[]>([]);
|
||||
@@ -194,6 +227,8 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
const onTimeUpdateRef = useRef(onTimeUpdate);
|
||||
const onPlayStateChangeRef = useRef(onPlayStateChange);
|
||||
const videoReadyRafRef = useRef<number | null>(null);
|
||||
const smoothedAutoFocusRef = useRef<ZoomFocus | null>(null);
|
||||
const prevTargetProgressRef = useRef(0);
|
||||
|
||||
const clampFocusToStage = useCallback((focus: ZoomFocus, depth: ZoomDepth) => {
|
||||
return clampFocusToStageUtil(focus, depth, stageSizeRef.current);
|
||||
@@ -271,7 +306,9 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
padding,
|
||||
webcamDimensions,
|
||||
webcamLayoutPreset,
|
||||
webcamSizePreset,
|
||||
webcamPosition,
|
||||
webcamMaskShape,
|
||||
});
|
||||
|
||||
if (result) {
|
||||
@@ -301,13 +338,20 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
padding,
|
||||
webcamDimensions,
|
||||
webcamLayoutPreset,
|
||||
webcamSizePreset,
|
||||
webcamPosition,
|
||||
webcamMaskShape,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
layoutVideoContentRef.current = layoutVideoContent;
|
||||
}, [layoutVideoContent]);
|
||||
|
||||
const setOverlayRefs = useCallback((node: HTMLDivElement | null) => {
|
||||
overlayRef.current = node;
|
||||
setOverlayElement(node);
|
||||
}, []);
|
||||
|
||||
const selectedZoom = useMemo(() => {
|
||||
if (!selectedZoomId) return null;
|
||||
return zoomRegions.find((region) => region.id === selectedZoomId) ?? null;
|
||||
@@ -379,6 +423,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
if (!regionId) return;
|
||||
const region = zoomRegionsRef.current.find((r) => r.id === regionId);
|
||||
if (!region) return;
|
||||
if (region.focusMode === "auto") return;
|
||||
onSelectZoom(region.id);
|
||||
event.preventDefault();
|
||||
isDraggingFocusRef.current = true;
|
||||
@@ -462,6 +507,10 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
zoomRegionsRef.current = zoomRegions;
|
||||
}, [zoomRegions]);
|
||||
|
||||
useEffect(() => {
|
||||
cursorTelemetryRef.current = cursorTelemetry;
|
||||
}, [cursorTelemetry]);
|
||||
|
||||
useEffect(() => {
|
||||
selectedZoomIdRef.current = selectedZoomId;
|
||||
}, [selectedZoomId]);
|
||||
@@ -596,7 +645,8 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
}, [selectedZoom, pixiReady, videoReady, updateOverlayForRegion]);
|
||||
|
||||
useEffect(() => {
|
||||
const overlayEl = overlayRef.current;
|
||||
if (!pixiReady || !videoReady) return;
|
||||
const overlayEl = overlayElement;
|
||||
if (!overlayEl) return;
|
||||
if (!selectedZoom) {
|
||||
overlayEl.style.cursor = "default";
|
||||
@@ -605,7 +655,34 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
}
|
||||
overlayEl.style.cursor = isPlaying ? "not-allowed" : "grab";
|
||||
overlayEl.style.pointerEvents = isPlaying ? "none" : "auto";
|
||||
}, [selectedZoom, isPlaying]);
|
||||
}, [selectedZoom, isPlaying, pixiReady, videoReady, overlayElement]);
|
||||
|
||||
useEffect(() => {
|
||||
const overlayEl = overlayElement;
|
||||
if (!overlayEl) return;
|
||||
|
||||
const updateOverlaySize = () => {
|
||||
const width = overlayEl.clientWidth || 800;
|
||||
const height = overlayEl.clientHeight || 600;
|
||||
setOverlaySize((prev) => {
|
||||
if (prev.width === width && prev.height === height) return prev;
|
||||
return { width, height };
|
||||
});
|
||||
};
|
||||
|
||||
updateOverlaySize();
|
||||
|
||||
if (typeof ResizeObserver !== "undefined") {
|
||||
const observer = new ResizeObserver(() => {
|
||||
updateOverlaySize();
|
||||
});
|
||||
observer.observe(overlayEl);
|
||||
return () => observer.disconnect();
|
||||
}
|
||||
|
||||
window.addEventListener("resize", updateOverlaySize);
|
||||
return () => window.removeEventListener("resize", updateOverlaySize);
|
||||
}, [overlayElement]);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
@@ -629,7 +706,11 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
app.ticker.maxFPS = 60;
|
||||
|
||||
if (!mounted) {
|
||||
app.destroy(true, { children: true, texture: true, textureSource: true });
|
||||
app.destroy(true, {
|
||||
children: true,
|
||||
texture: true,
|
||||
textureSource: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -653,7 +734,11 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
mounted = false;
|
||||
setPixiReady(false);
|
||||
if (app && app.renderer) {
|
||||
app.destroy(true, { children: true, texture: true, textureSource: true });
|
||||
app.destroy(true, {
|
||||
children: true,
|
||||
texture: true,
|
||||
textureSource: true,
|
||||
});
|
||||
}
|
||||
appRef.current = null;
|
||||
cameraContainerRef.current = null;
|
||||
@@ -830,10 +915,23 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
};
|
||||
|
||||
const ticker = () => {
|
||||
const bm = baseMaskRef.current;
|
||||
const ss = stageSizeRef.current;
|
||||
const viewportRatio =
|
||||
bm.width > 0 && bm.height > 0
|
||||
? {
|
||||
widthRatio: ss.width / bm.width,
|
||||
heightRatio: ss.height / bm.height,
|
||||
}
|
||||
: undefined;
|
||||
const { region, strength, blendedScale, transition } = findDominantRegion(
|
||||
zoomRegionsRef.current,
|
||||
currentTimeRef.current,
|
||||
{ connectZooms: true },
|
||||
{
|
||||
connectZooms: true,
|
||||
cursorTelemetry: cursorTelemetryRef.current,
|
||||
viewportRatio,
|
||||
},
|
||||
);
|
||||
|
||||
const defaultFocus = DEFAULT_FOCUS;
|
||||
@@ -854,6 +952,47 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
targetFocus = regionFocus;
|
||||
targetProgress = strength;
|
||||
|
||||
// Apply adaptive smoothing for auto-follow mode
|
||||
if (region.focusMode === "auto" && !transition) {
|
||||
const raw = targetFocus;
|
||||
const isZoomingIn =
|
||||
targetProgress < 0.999 && targetProgress >= prevTargetProgressRef.current;
|
||||
if (targetProgress >= 0.999) {
|
||||
// Full zoom: adaptive smoothing — moves faster when far, decelerates when close
|
||||
const prev = smoothedAutoFocusRef.current ?? raw;
|
||||
const factor = adaptiveSmoothFactor(
|
||||
raw,
|
||||
prev,
|
||||
AUTO_FOLLOW_SMOOTHING_FACTOR,
|
||||
AUTO_FOLLOW_SMOOTHING_FACTOR_MAX,
|
||||
AUTO_FOLLOW_RAMP_DISTANCE,
|
||||
);
|
||||
const smoothed = smoothCursorFocus(raw, prev, factor);
|
||||
smoothedAutoFocusRef.current = smoothed;
|
||||
targetFocus = smoothed;
|
||||
} else if (isZoomingIn) {
|
||||
// Zoom-in: track cursor directly so zoom always aims at current cursor
|
||||
// position; keep ref in sync to avoid snap when full-zoom begins
|
||||
smoothedAutoFocusRef.current = raw;
|
||||
} else {
|
||||
// Zoom-out: keep smoothing for continuity — avoids snap at zoom-out start
|
||||
const prev = smoothedAutoFocusRef.current ?? raw;
|
||||
const factor = adaptiveSmoothFactor(
|
||||
raw,
|
||||
prev,
|
||||
AUTO_FOLLOW_SMOOTHING_FACTOR,
|
||||
AUTO_FOLLOW_SMOOTHING_FACTOR_MAX,
|
||||
AUTO_FOLLOW_RAMP_DISTANCE,
|
||||
);
|
||||
const smoothed = smoothCursorFocus(raw, prev, factor);
|
||||
smoothedAutoFocusRef.current = smoothed;
|
||||
targetFocus = smoothed;
|
||||
}
|
||||
} else if (region.focusMode !== "auto") {
|
||||
smoothedAutoFocusRef.current = null;
|
||||
}
|
||||
prevTargetProgressRef.current = targetProgress;
|
||||
|
||||
// Handle connected zoom transitions (pan between adjacent zoom regions)
|
||||
if (transition) {
|
||||
const startTransform = computeZoomTransform({
|
||||
@@ -1154,35 +1293,51 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
: "none",
|
||||
}}
|
||||
/>
|
||||
{webcamVideoPath && (
|
||||
<video
|
||||
ref={webcamVideoRef}
|
||||
src={webcamVideoPath}
|
||||
className={`absolute object-cover ${webcamLayoutPreset === "picture-in-picture" ? "cursor-grab active:cursor-grabbing" : "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: webcamCssBoxShadow,
|
||||
zIndex: 20,
|
||||
opacity: webcamLayout ? 1 : 0,
|
||||
backgroundColor: "#000",
|
||||
}}
|
||||
onPointerDown={handleWebcamPointerDown}
|
||||
onPointerMove={handleWebcamPointerMove}
|
||||
onPointerUp={handleWebcamPointerUp}
|
||||
onPointerLeave={handleWebcamPointerUp}
|
||||
muted
|
||||
preload="metadata"
|
||||
playsInline
|
||||
/>
|
||||
)}
|
||||
{webcamVideoPath &&
|
||||
(() => {
|
||||
const clipPath = getCssClipPath(webcamLayout?.maskShape ?? "rectangle");
|
||||
const useClipPath = !!clipPath;
|
||||
return (
|
||||
<div
|
||||
className="absolute"
|
||||
style={{
|
||||
left: webcamLayout?.x ?? 0,
|
||||
top: webcamLayout?.y ?? 0,
|
||||
width: webcamLayout?.width ?? 0,
|
||||
height: webcamLayout?.height ?? 0,
|
||||
zIndex: 20,
|
||||
opacity: webcamLayout ? 1 : 0,
|
||||
filter:
|
||||
useClipPath && webcamCssBoxShadow !== "none"
|
||||
? `drop-shadow(${webcamCssBoxShadow})`
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
<video
|
||||
ref={webcamVideoRef}
|
||||
src={webcamVideoPath}
|
||||
className={`w-full h-full object-cover ${webcamLayoutPreset === "picture-in-picture" ? "cursor-grab active:cursor-grabbing" : "pointer-events-none"}`}
|
||||
style={{
|
||||
borderRadius: useClipPath ? 0 : (webcamLayout?.borderRadius ?? 0),
|
||||
clipPath: clipPath ?? undefined,
|
||||
boxShadow: useClipPath ? "none" : webcamCssBoxShadow,
|
||||
backgroundColor: "#000",
|
||||
}}
|
||||
onPointerDown={handleWebcamPointerDown}
|
||||
onPointerMove={handleWebcamPointerMove}
|
||||
onPointerUp={handleWebcamPointerUp}
|
||||
onPointerLeave={handleWebcamPointerUp}
|
||||
muted
|
||||
preload="metadata"
|
||||
playsInline
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{/* Only render overlay after PIXI and video are fully initialized */}
|
||||
{pixiReady && videoReady && (
|
||||
<div
|
||||
ref={overlayRef}
|
||||
ref={setOverlayRefs}
|
||||
className="absolute inset-0 select-none"
|
||||
style={{ pointerEvents: "none", zIndex: 30 }}
|
||||
onPointerDown={handleOverlayPointerDown}
|
||||
@@ -1196,7 +1351,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
style={{ display: "none", pointerEvents: "none" }}
|
||||
/>
|
||||
{(() => {
|
||||
const filtered = (annotationRegions || []).filter((annotation) => {
|
||||
const filteredAnnotations = (annotationRegions || []).filter((annotation) => {
|
||||
if (typeof annotation.startMs !== "number" || typeof annotation.endMs !== "number")
|
||||
return false;
|
||||
|
||||
@@ -1206,37 +1361,93 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
return timeMs >= annotation.startMs && timeMs <= annotation.endMs;
|
||||
});
|
||||
|
||||
// Sort by z-index (lowest to highest) so higher z-index renders on top
|
||||
const sorted = [...filtered].sort((a, b) => a.zIndex - b.zIndex);
|
||||
const filteredBlurRegions = (blurRegions || []).filter((blurRegion) => {
|
||||
if (typeof blurRegion.startMs !== "number" || typeof blurRegion.endMs !== "number")
|
||||
return false;
|
||||
|
||||
if (blurRegion.id === selectedBlurId) return true;
|
||||
|
||||
const timeMs = Math.round(currentTime * 1000);
|
||||
return timeMs >= blurRegion.startMs && timeMs <= blurRegion.endMs;
|
||||
});
|
||||
|
||||
const sorted = [
|
||||
...filteredAnnotations.map((annotation) => ({
|
||||
kind: "annotation" as const,
|
||||
region: annotation,
|
||||
})),
|
||||
...filteredBlurRegions.map((blurRegion) => ({
|
||||
kind: "blur" as const,
|
||||
region: blurRegion,
|
||||
})),
|
||||
].sort((a, b) => a.region.zIndex - b.region.zIndex);
|
||||
|
||||
// Handle click-through cycling: when clicking same annotation, cycle to next
|
||||
const handleAnnotationClick = (clickedId: string) => {
|
||||
if (!onSelectAnnotation) return;
|
||||
|
||||
// If clicking on already selected annotation and there are multiple overlapping
|
||||
if (clickedId === selectedAnnotationId && sorted.length > 1) {
|
||||
if (clickedId === selectedAnnotationId && filteredAnnotations.length > 1) {
|
||||
// Find current index and cycle to next
|
||||
const currentIndex = sorted.findIndex((a) => a.id === clickedId);
|
||||
const nextIndex = (currentIndex + 1) % sorted.length;
|
||||
onSelectAnnotation(sorted[nextIndex].id);
|
||||
const currentIndex = filteredAnnotations.findIndex((a) => a.id === clickedId);
|
||||
const nextIndex = (currentIndex + 1) % filteredAnnotations.length;
|
||||
onSelectAnnotation(filteredAnnotations[nextIndex].id);
|
||||
} else {
|
||||
// First click or clicking different annotation
|
||||
onSelectAnnotation(clickedId);
|
||||
}
|
||||
};
|
||||
|
||||
return sorted.map((annotation) => (
|
||||
const handleBlurClick = (clickedId: string) => {
|
||||
if (!onSelectBlur) return;
|
||||
|
||||
if (clickedId === selectedBlurId && filteredBlurRegions.length > 1) {
|
||||
const currentIndex = filteredBlurRegions.findIndex((a) => a.id === clickedId);
|
||||
const nextIndex = (currentIndex + 1) % filteredBlurRegions.length;
|
||||
onSelectBlur(filteredBlurRegions[nextIndex].id);
|
||||
} else {
|
||||
onSelectBlur(clickedId);
|
||||
}
|
||||
};
|
||||
|
||||
return sorted.map((item) => (
|
||||
<AnnotationOverlay
|
||||
key={annotation.id}
|
||||
annotation={annotation}
|
||||
isSelected={annotation.id === selectedAnnotationId}
|
||||
containerWidth={overlayRef.current?.clientWidth || 800}
|
||||
containerHeight={overlayRef.current?.clientHeight || 600}
|
||||
onPositionChange={(id, position) => onAnnotationPositionChange?.(id, position)}
|
||||
onSizeChange={(id, size) => onAnnotationSizeChange?.(id, size)}
|
||||
onClick={handleAnnotationClick}
|
||||
zIndex={annotation.zIndex}
|
||||
isSelectedBoost={annotation.id === selectedAnnotationId}
|
||||
key={
|
||||
item.kind === "blur"
|
||||
? `${item.region.id}-${overlaySize.width}-${overlaySize.height}-${item.region.blurData?.shape ?? "rectangle"}-${(item.region.blurData?.freehandPoints ?? []).map((p) => `${Math.round(p.x)}_${Math.round(p.y)}`).join("-")}`
|
||||
: `${item.region.id}-${overlaySize.width}-${overlaySize.height}`
|
||||
}
|
||||
annotation={item.region}
|
||||
isSelected={
|
||||
item.kind === "blur"
|
||||
? item.region.id === selectedBlurId
|
||||
: item.region.id === selectedAnnotationId
|
||||
}
|
||||
containerWidth={overlaySize.width}
|
||||
containerHeight={overlaySize.height}
|
||||
onPositionChange={(id, position) =>
|
||||
item.kind === "blur"
|
||||
? onBlurPositionChange?.(id, position)
|
||||
: onAnnotationPositionChange?.(id, position)
|
||||
}
|
||||
onSizeChange={(id, size) =>
|
||||
item.kind === "blur"
|
||||
? onBlurSizeChange?.(id, size)
|
||||
: onAnnotationSizeChange?.(id, size)
|
||||
}
|
||||
onBlurDataChange={
|
||||
item.kind === "blur"
|
||||
? (id, blurData) => onBlurDataChange?.(id, blurData)
|
||||
: undefined
|
||||
}
|
||||
onBlurDataCommit={item.kind === "blur" ? onBlurDataCommit : undefined}
|
||||
onClick={item.kind === "blur" ? handleBlurClick : handleAnnotationClick}
|
||||
zIndex={item.region.zIndex}
|
||||
isSelectedBoost={
|
||||
item.kind === "blur"
|
||||
? item.region.id === selectedBlurId
|
||||
: item.region.id === selectedAnnotationId
|
||||
}
|
||||
/>
|
||||
));
|
||||
})()}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createProjectData,
|
||||
createProjectSnapshot,
|
||||
hasProjectUnsavedChanges,
|
||||
normalizeProjectEditor,
|
||||
PROJECT_VERSION,
|
||||
resolveProjectMedia,
|
||||
validateProjectData,
|
||||
@@ -40,6 +43,7 @@ describe("projectPersistence media compatibility", () => {
|
||||
annotationRegions: [],
|
||||
aspectRatio: "16:9",
|
||||
webcamLayoutPreset: "picture-in-picture",
|
||||
webcamMaskShape: "circle",
|
||||
exportQuality: "good",
|
||||
exportFormat: "mp4",
|
||||
gifFrameRate: 15,
|
||||
@@ -55,4 +59,47 @@ describe("projectPersistence media compatibility", () => {
|
||||
});
|
||||
expect(validateProjectData(project)).toBe(true);
|
||||
});
|
||||
|
||||
it("normalizes webcam mask shape values safely", () => {
|
||||
expect(normalizeProjectEditor({ webcamMaskShape: "rounded" }).webcamMaskShape).toBe("rounded");
|
||||
expect(
|
||||
normalizeProjectEditor({ webcamMaskShape: "not-a-real-shape" as never }).webcamMaskShape,
|
||||
).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,23 +5,36 @@ import { ASPECT_RATIOS, type AspectRatio } from "@/utils/aspectRatioUtils";
|
||||
import {
|
||||
type AnnotationRegion,
|
||||
type CropRegion,
|
||||
clampPlaybackSpeed,
|
||||
DEFAULT_ANNOTATION_POSITION,
|
||||
DEFAULT_ANNOTATION_SIZE,
|
||||
DEFAULT_ANNOTATION_STYLE,
|
||||
DEFAULT_BLUR_DATA,
|
||||
DEFAULT_BLUR_FREEHAND_POINTS,
|
||||
DEFAULT_BLUR_INTENSITY,
|
||||
DEFAULT_CROP_REGION,
|
||||
DEFAULT_FIGURE_DATA,
|
||||
DEFAULT_PLAYBACK_SPEED,
|
||||
DEFAULT_WEBCAM_LAYOUT_PRESET,
|
||||
DEFAULT_WEBCAM_MASK_SHAPE,
|
||||
DEFAULT_WEBCAM_POSITION,
|
||||
DEFAULT_WEBCAM_SIZE_PRESET,
|
||||
DEFAULT_ZOOM_DEPTH,
|
||||
MAX_BLUR_INTENSITY,
|
||||
MAX_PLAYBACK_SPEED,
|
||||
MIN_BLUR_INTENSITY,
|
||||
MIN_PLAYBACK_SPEED,
|
||||
type SpeedRegion,
|
||||
type TrimRegion,
|
||||
type WebcamLayoutPreset,
|
||||
type WebcamMaskShape,
|
||||
type WebcamPosition,
|
||||
type WebcamSizePreset,
|
||||
type ZoomRegion,
|
||||
} from "./types";
|
||||
|
||||
const WALLPAPER_COUNT = 18;
|
||||
const VALID_BLUR_SHAPES = new Set(["rectangle", "oval", "freehand"] as const);
|
||||
|
||||
export const WALLPAPER_PATHS = Array.from(
|
||||
{ length: WALLPAPER_COUNT },
|
||||
@@ -44,6 +57,8 @@ export interface ProjectEditorState {
|
||||
annotationRegions: AnnotationRegion[];
|
||||
aspectRatio: AspectRatio;
|
||||
webcamLayoutPreset: WebcamLayoutPreset;
|
||||
webcamMaskShape: WebcamMaskShape;
|
||||
webcamSizePreset: WebcamSizePreset;
|
||||
webcamPosition: WebcamPosition | null;
|
||||
exportQuality: ExportQuality;
|
||||
exportFormat: ExportFormat;
|
||||
@@ -189,6 +204,7 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
|
||||
cx: clamp(isFiniteNumber(region.focus?.cx) ? region.focus.cx : 0.5, 0, 1),
|
||||
cy: clamp(isFiniteNumber(region.focus?.cy) ? region.focus.cy : 0.5, 0, 1),
|
||||
},
|
||||
focusMode: region.focusMode === "auto" ? "auto" : "manual",
|
||||
};
|
||||
})
|
||||
: [];
|
||||
@@ -219,14 +235,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 {
|
||||
@@ -248,12 +260,20 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
|
||||
const rawEnd = isFiniteNumber(region.endMs) ? Math.round(region.endMs) : rawStart + 1000;
|
||||
const startMs = Math.max(0, Math.min(rawStart, rawEnd));
|
||||
const endMs = Math.max(startMs + 1, rawEnd);
|
||||
const blurShape =
|
||||
typeof region.blurData?.shape === "string" &&
|
||||
VALID_BLUR_SHAPES.has(region.blurData.shape)
|
||||
? region.blurData.shape
|
||||
: DEFAULT_BLUR_DATA.shape;
|
||||
|
||||
return {
|
||||
id: region.id,
|
||||
startMs,
|
||||
endMs,
|
||||
type: region.type === "image" || region.type === "figure" ? region.type : "text",
|
||||
type:
|
||||
region.type === "image" || region.type === "figure" || region.type === "blur"
|
||||
? region.type
|
||||
: "text",
|
||||
content: typeof region.content === "string" ? region.content : "",
|
||||
textContent: typeof region.textContent === "string" ? region.textContent : undefined,
|
||||
imageContent: typeof region.imageContent === "string" ? region.imageContent : undefined,
|
||||
@@ -300,6 +320,37 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
|
||||
...region.figureData,
|
||||
}
|
||||
: undefined,
|
||||
blurData:
|
||||
region.blurData && typeof region.blurData === "object"
|
||||
? {
|
||||
...DEFAULT_BLUR_DATA,
|
||||
...region.blurData,
|
||||
shape: blurShape,
|
||||
intensity: isFiniteNumber(region.blurData.intensity)
|
||||
? clamp(region.blurData.intensity, MIN_BLUR_INTENSITY, MAX_BLUR_INTENSITY)
|
||||
: DEFAULT_BLUR_INTENSITY,
|
||||
freehandPoints: Array.isArray(region.blurData.freehandPoints)
|
||||
? region.blurData.freehandPoints
|
||||
.filter(
|
||||
(
|
||||
point,
|
||||
): point is {
|
||||
x: number;
|
||||
y: number;
|
||||
} =>
|
||||
Boolean(
|
||||
point &&
|
||||
isFiniteNumber((point as { x?: unknown }).x) &&
|
||||
isFiniteNumber((point as { y?: unknown }).y),
|
||||
),
|
||||
)
|
||||
.map((point) => ({
|
||||
x: clamp(point.x, 0, 100),
|
||||
y: clamp(point.y, 0, 100),
|
||||
}))
|
||||
: DEFAULT_BLUR_FREEHAND_POINTS,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
})
|
||||
: [];
|
||||
@@ -352,6 +403,17 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
|
||||
editor.webcamLayoutPreset === "picture-in-picture"
|
||||
? editor.webcamLayoutPreset
|
||||
: DEFAULT_WEBCAM_LAYOUT_PRESET,
|
||||
webcamMaskShape:
|
||||
editor.webcamMaskShape === "rectangle" ||
|
||||
editor.webcamMaskShape === "circle" ||
|
||||
editor.webcamMaskShape === "square" ||
|
||||
editor.webcamMaskShape === "rounded"
|
||||
? editor.webcamMaskShape
|
||||
: DEFAULT_WEBCAM_MASK_SHAPE,
|
||||
webcamSizePreset:
|
||||
typeof editor.webcamSizePreset === "number" && isFiniteNumber(editor.webcamSizePreset)
|
||||
? Math.max(10, Math.min(50, editor.webcamSizePreset))
|
||||
: DEFAULT_WEBCAM_SIZE_PRESET,
|
||||
webcamPosition:
|
||||
editor.webcamPosition &&
|
||||
typeof editor.webcamPosition === "object" &&
|
||||
@@ -394,3 +456,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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ interface ItemProps {
|
||||
onSelect?: () => void;
|
||||
zoomDepth?: number;
|
||||
speedValue?: number;
|
||||
variant?: "zoom" | "trim" | "annotation" | "speed";
|
||||
variant?: "zoom" | "trim" | "annotation" | "speed" | "blur";
|
||||
}
|
||||
|
||||
// Map zoom depth to multiplier labels
|
||||
|
||||
@@ -44,6 +44,7 @@ import { detectZoomDwellCandidates, normalizeCursorTelemetry } from "./zoomSugge
|
||||
const ZOOM_ROW_ID = "row-zoom";
|
||||
const TRIM_ROW_ID = "row-trim";
|
||||
const ANNOTATION_ROW_ID = "row-annotation";
|
||||
const BLUR_ROW_ID = "row-blur";
|
||||
const SPEED_ROW_ID = "row-speed";
|
||||
const FALLBACK_RANGE_MS = 1000;
|
||||
const TARGET_MARKER_COUNT = 12;
|
||||
@@ -73,6 +74,12 @@ interface TimelineEditorProps {
|
||||
onAnnotationDelete?: (id: string) => void;
|
||||
selectedAnnotationId?: string | null;
|
||||
onSelectAnnotation?: (id: string | null) => void;
|
||||
blurRegions?: AnnotationRegion[];
|
||||
onBlurAdded?: (span: Span) => void;
|
||||
onBlurSpanChange?: (id: string, span: Span) => void;
|
||||
onBlurDelete?: (id: string) => void;
|
||||
selectedBlurId?: string | null;
|
||||
onSelectBlur?: (id: string | null) => void;
|
||||
speedRegions?: SpeedRegion[];
|
||||
onSpeedAdded?: (span: Span) => void;
|
||||
onSpeedSpanChange?: (id: string, span: Span) => void;
|
||||
@@ -96,7 +103,7 @@ interface TimelineRenderItem {
|
||||
label: string;
|
||||
zoomDepth?: number;
|
||||
speedValue?: number;
|
||||
variant: "zoom" | "trim" | "annotation" | "speed";
|
||||
variant: "zoom" | "trim" | "annotation" | "speed" | "blur";
|
||||
}
|
||||
|
||||
const SCALE_CANDIDATES = [
|
||||
@@ -525,10 +532,12 @@ function Timeline({
|
||||
onSelectZoom,
|
||||
onSelectTrim,
|
||||
onSelectAnnotation,
|
||||
onSelectBlur,
|
||||
onSelectSpeed,
|
||||
selectedZoomId,
|
||||
selectedTrimId,
|
||||
selectedAnnotationId,
|
||||
selectedBlurId,
|
||||
selectedSpeedId,
|
||||
keyframes = [],
|
||||
}: {
|
||||
@@ -540,10 +549,12 @@ function Timeline({
|
||||
onSelectZoom?: (id: string | null) => void;
|
||||
onSelectTrim?: (id: string | null) => void;
|
||||
onSelectAnnotation?: (id: string | null) => void;
|
||||
onSelectBlur?: (id: string | null) => void;
|
||||
onSelectSpeed?: (id: string | null) => void;
|
||||
selectedZoomId: string | null;
|
||||
selectedTrimId?: string | null;
|
||||
selectedAnnotationId?: string | null;
|
||||
selectedBlurId?: string | null;
|
||||
selectedSpeedId?: string | null;
|
||||
keyframes?: { id: string; time: number }[];
|
||||
}) {
|
||||
@@ -568,6 +579,7 @@ function Timeline({
|
||||
onSelectZoom?.(null);
|
||||
onSelectTrim?.(null);
|
||||
onSelectAnnotation?.(null);
|
||||
onSelectBlur?.(null);
|
||||
onSelectSpeed?.(null);
|
||||
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
@@ -586,6 +598,7 @@ function Timeline({
|
||||
onSelectZoom,
|
||||
onSelectTrim,
|
||||
onSelectAnnotation,
|
||||
onSelectBlur,
|
||||
onSelectSpeed,
|
||||
videoDurationMs,
|
||||
sidebarWidth,
|
||||
@@ -637,6 +650,7 @@ function Timeline({
|
||||
const zoomItems = items.filter((item) => item.rowId === ZOOM_ROW_ID);
|
||||
const trimItems = items.filter((item) => item.rowId === TRIM_ROW_ID);
|
||||
const annotationItems = items.filter((item) => item.rowId === ANNOTATION_ROW_ID);
|
||||
const blurItems = items.filter((item) => item.rowId === BLUR_ROW_ID);
|
||||
const speedItems = items.filter((item) => item.rowId === SPEED_ROW_ID);
|
||||
|
||||
return (
|
||||
@@ -711,6 +725,22 @@ function Timeline({
|
||||
))}
|
||||
</Row>
|
||||
|
||||
<Row id={BLUR_ROW_ID} isEmpty={blurItems.length === 0} hint={t("hints.pressBlur")}>
|
||||
{blurItems.map((item) => (
|
||||
<Item
|
||||
id={item.id}
|
||||
key={item.id}
|
||||
rowId={item.rowId}
|
||||
span={item.span}
|
||||
isSelected={item.id === selectedBlurId}
|
||||
onSelect={() => onSelectBlur?.(item.id)}
|
||||
variant={item.variant}
|
||||
>
|
||||
{item.label}
|
||||
</Item>
|
||||
))}
|
||||
</Row>
|
||||
|
||||
<Row id={SPEED_ROW_ID} isEmpty={speedItems.length === 0} hint={t("hints.pressSpeed")}>
|
||||
{speedItems.map((item) => (
|
||||
<Item
|
||||
@@ -755,6 +785,12 @@ export default function TimelineEditor({
|
||||
onAnnotationDelete,
|
||||
selectedAnnotationId,
|
||||
onSelectAnnotation,
|
||||
blurRegions = [],
|
||||
onBlurAdded,
|
||||
onBlurSpanChange,
|
||||
onBlurDelete,
|
||||
selectedBlurId,
|
||||
onSelectBlur,
|
||||
speedRegions = [],
|
||||
onSpeedAdded,
|
||||
onSpeedSpanChange,
|
||||
@@ -839,6 +875,12 @@ export default function TimelineEditor({
|
||||
onSelectAnnotation(null);
|
||||
}, [selectedAnnotationId, onAnnotationDelete, onSelectAnnotation]);
|
||||
|
||||
const deleteSelectedBlur = useCallback(() => {
|
||||
if (!selectedBlurId || !onBlurDelete || !onSelectBlur) return;
|
||||
onBlurDelete(selectedBlurId);
|
||||
onSelectBlur(null);
|
||||
}, [selectedBlurId, onBlurDelete, onSelectBlur]);
|
||||
|
||||
const deleteSelectedSpeed = useCallback(() => {
|
||||
if (!selectedSpeedId || !onSpeedDelete || !onSelectSpeed) return;
|
||||
onSpeedDelete(selectedSpeedId);
|
||||
@@ -908,9 +950,10 @@ export default function TimelineEditor({
|
||||
const isZoomItem = zoomRegions.some((r) => r.id === excludeId);
|
||||
const isTrimItem = trimRegions.some((r) => r.id === excludeId);
|
||||
const isAnnotationItem = annotationRegions.some((r) => r.id === excludeId);
|
||||
const isBlurItem = blurRegions.some((r) => r.id === excludeId);
|
||||
const isSpeedItem = speedRegions.some((r) => r.id === excludeId);
|
||||
|
||||
if (isAnnotationItem) {
|
||||
if (isAnnotationItem || isBlurItem) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -937,7 +980,7 @@ export default function TimelineEditor({
|
||||
|
||||
return false;
|
||||
},
|
||||
[zoomRegions, trimRegions, annotationRegions, speedRegions],
|
||||
[zoomRegions, trimRegions, annotationRegions, blurRegions, speedRegions],
|
||||
);
|
||||
|
||||
// At least 5% of the timeline or 1000ms, whichever is larger, so the region
|
||||
@@ -1165,6 +1208,21 @@ export default function TimelineEditor({
|
||||
onAnnotationAdded({ start: startPos, end: endPos });
|
||||
}, [videoDuration, totalMs, currentTimeMs, onAnnotationAdded, defaultRegionDurationMs]);
|
||||
|
||||
const handleAddBlur = useCallback(() => {
|
||||
if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onBlurAdded) {
|
||||
return;
|
||||
}
|
||||
|
||||
const defaultDuration = Math.min(defaultRegionDurationMs, totalMs);
|
||||
if (defaultDuration <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const startPos = Math.max(0, Math.min(currentTimeMs, totalMs));
|
||||
const endPos = Math.min(startPos + defaultDuration, totalMs);
|
||||
onBlurAdded({ start: startPos, end: endPos });
|
||||
}, [videoDuration, totalMs, currentTimeMs, onBlurAdded, defaultRegionDurationMs]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
|
||||
@@ -1183,6 +1241,9 @@ export default function TimelineEditor({
|
||||
if (matchesShortcut(e, keyShortcuts.addAnnotation, isMac)) {
|
||||
handleAddAnnotation();
|
||||
}
|
||||
if (matchesShortcut(e, keyShortcuts.addBlur, isMac)) {
|
||||
handleAddBlur();
|
||||
}
|
||||
if (matchesShortcut(e, keyShortcuts.addSpeed, isMac)) {
|
||||
handleAddSpeed();
|
||||
}
|
||||
@@ -1223,6 +1284,8 @@ export default function TimelineEditor({
|
||||
deleteSelectedTrim();
|
||||
} else if (selectedAnnotationId) {
|
||||
deleteSelectedAnnotation();
|
||||
} else if (selectedBlurId) {
|
||||
deleteSelectedBlur();
|
||||
} else if (selectedSpeedId) {
|
||||
deleteSelectedSpeed();
|
||||
}
|
||||
@@ -1235,18 +1298,22 @@ export default function TimelineEditor({
|
||||
handleAddZoom,
|
||||
handleAddTrim,
|
||||
handleAddAnnotation,
|
||||
handleAddBlur,
|
||||
handleAddSpeed,
|
||||
deleteSelectedKeyframe,
|
||||
deleteSelectedZoom,
|
||||
deleteSelectedTrim,
|
||||
deleteSelectedAnnotation,
|
||||
deleteSelectedBlur,
|
||||
deleteSelectedSpeed,
|
||||
selectedKeyframeId,
|
||||
selectedZoomId,
|
||||
selectedTrimId,
|
||||
selectedAnnotationId,
|
||||
selectedBlurId,
|
||||
selectedSpeedId,
|
||||
annotationRegions,
|
||||
blurRegions,
|
||||
currentTime,
|
||||
onSelectAnnotation,
|
||||
keyShortcuts,
|
||||
@@ -1304,6 +1371,14 @@ export default function TimelineEditor({
|
||||
};
|
||||
});
|
||||
|
||||
const blurs: TimelineRenderItem[] = blurRegions.map((region, index) => ({
|
||||
id: region.id,
|
||||
rowId: BLUR_ROW_ID,
|
||||
span: { start: region.startMs, end: region.endMs },
|
||||
label: t("labels.blurItem", { index: String(index + 1) }),
|
||||
variant: "blur",
|
||||
}));
|
||||
|
||||
const speeds: TimelineRenderItem[] = speedRegions.map((region, index) => ({
|
||||
id: region.id,
|
||||
rowId: SPEED_ROW_ID,
|
||||
@@ -1313,8 +1388,8 @@ export default function TimelineEditor({
|
||||
variant: "speed",
|
||||
}));
|
||||
|
||||
return [...zooms, ...trims, ...annotations, ...speeds];
|
||||
}, [zoomRegions, trimRegions, annotationRegions, speedRegions, t]);
|
||||
return [...zooms, ...trims, ...annotations, ...blurs, ...speeds];
|
||||
}, [zoomRegions, trimRegions, annotationRegions, blurRegions, speedRegions, t]);
|
||||
|
||||
// Flat list of all non-annotation region spans for neighbour-clamping during drag/resize
|
||||
const allRegionSpans = useMemo(() => {
|
||||
@@ -1335,6 +1410,8 @@ export default function TimelineEditor({
|
||||
onSpeedSpanChange?.(id, span);
|
||||
} else if (annotationRegions.some((r) => r.id === id)) {
|
||||
onAnnotationSpanChange?.(id, span);
|
||||
} else if (blurRegions.some((r) => r.id === id)) {
|
||||
onBlurSpanChange?.(id, span);
|
||||
}
|
||||
},
|
||||
[
|
||||
@@ -1342,10 +1419,12 @@ export default function TimelineEditor({
|
||||
trimRegions,
|
||||
speedRegions,
|
||||
annotationRegions,
|
||||
blurRegions,
|
||||
onZoomSpanChange,
|
||||
onTrimSpanChange,
|
||||
onSpeedSpanChange,
|
||||
onAnnotationSpanChange,
|
||||
onBlurSpanChange,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1403,6 +1482,25 @@ export default function TimelineEditor({
|
||||
>
|
||||
<MessageSquare className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleAddBlur}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-slate-400 hover:text-[#7dd3fc] hover:bg-[#7dd3fc]/10 transition-all"
|
||||
title={t("buttons.addBlur")}
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<circle cx="8" cy="12" r="3" />
|
||||
<circle cx="16" cy="12" r="3" />
|
||||
<path d="M6 6h12M6 18h12" />
|
||||
</svg>
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleAddSpeed}
|
||||
variant="ghost"
|
||||
@@ -1489,10 +1587,12 @@ export default function TimelineEditor({
|
||||
onSelectZoom={onSelectZoom}
|
||||
onSelectTrim={onSelectTrim}
|
||||
onSelectAnnotation={onSelectAnnotation}
|
||||
onSelectBlur={onSelectBlur}
|
||||
onSelectSpeed={onSelectSpeed}
|
||||
selectedZoomId={selectedZoomId}
|
||||
selectedTrimId={selectedTrimId}
|
||||
selectedAnnotationId={selectedAnnotationId}
|
||||
selectedBlurId={selectedBlurId}
|
||||
selectedSpeedId={selectedSpeedId}
|
||||
keyframes={keyframes}
|
||||
/>
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
import type { WebcamLayoutPreset } from "@/lib/compositeLayout";
|
||||
|
||||
export type ZoomDepth = 1 | 2 | 3 | 4 | 5 | 6;
|
||||
export type ZoomFocusMode = "manual" | "auto";
|
||||
export type { WebcamLayoutPreset };
|
||||
/** Webcam size as a percentage of the canvas reference dimension (10–50). */
|
||||
export type WebcamSizePreset = number;
|
||||
|
||||
export const DEFAULT_WEBCAM_SIZE_PRESET: WebcamSizePreset = 25;
|
||||
|
||||
export const DEFAULT_WEBCAM_LAYOUT_PRESET: WebcamLayoutPreset = "picture-in-picture";
|
||||
|
||||
export type WebcamMaskShape = "rectangle" | "circle" | "square" | "rounded";
|
||||
|
||||
export const DEFAULT_WEBCAM_MASK_SHAPE: WebcamMaskShape = "rectangle";
|
||||
|
||||
export interface WebcamPosition {
|
||||
cx: number; // normalized horizontal center (0-1)
|
||||
cy: number; // normalized vertical center (0-1)
|
||||
@@ -23,6 +32,7 @@ export interface ZoomRegion {
|
||||
endMs: number;
|
||||
depth: ZoomDepth;
|
||||
focus: ZoomFocus;
|
||||
focusMode?: ZoomFocusMode;
|
||||
}
|
||||
|
||||
export interface CursorTelemetryPoint {
|
||||
@@ -37,7 +47,7 @@ export interface TrimRegion {
|
||||
endMs: number;
|
||||
}
|
||||
|
||||
export type AnnotationType = "text" | "image" | "figure";
|
||||
export type AnnotationType = "text" | "image" | "figure" | "blur";
|
||||
|
||||
export type ArrowDirection =
|
||||
| "up"
|
||||
@@ -55,6 +65,19 @@ export interface FigureData {
|
||||
strokeWidth: number;
|
||||
}
|
||||
|
||||
export type BlurShape = "rectangle" | "oval" | "freehand";
|
||||
|
||||
export const MIN_BLUR_INTENSITY = 2;
|
||||
export const MAX_BLUR_INTENSITY = 40;
|
||||
export const DEFAULT_BLUR_INTENSITY = 12;
|
||||
|
||||
export interface BlurData {
|
||||
shape: BlurShape;
|
||||
intensity: number;
|
||||
// Points are normalized (0-100) within the annotation bounds.
|
||||
freehandPoints?: Array<{ x: number; y: number }>;
|
||||
}
|
||||
|
||||
export interface AnnotationPosition {
|
||||
x: number;
|
||||
y: number;
|
||||
@@ -89,6 +112,7 @@ export interface AnnotationRegion {
|
||||
style: AnnotationTextStyle;
|
||||
zIndex: number;
|
||||
figureData?: FigureData;
|
||||
blurData?: BlurData;
|
||||
}
|
||||
|
||||
export const DEFAULT_ANNOTATION_POSITION: AnnotationPosition = {
|
||||
@@ -118,6 +142,24 @@ export const DEFAULT_FIGURE_DATA: FigureData = {
|
||||
strokeWidth: 4,
|
||||
};
|
||||
|
||||
export const DEFAULT_BLUR_FREEHAND_POINTS: Array<{ x: number; y: number }> = [
|
||||
{ x: 10, y: 30 },
|
||||
{ x: 25, y: 10 },
|
||||
{ x: 55, y: 8 },
|
||||
{ x: 82, y: 20 },
|
||||
{ x: 90, y: 45 },
|
||||
{ x: 78, y: 72 },
|
||||
{ x: 52, y: 90 },
|
||||
{ x: 22, y: 84 },
|
||||
{ x: 8, y: 58 },
|
||||
];
|
||||
|
||||
export const DEFAULT_BLUR_DATA: BlurData = {
|
||||
shape: "rectangle",
|
||||
intensity: DEFAULT_BLUR_INTENSITY,
|
||||
freehandPoints: DEFAULT_BLUR_FREEHAND_POINTS,
|
||||
};
|
||||
|
||||
export interface CropRegion {
|
||||
x: number;
|
||||
y: number;
|
||||
@@ -132,7 +174,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;
|
||||
@@ -149,6 +200,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;
|
||||
|
||||
@@ -8,3 +8,6 @@ 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;
|
||||
export const AUTO_FOLLOW_SMOOTHING_FACTOR = 0.1;
|
||||
export const AUTO_FOLLOW_SMOOTHING_FACTOR_MAX = 0.25;
|
||||
export const AUTO_FOLLOW_RAMP_DISTANCE = 0.15;
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { CursorTelemetryPoint, ZoomFocus } from "../types";
|
||||
|
||||
/**
|
||||
* Binary-search the sorted telemetry array and linearly interpolate
|
||||
* the cursor position at the given playback time.
|
||||
*/
|
||||
export function interpolateCursorAt(
|
||||
telemetry: CursorTelemetryPoint[],
|
||||
timeMs: number,
|
||||
): ZoomFocus | null {
|
||||
if (telemetry.length === 0) return null;
|
||||
|
||||
if (timeMs <= telemetry[0].timeMs) {
|
||||
return { cx: telemetry[0].cx, cy: telemetry[0].cy };
|
||||
}
|
||||
|
||||
const last = telemetry[telemetry.length - 1];
|
||||
if (timeMs >= last.timeMs) {
|
||||
return { cx: last.cx, cy: last.cy };
|
||||
}
|
||||
|
||||
let lo = 0;
|
||||
let hi = telemetry.length - 1;
|
||||
|
||||
while (lo < hi - 1) {
|
||||
const mid = (lo + hi) >>> 1;
|
||||
if (telemetry[mid].timeMs <= timeMs) {
|
||||
lo = mid;
|
||||
} else {
|
||||
hi = mid;
|
||||
}
|
||||
}
|
||||
|
||||
const before = telemetry[lo];
|
||||
const after = telemetry[hi];
|
||||
const span = after.timeMs - before.timeMs;
|
||||
const t = span > 0 ? (timeMs - before.timeMs) / span : 0;
|
||||
|
||||
return {
|
||||
cx: before.cx + (after.cx - before.cx) * t,
|
||||
cy: before.cy + (after.cy - before.cy) * t,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Exponential smoothing to reduce jitter from high-frequency cursor data.
|
||||
* Lower factor = smoother / more lag, higher = more responsive.
|
||||
*/
|
||||
export function smoothCursorFocus(raw: ZoomFocus, prev: ZoomFocus, factor: number): ZoomFocus {
|
||||
return {
|
||||
cx: prev.cx + (raw.cx - prev.cx) * factor,
|
||||
cy: prev.cy + (raw.cy - prev.cy) * factor,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute an adaptive smoothing factor that scales with distance:
|
||||
* far from target → faster (maxFactor), close → slower (minFactor).
|
||||
* This replaces the hard deadzone with a natural deceleration curve.
|
||||
*/
|
||||
export function adaptiveSmoothFactor(
|
||||
raw: ZoomFocus,
|
||||
prev: ZoomFocus,
|
||||
minFactor: number,
|
||||
maxFactor: number,
|
||||
rampDistance: number,
|
||||
): number {
|
||||
const dx = raw.cx - prev.cx;
|
||||
const dy = raw.cy - prev.cy;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
const t = Math.min(1, distance / rampDistance);
|
||||
return minFactor + (maxFactor - minFactor) * t;
|
||||
}
|
||||
@@ -39,9 +39,16 @@ function getFocusBounds(depth: ZoomDepth) {
|
||||
return getFocusBoundsForScale(zoomScale);
|
||||
}
|
||||
|
||||
function getFocusBoundsForScale(zoomScale: number) {
|
||||
const marginX = 1 / (2 * zoomScale);
|
||||
const marginY = 1 / (2 * zoomScale);
|
||||
interface ViewportRatio {
|
||||
widthRatio: number;
|
||||
heightRatio: number;
|
||||
}
|
||||
|
||||
function getFocusBoundsForScale(zoomScale: number, viewportRatio?: ViewportRatio) {
|
||||
const wr = viewportRatio?.widthRatio ?? 1;
|
||||
const hr = viewportRatio?.heightRatio ?? 1;
|
||||
const marginX = Math.min(0.5, wr / (2 * zoomScale));
|
||||
const marginY = Math.min(0.5, hr / (2 * zoomScale));
|
||||
|
||||
return {
|
||||
minX: marginX,
|
||||
@@ -65,12 +72,16 @@ export function clampFocusToStage(
|
||||
};
|
||||
}
|
||||
|
||||
export function clampFocusToScale(focus: ZoomFocus, zoomScale: number): ZoomFocus {
|
||||
export function clampFocusToScale(
|
||||
focus: ZoomFocus,
|
||||
zoomScale: number,
|
||||
viewportRatio?: ViewportRatio,
|
||||
): ZoomFocus {
|
||||
const baseFocus = {
|
||||
cx: clamp(focus.cx, 0, 1),
|
||||
cy: clamp(focus.cy, 0, 1),
|
||||
};
|
||||
const bounds = getFocusBoundsForScale(zoomScale);
|
||||
const bounds = getFocusBoundsForScale(zoomScale, viewportRatio);
|
||||
|
||||
return {
|
||||
cx: clamp(baseFocus.cx, bounds.minX, bounds.maxX),
|
||||
@@ -78,12 +89,16 @@ export function clampFocusToScale(focus: ZoomFocus, zoomScale: number): ZoomFocu
|
||||
};
|
||||
}
|
||||
|
||||
export function softenFocusToScale(focus: ZoomFocus, zoomScale: number): ZoomFocus {
|
||||
export function softenFocusToScale(
|
||||
focus: ZoomFocus,
|
||||
zoomScale: number,
|
||||
viewportRatio?: ViewportRatio,
|
||||
): ZoomFocus {
|
||||
const baseFocus = {
|
||||
cx: clamp(focus.cx, 0, 1),
|
||||
cy: clamp(focus.cy, 0, 1),
|
||||
};
|
||||
const bounds = getFocusBoundsForScale(zoomScale);
|
||||
const bounds = getFocusBoundsForScale(zoomScale, viewportRatio);
|
||||
const horizontalRange = bounds.maxX - bounds.minX;
|
||||
const verticalRange = bounds.maxY - bounds.minY;
|
||||
const horizontalSoftness = Math.min(0.12, horizontalRange * 0.35);
|
||||
|
||||
@@ -5,8 +5,9 @@ import {
|
||||
type Size,
|
||||
type StyledRenderRect,
|
||||
type WebcamLayoutPreset,
|
||||
type WebcamSizePreset,
|
||||
} from "@/lib/compositeLayout";
|
||||
import type { CropRegion } from "../types";
|
||||
import type { CropRegion, WebcamMaskShape } from "../types";
|
||||
|
||||
interface LayoutParams {
|
||||
container: HTMLDivElement;
|
||||
@@ -20,7 +21,9 @@ interface LayoutParams {
|
||||
padding?: number;
|
||||
webcamDimensions?: Size | null;
|
||||
webcamLayoutPreset?: WebcamLayoutPreset;
|
||||
webcamSizePreset?: WebcamSizePreset;
|
||||
webcamPosition?: { cx: number; cy: number } | null;
|
||||
webcamMaskShape?: WebcamMaskShape;
|
||||
}
|
||||
|
||||
interface LayoutResult {
|
||||
@@ -46,7 +49,9 @@ export function layoutVideoContent(params: LayoutParams): LayoutResult | null {
|
||||
padding = 0,
|
||||
webcamDimensions,
|
||||
webcamLayoutPreset,
|
||||
webcamSizePreset,
|
||||
webcamPosition,
|
||||
webcamMaskShape,
|
||||
} = params;
|
||||
|
||||
const videoWidth = lockedVideoDimensions?.width || videoElement.videoWidth;
|
||||
@@ -93,7 +98,9 @@ export function layoutVideoContent(params: LayoutParams): LayoutResult | null {
|
||||
screenSize: { width: croppedVideoWidth, height: croppedVideoHeight },
|
||||
webcamSize: webcamDimensions,
|
||||
layoutPreset: webcamLayoutPreset,
|
||||
webcamSizePreset,
|
||||
webcamPosition,
|
||||
webcamMaskShape,
|
||||
});
|
||||
|
||||
if (!compositeLayout) {
|
||||
|
||||
@@ -14,7 +14,7 @@ interface OverlayUpdateParams {
|
||||
export function updateOverlayIndicator(params: OverlayUpdateParams) {
|
||||
const { overlayEl, indicatorEl, region, focusOverride, videoSize, baseScale, isPlaying } = params;
|
||||
|
||||
if (!region) {
|
||||
if (!region || region.focusMode === "auto") {
|
||||
indicatorEl.style.display = "none";
|
||||
overlayEl.style.pointerEvents = "none";
|
||||
return;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ZoomFocus, ZoomRegion } from "../types";
|
||||
import type { CursorTelemetryPoint, ZoomFocus, ZoomRegion } from "../types";
|
||||
import { ZOOM_DEPTH_SCALES } from "../types";
|
||||
import { TRANSITION_WINDOW_MS, ZOOM_IN_TRANSITION_WINDOW_MS } from "./constants";
|
||||
import { interpolateCursorAt } from "./cursorFollowUtils";
|
||||
import { clampFocusToScale } from "./focusUtils";
|
||||
import { clamp01, cubicBezier, easeOutScreenStudio } from "./mathUtils";
|
||||
|
||||
@@ -10,6 +11,8 @@ const ZOOM_IN_OVERLAP_MS = 500;
|
||||
|
||||
type DominantRegionOptions = {
|
||||
connectZooms?: boolean;
|
||||
cursorTelemetry?: CursorTelemetryPoint[];
|
||||
viewportRatio?: ViewportRatio;
|
||||
};
|
||||
|
||||
type ConnectedRegionPair = {
|
||||
@@ -64,8 +67,33 @@ function getLinearFocus(start: ZoomFocus, end: ZoomFocus, amount: number): ZoomF
|
||||
};
|
||||
}
|
||||
|
||||
function getResolvedFocus(region: ZoomRegion, zoomScale: number): ZoomFocus {
|
||||
return clampFocusToScale(region.focus, zoomScale);
|
||||
interface ViewportRatio {
|
||||
widthRatio: number;
|
||||
heightRatio: number;
|
||||
}
|
||||
|
||||
function getResolvedFocus(
|
||||
region: ZoomRegion,
|
||||
zoomScale: number,
|
||||
timeMs?: number,
|
||||
cursorTelemetry?: CursorTelemetryPoint[],
|
||||
viewportRatio?: ViewportRatio,
|
||||
): ZoomFocus {
|
||||
let focus = region.focus;
|
||||
|
||||
if (
|
||||
region.focusMode === "auto" &&
|
||||
cursorTelemetry &&
|
||||
cursorTelemetry.length > 0 &&
|
||||
timeMs !== undefined
|
||||
) {
|
||||
const cursorFocus = interpolateCursorAt(cursorTelemetry, timeMs);
|
||||
if (cursorFocus) {
|
||||
focus = cursorFocus;
|
||||
}
|
||||
}
|
||||
|
||||
return clampFocusToScale(focus, zoomScale, viewportRatio);
|
||||
}
|
||||
|
||||
function getConnectedRegionPairs(regions: ZoomRegion[]) {
|
||||
@@ -96,6 +124,8 @@ function getActiveRegion(
|
||||
regions: ZoomRegion[],
|
||||
timeMs: number,
|
||||
connectedPairs: ConnectedRegionPair[],
|
||||
cursorTelemetry?: CursorTelemetryPoint[],
|
||||
viewportRatio?: ViewportRatio,
|
||||
) {
|
||||
const activeRegions = regions
|
||||
.map((region) => {
|
||||
@@ -130,21 +160,32 @@ function getActiveRegion(
|
||||
return {
|
||||
region: {
|
||||
...activeRegion,
|
||||
focus: getResolvedFocus(activeRegion, activeScale),
|
||||
focus: getResolvedFocus(activeRegion, activeScale, timeMs, cursorTelemetry, viewportRatio),
|
||||
},
|
||||
strength: activeRegions[0].strength,
|
||||
blendedScale: null,
|
||||
};
|
||||
}
|
||||
|
||||
function getConnectedRegionHold(timeMs: number, connectedPairs: ConnectedRegionPair[]) {
|
||||
function getConnectedRegionHold(
|
||||
timeMs: number,
|
||||
connectedPairs: ConnectedRegionPair[],
|
||||
cursorTelemetry?: CursorTelemetryPoint[],
|
||||
viewportRatio?: ViewportRatio,
|
||||
) {
|
||||
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),
|
||||
focus: getResolvedFocus(
|
||||
pair.nextRegion,
|
||||
nextScale,
|
||||
timeMs,
|
||||
cursorTelemetry,
|
||||
viewportRatio,
|
||||
),
|
||||
},
|
||||
strength: 1,
|
||||
blendedScale: null,
|
||||
@@ -155,7 +196,12 @@ function getConnectedRegionHold(timeMs: number, connectedPairs: ConnectedRegionP
|
||||
return null;
|
||||
}
|
||||
|
||||
function getConnectedRegionTransition(connectedPairs: ConnectedRegionPair[], timeMs: number) {
|
||||
function getConnectedRegionTransition(
|
||||
connectedPairs: ConnectedRegionPair[],
|
||||
timeMs: number,
|
||||
cursorTelemetry?: CursorTelemetryPoint[],
|
||||
viewportRatio?: ViewportRatio,
|
||||
) {
|
||||
for (const pair of connectedPairs) {
|
||||
const { currentRegion, nextRegion, transitionStart, transitionEnd } = pair;
|
||||
|
||||
@@ -169,8 +215,23 @@ function getConnectedRegionTransition(connectedPairs: ConnectedRegionPair[], tim
|
||||
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);
|
||||
// Both regions share the same timeMs, so interpolate cursor once and reuse.
|
||||
const sharedCursorFocus =
|
||||
cursorTelemetry && cursorTelemetry.length > 0
|
||||
? interpolateCursorAt(cursorTelemetry, timeMs)
|
||||
: null;
|
||||
const currentFocus = clampFocusToScale(
|
||||
currentRegion.focusMode === "auto" && sharedCursorFocus
|
||||
? sharedCursorFocus
|
||||
: currentRegion.focus,
|
||||
currentScale,
|
||||
viewportRatio,
|
||||
);
|
||||
const nextFocus = clampFocusToScale(
|
||||
nextRegion.focusMode === "auto" && sharedCursorFocus ? sharedCursorFocus : nextRegion.focus,
|
||||
nextScale,
|
||||
viewportRatio,
|
||||
);
|
||||
const transitionFocus = getLinearFocus(currentFocus, nextFocus, transitionProgress);
|
||||
|
||||
return {
|
||||
@@ -204,20 +265,22 @@ export function findDominantRegion(
|
||||
transition: ConnectedPanTransition | null;
|
||||
} {
|
||||
const connectedPairs = options.connectZooms ? getConnectedRegionPairs(regions) : [];
|
||||
const telemetry = options.cursorTelemetry;
|
||||
const vr = options.viewportRatio;
|
||||
|
||||
if (options.connectZooms) {
|
||||
const connectedTransition = getConnectedRegionTransition(connectedPairs, timeMs);
|
||||
const connectedTransition = getConnectedRegionTransition(connectedPairs, timeMs, telemetry, vr);
|
||||
if (connectedTransition) {
|
||||
return connectedTransition;
|
||||
}
|
||||
|
||||
const connectedHold = getConnectedRegionHold(timeMs, connectedPairs);
|
||||
const connectedHold = getConnectedRegionHold(timeMs, connectedPairs, telemetry, vr);
|
||||
if (connectedHold) {
|
||||
return { ...connectedHold, transition: null };
|
||||
}
|
||||
}
|
||||
|
||||
const activeRegion = getActiveRegion(regions, timeMs, connectedPairs);
|
||||
const activeRegion = getActiveRegion(regions, timeMs, connectedPairs, telemetry, vr);
|
||||
return activeRegion
|
||||
? { ...activeRegion, transition: null }
|
||||
: { region: null, strength: 0, blendedScale: null, transition: null };
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useCameraDevices } from "./useCameraDevices";
|
||||
|
||||
// Mock navigator.mediaDevices
|
||||
const mockDevices = [
|
||||
{ kind: "videoinput", deviceId: "cam1", label: "Camera 1", groupId: "group1" },
|
||||
{ kind: "videoinput", deviceId: "cam2", label: "Camera 2", groupId: "group1" },
|
||||
{ kind: "audioinput", deviceId: "mic1", label: "Mic 1", groupId: "group2" },
|
||||
];
|
||||
|
||||
const mockGetUserMedia = vi.fn().mockResolvedValue({
|
||||
getTracks: () => [{ stop: vi.fn() }],
|
||||
});
|
||||
|
||||
const mockEnumerateDevices = vi.fn().mockResolvedValue(mockDevices);
|
||||
|
||||
Object.defineProperty(global.navigator, "mediaDevices", {
|
||||
value: {
|
||||
enumerateDevices: mockEnumerateDevices,
|
||||
getUserMedia: mockGetUserMedia,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
describe("useCameraDevices", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockEnumerateDevices.mockResolvedValue(mockDevices);
|
||||
mockGetUserMedia.mockResolvedValue({
|
||||
getTracks: () => [{ stop: vi.fn() }],
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it("should list video input devices", async () => {
|
||||
const { result } = renderHook(() => useCameraDevices(true));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.devices).toHaveLength(2);
|
||||
});
|
||||
|
||||
expect(result.current.devices[0].label).toBe("Camera 1");
|
||||
expect(result.current.devices[1].deviceId).toBe("cam2");
|
||||
});
|
||||
|
||||
it("should set first device as default", async () => {
|
||||
const { result } = renderHook(() => useCameraDevices(true));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.selectedDeviceId).toBe("cam1");
|
||||
});
|
||||
});
|
||||
|
||||
it("should use device ID as fallback label when label is missing", async () => {
|
||||
mockEnumerateDevices.mockResolvedValueOnce([
|
||||
{ kind: "videoinput", deviceId: "cam1abc123456", label: "", groupId: "group1" },
|
||||
]);
|
||||
|
||||
const { result } = renderHook(() => useCameraDevices(true));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.devices[0]?.label).toBe("Camera cam1abc1");
|
||||
});
|
||||
|
||||
expect(mockGetUserMedia).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should set error state when enumeration fails", async () => {
|
||||
mockEnumerateDevices.mockRejectedValueOnce(new Error("Permission denied"));
|
||||
|
||||
const { result } = renderHook(() => useCameraDevices(true));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.error).toBe("Permission denied");
|
||||
});
|
||||
|
||||
expect(result.current.devices).toHaveLength(0);
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
it("should fall back to first available device when selected device is unplugged", async () => {
|
||||
const { result } = renderHook(() => useCameraDevices(true));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.selectedDeviceId).toBe("cam1");
|
||||
});
|
||||
|
||||
// Simulate cam1 being unplugged — only cam2 remains
|
||||
const cam2Only = [
|
||||
{ kind: "videoinput", deviceId: "cam2", label: "Camera 2", groupId: "group1" },
|
||||
];
|
||||
mockEnumerateDevices.mockResolvedValueOnce(cam2Only);
|
||||
|
||||
// Trigger devicechange event via the registered handler
|
||||
const devicechangeHandler = (
|
||||
navigator.mediaDevices.addEventListener as ReturnType<typeof vi.fn>
|
||||
).mock.calls[0]?.[1] as (() => void) | undefined;
|
||||
|
||||
await act(async () => {
|
||||
devicechangeHandler?.();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.selectedDeviceId).toBe("cam2");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
export interface CameraDevice {
|
||||
deviceId: string;
|
||||
label: string;
|
||||
groupId: string;
|
||||
}
|
||||
|
||||
export function useCameraDevices(enabled: boolean = false) {
|
||||
const [devices, setDevices] = useState<CameraDevice[]>([]);
|
||||
const [selectedDeviceId, setSelectedDeviceId] = useState<string>("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const selectedDeviceIdRef = useRef(selectedDeviceId);
|
||||
selectedDeviceIdRef.current = selectedDeviceId;
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
let mounted = true;
|
||||
|
||||
const loadDevices = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Enumerate without requesting a second stream — the recorder handles
|
||||
// the real acquisition; unlabeled devices fall back to their device ID.
|
||||
const allDevices = await navigator.mediaDevices.enumerateDevices();
|
||||
const videoInputs = allDevices
|
||||
.filter((device) => device.kind === "videoinput")
|
||||
.map((device) => ({
|
||||
deviceId: device.deviceId,
|
||||
label: device.label || `Camera ${device.deviceId.slice(0, 8)}`,
|
||||
groupId: device.groupId,
|
||||
}));
|
||||
|
||||
if (mounted) {
|
||||
setDevices(videoInputs);
|
||||
const currentId = selectedDeviceIdRef.current;
|
||||
const stillAvailable = videoInputs.some((d) => d.deviceId === currentId);
|
||||
if (!currentId || !stillAvailable) {
|
||||
setSelectedDeviceId(videoInputs[0]?.deviceId ?? "");
|
||||
}
|
||||
setIsLoading(false);
|
||||
}
|
||||
} catch (err) {
|
||||
if (mounted) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load cameras");
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadDevices();
|
||||
|
||||
navigator.mediaDevices.addEventListener("devicechange", loadDevices);
|
||||
return () => {
|
||||
mounted = false;
|
||||
navigator.mediaDevices.removeEventListener("devicechange", loadDevices);
|
||||
};
|
||||
}, [enabled]);
|
||||
|
||||
return { devices, selectedDeviceId, setSelectedDeviceId, isLoading, error };
|
||||
}
|
||||
@@ -5,13 +5,17 @@ import type {
|
||||
SpeedRegion,
|
||||
TrimRegion,
|
||||
WebcamLayoutPreset,
|
||||
WebcamMaskShape,
|
||||
WebcamPosition,
|
||||
WebcamSizePreset,
|
||||
ZoomRegion,
|
||||
} from "@/components/video-editor/types";
|
||||
import {
|
||||
DEFAULT_CROP_REGION,
|
||||
DEFAULT_WEBCAM_LAYOUT_PRESET,
|
||||
DEFAULT_WEBCAM_MASK_SHAPE,
|
||||
DEFAULT_WEBCAM_POSITION,
|
||||
DEFAULT_WEBCAM_SIZE_PRESET,
|
||||
} from "@/components/video-editor/types";
|
||||
import type { AspectRatio } from "@/utils/aspectRatioUtils";
|
||||
|
||||
@@ -31,6 +35,8 @@ export interface EditorState {
|
||||
padding: number;
|
||||
aspectRatio: AspectRatio;
|
||||
webcamLayoutPreset: WebcamLayoutPreset;
|
||||
webcamMaskShape: WebcamMaskShape;
|
||||
webcamSizePreset: WebcamSizePreset;
|
||||
webcamPosition: WebcamPosition | null;
|
||||
}
|
||||
|
||||
@@ -48,6 +54,8 @@ export const INITIAL_EDITOR_STATE: EditorState = {
|
||||
padding: 50,
|
||||
aspectRatio: "16:9",
|
||||
webcamLayoutPreset: DEFAULT_WEBCAM_LAYOUT_PRESET,
|
||||
webcamMaskShape: DEFAULT_WEBCAM_MASK_SHAPE,
|
||||
webcamSizePreset: DEFAULT_WEBCAM_SIZE_PRESET,
|
||||
webcamPosition: DEFAULT_WEBCAM_POSITION,
|
||||
};
|
||||
|
||||
|
||||
+140
-15
@@ -41,12 +41,18 @@ 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;
|
||||
setMicrophoneDeviceId: (deviceId: string | undefined) => void;
|
||||
webcamDeviceId: string | undefined;
|
||||
setWebcamDeviceId: (deviceId: string | undefined) => void;
|
||||
systemAudioEnabled: boolean;
|
||||
setSystemAudioEnabled: (enabled: boolean) => void;
|
||||
webcamEnabled: boolean;
|
||||
@@ -83,8 +89,11 @@ 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);
|
||||
const [systemAudioEnabled, setSystemAudioEnabled] = useState(false);
|
||||
const [webcamEnabled, setWebcamEnabledState] = useState(false);
|
||||
const screenRecorder = useRef<RecorderHandle | null>(null);
|
||||
@@ -94,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",
|
||||
@@ -199,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 () => {
|
||||
@@ -270,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(
|
||||
@@ -280,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 {
|
||||
@@ -288,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 {
|
||||
@@ -313,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 {
|
||||
@@ -409,11 +441,18 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
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 },
|
||||
},
|
||||
video: webcamDeviceId
|
||||
? {
|
||||
deviceId: { exact: webcamDeviceId },
|
||||
width: { ideal: WEBCAM_TARGET_WIDTH },
|
||||
height: { ideal: WEBCAM_TARGET_HEIGHT },
|
||||
frameRate: { ideal: WEBCAM_TARGET_FRAME_RATE, max: WEBCAM_TARGET_FRAME_RATE },
|
||||
}
|
||||
: {
|
||||
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);
|
||||
@@ -508,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;
|
||||
@@ -526,7 +568,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
finalizeRecording(
|
||||
activeScreenRecorder,
|
||||
activeWebcamRecorder ?? null,
|
||||
Math.max(0, Date.now() - startTime.current),
|
||||
Math.max(0, getRecordingDurationMs()),
|
||||
activeRecordingId,
|
||||
);
|
||||
},
|
||||
@@ -542,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();
|
||||
};
|
||||
@@ -556,13 +642,14 @@ 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;
|
||||
|
||||
restarting.current = true;
|
||||
discardRecordingId.current = activeRecordingId;
|
||||
allowAutoFinalize.current = false;
|
||||
|
||||
const stopPromises = [
|
||||
new Promise<void>((resolve) => {
|
||||
@@ -570,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(), {
|
||||
@@ -590,14 +680,49 @@ 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,
|
||||
setMicrophoneDeviceId,
|
||||
webcamDeviceId,
|
||||
setWebcamDeviceId,
|
||||
systemAudioEnabled,
|
||||
setSystemAudioEnabled,
|
||||
webcamEnabled,
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
export const DEFAULT_LOCALE = "en" as const;
|
||||
export const SUPPORTED_LOCALES = ["en", "zh-CN", "es"] as const;
|
||||
export const SUPPORTED_LOCALES = ["en", "zh-CN", "es", "fr", "tr"] as const;
|
||||
export const I18N_NAMESPACES = [
|
||||
"common",
|
||||
"dialogs",
|
||||
|
||||
@@ -27,10 +27,11 @@
|
||||
"triggerLabel": "How trimming works",
|
||||
"title": "How Trimming Works",
|
||||
"description": "Understanding how to cut out unwanted parts of your video.",
|
||||
"explanation": "The Trim tool works by defining the segments you want to",
|
||||
"explanationRemove": "remove",
|
||||
"explanationCovered": "covered",
|
||||
"explanationEnd": "by a red trim segment will be cut out when you export.",
|
||||
"explanationBefore": "The Trim tool works by defining the segments you want to",
|
||||
"remove": "remove",
|
||||
"explanationMiddle": " — anything",
|
||||
"covered": "covered",
|
||||
"explanationAfter": "by a red trim segment will be cut out when you export.",
|
||||
"visualExample": "Visual Example",
|
||||
"removed": "REMOVED",
|
||||
"kept": "Kept",
|
||||
@@ -39,7 +40,9 @@
|
||||
"part3": "Part 3",
|
||||
"finalVideo": "Final Video",
|
||||
"step1Title": "1. Add Trim",
|
||||
"step1Description": "Press T or click the scissors icon to mark a section for removal.",
|
||||
"step1DescriptionBefore": "Press ",
|
||||
"step1DescriptionAfter": " or click the scissors icon to mark a section for removal.",
|
||||
|
||||
"step2Title": "2. Adjust",
|
||||
"step2Description": "Drag the edges of the red region to cover exactly what you want to cut out."
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
@@ -10,11 +13,16 @@
|
||||
"enableSystemAudio": "Enable system audio",
|
||||
"disableSystemAudio": "Disable system audio",
|
||||
"enableMicrophone": "Enable microphone",
|
||||
"disableMicrophone": "Disable microphone"
|
||||
"disableMicrophone": "Disable microphone",
|
||||
"defaultMicrophone": "Default Microphone"
|
||||
},
|
||||
"webcam": {
|
||||
"enableWebcam": "Enable webcam",
|
||||
"disableWebcam": "Disable webcam"
|
||||
"disableWebcam": "Disable webcam",
|
||||
"defaultCamera": "Default Camera",
|
||||
"searching": "Searching...",
|
||||
"noneFound": "No camera found",
|
||||
"unavailable": "Camera unavailable"
|
||||
},
|
||||
"sourceSelector": {
|
||||
"loading": "Loading sources...",
|
||||
|
||||
@@ -2,12 +2,20 @@
|
||||
"zoom": {
|
||||
"level": "Zoom Level",
|
||||
"selectRegion": "Select a zoom region to adjust",
|
||||
"deleteZoom": "Delete Zoom"
|
||||
"deleteZoom": "Delete Zoom",
|
||||
"focusMode": {
|
||||
"title": "Focus Mode",
|
||||
"manual": "Manual",
|
||||
"auto": "Auto",
|
||||
"autoDescription": "Camera follows the recorded cursor position"
|
||||
}
|
||||
},
|
||||
"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"
|
||||
@@ -17,7 +25,9 @@
|
||||
"preset": "Preset",
|
||||
"selectPreset": "Select preset",
|
||||
"pictureInPicture": "Picture in Picture",
|
||||
"verticalStack": "Vertical Stack"
|
||||
"verticalStack": "Vertical Stack",
|
||||
"webcamShape": "Camera Shape",
|
||||
"webcamSize": "Webcam Size"
|
||||
},
|
||||
"effects": {
|
||||
"title": "Video Effects",
|
||||
@@ -91,6 +101,7 @@
|
||||
"typeText": "Text",
|
||||
"typeImage": "Image",
|
||||
"typeArrow": "Arrow",
|
||||
"typeBlur": "Blur",
|
||||
"textContent": "Text Content",
|
||||
"textPlaceholder": "Enter your text...",
|
||||
"fontStyle": "Font Style",
|
||||
@@ -107,6 +118,11 @@
|
||||
"arrowDirection": "Arrow Direction",
|
||||
"strokeWidth": "Stroke Width: {{width}}px",
|
||||
"arrowColor": "Arrow Color",
|
||||
"blurShape": "Blur Shape",
|
||||
"blurIntensity": "Blur Intensity",
|
||||
"blurShapeRectangle": "Rectangle",
|
||||
"blurShapeOval": "Oval",
|
||||
"blurShapeFreehand": "Freehand",
|
||||
"deleteAnnotation": "Delete Annotation",
|
||||
"shortcutsAndTips": "Shortcuts & Tips",
|
||||
"tipMovePlayhead": "Move playhead to overlapping annotation section and select an item.",
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"addTrim": "Add Trim",
|
||||
"addSpeed": "Add Speed",
|
||||
"addAnnotation": "Add Annotation",
|
||||
"addBlur": "Add Blur",
|
||||
"addKeyframe": "Add Keyframe",
|
||||
"deleteSelected": "Delete Selected",
|
||||
"playPause": "Play / Pause"
|
||||
@@ -29,6 +30,8 @@
|
||||
"cycleAnnotationsBackward": "Cycle Annotations Backward",
|
||||
"deleteSelectedAlt": "Delete Selected (alt)",
|
||||
"panTimeline": "Pan Timeline",
|
||||
"zoomTimeline": "Zoom Timeline"
|
||||
"zoomTimeline": "Zoom Timeline",
|
||||
"frameBack": "Frame Back",
|
||||
"frameForward": "Frame Forward"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,12 +4,14 @@
|
||||
"suggestZooms": "Suggest Zooms from Cursor",
|
||||
"addTrim": "Add Trim (T)",
|
||||
"addAnnotation": "Add Annotation (A)",
|
||||
"addBlur": "Add Blur (B)",
|
||||
"addSpeed": "Add Speed (S)"
|
||||
},
|
||||
"hints": {
|
||||
"pressZoom": "Press Z to add zoom",
|
||||
"pressTrim": "Press T to add trim",
|
||||
"pressAnnotation": "Press A to add annotation",
|
||||
"pressBlur": "Press B to add blur region",
|
||||
"pressSpeed": "Press S to add speed"
|
||||
},
|
||||
"labels": {
|
||||
@@ -19,6 +21,7 @@
|
||||
"trimItem": "Trim {{index}}",
|
||||
"speedItem": "Speed {{index}}",
|
||||
"annotationItem": "Annotation",
|
||||
"blurItem": "Blur {{index}}",
|
||||
"imageItem": "Image",
|
||||
"emptyText": "Empty text"
|
||||
},
|
||||
|
||||
@@ -27,10 +27,11 @@
|
||||
"triggerLabel": "Cómo funciona el recorte",
|
||||
"title": "Cómo funciona el recorte",
|
||||
"description": "Aprende a eliminar las partes no deseadas de tu video.",
|
||||
"explanation": "La herramienta de recorte funciona definiendo los segmentos que deseas",
|
||||
"explanationRemove": "eliminar",
|
||||
"explanationCovered": "cubierto",
|
||||
"explanationEnd": "por un segmento rojo de recorte será eliminado al exportar.",
|
||||
"explanationBefore": "La herramienta de recorte funciona definiendo los segmentos que deseas",
|
||||
"remove": "eliminar",
|
||||
"explanationMiddle": " — cualquier parte",
|
||||
"covered": "cubierta",
|
||||
"explanationAfter": "por un segmento rojo será eliminada al exportar.",
|
||||
"visualExample": "Ejemplo visual",
|
||||
"removed": "ELIMINADO",
|
||||
"kept": "Conservado",
|
||||
@@ -39,7 +40,8 @@
|
||||
"part3": "Parte 3",
|
||||
"finalVideo": "Video final",
|
||||
"step1Title": "1. Agregar recorte",
|
||||
"step1Description": "Presiona T o haz clic en el ícono de tijeras para marcar una sección a eliminar.",
|
||||
"step1DescriptionBefore": "Presiona ",
|
||||
"step1DescriptionAfter": " o haz clic en el ícono de tijeras para marcar una sección a eliminar.",
|
||||
"step2Title": "2. Ajustar",
|
||||
"step2Description": "Arrastra los bordes de la región roja para cubrir exactamente lo que deseas eliminar."
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
@@ -10,11 +13,16 @@
|
||||
"enableSystemAudio": "Activar audio del sistema",
|
||||
"disableSystemAudio": "Desactivar audio del sistema",
|
||||
"enableMicrophone": "Activar micrófono",
|
||||
"disableMicrophone": "Desactivar micrófono"
|
||||
"disableMicrophone": "Desactivar micrófono",
|
||||
"defaultMicrophone": "Micrófono predeterminado"
|
||||
},
|
||||
"webcam": {
|
||||
"enableWebcam": "Activar cámara web",
|
||||
"disableWebcam": "Desactivar cámara web"
|
||||
"disableWebcam": "Desactivar cámara web",
|
||||
"defaultCamera": "Cámara predeterminada",
|
||||
"searching": "Buscando...",
|
||||
"noneFound": "No se encontró cámara",
|
||||
"unavailable": "Cámara no disponible"
|
||||
},
|
||||
"sourceSelector": {
|
||||
"loading": "Cargando fuentes...",
|
||||
|
||||
@@ -2,12 +2,20 @@
|
||||
"zoom": {
|
||||
"level": "Nivel de zoom",
|
||||
"selectRegion": "Selecciona una región de zoom para ajustar",
|
||||
"deleteZoom": "Eliminar zoom"
|
||||
"deleteZoom": "Eliminar zoom",
|
||||
"focusMode": {
|
||||
"title": "Modo de enfoque",
|
||||
"manual": "Manual",
|
||||
"auto": "Auto",
|
||||
"autoDescription": "La cámara sigue la posición del cursor grabado"
|
||||
}
|
||||
},
|
||||
"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"
|
||||
@@ -17,7 +25,9 @@
|
||||
"preset": "Predefinido",
|
||||
"selectPreset": "Seleccionar predefinido",
|
||||
"pictureInPicture": "Imagen en imagen",
|
||||
"verticalStack": "Apilado vertical"
|
||||
"verticalStack": "Apilado vertical",
|
||||
"webcamShape": "Forma de cámara",
|
||||
"webcamSize": "Tamaño de cámara"
|
||||
},
|
||||
"effects": {
|
||||
"title": "Efectos de video",
|
||||
@@ -91,6 +101,7 @@
|
||||
"typeText": "Texto",
|
||||
"typeImage": "Imagen",
|
||||
"typeArrow": "Flecha",
|
||||
"typeBlur": "Desenfoque",
|
||||
"textContent": "Contenido de texto",
|
||||
"textPlaceholder": "Escribe tu texto...",
|
||||
"fontStyle": "Estilo de fuente",
|
||||
@@ -107,6 +118,11 @@
|
||||
"arrowDirection": "Dirección de la flecha",
|
||||
"strokeWidth": "Grosor del trazo: {{width}}px",
|
||||
"arrowColor": "Color de la flecha",
|
||||
"blurShape": "Forma del desenfoque",
|
||||
"blurIntensity": "Intensidad del desenfoque",
|
||||
"blurShapeRectangle": "Rectángulo",
|
||||
"blurShapeOval": "Óvalo",
|
||||
"blurShapeFreehand": "Mano alzada",
|
||||
"deleteAnnotation": "Eliminar anotación",
|
||||
"shortcutsAndTips": "Atajos y consejos",
|
||||
"tipMovePlayhead": "Mueve el cabezal de reproducción a la sección de anotación superpuesta y selecciona un elemento.",
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"addTrim": "Agregar recorte",
|
||||
"addSpeed": "Agregar velocidad",
|
||||
"addAnnotation": "Agregar anotación",
|
||||
"addBlur": "Agregar desenfoque",
|
||||
"addKeyframe": "Agregar fotograma clave",
|
||||
"deleteSelected": "Eliminar seleccionado",
|
||||
"playPause": "Reproducir / Pausar"
|
||||
@@ -29,6 +30,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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,13 +4,15 @@
|
||||
"suggestZooms": "Sugerir zooms desde el cursor",
|
||||
"addTrim": "Agregar recorte (T)",
|
||||
"addAnnotation": "Agregar anotación (A)",
|
||||
"addSpeed": "Agregar velocidad (S)"
|
||||
"addSpeed": "Agregar velocidad (S)",
|
||||
"addBlur": "Agregar desenfoque (B)"
|
||||
},
|
||||
"hints": {
|
||||
"pressZoom": "Presiona Z para agregar zoom",
|
||||
"pressTrim": "Presiona T para agregar recorte",
|
||||
"pressAnnotation": "Presiona A para agregar anotación",
|
||||
"pressSpeed": "Presiona S para agregar velocidad"
|
||||
"pressSpeed": "Presiona S para agregar velocidad",
|
||||
"pressBlur": "Presiona B para agregar una región de desenfoque"
|
||||
},
|
||||
"labels": {
|
||||
"pan": "Desplazar",
|
||||
@@ -20,7 +22,8 @@
|
||||
"speedItem": "Velocidad {{index}}",
|
||||
"annotationItem": "Anotación",
|
||||
"imageItem": "Imagen",
|
||||
"emptyText": "Texto vacío"
|
||||
"emptyText": "Texto vacío",
|
||||
"blurItem": "Desenfoque {{index}}"
|
||||
},
|
||||
"emptyState": {
|
||||
"noVideo": "No hay video cargado",
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"actions": {
|
||||
"cancel": "Annuler",
|
||||
"save": "Enregistrer",
|
||||
"delete": "Supprimer",
|
||||
"close": "Fermer",
|
||||
"share": "Partager",
|
||||
"done": "Terminer",
|
||||
"open": "Ouvrir",
|
||||
"upload": "Téléverser",
|
||||
"export": "Exporter",
|
||||
"file": "Fichier",
|
||||
"edit": "Éditer",
|
||||
"view": "Affichage",
|
||||
"window": "Fenêtre",
|
||||
"quit": "Quitter",
|
||||
"stopRecording": "Arrêter l'enregistrement"
|
||||
},
|
||||
"playback": {
|
||||
"play": "Lecture",
|
||||
"pause": "Pause",
|
||||
"fullscreen": "Plein écran",
|
||||
"exitFullscreen": "Quitter le plein écran"
|
||||
},
|
||||
"locale": {
|
||||
"name": "Français",
|
||||
"short": "FR"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"export": {
|
||||
"complete": "Export terminé",
|
||||
"yourFormatReady": "Votre {{format}} est prêt",
|
||||
"showInFolder": "Afficher dans le dossier",
|
||||
"finalizingVideo": "Finalisation de l'export vidéo...",
|
||||
"compilingGifProgress": "Compilation du GIF... {{progress}}%",
|
||||
"compilingGifWait": "Compilation du GIF... Cela peut prendre un moment",
|
||||
"takeMoment": "Cela peut prendre un moment...",
|
||||
"failed": "Export échoué",
|
||||
"tryAgain": "Veuillez réessayer",
|
||||
"finalizingVideoTitle": "Finalisation de la vidéo",
|
||||
"compilingGif": "Compilation du GIF",
|
||||
"exportingFormat": "Export de {{format}}",
|
||||
"compiling": "Compilation en cours",
|
||||
"renderingFrames": "Rendu des images",
|
||||
"processing": "Traitement en cours...",
|
||||
"finalizing": "Finalisation...",
|
||||
"compilingStatus": "Compilation...",
|
||||
"status": "Statut",
|
||||
"format": "Format",
|
||||
"frames": "Images",
|
||||
"cancelExport": "Annuler l'export",
|
||||
"savedSuccessfully": "{{format}} enregistré avec succès !"
|
||||
},
|
||||
"tutorial": {
|
||||
"triggerLabel": "Comment fonctionne la coupe",
|
||||
"title": "Comment fonctionne la coupe",
|
||||
"description": "Comprendre comment supprimer les parties indésirables de votre vidéo.",
|
||||
"explanation": "L'outil Coupe fonctionne en définissant les segments que vous souhaitez",
|
||||
"explanationRemove": "supprimer",
|
||||
"explanationCovered": "couvert",
|
||||
"explanationEnd": "par un segment de coupe rouge sera coupé lors de l'export.",
|
||||
"visualExample": "Exemple visuel",
|
||||
"removed": "SUPPRIMÉ",
|
||||
"kept": "Conservé",
|
||||
"part1": "Partie 1",
|
||||
"part2": "Partie 2",
|
||||
"part3": "Partie 3",
|
||||
"finalVideo": "Vidéo finale",
|
||||
"step1Title": "1. Ajouter une coupe",
|
||||
"step1Description": "Appuyez sur T ou cliquez sur l'icône ciseaux pour marquer une section à supprimer.",
|
||||
"step2Title": "2. Ajuster",
|
||||
"step2Description": "Faites glisser les bords de la région rouge pour couvrir exactement ce que vous souhaitez couper."
|
||||
},
|
||||
"unsavedChanges": {
|
||||
"title": "Modifications non enregistrées",
|
||||
"message": "Vous avez des modifications non enregistrées.",
|
||||
"detail": "Voulez-vous enregistrer votre projet avant de fermer ?",
|
||||
"saveAndClose": "Enregistrer et fermer",
|
||||
"discardAndClose": "Ignorer et fermer",
|
||||
"loadProject": "Charger un projet…",
|
||||
"saveProject": "Enregistrer le projet…",
|
||||
"saveProjectAs": "Enregistrer le projet sous…"
|
||||
},
|
||||
"fileDialogs": {
|
||||
"saveGif": "Enregistrer le GIF exporté",
|
||||
"saveVideo": "Enregistrer la vidéo exportée",
|
||||
"selectVideo": "Sélectionner un fichier vidéo",
|
||||
"saveProject": "Enregistrer le projet OpenScreen",
|
||||
"openProject": "Ouvrir un projet OpenScreen",
|
||||
"gifImage": "Image GIF",
|
||||
"mp4Video": "Vidéo MP4",
|
||||
"videoFiles": "Fichiers vidéo",
|
||||
"openscreenProject": "Projet OpenScreen",
|
||||
"allFiles": "Tous les fichiers"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"errors": {
|
||||
"noVideoLoaded": "Aucune vidéo chargée",
|
||||
"videoNotReady": "Vidéo non prête",
|
||||
"unableToDetermineSourcePath": "Impossible de déterminer le chemin de la vidéo source",
|
||||
"failedToSaveGif": "Échec de l'enregistrement du GIF",
|
||||
"gifExportFailed": "L'export du GIF a échoué",
|
||||
"failedToSaveVideo": "Échec de l'enregistrement de la vidéo",
|
||||
"exportFailed": "L'export a échoué",
|
||||
"exportFailedWithError": "L'export a échoué : {{error}}",
|
||||
"failedToSaveExport": "Échec de l'enregistrement de l'export",
|
||||
"failedToSaveExportedVideo": "Échec de l'enregistrement de la vidéo exportée",
|
||||
"failedToRevealInFolder": "Erreur lors de l'affichage dans le dossier : {{error}}"
|
||||
},
|
||||
"export": {
|
||||
"canceled": "Export annulé",
|
||||
"exportedSuccessfully": "{{format}} exporté avec succès"
|
||||
},
|
||||
"project": {
|
||||
"saveCanceled": "Enregistrement du projet annulé",
|
||||
"failedToSave": "Échec de l'enregistrement du projet",
|
||||
"savedTo": "Projet enregistré dans {{path}}",
|
||||
"failedToLoad": "Échec du chargement du projet",
|
||||
"invalidFormat": "Format de fichier projet invalide",
|
||||
"loadedFrom": "Projet chargé depuis {{path}}"
|
||||
},
|
||||
"recording": {
|
||||
"failedCameraAccess": "Échec de la demande d'accès à la caméra.",
|
||||
"cameraBlocked": "L'accès à la caméra est bloqué. Activez-le dans les paramètres système pour utiliser la webcam.",
|
||||
"systemAudioUnavailable": "Audio système non disponible. Enregistrement sans audio système.",
|
||||
"microphoneDenied": "Accès au microphone refusé. L'enregistrement continuera sans audio.",
|
||||
"cameraDenied": "Accès à la caméra refusé. L'enregistrement continuera sans webcam.",
|
||||
"permissionDenied": "Permission d'enregistrement refusée. Veuillez autoriser l'enregistrement d'écran."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"tooltips": {
|
||||
"hideHUD": "Masquer le HUD",
|
||||
"closeApp": "Fermer l'application",
|
||||
"restartRecording": "Redémarrer l'enregistrement",
|
||||
"cancelRecording": "Annuler l'enregistrement",
|
||||
"pauseRecording": "Mettre en pause l'enregistrement",
|
||||
"resumeRecording": "Reprendre l'enregistrement",
|
||||
"openVideoFile": "Ouvrir un fichier vidéo",
|
||||
"openProject": "Ouvrir un projet"
|
||||
},
|
||||
"audio": {
|
||||
"enableSystemAudio": "Activer l'audio système",
|
||||
"disableSystemAudio": "Désactiver l'audio système",
|
||||
"enableMicrophone": "Activer le microphone",
|
||||
"disableMicrophone": "Désactiver le microphone",
|
||||
"defaultMicrophone": "Microphone par défaut"
|
||||
},
|
||||
"webcam": {
|
||||
"enableWebcam": "Activer la webcam",
|
||||
"disableWebcam": "Désactiver la webcam",
|
||||
"defaultCamera": "Caméra par défaut",
|
||||
"searching": "Recherche en cours...",
|
||||
"noneFound": "Aucune caméra trouvée",
|
||||
"unavailable": "Caméra non disponible"
|
||||
},
|
||||
"sourceSelector": {
|
||||
"loading": "Chargement des sources...",
|
||||
"screens": "Écrans ({{count}})",
|
||||
"windows": "Fenêtres ({{count}})",
|
||||
"defaultSourceName": "Écran"
|
||||
},
|
||||
"recording": {
|
||||
"selectSource": "Veuillez sélectionner une source à enregistrer"
|
||||
},
|
||||
"language": "Langue"
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
{
|
||||
"zoom": {
|
||||
"level": "Niveau de zoom",
|
||||
"selectRegion": "Sélectionnez une région de zoom à ajuster",
|
||||
"deleteZoom": "Supprimer le zoom",
|
||||
"focusMode": {
|
||||
"title": "Mode focus",
|
||||
"manual": "Manuel",
|
||||
"auto": "Auto",
|
||||
"autoDescription": "La caméra suit la position du curseur enregistré"
|
||||
}
|
||||
},
|
||||
"speed": {
|
||||
"playbackSpeed": "Vitesse de lecture",
|
||||
"selectRegion": "Sélectionnez une région de vitesse à ajuster",
|
||||
"deleteRegion": "Supprimer la région de vitesse"
|
||||
},
|
||||
"trim": {
|
||||
"deleteRegion": "Supprimer la région de coupe"
|
||||
},
|
||||
"layout": {
|
||||
"title": "Mise en page",
|
||||
"preset": "Préréglage",
|
||||
"selectPreset": "Choisir un préréglage",
|
||||
"pictureInPicture": "Incrustation d'image",
|
||||
"verticalStack": "Empilement vertical",
|
||||
"webcamShape": "Forme de la caméra"
|
||||
},
|
||||
"effects": {
|
||||
"title": "Effets vidéo",
|
||||
"blurBg": "Flou arrière-plan",
|
||||
"motionBlur": "Flou de mouvement",
|
||||
"off": "désactivé",
|
||||
"shadow": "Ombre",
|
||||
"roundness": "Arrondi",
|
||||
"padding": "Marge"
|
||||
},
|
||||
"background": {
|
||||
"title": "Arrière-plan",
|
||||
"image": "Image",
|
||||
"color": "Couleur",
|
||||
"gradient": "Dégradé",
|
||||
"uploadCustom": "Téléverser une image",
|
||||
"gradientLabel": "Dégradé {{index}}"
|
||||
},
|
||||
"crop": {
|
||||
"title": "Recadrage",
|
||||
"cropVideo": "Recadrer la vidéo",
|
||||
"dragInstruction": "Faites glisser chaque côté pour ajuster la zone de recadrage",
|
||||
"ratio": "Ratio",
|
||||
"free": "Libre",
|
||||
"done": "Terminer",
|
||||
"lockAspectRatio": "Verrouiller le ratio",
|
||||
"unlockAspectRatio": "Déverrouiller le ratio"
|
||||
},
|
||||
"exportFormat": {
|
||||
"mp4": "MP4",
|
||||
"gif": "GIF",
|
||||
"mp4Video": "Vidéo MP4",
|
||||
"mp4Description": "Fichier vidéo haute qualité",
|
||||
"gifAnimation": "Animation GIF",
|
||||
"gifDescription": "Image animée pour le partage"
|
||||
},
|
||||
"exportQuality": {
|
||||
"title": "Qualité d'export",
|
||||
"low": "Faible",
|
||||
"medium": "Moyenne",
|
||||
"high": "Haute"
|
||||
},
|
||||
"gifSettings": {
|
||||
"frameRate": "Fréquence d'images GIF",
|
||||
"size": "Taille du GIF",
|
||||
"loop": "GIF en boucle"
|
||||
},
|
||||
"project": {
|
||||
"save": "Enregistrer le projet",
|
||||
"load": "Charger un projet"
|
||||
},
|
||||
"export": {
|
||||
"videoButton": "Exporter la vidéo",
|
||||
"gifButton": "Exporter le GIF",
|
||||
"chooseSaveLocation": "Choisir l'emplacement d'enregistrement"
|
||||
},
|
||||
"links": {
|
||||
"reportBug": "Signaler un bug",
|
||||
"starOnGithub": "Étoile sur GitHub"
|
||||
},
|
||||
"imageUpload": {
|
||||
"invalidFileType": "Type de fichier invalide",
|
||||
"jpgOnly": "Veuillez téléverser un fichier image JPG ou JPEG.",
|
||||
"uploadSuccess": "Image personnalisée téléversée avec succès !",
|
||||
"failedToUpload": "Échec du téléversement de l'image",
|
||||
"errorReading": "Une erreur s'est produite lors de la lecture du fichier."
|
||||
},
|
||||
"annotation": {
|
||||
"title": "Paramètres d'annotation",
|
||||
"active": "Actif",
|
||||
"typeText": "Texte",
|
||||
"typeImage": "Image",
|
||||
"typeArrow": "Flèche",
|
||||
"typeBlur": "Flou",
|
||||
"textContent": "Contenu du texte",
|
||||
"textPlaceholder": "Saisissez votre texte...",
|
||||
"fontStyle": "Style de police",
|
||||
"selectStyle": "Choisir un style",
|
||||
"size": "Taille",
|
||||
"customFonts": "Polices personnalisées",
|
||||
"textColor": "Couleur du texte",
|
||||
"background": "Arrière-plan",
|
||||
"none": "Aucun",
|
||||
"color": "Couleur",
|
||||
"clearBackground": "Supprimer l'arrière-plan",
|
||||
"uploadImage": "Téléverser une image",
|
||||
"supportedFormats": "Formats supportés : JPG, PNG, GIF, WebP",
|
||||
"arrowDirection": "Direction de la flèche",
|
||||
"strokeWidth": "Épaisseur du trait : {{width}}px",
|
||||
"arrowColor": "Couleur de la flèche",
|
||||
"blurShape": "Forme du flou",
|
||||
"blurIntensity": "Intensité du flou",
|
||||
"blurShapeRectangle": "Rectangle",
|
||||
"blurShapeOval": "Ovale",
|
||||
"blurShapeFreehand": "Main levée",
|
||||
"deleteAnnotation": "Supprimer l'annotation",
|
||||
"shortcutsAndTips": "Raccourcis & Astuces",
|
||||
"tipMovePlayhead": "Déplacez la tête de lecture sur la section d'annotation et sélectionnez un élément.",
|
||||
"tipTabCycle": "Utilisez Tab pour cycler entre les éléments superposés.",
|
||||
"tipShiftTabCycle": "Utilisez Shift+Tab pour cycler en sens inverse.",
|
||||
"invalidImageType": "Type de fichier invalide",
|
||||
"imageFormatsOnly": "Veuillez téléverser un fichier image JPG, PNG, GIF ou WebP.",
|
||||
"imageUploadSuccess": "Image téléversée avec succès !",
|
||||
"failedImageUpload": "Échec du téléversement de l'image"
|
||||
},
|
||||
"fontStyles": {
|
||||
"classic": "Classique",
|
||||
"editor": "Éditeur",
|
||||
"strong": "Gras",
|
||||
"typewriter": "Machine à écrire",
|
||||
"deco": "Déco",
|
||||
"simple": "Simple",
|
||||
"modern": "Moderne",
|
||||
"clean": "Épuré"
|
||||
},
|
||||
"customFont": {
|
||||
"dialogTitle": "Ajouter une police Google",
|
||||
"urlLabel": "URL d'import Google Fonts",
|
||||
"urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
|
||||
"urlHelp": "Obtenez-la depuis Google Fonts : Sélectionnez une police → Cliquez sur « Obtenir la police » → Copiez l'URL @import",
|
||||
"nameLabel": "Nom d'affichage",
|
||||
"namePlaceholder": "Ma police personnalisée",
|
||||
"nameHelp": "C'est ainsi que la police apparaîtra dans le sélecteur de polices",
|
||||
"addButton": "Ajouter la police",
|
||||
"addingButton": "Ajout en cours...",
|
||||
"errorEmptyUrl": "Veuillez saisir une URL d'import Google Fonts",
|
||||
"errorInvalidUrl": "Veuillez saisir une URL Google Fonts valide",
|
||||
"errorEmptyName": "Veuillez saisir un nom de police",
|
||||
"errorExtractFailed": "Impossible d'extraire la famille de polices depuis l'URL",
|
||||
"successMessage": "Police « {{fontName}} » ajoutée avec succès",
|
||||
"failedToAdd": "Échec de l'ajout de la police",
|
||||
"errorTimeout": "La police a mis trop de temps à charger. Vérifiez l'URL et réessayez.",
|
||||
"errorLoadFailed": "La police n'a pas pu être chargée. Vérifiez que l'URL Google Fonts est correcte."
|
||||
},
|
||||
"language": {
|
||||
"title": "Langue"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"title": "Raccourcis clavier",
|
||||
"customize": "Personnaliser",
|
||||
"configurable": "Configurable",
|
||||
"fixed": "Fixe",
|
||||
"pressKey": "Appuyez sur une touche…",
|
||||
"clickToChange": "Cliquez pour modifier",
|
||||
"pressEscToCancel": "Appuyez sur Échap pour annuler",
|
||||
"helpText": "Cliquez sur un raccourci puis appuyez sur la nouvelle combinaison de touches. Appuyez sur Échap pour annuler.",
|
||||
"resetToDefaults": "Réinitialiser les valeurs par défaut",
|
||||
"alreadyUsedBy": "Déjà utilisé par {{action}}",
|
||||
"swap": "Échanger",
|
||||
"reservedShortcut": "Ce raccourci est réservé pour « {{label}} » et ne peut pas être réassigné.",
|
||||
"savedToast": "Raccourcis clavier enregistrés",
|
||||
"resetToast": "Réinitialisé aux raccourcis par défaut — cliquez sur Enregistrer pour appliquer",
|
||||
"actions": {
|
||||
"addZoom": "Ajouter un zoom",
|
||||
"addTrim": "Ajouter une coupe",
|
||||
"addSpeed": "Ajouter une vitesse",
|
||||
"addAnnotation": "Ajouter une annotation",
|
||||
"addBlur": "Ajouter un flou",
|
||||
"addKeyframe": "Ajouter une image-clé",
|
||||
"deleteSelected": "Supprimer la sélection",
|
||||
"playPause": "Lecture / Pause"
|
||||
},
|
||||
"fixedActions": {
|
||||
"undo": "Annuler",
|
||||
"redo": "Rétablir",
|
||||
"cycleAnnotationsForward": "Parcourir les annotations en avant",
|
||||
"cycleAnnotationsBackward": "Parcourir les annotations en arrière",
|
||||
"deleteSelectedAlt": "Supprimer la sélection (alt)",
|
||||
"panTimeline": "Panoramique de la timeline",
|
||||
"zoomTimeline": "Zoom de la timeline",
|
||||
"frameBack": "Image précédente",
|
||||
"frameForward": "Image suivante"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"buttons": {
|
||||
"addZoom": "Ajouter un zoom (Z)",
|
||||
"suggestZooms": "Suggérer des zooms depuis le curseur",
|
||||
"addTrim": "Ajouter une coupe (T)",
|
||||
"addAnnotation": "Ajouter une annotation (A)",
|
||||
"addSpeed": "Ajouter une vitesse (S)",
|
||||
"addBlur": "Ajouter un flou (B)"
|
||||
},
|
||||
"hints": {
|
||||
"pressZoom": "Appuyez sur Z pour ajouter un zoom",
|
||||
"pressTrim": "Appuyez sur T pour ajouter une coupe",
|
||||
"pressAnnotation": "Appuyez sur A pour ajouter une annotation",
|
||||
"pressSpeed": "Appuyez sur S pour ajouter une vitesse",
|
||||
"pressBlur": "Appuyez sur B pour ajouter une zone de flou"
|
||||
},
|
||||
"labels": {
|
||||
"pan": "Panoramique",
|
||||
"zoom": "Zoom",
|
||||
"zoomItem": "Zoom {{index}}",
|
||||
"trimItem": "Coupe {{index}}",
|
||||
"speedItem": "Vitesse {{index}}",
|
||||
"annotationItem": "Annotation",
|
||||
"imageItem": "Image",
|
||||
"emptyText": "Texte vide",
|
||||
"blurItem": "Flou {{index}}"
|
||||
},
|
||||
"emptyState": {
|
||||
"noVideo": "Aucune vidéo chargée",
|
||||
"dragAndDrop": "Glissez-déposez une vidéo pour commencer à éditer"
|
||||
},
|
||||
"errors": {
|
||||
"cannotPlaceZoom": "Impossible de placer le zoom ici",
|
||||
"zoomExistsAtLocation": "Un zoom existe déjà à cet emplacement ou l\u0027espace disponible est insuffisant.",
|
||||
"zoomSuggestionUnavailable": "Gestionnaire de suggestions de zoom non disponible",
|
||||
"noCursorTelemetry": "Aucune télémétrie de curseur disponible",
|
||||
"noCursorTelemetryDescription": "Enregistrez d\u0027abord un screencast pour générer des suggestions basées sur le curseur.",
|
||||
"noUsableTelemetry": "Aucune télémétrie de curseur utilisable",
|
||||
"noUsableTelemetryDescription": "L\u0027enregistrement ne contient pas suffisamment de données de mouvement du curseur.",
|
||||
"noDwellMoments": "Aucun moment de pause du curseur trouvé",
|
||||
"noDwellMomentsDescription": "Essayez un enregistrement avec des pauses plus lentes du curseur sur les actions importantes.",
|
||||
"noAutoZoomSlots": "Aucun emplacement de zoom automatique disponible",
|
||||
"noAutoZoomSlotsDescription": "Les points de pause détectés chevauchent des régions de zoom existantes.",
|
||||
"cannotPlaceTrim": "Impossible de placer la coupe ici",
|
||||
"trimExistsAtLocation": "Une coupe existe déjà à cet emplacement ou l\u0027espace disponible est insuffisant.",
|
||||
"cannotPlaceSpeed": "Impossible de placer la vitesse ici",
|
||||
"speedExistsAtLocation": "Une région de vitesse existe déjà à cet emplacement ou l\u0027espace disponible est insuffisant."
|
||||
},
|
||||
"success": {
|
||||
"addedZoomSuggestions": "{{count}} suggestion de zoom basée sur le curseur ajoutée",
|
||||
"addedZoomSuggestionsPlural": "{{count}} suggestions de zoom basées sur le curseur ajoutées"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"actions": {
|
||||
"cancel": "İptal",
|
||||
"save": "Kaydet",
|
||||
"delete": "Sil",
|
||||
"close": "Kapat",
|
||||
"share": "Paylaş",
|
||||
"done": "Tamam",
|
||||
"open": "Aç",
|
||||
"upload": "Yükle",
|
||||
"export": "Dışa Aktar",
|
||||
"file": "Dosya",
|
||||
"edit": "Düzenle",
|
||||
"view": "Görünüm",
|
||||
"window": "Pencere",
|
||||
"quit": "Çıkış",
|
||||
"stopRecording": "Kaydı Durdur"
|
||||
},
|
||||
"playback": {
|
||||
"play": "Oynat",
|
||||
"pause": "Duraklat",
|
||||
"fullscreen": "Tam Ekran",
|
||||
"exitFullscreen": "Tam Ekrandan Çık"
|
||||
},
|
||||
"locale": {
|
||||
"name": "Türkçe",
|
||||
"short": "TR"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"export": {
|
||||
"complete": "Dışa Aktarım Tamamlandı",
|
||||
"yourFormatReady": "{{format}} dosyanız hazır",
|
||||
"showInFolder": "Klasörde Göster",
|
||||
"finalizingVideo": "Video dışa aktarımı sonlandırılıyor...",
|
||||
"compilingGifProgress": "GIF derleniyor... %{{progress}}",
|
||||
"compilingGifWait": "GIF derleniyor... Bu biraz zaman alabilir",
|
||||
"takeMoment": "Bu biraz zaman alabilir...",
|
||||
"failed": "Dışa Aktarım Başarısız",
|
||||
"tryAgain": "Lütfen tekrar deneyin",
|
||||
"finalizingVideoTitle": "Video Sonlandırılıyor",
|
||||
"compilingGif": "GIF Derleniyor",
|
||||
"exportingFormat": "{{format}} Dışa Aktarılıyor",
|
||||
"compiling": "Derleniyor",
|
||||
"renderingFrames": "Kareler İşleniyor",
|
||||
"processing": "İşleniyor...",
|
||||
"finalizing": "Sonlandırılıyor...",
|
||||
"compilingStatus": "Derleniyor...",
|
||||
"status": "Durum",
|
||||
"format": "Biçim",
|
||||
"frames": "Kareler",
|
||||
"cancelExport": "Dışa Aktarımı İptal Et",
|
||||
"savedSuccessfully": "{{format}} başarıyla kaydedildi!"
|
||||
},
|
||||
"tutorial": {
|
||||
"triggerLabel": "Kırpma nasıl çalışır",
|
||||
"title": "Kırpma Nasıl Çalışır",
|
||||
"description": "Videonuzun istenmeyen bölümlerini nasıl keseceğinizi anlayın.",
|
||||
"explanation": "Kırpma aracı, kaldırmak istediğiniz bölümleri tanımlayarak çalışır.",
|
||||
"explanationRemove": "kaldırmak",
|
||||
"explanationCovered": "kaplanan",
|
||||
"explanationEnd": "kırmızı kırpma bölgesi ile işaretlenen kısımlar dışa aktarımda kesilecektir.",
|
||||
"visualExample": "Görsel Örnek",
|
||||
"removed": "KALDIRILDI",
|
||||
"kept": "Korundu",
|
||||
"part1": "Bölüm 1",
|
||||
"part2": "Bölüm 2",
|
||||
"part3": "Bölüm 3",
|
||||
"finalVideo": "Son Video",
|
||||
"step1Title": "1. Kırpma Ekle",
|
||||
"step1Description": "Kaldırılacak bölümü işaretlemek için T tuşuna basın veya makas simgesine tıklayın.",
|
||||
"step2Title": "2. Ayarla",
|
||||
"step2Description": "Kesmek istediğiniz kısmı tam olarak kaplamak için kırmızı bölgenin kenarlarını sürükleyin."
|
||||
},
|
||||
"unsavedChanges": {
|
||||
"title": "Kaydedilmemiş Değişiklikler",
|
||||
"message": "Kaydedilmemiş değişiklikleriniz var.",
|
||||
"detail": "Kapatmadan önce projenizi kaydetmek ister misiniz?",
|
||||
"saveAndClose": "Kaydet ve Kapat",
|
||||
"discardAndClose": "Kaydetmeden Kapat",
|
||||
"loadProject": "Proje Yükle…",
|
||||
"saveProject": "Proje Kaydet…",
|
||||
"saveProjectAs": "Farklı Kaydet…"
|
||||
},
|
||||
"fileDialogs": {
|
||||
"saveGif": "Dışa Aktarılan GIF'i Kaydet",
|
||||
"saveVideo": "Dışa Aktarılan Videoyu Kaydet",
|
||||
"selectVideo": "Video Dosyası Seç",
|
||||
"saveProject": "OpenScreen Projesini Kaydet",
|
||||
"openProject": "OpenScreen Projesini Aç",
|
||||
"gifImage": "GIF Görüntüsü",
|
||||
"mp4Video": "MP4 Video",
|
||||
"videoFiles": "Video Dosyaları",
|
||||
"openscreenProject": "OpenScreen Projesi",
|
||||
"allFiles": "Tüm Dosyalar"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"errors": {
|
||||
"noVideoLoaded": "Video yüklenmedi",
|
||||
"videoNotReady": "Video hazır değil",
|
||||
"unableToDetermineSourcePath": "Kaynak video yolu belirlenemiyor",
|
||||
"failedToSaveGif": "GIF kaydedilemedi",
|
||||
"gifExportFailed": "GIF dışa aktarımı başarısız oldu",
|
||||
"failedToSaveVideo": "Video kaydedilemedi",
|
||||
"exportFailed": "Dışa aktarım başarısız oldu",
|
||||
"exportFailedWithError": "Dışa aktarım başarısız: {{error}}",
|
||||
"failedToSaveExport": "Dışa aktarım kaydedilemedi",
|
||||
"failedToSaveExportedVideo": "Dışa aktarılan video kaydedilemedi",
|
||||
"failedToRevealInFolder": "Klasörde gösterme hatası: {{error}}"
|
||||
},
|
||||
"export": {
|
||||
"canceled": "Dışa aktarım iptal edildi",
|
||||
"exportedSuccessfully": "{{format}} başarıyla dışa aktarıldı"
|
||||
},
|
||||
"project": {
|
||||
"saveCanceled": "Proje kaydetme iptal edildi",
|
||||
"failedToSave": "Proje kaydedilemedi",
|
||||
"savedTo": "Proje şuraya kaydedildi: {{path}}",
|
||||
"failedToLoad": "Proje yüklenemedi",
|
||||
"invalidFormat": "Geçersiz proje dosyası biçimi",
|
||||
"loadedFrom": "Proje şuradan yüklendi: {{path}}"
|
||||
},
|
||||
"recording": {
|
||||
"failedCameraAccess": "Kamera erişimi istenemedi.",
|
||||
"cameraBlocked": "Kamera erişimi engellendi. Kamerayı kullanmak için sistem ayarlarından izin verin.",
|
||||
"systemAudioUnavailable": "Sistem sesi kullanılamıyor. Sistem sesi olmadan kaydediliyor.",
|
||||
"microphoneDenied": "Mikrofon erişimi reddedildi. Kayıt ses olmadan devam edecek.",
|
||||
"cameraDenied": "Kamera erişimi reddedildi. Kayıt kamera olmadan devam edecek.",
|
||||
"permissionDenied": "Kayıt izni reddedildi. Lütfen ekran kaydına izin verin."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"tooltips": {
|
||||
"hideHUD": "Kontrol panelini gizle",
|
||||
"closeApp": "Uygulamayı kapat",
|
||||
"restartRecording": "Kaydı yeniden başlat",
|
||||
"cancelRecording": "Kaydı iptal et",
|
||||
"pauseRecording": "Kaydı duraklat",
|
||||
"resumeRecording": "Kayda devam et",
|
||||
"openVideoFile": "Video dosyası aç",
|
||||
"openProject": "Proje aç"
|
||||
},
|
||||
"audio": {
|
||||
"enableSystemAudio": "Sistem sesini etkinleştir",
|
||||
"disableSystemAudio": "Sistem sesini devre dışı bırak",
|
||||
"enableMicrophone": "Mikrofonu etkinleştir",
|
||||
"disableMicrophone": "Mikrofonu devre dışı bırak",
|
||||
"defaultMicrophone": "Varsayılan Mikrofon",
|
||||
"enableNoiseReduction": "Gürültü azaltmayı etkinleştir (yapay zeka destekli)",
|
||||
"disableNoiseReduction": "Gürültü azaltmayı devre dışı bırak",
|
||||
"noiseReduction": "Gürültü azaltma",
|
||||
"clickToCycle": "Seviye değiştirmek için tıklayın",
|
||||
"nrLevel": {
|
||||
"light": "Hafif",
|
||||
"moderate": "Orta",
|
||||
"aggressive": "Güçlü"
|
||||
},
|
||||
"noiseReductionPrompt": "Daha net ses için yapay zeka destekli gürültü azaltmayı etkinleştirmek ister misiniz?",
|
||||
"enableNoiseReductionShort": "Etkinleştir"
|
||||
},
|
||||
"webcam": {
|
||||
"enableWebcam": "Kamerayı etkinleştir",
|
||||
"disableWebcam": "Kamerayı devre dışı bırak",
|
||||
"defaultCamera": "Varsayılan Kamera",
|
||||
"searching": "Aranıyor...",
|
||||
"noneFound": "Kamera bulunamadı",
|
||||
"unavailable": "Kamera kullanılamıyor"
|
||||
},
|
||||
"sourceSelector": {
|
||||
"loading": "Kaynaklar yükleniyor...",
|
||||
"screens": "Ekranlar ({{count}})",
|
||||
"windows": "Pencereler ({{count}})",
|
||||
"defaultSourceName": "Ekran"
|
||||
},
|
||||
"recording": {
|
||||
"selectSource": "Lütfen kayıt için bir kaynak seçin"
|
||||
},
|
||||
"language": "Dil"
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
{
|
||||
"zoom": {
|
||||
"level": "Yakınlaştırma Seviyesi",
|
||||
"selectRegion": "Ayarlamak için bir yakınlaştırma bölgesi seçin",
|
||||
"deleteZoom": "Yakınlaştırmayı Sil",
|
||||
"focusMode": {
|
||||
"title": "Odak Modu",
|
||||
"manual": "Manuel",
|
||||
"auto": "Otomatik",
|
||||
"autoDescription": "Kamera kaydedilen imleç konumunu takip eder"
|
||||
}
|
||||
},
|
||||
"speed": {
|
||||
"playbackSpeed": "Oynatma Hızı",
|
||||
"selectRegion": "Ayarlamak için bir hız bölgesi seçin",
|
||||
"deleteRegion": "Hız Bölgesini Sil"
|
||||
},
|
||||
"trim": {
|
||||
"deleteRegion": "Kırpma Bölgesini Sil"
|
||||
},
|
||||
"layout": {
|
||||
"title": "Düzen",
|
||||
"preset": "Ön Ayar",
|
||||
"selectPreset": "Ön ayar seçin",
|
||||
"pictureInPicture": "Resim İçinde Resim",
|
||||
"verticalStack": "Dikey Yığın",
|
||||
"webcamShape": "Kamera Şekli"
|
||||
},
|
||||
"effects": {
|
||||
"title": "Video Efektleri",
|
||||
"blurBg": "Arka Planı Bulanıklaştır",
|
||||
"motionBlur": "Hareket Bulanıklığı",
|
||||
"off": "kapalı",
|
||||
"shadow": "Gölge",
|
||||
"roundness": "Yuvarlaklık",
|
||||
"padding": "Dolgu"
|
||||
},
|
||||
"background": {
|
||||
"title": "Arka Plan",
|
||||
"image": "Görüntü",
|
||||
"color": "Renk",
|
||||
"gradient": "Gradyan",
|
||||
"uploadCustom": "Özel Yükle",
|
||||
"gradientLabel": "Gradyan {{index}}"
|
||||
},
|
||||
"crop": {
|
||||
"title": "Kırpma",
|
||||
"cropVideo": "Videoyu Kırp",
|
||||
"dragInstruction": "Kırpma alanını ayarlamak için her kenarı sürükleyin",
|
||||
"ratio": "Oran",
|
||||
"free": "Serbest",
|
||||
"done": "Tamam",
|
||||
"lockAspectRatio": "En boy oranını kilitle",
|
||||
"unlockAspectRatio": "En boy oranının kilidini aç"
|
||||
},
|
||||
"exportFormat": {
|
||||
"mp4": "MP4",
|
||||
"gif": "GIF",
|
||||
"mp4Video": "MP4 Video",
|
||||
"mp4Description": "Yüksek kaliteli video dosyası",
|
||||
"gifAnimation": "GIF Animasyon",
|
||||
"gifDescription": "Paylaşım için hareketli görüntü"
|
||||
},
|
||||
"exportQuality": {
|
||||
"title": "Dışa Aktarım Kalitesi",
|
||||
"low": "Düşük",
|
||||
"medium": "Orta",
|
||||
"high": "Yüksek"
|
||||
},
|
||||
"gifSettings": {
|
||||
"frameRate": "GIF Kare Hızı",
|
||||
"size": "GIF Boyutu",
|
||||
"loop": "GIF Döngüsü"
|
||||
},
|
||||
"project": {
|
||||
"save": "Projeyi Kaydet",
|
||||
"load": "Proje Yükle"
|
||||
},
|
||||
"export": {
|
||||
"videoButton": "Videoyu Dışa Aktar",
|
||||
"gifButton": "GIF Olarak Dışa Aktar",
|
||||
"chooseSaveLocation": "Kayıt Konumu Seç"
|
||||
},
|
||||
"links": {
|
||||
"reportBug": "Hata Bildir",
|
||||
"starOnGithub": "GitHub'da Yıldızla"
|
||||
},
|
||||
"imageUpload": {
|
||||
"invalidFileType": "Geçersiz dosya türü",
|
||||
"jpgOnly": "Lütfen bir JPG veya JPEG görüntü dosyası yükleyin.",
|
||||
"uploadSuccess": "Özel görüntü başarıyla yüklendi!",
|
||||
"failedToUpload": "Görüntü yüklenemedi",
|
||||
"errorReading": "Dosya okunurken bir hata oluştu."
|
||||
},
|
||||
"annotation": {
|
||||
"title": "Açıklama Ayarları",
|
||||
"active": "Aktif",
|
||||
"typeText": "Metin",
|
||||
"typeImage": "Görüntü",
|
||||
"typeArrow": "Ok",
|
||||
"typeBlur": "Bulanık",
|
||||
"textContent": "Metin İçeriği",
|
||||
"textPlaceholder": "Metninizi girin...",
|
||||
"fontStyle": "Yazı Tipi Stili",
|
||||
"selectStyle": "Stil seçin",
|
||||
"size": "Boyut",
|
||||
"customFonts": "Özel Yazı Tipleri",
|
||||
"textColor": "Metin Rengi",
|
||||
"background": "Arka Plan",
|
||||
"none": "Yok",
|
||||
"color": "Renk",
|
||||
"clearBackground": "Arka Planı Temizle",
|
||||
"uploadImage": "Görüntü Yükle",
|
||||
"supportedFormats": "Desteklenen biçimler: JPG, PNG, GIF, WebP",
|
||||
"arrowDirection": "Ok Yönü",
|
||||
"strokeWidth": "Çizgi Kalınlığı: {{width}}px",
|
||||
"arrowColor": "Ok Rengi",
|
||||
"blurShape": "Bulanık Şekli",
|
||||
"blurIntensity": "Bulanıklık Yoğunluğu",
|
||||
"blurShapeRectangle": "Dikdörtgen",
|
||||
"blurShapeOval": "Oval",
|
||||
"blurShapeFreehand": "Serbest",
|
||||
"deleteAnnotation": "Açıklamayı Sil",
|
||||
"shortcutsAndTips": "Kısayollar ve İpuçları",
|
||||
"tipMovePlayhead": "Oynatma imlecini çakışan açıklama bölümüne taşıyın ve bir öğe seçin.",
|
||||
"tipTabCycle": "Çakışan öğeler arasında geçiş yapmak için Tab tuşunu kullanın.",
|
||||
"tipShiftTabCycle": "Geriye doğru geçiş yapmak için Shift+Tab kullanın.",
|
||||
"invalidImageType": "Geçersiz dosya türü",
|
||||
"imageFormatsOnly": "Lütfen bir JPG, PNG, GIF veya WebP görüntü dosyası yükleyin.",
|
||||
"imageUploadSuccess": "Görüntü başarıyla yüklendi!",
|
||||
"failedImageUpload": "Görüntü yüklenemedi"
|
||||
},
|
||||
"fontStyles": {
|
||||
"classic": "Klasik",
|
||||
"editor": "Editör",
|
||||
"strong": "Kalın",
|
||||
"typewriter": "Daktilo",
|
||||
"deco": "Dekoratif",
|
||||
"simple": "Sade",
|
||||
"modern": "Modern",
|
||||
"clean": "Temiz"
|
||||
},
|
||||
"customFont": {
|
||||
"dialogTitle": "Google Yazı Tipi Ekle",
|
||||
"urlLabel": "Google Fonts İçe Aktarım URL'si",
|
||||
"urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
|
||||
"urlHelp": "Google Fonts'tan alabilirsiniz: Bir yazı tipi seçin → \"Get font\"a tıklayın → @import URL'sini kopyalayın",
|
||||
"nameLabel": "Görünen Ad",
|
||||
"namePlaceholder": "Özel Yazı Tipim",
|
||||
"nameHelp": "Yazı tipinin seçicide nasıl görüneceğini belirler",
|
||||
"addButton": "Yazı Tipi Ekle",
|
||||
"addingButton": "Ekleniyor...",
|
||||
"errorEmptyUrl": "Lütfen bir Google Fonts içe aktarım URL'si girin",
|
||||
"errorInvalidUrl": "Lütfen geçerli bir Google Fonts URL'si girin",
|
||||
"errorEmptyName": "Lütfen bir yazı tipi adı girin",
|
||||
"errorExtractFailed": "URL'den yazı tipi ailesi çıkarılamadı",
|
||||
"successMessage": "\"{{fontName}}\" yazı tipi başarıyla eklendi",
|
||||
"failedToAdd": "Yazı tipi eklenemedi",
|
||||
"errorTimeout": "Yazı tipinin yüklenmesi çok uzun sürdü. Lütfen URL'yi kontrol edip tekrar deneyin.",
|
||||
"errorLoadFailed": "Yazı tipi yüklenemedi. Lütfen Google Fonts URL'sinin doğruluğunu kontrol edin."
|
||||
},
|
||||
"language": {
|
||||
"title": "Dil"
|
||||
},
|
||||
"audio": {
|
||||
"title": "Ses",
|
||||
"noiseReduction": "Gürültü Azaltma",
|
||||
"level": "Seviye",
|
||||
"nrLevel": {
|
||||
"light": "Hafif",
|
||||
"moderate": "Orta",
|
||||
"aggressive": "Güçlü"
|
||||
},
|
||||
"nrDescription": "Yapay zeka destekli gürültü azaltma arka plan gürültüsünü temizler. Daha yüksek seviyeler daha agresiftir ancak ses kalitesini etkileyebilir."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"title": "Klavye Kısayolları",
|
||||
"customize": "Özelleştir",
|
||||
"configurable": "Yapılandırılabilir",
|
||||
"fixed": "Sabit",
|
||||
"pressKey": "Bir tuşa basın…",
|
||||
"clickToChange": "Değiştirmek için tıklayın",
|
||||
"pressEscToCancel": "İptal etmek için Esc tuşuna basın",
|
||||
"helpText": "Bir kısayola tıklayın, ardından yeni tuş kombinasyonuna basın. İptal etmek için Esc tuşuna basın.",
|
||||
"resetToDefaults": "Varsayılanlara sıfırla",
|
||||
"alreadyUsedBy": "\"{{action}}\" tarafından zaten kullanılıyor",
|
||||
"swap": "Değiştir",
|
||||
"reservedShortcut": "Bu kısayol \"{{label}}\" için ayrılmıştır ve yeniden atanamaz.",
|
||||
"savedToast": "Klavye kısayolları kaydedildi",
|
||||
"resetToast": "Varsayılan kısayollara sıfırlandı — uygulamak için Kaydet'e tıklayın",
|
||||
"actions": {
|
||||
"addZoom": "Yakınlaştırma Ekle",
|
||||
"addTrim": "Kırpma Ekle",
|
||||
"addSpeed": "Hız Ekle",
|
||||
"addAnnotation": "Açıklama Ekle",
|
||||
"addBlur": "Bulanik Ekle",
|
||||
"addKeyframe": "Anahtar Kare Ekle",
|
||||
"deleteSelected": "Seçileni Sil",
|
||||
"playPause": "Oynat / Duraklat"
|
||||
},
|
||||
"fixedActions": {
|
||||
"undo": "Geri Al",
|
||||
"redo": "Yinele",
|
||||
"cycleAnnotationsForward": "Açıklamalar Arasında İleri Geç",
|
||||
"cycleAnnotationsBackward": "Açıklamalar Arasında Geri Geç",
|
||||
"deleteSelectedAlt": "Seçileni Sil (alternatif)",
|
||||
"panTimeline": "Zaman Çizelgesini Kaydır",
|
||||
"zoomTimeline": "Zaman Çizelgesini Yakınlaştır",
|
||||
"frameBack": "Önceki Kare",
|
||||
"frameForward": "Sonraki Kare"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"buttons": {
|
||||
"addZoom": "Yakınlaştırma Ekle (Z)",
|
||||
"suggestZooms": "İmleçten Yakınlaştırma Öner",
|
||||
"addTrim": "Kırpma Ekle (T)",
|
||||
"addAnnotation": "Açıklama Ekle (A)",
|
||||
"addSpeed": "Hız Ekle (S)",
|
||||
"addBlur": "Bulanık ekle (B)"
|
||||
},
|
||||
"hints": {
|
||||
"pressZoom": "Yakınlaştırma eklemek için Z tuşuna basın",
|
||||
"pressTrim": "Kırpma eklemek için T tuşuna basın",
|
||||
"pressAnnotation": "Açıklama eklemek için A tuşuna basın",
|
||||
"pressSpeed": "Hız eklemek için S tuşuna basın",
|
||||
"pressBlur": "Bulanık bölge eklemek için B tuşuna basın"
|
||||
},
|
||||
"labels": {
|
||||
"pan": "Kaydır",
|
||||
"zoom": "Yakınlaştır",
|
||||
"zoomItem": "Yakınlaştırma {{index}}",
|
||||
"trimItem": "Kırpma {{index}}",
|
||||
"speedItem": "Hız {{index}}",
|
||||
"annotationItem": "Açıklama",
|
||||
"imageItem": "Görüntü",
|
||||
"emptyText": "Boş metin",
|
||||
"blurItem": "Bulanık {{index}}"
|
||||
},
|
||||
"emptyState": {
|
||||
"noVideo": "Video Yüklenmedi",
|
||||
"dragAndDrop": "Düzenlemeye başlamak için bir video sürükleyip bırakın"
|
||||
},
|
||||
"errors": {
|
||||
"cannotPlaceZoom": "Buraya yakınlaştırma yerleştirilemiyor",
|
||||
"zoomExistsAtLocation": "Bu konumda zaten bir yakınlaştırma var veya yeterli alan yok.",
|
||||
"zoomSuggestionUnavailable": "Yakınlaştırma öneri işleyicisi kullanılamıyor",
|
||||
"noCursorTelemetry": "İmleç telemetrisi mevcut değil",
|
||||
"noCursorTelemetryDescription": "İmleç tabanlı öneriler oluşturmak için önce bir ekran kaydı yapın.",
|
||||
"noUsableTelemetry": "Kullanılabilir imleç telemetrisi yok",
|
||||
"noUsableTelemetryDescription": "Kayıt yeterli imleç hareketi verisi içermiyor.",
|
||||
"noDwellMoments": "Belirgin imleç bekleme anları bulunamadı",
|
||||
"noDwellMomentsDescription": "Önemli işlemlerde daha yavaş imleç duraklamaları olan bir kayıt deneyin.",
|
||||
"noAutoZoomSlots": "Otomatik yakınlaştırma alanı yok",
|
||||
"noAutoZoomSlotsDescription": "Algılanan bekleme noktaları mevcut yakınlaştırma bölgeleriyle çakışıyor.",
|
||||
"cannotPlaceTrim": "Buraya kırpma yerleştirilemiyor",
|
||||
"trimExistsAtLocation": "Bu konumda zaten bir kırpma var veya yeterli alan yok.",
|
||||
"cannotPlaceSpeed": "Buraya hız yerleştirilemiyor",
|
||||
"speedExistsAtLocation": "Bu konumda zaten bir hız bölgesi var veya yeterli alan yok."
|
||||
},
|
||||
"success": {
|
||||
"addedZoomSuggestions": "{{count}} imleç tabanlı yakınlaştırma önerisi eklendi",
|
||||
"addedZoomSuggestionsPlural": "{{count}} imleç tabanlı yakınlaştırma önerisi eklendi"
|
||||
}
|
||||
}
|
||||
@@ -27,10 +27,11 @@
|
||||
"triggerLabel": "剪辑功能说明",
|
||||
"title": "剪辑功能说明",
|
||||
"description": "了解如何剪掉视频中不需要的部分。",
|
||||
"explanation": "剪辑工具通过定义您要",
|
||||
"explanationRemove": "移除",
|
||||
"explanationCovered": "覆盖",
|
||||
"explanationEnd": "的片段来工作。被红色剪辑区域覆盖的部分将在导出时被剪掉。",
|
||||
"explanationBefore": "剪辑工具通过定义您要",
|
||||
"remove": "移除",
|
||||
"explanationMiddle": "——任何被",
|
||||
"covered": "覆盖",
|
||||
"explanationAfter": "的红色剪辑区域部分将在导出时被剪掉。",
|
||||
"visualExample": "示例演示",
|
||||
"removed": "已移除",
|
||||
"kept": "保留",
|
||||
@@ -39,7 +40,8 @@
|
||||
"part3": "第 3 部分",
|
||||
"finalVideo": "最终视频",
|
||||
"step1Title": "1. 添加剪辑",
|
||||
"step1Description": "按 T 或点击剪刀图标来标记要移除的片段。",
|
||||
"step1DescriptionBefore": "按",
|
||||
"step1DescriptionAfter": "键或点击剪刀图标来标记要移除的片段。",
|
||||
"step2Title": "2. 调整",
|
||||
"step2Description": "拖动红色区域的边缘,精确覆盖您要剪掉的部分。"
|
||||
},
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
"hideHUD": "隐藏控制面板",
|
||||
"closeApp": "关闭应用",
|
||||
"restartRecording": "重新开始录制",
|
||||
"cancelRecording": "取消录制",
|
||||
"pauseRecording": "暂停录制",
|
||||
"resumeRecording": "继续录制",
|
||||
"openVideoFile": "打开视频文件",
|
||||
"openProject": "打开项目"
|
||||
},
|
||||
@@ -10,11 +13,16 @@
|
||||
"enableSystemAudio": "启用系统音频",
|
||||
"disableSystemAudio": "禁用系统音频",
|
||||
"enableMicrophone": "启用麦克风",
|
||||
"disableMicrophone": "禁用麦克风"
|
||||
"disableMicrophone": "禁用麦克风",
|
||||
"defaultMicrophone": "默认麦克风"
|
||||
},
|
||||
"webcam": {
|
||||
"enableWebcam": "启用摄像头",
|
||||
"disableWebcam": "禁用摄像头"
|
||||
"disableWebcam": "禁用摄像头",
|
||||
"defaultCamera": "默认摄像头",
|
||||
"searching": "正在搜索...",
|
||||
"noneFound": "未找到摄像头",
|
||||
"unavailable": "摄像头不可用"
|
||||
},
|
||||
"sourceSelector": {
|
||||
"loading": "正在加载源...",
|
||||
|
||||
@@ -2,12 +2,20 @@
|
||||
"zoom": {
|
||||
"level": "缩放级别",
|
||||
"selectRegion": "选择要调整的缩放区域",
|
||||
"deleteZoom": "删除缩放"
|
||||
"deleteZoom": "删除缩放",
|
||||
"focusMode": {
|
||||
"title": "对焦模式",
|
||||
"manual": "手动",
|
||||
"auto": "自动",
|
||||
"autoDescription": "摄像头跟随录制时的光标位置"
|
||||
}
|
||||
},
|
||||
"speed": {
|
||||
"playbackSpeed": "播放速度",
|
||||
"selectRegion": "选择要调整的速度区域",
|
||||
"deleteRegion": "删除速度区域"
|
||||
"deleteRegion": "删除速度区域",
|
||||
"customPlaybackSpeed": "自定义播放速度",
|
||||
"maxSpeedError": "速度不能超过 16×"
|
||||
},
|
||||
"trim": {
|
||||
"deleteRegion": "删除剪辑区域"
|
||||
@@ -17,7 +25,9 @@
|
||||
"preset": "预设",
|
||||
"selectPreset": "选择预设",
|
||||
"pictureInPicture": "画中画",
|
||||
"verticalStack": "垂直堆叠"
|
||||
"verticalStack": "垂直堆叠",
|
||||
"webcamShape": "摄像头形状",
|
||||
"webcamSize": "摄像头大小"
|
||||
},
|
||||
"effects": {
|
||||
"title": "视频效果",
|
||||
@@ -91,6 +101,7 @@
|
||||
"typeText": "文本",
|
||||
"typeImage": "图片",
|
||||
"typeArrow": "箭头",
|
||||
"typeBlur": "模糊",
|
||||
"textContent": "文本内容",
|
||||
"textPlaceholder": "输入您的文本...",
|
||||
"fontStyle": "字体样式",
|
||||
@@ -107,6 +118,11 @@
|
||||
"arrowDirection": "箭头方向",
|
||||
"strokeWidth": "描边宽度:{{width}}px",
|
||||
"arrowColor": "箭头颜色",
|
||||
"blurShape": "模糊形状",
|
||||
"blurIntensity": "模糊强度",
|
||||
"blurShapeRectangle": "矩形",
|
||||
"blurShapeOval": "椭圆",
|
||||
"blurShapeFreehand": "自由手绘",
|
||||
"deleteAnnotation": "删除标注",
|
||||
"shortcutsAndTips": "快捷键与提示",
|
||||
"tipMovePlayhead": "将播放头移动到重叠的标注区域并选择一个项目。",
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"addTrim": "添加剪辑",
|
||||
"addSpeed": "添加速度",
|
||||
"addAnnotation": "添加标注",
|
||||
"addBlur": "添加模糊",
|
||||
"addKeyframe": "添加关键帧",
|
||||
"deleteSelected": "删除所选",
|
||||
"playPause": "播放 / 暂停"
|
||||
@@ -29,6 +30,8 @@
|
||||
"cycleAnnotationsBackward": "向后切换标注",
|
||||
"deleteSelectedAlt": "删除所选(替代)",
|
||||
"panTimeline": "平移时间轴",
|
||||
"zoomTimeline": "缩放时间轴"
|
||||
"zoomTimeline": "缩放时间轴",
|
||||
"frameBack": "上一帧",
|
||||
"frameForward": "下一帧"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,13 +4,15 @@
|
||||
"suggestZooms": "根据光标建议缩放",
|
||||
"addTrim": "添加剪辑 (T)",
|
||||
"addAnnotation": "添加标注 (A)",
|
||||
"addSpeed": "添加速度 (S)"
|
||||
"addSpeed": "添加速度 (S)",
|
||||
"addBlur": "添加模糊 (B)"
|
||||
},
|
||||
"hints": {
|
||||
"pressZoom": "按 Z 添加缩放",
|
||||
"pressTrim": "按 T 添加剪辑",
|
||||
"pressAnnotation": "按 A 添加标注",
|
||||
"pressSpeed": "按 S 添加速度"
|
||||
"pressSpeed": "按 S 添加速度",
|
||||
"pressBlur": "按 B 添加模糊区域"
|
||||
},
|
||||
"labels": {
|
||||
"pan": "平移",
|
||||
@@ -20,7 +22,8 @@
|
||||
"speedItem": "速度 {{index}}",
|
||||
"annotationItem": "标注",
|
||||
"imageItem": "图片",
|
||||
"emptyText": "空文本"
|
||||
"emptyText": "空文本",
|
||||
"blurItem": "模糊 {{index}}"
|
||||
},
|
||||
"emptyState": {
|
||||
"noVideo": "未加载视频",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
+157
-20
@@ -24,15 +24,110 @@ describe("computeCompositeLayout", () => {
|
||||
webcamSize: { width: 1920, height: 1080 },
|
||||
});
|
||||
|
||||
const refDim = Math.sqrt(1280 * 720);
|
||||
const defaultFraction = 25 / 100; // DEFAULT_WEBCAM_SIZE_PRESET = 25
|
||||
expect(layout).not.toBeNull();
|
||||
expect(layout!.webcamRect).not.toBeNull();
|
||||
expect(layout!.webcamRect!.width).toBeLessThanOrEqual(Math.round(1280 * 0.18) + 1);
|
||||
expect(layout!.webcamRect!.height).toBeLessThanOrEqual(Math.round(720 * 0.18) + 1);
|
||||
expect(layout!.webcamRect!.width).toBeLessThanOrEqual(Math.round(refDim * defaultFraction) + 1);
|
||||
expect(layout!.webcamRect!.height).toBeLessThanOrEqual(
|
||||
Math.round(refDim * defaultFraction) + 1,
|
||||
);
|
||||
expect(
|
||||
Math.abs(layout!.webcamRect!.width * 1080 - layout!.webcamRect!.height * 1920),
|
||||
).toBeLessThanOrEqual(1920);
|
||||
});
|
||||
|
||||
it("produces consistent webcam size across landscape and portrait aspect ratios", () => {
|
||||
const webcamSize = { width: 1280, height: 720 };
|
||||
const landscape = computeCompositeLayout({
|
||||
canvasSize: { width: 1920, height: 1080 },
|
||||
screenSize: { width: 1920, height: 1080 },
|
||||
webcamSize,
|
||||
webcamSizePreset: 50,
|
||||
});
|
||||
const portrait = computeCompositeLayout({
|
||||
canvasSize: { width: 1080, height: 1920 },
|
||||
screenSize: { width: 1080, height: 1920 },
|
||||
webcamSize,
|
||||
webcamSizePreset: 50,
|
||||
});
|
||||
|
||||
expect(landscape).not.toBeNull();
|
||||
expect(portrait).not.toBeNull();
|
||||
// Same total pixel count — webcam area should be comparable
|
||||
const landscapeArea = landscape!.webcamRect!.width * landscape!.webcamRect!.height;
|
||||
const portraitArea = portrait!.webcamRect!.width * portrait!.webcamRect!.height;
|
||||
expect(landscapeArea).toBe(portraitArea);
|
||||
});
|
||||
|
||||
it("scales the webcam proportionally as webcamSizePreset increases", () => {
|
||||
const canvasSize = { width: 1920, height: 1080 };
|
||||
const screenSize = { width: 1920, height: 1080 };
|
||||
const webcamSize = { width: 1280, height: 720 };
|
||||
|
||||
const small = computeCompositeLayout({
|
||||
canvasSize,
|
||||
screenSize,
|
||||
webcamSize,
|
||||
webcamSizePreset: 10,
|
||||
});
|
||||
const medium = computeCompositeLayout({
|
||||
canvasSize,
|
||||
screenSize,
|
||||
webcamSize,
|
||||
webcamSizePreset: 25,
|
||||
});
|
||||
const large = computeCompositeLayout({
|
||||
canvasSize,
|
||||
screenSize,
|
||||
webcamSize,
|
||||
webcamSizePreset: 50,
|
||||
});
|
||||
|
||||
expect(small!.webcamRect!.width).toBeLessThan(medium!.webcamRect!.width);
|
||||
expect(medium!.webcamRect!.width).toBeLessThan(large!.webcamRect!.width);
|
||||
expect(small!.webcamRect!.height).toBeLessThan(medium!.webcamRect!.height);
|
||||
expect(medium!.webcamRect!.height).toBeLessThan(large!.webcamRect!.height);
|
||||
});
|
||||
|
||||
it("clamps webcamSizePreset to the valid range (10–50)", () => {
|
||||
const canvasSize = { width: 1920, height: 1080 };
|
||||
const screenSize = { width: 1920, height: 1080 };
|
||||
const webcamSize = { width: 1280, height: 720 };
|
||||
|
||||
const atMin = computeCompositeLayout({
|
||||
canvasSize,
|
||||
screenSize,
|
||||
webcamSize,
|
||||
webcamSizePreset: 10,
|
||||
});
|
||||
const belowMin = computeCompositeLayout({
|
||||
canvasSize,
|
||||
screenSize,
|
||||
webcamSize,
|
||||
webcamSizePreset: 1,
|
||||
});
|
||||
const atMax = computeCompositeLayout({
|
||||
canvasSize,
|
||||
screenSize,
|
||||
webcamSize,
|
||||
webcamSizePreset: 50,
|
||||
});
|
||||
const aboveMax = computeCompositeLayout({
|
||||
canvasSize,
|
||||
screenSize,
|
||||
webcamSize,
|
||||
webcamSizePreset: 100,
|
||||
});
|
||||
|
||||
// Values below 10 should clamp to 10
|
||||
expect(belowMin!.webcamRect!.width).toBe(atMin!.webcamRect!.width);
|
||||
expect(belowMin!.webcamRect!.height).toBe(atMin!.webcamRect!.height);
|
||||
// Values above 50 should clamp to 50
|
||||
expect(aboveMax!.webcamRect!.width).toBe(atMax!.webcamRect!.width);
|
||||
expect(aboveMax!.webcamRect!.height).toBe(atMax!.webcamRect!.height);
|
||||
});
|
||||
|
||||
it("centers the combined screen and webcam stack in vertical stack mode", () => {
|
||||
const layout = computeCompositeLayout({
|
||||
canvasSize: { width: 1920, height: 1080 },
|
||||
@@ -43,22 +138,19 @@ describe("computeCompositeLayout", () => {
|
||||
});
|
||||
|
||||
expect(layout).not.toBeNull();
|
||||
expect(layout?.screenRect).toEqual({
|
||||
x: 576,
|
||||
y: 108,
|
||||
width: 768,
|
||||
height: 432,
|
||||
});
|
||||
expect(layout?.webcamRect).toEqual({
|
||||
x: 576,
|
||||
y: 540,
|
||||
width: 768,
|
||||
height: 432,
|
||||
borderRadius: 0,
|
||||
});
|
||||
// Webcam is full-width at the bottom
|
||||
expect(layout!.webcamRect).not.toBeNull();
|
||||
expect(layout!.webcamRect!.x).toBe(0);
|
||||
expect(layout!.webcamRect!.width).toBe(1920);
|
||||
expect(layout!.webcamRect!.borderRadius).toBe(0);
|
||||
// Screen fills remaining space at the top (cover mode)
|
||||
expect(layout!.screenRect.x).toBe(0);
|
||||
expect(layout!.screenRect.y).toBe(0);
|
||||
expect(layout!.screenRect.width).toBe(1920);
|
||||
expect(layout!.screenCover).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the screen centered and omits the webcam when dimensions are unavailable", () => {
|
||||
it("keeps the screen full-canvas and omits the webcam when dimensions are unavailable in stack mode", () => {
|
||||
const layout = computeCompositeLayout({
|
||||
canvasSize: { width: 1920, height: 1080 },
|
||||
maxContentSize: { width: 1536, height: 864 },
|
||||
@@ -68,11 +160,56 @@ describe("computeCompositeLayout", () => {
|
||||
|
||||
expect(layout).not.toBeNull();
|
||||
expect(layout?.screenRect).toEqual({
|
||||
x: 192,
|
||||
y: 108,
|
||||
width: 1536,
|
||||
height: 864,
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
});
|
||||
expect(layout?.webcamRect).toBeNull();
|
||||
expect(layout?.screenCover).toBe(true);
|
||||
});
|
||||
|
||||
it("forces circular and square masks to use square dimensions", () => {
|
||||
const circularLayout = computeCompositeLayout({
|
||||
canvasSize: { width: 1920, height: 1080 },
|
||||
screenSize: { width: 1920, height: 1080 },
|
||||
webcamSize: { width: 1280, height: 720 },
|
||||
webcamMaskShape: "circle",
|
||||
});
|
||||
const squareLayout = computeCompositeLayout({
|
||||
canvasSize: { width: 1920, height: 1080 },
|
||||
screenSize: { width: 1920, height: 1080 },
|
||||
webcamSize: { width: 1280, height: 720 },
|
||||
webcamMaskShape: "square",
|
||||
});
|
||||
|
||||
expect(circularLayout?.webcamRect).not.toBeNull();
|
||||
expect(squareLayout?.webcamRect).not.toBeNull();
|
||||
expect(circularLayout?.webcamRect?.width).toBe(circularLayout?.webcamRect?.height);
|
||||
expect(squareLayout?.webcamRect?.width).toBe(squareLayout?.webcamRect?.height);
|
||||
expect(circularLayout?.webcamRect?.maskShape).toBe("circle");
|
||||
expect(squareLayout?.webcamRect?.maskShape).toBe("square");
|
||||
});
|
||||
|
||||
it("applies larger rounding for the rounded webcam mask", () => {
|
||||
const roundedLayout = computeCompositeLayout({
|
||||
canvasSize: { width: 1920, height: 1080 },
|
||||
screenSize: { width: 1920, height: 1080 },
|
||||
webcamSize: { width: 1280, height: 720 },
|
||||
webcamMaskShape: "rounded",
|
||||
});
|
||||
const rectangleLayout = computeCompositeLayout({
|
||||
canvasSize: { width: 1920, height: 1080 },
|
||||
screenSize: { width: 1920, height: 1080 },
|
||||
webcamSize: { width: 1280, height: 720 },
|
||||
webcamMaskShape: "rectangle",
|
||||
});
|
||||
|
||||
expect(roundedLayout?.webcamRect).not.toBeNull();
|
||||
expect(rectangleLayout?.webcamRect).not.toBeNull();
|
||||
expect(roundedLayout?.webcamRect?.borderRadius).toBeGreaterThan(
|
||||
rectangleLayout?.webcamRect?.borderRadius ?? 0,
|
||||
);
|
||||
expect(roundedLayout?.webcamRect?.maskShape).toBe("rounded");
|
||||
});
|
||||
});
|
||||
|
||||
+48
-14
@@ -7,6 +7,7 @@ export interface RenderRect {
|
||||
|
||||
export interface StyledRenderRect extends RenderRect {
|
||||
borderRadius: number;
|
||||
maskShape?: import("@/components/video-editor/types").WebcamMaskShape;
|
||||
}
|
||||
|
||||
export interface Size {
|
||||
@@ -15,6 +16,8 @@ export interface Size {
|
||||
}
|
||||
|
||||
export type WebcamLayoutPreset = "picture-in-picture" | "vertical-stack";
|
||||
/** Webcam size as a percentage of the canvas reference dimension (10–50). */
|
||||
export type WebcamSizePreset = number;
|
||||
|
||||
export interface WebcamLayoutShadow {
|
||||
color: string;
|
||||
@@ -31,7 +34,6 @@ interface BorderRadiusRule {
|
||||
|
||||
interface OverlayTransform {
|
||||
type: "overlay";
|
||||
maxStageFraction: number;
|
||||
marginFraction: number;
|
||||
minMargin: number;
|
||||
minSize: number;
|
||||
@@ -56,7 +58,13 @@ export interface WebcamCompositeLayout {
|
||||
screenCover?: boolean;
|
||||
}
|
||||
|
||||
const MAX_STAGE_FRACTION = 0.18;
|
||||
/** Convert a webcam size percentage (10–50) to a fraction of the reference dimension. */
|
||||
function webcamSizeToFraction(percent: number): number {
|
||||
const safe = Number.isFinite(percent) ? percent : 25;
|
||||
const clamped = Math.max(10, Math.min(50, safe));
|
||||
return clamped / 100;
|
||||
}
|
||||
|
||||
const MARGIN_FRACTION = 0.02;
|
||||
const MAX_BORDER_RADIUS = 24;
|
||||
const WEBCAM_LAYOUT_PRESET_MAP: Record<WebcamLayoutPreset, WebcamLayoutPresetDefinition> = {
|
||||
@@ -64,7 +72,6 @@ const WEBCAM_LAYOUT_PRESET_MAP: Record<WebcamLayoutPreset, WebcamLayoutPresetDef
|
||||
label: "Picture in Picture",
|
||||
transform: {
|
||||
type: "overlay",
|
||||
maxStageFraction: MAX_STAGE_FRACTION,
|
||||
marginFraction: MARGIN_FRACTION,
|
||||
minMargin: 0,
|
||||
minSize: 0,
|
||||
@@ -124,7 +131,9 @@ export function computeCompositeLayout(params: {
|
||||
screenSize: Size;
|
||||
webcamSize?: Size | null;
|
||||
layoutPreset?: WebcamLayoutPreset;
|
||||
webcamSizePreset?: WebcamSizePreset;
|
||||
webcamPosition?: { cx: number; cy: number } | null;
|
||||
webcamMaskShape?: import("@/components/video-editor/types").WebcamMaskShape;
|
||||
}): WebcamCompositeLayout | null {
|
||||
const {
|
||||
canvasSize,
|
||||
@@ -132,7 +141,9 @@ export function computeCompositeLayout(params: {
|
||||
screenSize,
|
||||
webcamSize,
|
||||
layoutPreset = "picture-in-picture",
|
||||
webcamSizePreset = 25,
|
||||
webcamPosition,
|
||||
webcamMaskShape = "rectangle",
|
||||
} = params;
|
||||
const { width: canvasWidth, height: canvasHeight } = canvasSize;
|
||||
const { width: screenWidth, height: screenHeight } = screenSize;
|
||||
@@ -140,6 +151,8 @@ export function computeCompositeLayout(params: {
|
||||
const webcamHeight = webcamSize?.height;
|
||||
const preset = getWebcamLayoutPresetDefinition(layoutPreset);
|
||||
|
||||
const MAX_STAGE_FRACTION = webcamSizeToFraction(webcamSizePreset);
|
||||
|
||||
if (canvasWidth <= 0 || canvasHeight <= 0 || screenWidth <= 0 || screenHeight <= 0) {
|
||||
return null;
|
||||
}
|
||||
@@ -195,11 +208,21 @@ export function computeCompositeLayout(params: {
|
||||
transform.minMargin,
|
||||
Math.round(Math.min(canvasWidth, canvasHeight) * transform.marginFraction),
|
||||
);
|
||||
const maxWidth = Math.max(transform.minSize, canvasWidth * transform.maxStageFraction);
|
||||
const maxHeight = Math.max(transform.minSize, canvasHeight * transform.maxStageFraction);
|
||||
// Use geometric mean so the webcam occupies a consistent visual proportion
|
||||
// regardless of whether the canvas is portrait or landscape.
|
||||
const referenceDim = Math.sqrt(canvasWidth * canvasHeight);
|
||||
const maxWidth = Math.max(transform.minSize, referenceDim * MAX_STAGE_FRACTION);
|
||||
const maxHeight = Math.max(transform.minSize, referenceDim * MAX_STAGE_FRACTION);
|
||||
const scale = Math.min(maxWidth / webcamWidth, maxHeight / webcamHeight);
|
||||
const width = Math.round(webcamWidth * scale);
|
||||
const height = Math.round(webcamHeight * scale);
|
||||
let width = Math.round(webcamWidth * scale);
|
||||
let height = Math.round(webcamHeight * scale);
|
||||
|
||||
// Shape-specific dimension adjustments
|
||||
if (webcamMaskShape === "circle" || webcamMaskShape === "square") {
|
||||
const side = Math.min(width, height);
|
||||
width = side;
|
||||
height = side;
|
||||
}
|
||||
|
||||
let webcamX: number;
|
||||
let webcamY: number;
|
||||
@@ -217,6 +240,22 @@ export function computeCompositeLayout(params: {
|
||||
webcamY = Math.max(0, Math.round(canvasHeight - margin - height));
|
||||
}
|
||||
|
||||
// Shape-specific border radius
|
||||
let borderRadius: number;
|
||||
if (webcamMaskShape === "rounded") {
|
||||
borderRadius = Math.round(Math.min(width, height) * 0.3);
|
||||
} else if (webcamMaskShape === "circle") {
|
||||
borderRadius = Math.round(Math.min(width, height) / 2);
|
||||
} else {
|
||||
borderRadius = Math.min(
|
||||
preset.borderRadius.max,
|
||||
Math.max(
|
||||
preset.borderRadius.min,
|
||||
Math.round(Math.min(width, height) * preset.borderRadius.fraction),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
screenRect,
|
||||
webcamRect: {
|
||||
@@ -224,13 +263,8 @@ export function computeCompositeLayout(params: {
|
||||
y: webcamY,
|
||||
width,
|
||||
height,
|
||||
borderRadius: Math.min(
|
||||
preset.borderRadius.max,
|
||||
Math.max(
|
||||
preset.borderRadius.min,
|
||||
Math.round(Math.min(width, height) * preset.borderRadius.fraction),
|
||||
),
|
||||
),
|
||||
borderRadius,
|
||||
maskShape: webcamMaskShape,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
import type { AnnotationRegion, ArrowDirection } from "@/components/video-editor/types";
|
||||
import {
|
||||
type AnnotationRegion,
|
||||
type ArrowDirection,
|
||||
DEFAULT_BLUR_INTENSITY,
|
||||
MAX_BLUR_INTENSITY,
|
||||
MIN_BLUR_INTENSITY,
|
||||
} from "@/components/video-editor/types";
|
||||
|
||||
let blurScratchCanvas: HTMLCanvasElement | null = null;
|
||||
let blurScratchCtx: CanvasRenderingContext2D | null = null;
|
||||
|
||||
// SVG path data for each arrow direction
|
||||
const ARROW_PATHS: Record<ArrowDirection, string[]> = {
|
||||
@@ -96,6 +105,93 @@ function renderArrow(
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawBlurPath(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
annotation: AnnotationRegion,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
) {
|
||||
const shape = annotation.blurData?.shape || "rectangle";
|
||||
if (shape === "rectangle") {
|
||||
ctx.beginPath();
|
||||
ctx.rect(x, y, width, height);
|
||||
return;
|
||||
}
|
||||
|
||||
if (shape === "oval") {
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(x + width / 2, y + height / 2, width / 2, height / 2, 0, 0, Math.PI * 2);
|
||||
return;
|
||||
}
|
||||
|
||||
const points = annotation.blurData?.freehandPoints;
|
||||
if (shape === "freehand" && points && points.length >= 3) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + (points[0].x / 100) * width, y + (points[0].y / 100) * height);
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
ctx.lineTo(x + (points[i].x / 100) * width, y + (points[i].y / 100) * height);
|
||||
}
|
||||
ctx.closePath();
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.rect(x, y, width, height);
|
||||
}
|
||||
|
||||
function renderBlur(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
annotation: AnnotationRegion,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
scaleFactor: number,
|
||||
) {
|
||||
const canvas = ctx.canvas;
|
||||
const configuredIntensity = annotation.blurData?.intensity ?? DEFAULT_BLUR_INTENSITY;
|
||||
const blurRadius = Math.max(
|
||||
1,
|
||||
Math.round(clamp(configuredIntensity, MIN_BLUR_INTENSITY, MAX_BLUR_INTENSITY) * scaleFactor),
|
||||
);
|
||||
|
||||
// Sample pixels around the target shape too; without this padding, small blur regions
|
||||
// lose intensity because the filter has no neighboring pixels to blend with.
|
||||
const samplePadding = Math.max(2, Math.ceil(blurRadius * 2));
|
||||
const sx = Math.max(0, Math.floor(x) - samplePadding);
|
||||
const sy = Math.max(0, Math.floor(y) - samplePadding);
|
||||
const ex = Math.min(canvas.width, Math.ceil(x + width) + samplePadding);
|
||||
const ey = Math.min(canvas.height, Math.ceil(y + height) + samplePadding);
|
||||
const sw = Math.max(0, ex - sx);
|
||||
const sh = Math.max(0, ey - sy);
|
||||
if (sw <= 0 || sh <= 0) return;
|
||||
|
||||
if (!blurScratchCanvas || !blurScratchCtx) {
|
||||
blurScratchCanvas = document.createElement("canvas");
|
||||
blurScratchCtx = blurScratchCanvas.getContext("2d");
|
||||
}
|
||||
if (!blurScratchCanvas || !blurScratchCtx) return;
|
||||
|
||||
blurScratchCanvas.width = sw;
|
||||
blurScratchCanvas.height = sh;
|
||||
blurScratchCtx.clearRect(0, 0, sw, sh);
|
||||
blurScratchCtx.drawImage(canvas, sx, sy, sw, sh, 0, 0, sw, sh);
|
||||
|
||||
ctx.save();
|
||||
drawBlurPath(ctx, annotation, x, y, width, height);
|
||||
ctx.clip();
|
||||
ctx.filter = `blur(${blurRadius}px)`;
|
||||
ctx.drawImage(blurScratchCanvas, sx, sy);
|
||||
ctx.filter = "none";
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function renderText(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
annotation: AnnotationRegion,
|
||||
@@ -304,6 +400,10 @@ export async function renderAnnotations(
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case "blur":
|
||||
renderBlur(ctx, annotation, x, y, width, height, scaleFactor);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,15 +13,23 @@ import type {
|
||||
CropRegion,
|
||||
SpeedRegion,
|
||||
WebcamLayoutPreset,
|
||||
WebcamSizePreset,
|
||||
ZoomDepth,
|
||||
ZoomRegion,
|
||||
} from "@/components/video-editor/types";
|
||||
import { ZOOM_DEPTH_SCALES } from "@/components/video-editor/types";
|
||||
import {
|
||||
AUTO_FOLLOW_RAMP_DISTANCE,
|
||||
AUTO_FOLLOW_SMOOTHING_FACTOR,
|
||||
AUTO_FOLLOW_SMOOTHING_FACTOR_MAX,
|
||||
DEFAULT_FOCUS,
|
||||
ZOOM_SCALE_DEADZONE,
|
||||
ZOOM_TRANSLATION_DEADZONE_PX,
|
||||
} from "@/components/video-editor/videoPlayback/constants";
|
||||
import {
|
||||
adaptiveSmoothFactor,
|
||||
smoothCursorFocus,
|
||||
} from "@/components/video-editor/videoPlayback/cursorFollowUtils";
|
||||
import { clampFocusToStage as clampFocusToStageUtil } from "@/components/video-editor/videoPlayback/focusUtils";
|
||||
import { findDominantRegion } from "@/components/video-editor/videoPlayback/zoomRegionUtils";
|
||||
import {
|
||||
@@ -37,6 +45,7 @@ import {
|
||||
type Size,
|
||||
type StyledRenderRect,
|
||||
} from "@/lib/compositeLayout";
|
||||
import { drawCanvasClipPath } from "@/lib/webcamMaskShapes";
|
||||
import { renderAnnotations } from "./annotationRenderer";
|
||||
import {
|
||||
getLinearGradientPoints,
|
||||
@@ -61,11 +70,14 @@ interface FrameRenderConfig {
|
||||
videoHeight: number;
|
||||
webcamSize?: Size | null;
|
||||
webcamLayoutPreset?: WebcamLayoutPreset;
|
||||
webcamMaskShape?: import("@/components/video-editor/types").WebcamMaskShape;
|
||||
webcamSizePreset?: WebcamSizePreset;
|
||||
webcamPosition?: { cx: number; cy: number } | null;
|
||||
annotationRegions?: AnnotationRegion[];
|
||||
speedRegions?: SpeedRegion[];
|
||||
previewWidth?: number;
|
||||
previewHeight?: number;
|
||||
cursorTelemetry?: import("@/components/video-editor/types").CursorTelemetryPoint[];
|
||||
}
|
||||
|
||||
interface AnimationState {
|
||||
@@ -102,11 +114,16 @@ export class FrameRenderer {
|
||||
private shadowCtx: CanvasRenderingContext2D | null = null;
|
||||
private compositeCanvas: HTMLCanvasElement | null = null;
|
||||
private compositeCtx: CanvasRenderingContext2D | null = null;
|
||||
private rasterCanvas: HTMLCanvasElement | null = null;
|
||||
private rasterCtx: CanvasRenderingContext2D | null = null;
|
||||
private config: FrameRenderConfig;
|
||||
private animationState: AnimationState;
|
||||
private layoutCache: LayoutCache | null = null;
|
||||
private currentVideoTime = 0;
|
||||
private motionBlurState: MotionBlurState = createMotionBlurState();
|
||||
private smoothedAutoFocus: { cx: number; cy: number } | null = null;
|
||||
private prevAnimationTimeMs: number | null = null;
|
||||
private prevTargetProgress = 0;
|
||||
|
||||
constructor(config: FrameRenderConfig) {
|
||||
this.config = config;
|
||||
@@ -178,6 +195,14 @@ export class FrameRenderer {
|
||||
throw new Error("Failed to get 2D context for composite canvas");
|
||||
}
|
||||
|
||||
this.rasterCanvas = document.createElement("canvas");
|
||||
this.rasterCanvas.width = this.config.width;
|
||||
this.rasterCanvas.height = this.config.height;
|
||||
this.rasterCtx = this.rasterCanvas.getContext("2d");
|
||||
if (!this.rasterCtx) {
|
||||
throw new Error("Failed to get 2D context for raster canvas");
|
||||
}
|
||||
|
||||
// Setup shadow canvas if needed
|
||||
if (this.config.showShadow) {
|
||||
this.shadowCanvas = document.createElement("canvas");
|
||||
@@ -440,7 +465,9 @@ export class FrameRenderer {
|
||||
screenSize: { width: croppedVideoWidth, height: croppedVideoHeight },
|
||||
webcamSize: webcamFrame ? this.config.webcamSize : null,
|
||||
layoutPreset: this.config.webcamLayoutPreset,
|
||||
webcamSizePreset: this.config.webcamSizePreset,
|
||||
webcamPosition: this.config.webcamPosition,
|
||||
webcamMaskShape: this.config.webcamMaskShape,
|
||||
});
|
||||
if (!compositeLayout) return;
|
||||
|
||||
@@ -508,10 +535,16 @@ export class FrameRenderer {
|
||||
private updateAnimationState(timeMs: number): number {
|
||||
if (!this.cameraContainer || !this.layoutCache) return 0;
|
||||
|
||||
const bmEx = this.layoutCache.maskRect;
|
||||
const ssEx = this.layoutCache.stageSize;
|
||||
const viewportRatio =
|
||||
bmEx.width > 0 && bmEx.height > 0
|
||||
? { widthRatio: ssEx.width / bmEx.width, heightRatio: ssEx.height / bmEx.height }
|
||||
: undefined;
|
||||
const { region, strength, blendedScale, transition } = findDominantRegion(
|
||||
this.config.zoomRegions,
|
||||
timeMs,
|
||||
{ connectZooms: true },
|
||||
{ connectZooms: true, cursorTelemetry: this.config.cursorTelemetry, viewportRatio },
|
||||
);
|
||||
|
||||
const defaultFocus = DEFAULT_FOCUS;
|
||||
@@ -527,6 +560,50 @@ export class FrameRenderer {
|
||||
targetFocus = regionFocus;
|
||||
targetProgress = strength;
|
||||
|
||||
// Apply adaptive smoothing for auto-follow mode
|
||||
if (region.focusMode === "auto" && !transition) {
|
||||
const raw = targetFocus;
|
||||
const dtMs = this.prevAnimationTimeMs != null ? timeMs - this.prevAnimationTimeMs : 0;
|
||||
const framesElapsed = dtMs > 0 ? dtMs / (1000 / 60) : 1;
|
||||
const isZoomingIn = targetProgress < 0.999 && targetProgress >= this.prevTargetProgress;
|
||||
if (targetProgress >= 0.999) {
|
||||
// Full zoom: adaptive smoothing — moves faster when far, decelerates when close
|
||||
const prev = this.smoothedAutoFocus ?? raw;
|
||||
const baseFactor = adaptiveSmoothFactor(
|
||||
raw,
|
||||
prev,
|
||||
AUTO_FOLLOW_SMOOTHING_FACTOR,
|
||||
AUTO_FOLLOW_SMOOTHING_FACTOR_MAX,
|
||||
AUTO_FOLLOW_RAMP_DISTANCE,
|
||||
);
|
||||
const factor = 1 - Math.pow(1 - baseFactor, Math.max(1, framesElapsed));
|
||||
const smoothed = smoothCursorFocus(raw, prev, factor);
|
||||
this.smoothedAutoFocus = smoothed;
|
||||
targetFocus = smoothed;
|
||||
} else if (isZoomingIn) {
|
||||
// Zoom-in: track cursor directly so zoom always aims at current cursor
|
||||
// position; keep ref in sync to avoid snap when full-zoom begins
|
||||
this.smoothedAutoFocus = raw;
|
||||
} else {
|
||||
// Zoom-out: keep smoothing for continuity — avoids snap at zoom-out start
|
||||
const prev = this.smoothedAutoFocus ?? raw;
|
||||
const baseFactor = adaptiveSmoothFactor(
|
||||
raw,
|
||||
prev,
|
||||
AUTO_FOLLOW_SMOOTHING_FACTOR,
|
||||
AUTO_FOLLOW_SMOOTHING_FACTOR_MAX,
|
||||
AUTO_FOLLOW_RAMP_DISTANCE,
|
||||
);
|
||||
const factor = 1 - Math.pow(1 - baseFactor, Math.max(1, framesElapsed));
|
||||
const smoothed = smoothCursorFocus(raw, prev, factor);
|
||||
this.smoothedAutoFocus = smoothed;
|
||||
targetFocus = smoothed;
|
||||
}
|
||||
} else if (region.focusMode !== "auto") {
|
||||
this.smoothedAutoFocus = null;
|
||||
}
|
||||
this.prevTargetProgress = targetProgress;
|
||||
|
||||
if (transition) {
|
||||
const startTransform = computeZoomTransform({
|
||||
stageSize: this.layoutCache.stageSize,
|
||||
@@ -602,6 +679,8 @@ export class FrameRenderer {
|
||||
state.y = appliedY;
|
||||
state.appliedScale = appliedScale;
|
||||
|
||||
this.prevAnimationTimeMs = timeMs;
|
||||
|
||||
return Math.max(
|
||||
Math.abs(appliedScale - prevScale),
|
||||
Math.abs(appliedX - prevX) / Math.max(1, this.layoutCache.stageSize.width),
|
||||
@@ -609,10 +688,46 @@ export class FrameRenderer {
|
||||
);
|
||||
}
|
||||
|
||||
// On Linux/Wayland the implicit GPU→2D texture-sharing path
|
||||
// used by drawImage(webglCanvas) can fail silently (EGL/Ozone),
|
||||
// producing green/empty frames. Explicit gl.readPixels always
|
||||
// copies from GPU to CPU memory, bypassing that path.
|
||||
private readbackVideoCanvas(): HTMLCanvasElement {
|
||||
const glCanvas = this.app!.canvas as HTMLCanvasElement;
|
||||
const gl =
|
||||
(glCanvas.getContext("webgl2") as WebGL2RenderingContext | null) ??
|
||||
(glCanvas.getContext("webgl") as WebGLRenderingContext | null);
|
||||
|
||||
if (!gl || !this.rasterCanvas || !this.rasterCtx) {
|
||||
return glCanvas;
|
||||
}
|
||||
|
||||
const w = glCanvas.width;
|
||||
const h = glCanvas.height;
|
||||
const buf = new Uint8Array(w * h * 4);
|
||||
gl.readPixels(0, 0, w, h, gl.RGBA, gl.UNSIGNED_BYTE, buf);
|
||||
|
||||
// readPixels returns rows bottom-to-top; flip vertically
|
||||
const rowSize = w * 4;
|
||||
const temp = new Uint8Array(rowSize);
|
||||
for (let top = 0, bot = h - 1; top < bot; top++, bot--) {
|
||||
const tOff = top * rowSize;
|
||||
const bOff = bot * rowSize;
|
||||
temp.set(buf.subarray(tOff, tOff + rowSize));
|
||||
buf.copyWithin(tOff, bOff, bOff + rowSize);
|
||||
buf.set(temp, bOff);
|
||||
}
|
||||
|
||||
const imageData = new ImageData(new Uint8ClampedArray(buf.buffer), w, h);
|
||||
this.rasterCtx.putImageData(imageData, 0, 0);
|
||||
|
||||
return this.rasterCanvas;
|
||||
}
|
||||
|
||||
private compositeWithShadows(webcamFrame?: VideoFrame | null): void {
|
||||
if (!this.compositeCanvas || !this.compositeCtx || !this.app) return;
|
||||
|
||||
const videoCanvas = this.app.canvas as HTMLCanvasElement;
|
||||
const videoCanvas = this.readbackVideoCanvas();
|
||||
const ctx = this.compositeCtx;
|
||||
const w = this.compositeCanvas.width;
|
||||
const h = this.compositeCanvas.height;
|
||||
@@ -668,16 +783,17 @@ export class FrameRenderer {
|
||||
const webcamRect = this.layoutCache?.webcamRect ?? null;
|
||||
if (webcamFrame && webcamRect) {
|
||||
const preset = getWebcamLayoutPresetDefinition(this.config.webcamLayoutPreset);
|
||||
const shape = webcamRect.maskShape ?? this.config.webcamMaskShape ?? "rectangle";
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(
|
||||
drawCanvasClipPath(
|
||||
ctx,
|
||||
webcamRect.x,
|
||||
webcamRect.y,
|
||||
webcamRect.width,
|
||||
webcamRect.height,
|
||||
shape,
|
||||
webcamRect.borderRadius,
|
||||
);
|
||||
ctx.closePath();
|
||||
if (preset.shadow) {
|
||||
ctx.shadowColor = preset.shadow.color;
|
||||
ctx.shadowBlur = preset.shadow.blur;
|
||||
@@ -728,5 +844,7 @@ export class FrameRenderer {
|
||||
this.shadowCtx = null;
|
||||
this.compositeCanvas = null;
|
||||
this.compositeCtx = null;
|
||||
this.rasterCanvas = null;
|
||||
this.rasterCtx = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import sampleVideoUrl from "../../../tests/fixtures/sample.webm?url";
|
||||
import { GifExporter } from "./gifExporter";
|
||||
import type { ExportProgress } from "./types";
|
||||
|
||||
describe("GifExporter (real browser)", () => {
|
||||
it("exports a valid GIF blob from a real video", async () => {
|
||||
const progressEvents: ExportProgress[] = [];
|
||||
|
||||
const exporter = new GifExporter({
|
||||
videoUrl: sampleVideoUrl,
|
||||
width: 320,
|
||||
height: 180,
|
||||
frameRate: 15,
|
||||
loop: true,
|
||||
sizePreset: "medium",
|
||||
wallpaper: "#1a1a2e",
|
||||
zoomRegions: [],
|
||||
showShadow: false,
|
||||
shadowIntensity: 0,
|
||||
showBlur: false,
|
||||
cropRegion: { x: 0, y: 0, width: 1, height: 1 },
|
||||
onProgress: (p) => progressEvents.push(p),
|
||||
});
|
||||
|
||||
const result = await exporter.export();
|
||||
|
||||
expect(result.success, result.error).toBe(true);
|
||||
expect(result.blob).toBeInstanceOf(Blob);
|
||||
|
||||
const buf = await result.blob!.arrayBuffer();
|
||||
const header = new TextDecoder().decode(new Uint8Array(buf, 0, 6));
|
||||
expect(header).toMatch(/^GIF8[79]a/);
|
||||
|
||||
expect(result.blob!.size).toBeGreaterThan(1024);
|
||||
|
||||
expect(progressEvents.length).toBeGreaterThan(0);
|
||||
|
||||
const finalizing = progressEvents.filter((p) => p.phase === "finalizing");
|
||||
expect(finalizing.length).toBeGreaterThan(0);
|
||||
expect(finalizing.at(-1)!.percentage).toBe(100);
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
SpeedRegion,
|
||||
TrimRegion,
|
||||
WebcamLayoutPreset,
|
||||
WebcamSizePreset,
|
||||
ZoomRegion,
|
||||
} from "@/components/video-editor/types";
|
||||
import { AsyncVideoFrameQueue } from "./asyncVideoFrameQueue";
|
||||
@@ -41,10 +42,13 @@ interface GifExporterConfig {
|
||||
videoPadding?: number;
|
||||
cropRegion: CropRegion;
|
||||
webcamLayoutPreset?: WebcamLayoutPreset;
|
||||
webcamMaskShape?: import("@/components/video-editor/types").WebcamMaskShape;
|
||||
webcamSizePreset?: WebcamSizePreset;
|
||||
webcamPosition?: { cx: number; cy: number } | null;
|
||||
annotationRegions?: AnnotationRegion[];
|
||||
previewWidth?: number;
|
||||
previewHeight?: number;
|
||||
cursorTelemetry?: import("@/components/video-editor/types").CursorTelemetryPoint[];
|
||||
onProgress?: (progress: ExportProgress) => void;
|
||||
}
|
||||
|
||||
@@ -141,11 +145,14 @@ export class GifExporter {
|
||||
videoHeight: videoInfo.height,
|
||||
webcamSize: webcamInfo ? { width: webcamInfo.width, height: webcamInfo.height } : null,
|
||||
webcamLayoutPreset: this.config.webcamLayoutPreset,
|
||||
webcamMaskShape: this.config.webcamMaskShape,
|
||||
webcamSizePreset: this.config.webcamSizePreset,
|
||||
webcamPosition: this.config.webcamPosition,
|
||||
annotationRegions: this.config.annotationRegions,
|
||||
speedRegions: this.config.speedRegions,
|
||||
previewWidth: this.config.previewWidth,
|
||||
previewHeight: this.config.previewHeight,
|
||||
cursorTelemetry: this.config.cursorTelemetry,
|
||||
});
|
||||
await this.renderer.initialize();
|
||||
|
||||
|
||||
@@ -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,43 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import sampleVideoUrl from "../../../tests/fixtures/sample.webm?url";
|
||||
import type { ExportProgress } from "./types";
|
||||
import { VideoExporter } from "./videoExporter";
|
||||
|
||||
describe("VideoExporter (real browser)", () => {
|
||||
it("exports a valid MP4 blob from a real video", async () => {
|
||||
const progressEvents: ExportProgress[] = [];
|
||||
|
||||
const exporter = new VideoExporter({
|
||||
videoUrl: sampleVideoUrl,
|
||||
width: 320,
|
||||
height: 180,
|
||||
frameRate: 15,
|
||||
bitrate: 1_000_000,
|
||||
wallpaper: "#1a1a2e",
|
||||
zoomRegions: [],
|
||||
showShadow: false,
|
||||
shadowIntensity: 0,
|
||||
showBlur: false,
|
||||
cropRegion: { x: 0, y: 0, width: 1, height: 1 },
|
||||
onProgress: (p) => progressEvents.push(p),
|
||||
});
|
||||
|
||||
const result = await exporter.export();
|
||||
|
||||
expect(result.success, result.error).toBe(true);
|
||||
expect(result.blob).toBeInstanceOf(Blob);
|
||||
|
||||
const buf = await result.blob!.arrayBuffer();
|
||||
const bytes = new Uint8Array(buf);
|
||||
const ftyp = new TextDecoder().decode(bytes.slice(4, 8));
|
||||
expect(ftyp).toBe("ftyp");
|
||||
|
||||
expect(result.blob!.size).toBeGreaterThan(1024);
|
||||
|
||||
expect(progressEvents.length).toBeGreaterThan(0);
|
||||
|
||||
const finalizing = progressEvents.filter((p) => p.phase === "finalizing");
|
||||
expect(finalizing.length).toBeGreaterThan(0);
|
||||
expect(finalizing.at(-1)!.percentage).toBe(100);
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
SpeedRegion,
|
||||
TrimRegion,
|
||||
WebcamLayoutPreset,
|
||||
WebcamSizePreset,
|
||||
ZoomRegion,
|
||||
} from "@/components/video-editor/types";
|
||||
import { AsyncVideoFrameQueue } from "./asyncVideoFrameQueue";
|
||||
@@ -32,10 +33,13 @@ interface VideoExporterConfig extends ExportConfig {
|
||||
videoPadding?: number;
|
||||
cropRegion: CropRegion;
|
||||
webcamLayoutPreset?: WebcamLayoutPreset;
|
||||
webcamMaskShape?: import("@/components/video-editor/types").WebcamMaskShape;
|
||||
webcamSizePreset?: WebcamSizePreset;
|
||||
webcamPosition?: { cx: number; cy: number } | null;
|
||||
annotationRegions?: AnnotationRegion[];
|
||||
previewWidth?: number;
|
||||
previewHeight?: number;
|
||||
cursorTelemetry?: import("@/components/video-editor/types").CursorTelemetryPoint[];
|
||||
onProgress?: (progress: ExportProgress) => void;
|
||||
}
|
||||
|
||||
@@ -134,11 +138,14 @@ export class VideoExporter {
|
||||
videoHeight: videoInfo.height,
|
||||
webcamSize: webcamInfo ? { width: webcamInfo.width, height: webcamInfo.height } : null,
|
||||
webcamLayoutPreset: this.config.webcamLayoutPreset,
|
||||
webcamMaskShape: this.config.webcamMaskShape,
|
||||
webcamSizePreset: this.config.webcamSizePreset,
|
||||
webcamPosition: this.config.webcamPosition,
|
||||
annotationRegions: this.config.annotationRegions,
|
||||
speedRegions: this.config.speedRegions,
|
||||
previewWidth: this.config.previewWidth,
|
||||
previewHeight: this.config.previewHeight,
|
||||
cursorTelemetry: this.config.cursorTelemetry,
|
||||
});
|
||||
this.renderer = renderer;
|
||||
await renderer.initialize();
|
||||
@@ -230,8 +237,16 @@ export class VideoExporter {
|
||||
|
||||
const canvas = renderer.getCanvas();
|
||||
|
||||
// @ts-expect-error - colorSpace is available at runtime even if TS does not know it.
|
||||
const exportFrame = new VideoFrame(canvas, {
|
||||
// Read raw pixels from the canvas instead of passing
|
||||
// the canvas directly to VideoFrame. On some Linux
|
||||
// systems the GPU shared-image path (EGL/Ozone) fails
|
||||
// silently, producing empty frames.
|
||||
const canvasCtx = canvas.getContext("2d")!;
|
||||
const imageData = canvasCtx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const exportFrame = new VideoFrame(imageData.data.buffer, {
|
||||
format: "RGBA",
|
||||
codedWidth: canvas.width,
|
||||
codedHeight: canvas.height,
|
||||
timestamp,
|
||||
duration: frameDuration,
|
||||
colorSpace: {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
+30
-5
@@ -3,6 +3,7 @@ export const SHORTCUT_ACTIONS = [
|
||||
"addTrim",
|
||||
"addSpeed",
|
||||
"addAnnotation",
|
||||
"addBlur",
|
||||
"addKeyframe",
|
||||
"deleteSelected",
|
||||
"playPause",
|
||||
@@ -21,14 +22,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 +39,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 =
|
||||
@@ -87,6 +109,7 @@ export const DEFAULT_SHORTCUTS: ShortcutsConfig = {
|
||||
addTrim: { key: "t" },
|
||||
addSpeed: { key: "s" },
|
||||
addAnnotation: { key: "a" },
|
||||
addBlur: { key: "b" },
|
||||
addKeyframe: { key: "f" },
|
||||
deleteSelected: { key: "d", ctrl: true },
|
||||
playPause: { key: " " },
|
||||
@@ -97,6 +120,7 @@ export const SHORTCUT_LABELS: Record<ShortcutAction, string> = {
|
||||
addTrim: "Add Trim",
|
||||
addSpeed: "Add Speed",
|
||||
addAnnotation: "Add Annotation",
|
||||
addBlur: "Add Blur",
|
||||
addKeyframe: "Add Keyframe",
|
||||
deleteSelected: "Delete Selected",
|
||||
playPause: "Play / Pause",
|
||||
@@ -104,9 +128,10 @@ export const SHORTCUT_LABELS: Record<ShortcutAction, string> = {
|
||||
|
||||
export function matchesShortcut(
|
||||
e: KeyboardEvent,
|
||||
binding: ShortcutBinding,
|
||||
binding: ShortcutBinding | undefined,
|
||||
isMacPlatform: boolean,
|
||||
): boolean {
|
||||
if (!binding) return false;
|
||||
if (e.key.toLowerCase() !== binding.key.toLowerCase()) return false;
|
||||
|
||||
const primaryMod = isMacPlatform ? e.metaKey : e.ctrlKey;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { WebcamMaskShape } from "@/components/video-editor/types";
|
||||
|
||||
/**
|
||||
* Returns a CSS clip-path value for the given shape, or null if borderRadius alone suffices.
|
||||
*/
|
||||
export function getCssClipPath(shape: WebcamMaskShape): string | null {
|
||||
switch (shape) {
|
||||
case "circle":
|
||||
return "circle(50% at 50% 50%)";
|
||||
case "rectangle":
|
||||
case "rounded":
|
||||
case "square":
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws a Canvas 2D clip path for the given webcam mask shape.
|
||||
* Call ctx.beginPath() is handled internally; caller should call ctx.clip() after.
|
||||
*/
|
||||
export function drawCanvasClipPath(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
w: number,
|
||||
h: number,
|
||||
shape: WebcamMaskShape,
|
||||
borderRadius: number,
|
||||
): void {
|
||||
ctx.beginPath();
|
||||
switch (shape) {
|
||||
case "circle": {
|
||||
const cx = x + w / 2;
|
||||
const cy = y + h / 2;
|
||||
const r = Math.min(w, h) / 2;
|
||||
ctx.arc(cx, cy, r, 0, Math.PI * 2);
|
||||
break;
|
||||
}
|
||||
case "rectangle":
|
||||
case "rounded":
|
||||
case "square":
|
||||
default:
|
||||
ctx.roundRect(x, y, w, h, borderRadius);
|
||||
break;
|
||||
}
|
||||
ctx.closePath();
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import path from "node:path";
|
||||
import { playwright } from "@vitest/browser-playwright";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["src/**/*.browser.test.{ts,tsx}"],
|
||||
browser: {
|
||||
enabled: true,
|
||||
provider: playwright({
|
||||
launch: {
|
||||
// Software WebGL so Pixi.js works in headless CI without a GPU.
|
||||
args: ["--enable-unsafe-swiftshader", "--use-gl=swiftshader"],
|
||||
},
|
||||
}),
|
||||
headless: true,
|
||||
instances: [{ browser: "chromium" }],
|
||||
},
|
||||
testTimeout: 120_000,
|
||||
hookTimeout: 30_000,
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "src"),
|
||||
},
|
||||
},
|
||||
assetsInclude: ["**/*.webm"],
|
||||
});
|
||||
+1
-1
@@ -4,7 +4,7 @@ import { defineConfig } from "vitest/config";
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "node",
|
||||
environment: "jsdom",
|
||||
include: ["src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"],
|
||||
},
|
||||
resolve: {
|
||||
|
||||
Reference in New Issue
Block a user