mirror of
https://github.com/siddharthvaddem/openscreen.git
synced 2026-11-04 23:32:16 +08:00
Compare commits
97
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4c50c9a5e | ||
|
|
3e6dff9c34 | ||
|
|
1b6f4cce46 | ||
|
|
db815e362a | ||
|
|
ed9b8689f7 | ||
|
|
dc0856282f | ||
|
|
1dc2c06ee4 | ||
|
|
8013cc97bb | ||
|
|
e45611ade4 | ||
|
|
b986148d5d | ||
|
|
fe0c2829a7 | ||
|
|
e4672811de | ||
|
|
f3d761b28d | ||
|
|
ae971bc480 | ||
|
|
213637967e | ||
|
|
a8427b950e | ||
|
|
c868469be5 | ||
|
|
e90bba82ef | ||
|
|
475cbcd76c | ||
|
|
08f66c7c25 | ||
|
|
7072c05edd | ||
|
|
0bc3bbca6b | ||
|
|
5340272530 | ||
|
|
da16872809 | ||
|
|
11788ad703 | ||
|
|
66f9172a35 | ||
|
|
bd604cb658 | ||
|
|
4f48ecd4bc | ||
|
|
7d746196d2 | ||
|
|
d5f59a7b8e | ||
|
|
478fe316dc | ||
|
|
21893f07af | ||
|
|
763c187f87 | ||
|
|
20567db245 | ||
|
|
7a1113827c | ||
|
|
7e298d3bbf | ||
|
|
97c9a73578 | ||
|
|
3bfcd8576b | ||
|
|
b002f2a485 | ||
|
|
cd0f2ab318 | ||
|
|
54df597160 | ||
|
|
e5430eed39 | ||
|
|
baa30a9d6a | ||
|
|
b709d0d240 | ||
|
|
11bad60eb2 | ||
|
|
27853cc2c3 | ||
|
|
d6933813bd | ||
|
|
2b471783c0 | ||
|
|
b101820ab8 | ||
|
|
3061c141c6 | ||
|
|
846cf71e09 | ||
|
|
9d0ccf3bde | ||
|
|
914a3c7f7b | ||
|
|
2f36160174 | ||
|
|
05a87a8ab1 | ||
|
|
5c6621293a | ||
|
|
163b12d6fc | ||
|
|
3be195cc15 | ||
|
|
baec9a7585 | ||
|
|
9817c85acf | ||
|
|
9762448929 | ||
|
|
eade28079d | ||
|
|
fed5a44b5a | ||
|
|
a9222c9484 | ||
|
|
e72851d4ef | ||
|
|
317089d57f | ||
|
|
0a5e57ce76 | ||
|
|
e35780bd85 | ||
|
|
eae3f119a4 | ||
|
|
5d561ff06f | ||
|
|
a8bb0e88d5 | ||
|
|
cbbe2d7fbf | ||
|
|
7aca8b8bc1 | ||
|
|
4a299063c3 | ||
|
|
3d680e8521 | ||
|
|
c322825969 | ||
|
|
ece93683b8 | ||
|
|
203282be43 | ||
|
|
d8871d9228 | ||
|
|
dd0b7d6586 | ||
|
|
459b71f792 | ||
|
|
adc0cf795c | ||
|
|
796506819d | ||
|
|
2a2d7e7aba | ||
|
|
3eeecc46cf | ||
|
|
6236d2a13d | ||
|
|
c84c244761 | ||
|
|
83a60926d8 | ||
|
|
579887e2f8 | ||
|
|
a0682e6716 | ||
|
|
038d6c40ab | ||
|
|
45636410fe | ||
|
|
d968689975 | ||
|
|
7e65d52847 | ||
|
|
1680ef9b77 | ||
|
|
b7070f3ac8 | ||
|
|
de18a2f46f |
@@ -94,6 +94,7 @@ jobs:
|
|||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: linux-installer
|
name: linux-installer
|
||||||
path: release/**/*.AppImage
|
path: |
|
||||||
|
release/**/*.AppImage
|
||||||
|
release/**/*.zsync
|
||||||
retention-days: 30
|
retention-days: 30
|
||||||
|
|
||||||
|
|||||||
@@ -42,26 +42,3 @@ jobs:
|
|||||||
cache: npm
|
cache: npm
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
- run: npx vite build
|
- run: npx vite build
|
||||||
|
|
||||||
e2e:
|
|
||||||
name: E2E Tests
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 22
|
|
||||||
cache: npm
|
|
||||||
- run: npm ci
|
|
||||||
- run: npx playwright install --with-deps chromium
|
|
||||||
# Install Electron system dependencies not covered by Playwright's chromium deps
|
|
||||||
- run: npx electron . --version || sudo apt-get install -y libgbm-dev
|
|
||||||
- run: npm run build-vite
|
|
||||||
# xvfb provides a virtual display; Electron needs one on Linux even with show:false
|
|
||||||
- run: xvfb-run --auto-servernum npm run test:e2e
|
|
||||||
- uses: actions/upload-artifact@v4
|
|
||||||
if: failure()
|
|
||||||
with:
|
|
||||||
name: playwright-report
|
|
||||||
path: playwright-report/
|
|
||||||
retention-days: 7
|
|
||||||
|
|||||||
@@ -8,6 +8,10 @@
|
|||||||
<a href="https://deepwiki.com/siddharthvaddem/openscreen">
|
<a href="https://deepwiki.com/siddharthvaddem/openscreen">
|
||||||
<img src="https://deepwiki.com/badge.svg" alt="Ask DeepWiki" />
|
<img src="https://deepwiki.com/badge.svg" alt="Ask DeepWiki" />
|
||||||
</a>
|
</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>
|
||||||
|
|
||||||
# <p align="center">OpenScreen</p>
|
# <p align="center">OpenScreen</p>
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
"to": "assets/wallpapers"
|
"to": "assets/wallpapers"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"publish": [{"provider": "github"}],
|
||||||
|
|
||||||
"mac": {
|
"mac": {
|
||||||
"hardenedRuntime": false,
|
"hardenedRuntime": false,
|
||||||
|
|||||||
Vendored
+1
@@ -136,6 +136,7 @@ interface Window {
|
|||||||
setMicrophoneExpanded: (expanded: boolean) => void;
|
setMicrophoneExpanded: (expanded: boolean) => void;
|
||||||
setHasUnsavedChanges: (hasChanges: boolean) => void;
|
setHasUnsavedChanges: (hasChanges: boolean) => void;
|
||||||
onRequestSaveBeforeClose: (callback: () => Promise<boolean> | boolean) => () => void;
|
onRequestSaveBeforeClose: (callback: () => Promise<boolean> | boolean) => () => void;
|
||||||
|
setLocale: (locale: string) => Promise<void>;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
// Lightweight i18n for the Electron main process.
|
||||||
|
// Imports the same JSON translation files used by the renderer.
|
||||||
|
|
||||||
|
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 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 Namespace = "common" | "dialogs";
|
||||||
|
type MessageMap = Record<string, unknown>;
|
||||||
|
|
||||||
|
const messages: Record<Locale, Record<Namespace, MessageMap>> = {
|
||||||
|
en: { common: commonEn, dialogs: dialogsEn },
|
||||||
|
"zh-CN": { common: commonZh, dialogs: dialogsZh },
|
||||||
|
es: { common: commonEs, dialogs: dialogsEs },
|
||||||
|
};
|
||||||
|
|
||||||
|
let currentLocale: Locale = "en";
|
||||||
|
|
||||||
|
export function setMainLocale(locale: string) {
|
||||||
|
if (locale === "en" || locale === "zh-CN" || locale === "es") {
|
||||||
|
currentLocale = locale;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMainLocale(): Locale {
|
||||||
|
return currentLocale;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMessageValue(obj: unknown, dotPath: string): string | undefined {
|
||||||
|
const keys = dotPath.split(".");
|
||||||
|
let current: unknown = obj;
|
||||||
|
for (const key of keys) {
|
||||||
|
if (current == null || typeof current !== "object") return undefined;
|
||||||
|
current = (current as Record<string, unknown>)[key];
|
||||||
|
}
|
||||||
|
return typeof current === "string" ? current : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function interpolate(str: string, vars?: Record<string, string | number>): string {
|
||||||
|
if (!vars) return str;
|
||||||
|
return str.replace(/\{\{(\w+)\}\}/g, (_, key: string) => String(vars[key] ?? `{{${key}}}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mainT(
|
||||||
|
namespace: Namespace,
|
||||||
|
key: string,
|
||||||
|
vars?: Record<string, string | number>,
|
||||||
|
): string {
|
||||||
|
const value =
|
||||||
|
getMessageValue(messages[currentLocale]?.[namespace], key) ??
|
||||||
|
getMessageValue(messages.en?.[namespace], key);
|
||||||
|
|
||||||
|
if (value == null) return `${namespace}.${key}`;
|
||||||
|
return interpolate(value, vars);
|
||||||
|
}
|
||||||
+203
-40
@@ -14,14 +14,153 @@ import {
|
|||||||
import {
|
import {
|
||||||
normalizeProjectMedia,
|
normalizeProjectMedia,
|
||||||
normalizeRecordingSession,
|
normalizeRecordingSession,
|
||||||
|
type ProjectMedia,
|
||||||
type RecordingSession,
|
type RecordingSession,
|
||||||
type StoreRecordedSessionInput,
|
type StoreRecordedSessionInput,
|
||||||
} from "../../src/lib/recordingSession";
|
} from "../../src/lib/recordingSession";
|
||||||
|
import { mainT } from "../i18n";
|
||||||
import { RECORDINGS_DIR } from "../main";
|
import { RECORDINGS_DIR } from "../main";
|
||||||
|
|
||||||
const PROJECT_FILE_EXTENSION = "openscreen";
|
const PROJECT_FILE_EXTENSION = "openscreen";
|
||||||
const SHORTCUTS_FILE = path.join(app.getPath("userData"), "shortcuts.json");
|
const SHORTCUTS_FILE = path.join(app.getPath("userData"), "shortcuts.json");
|
||||||
const RECORDING_SESSION_SUFFIX = ".session.json";
|
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 = {
|
type SelectedSource = {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -120,12 +259,12 @@ async function storeRecordedSessionFiles(payload: StoreRecordedSessionInput) {
|
|||||||
typeof payload.createdAt === "number" && Number.isFinite(payload.createdAt)
|
typeof payload.createdAt === "number" && Number.isFinite(payload.createdAt)
|
||||||
? payload.createdAt
|
? payload.createdAt
|
||||||
: Date.now();
|
: 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));
|
await fs.writeFile(screenVideoPath, Buffer.from(payload.screen.videoData));
|
||||||
|
|
||||||
let webcamVideoPath: string | undefined;
|
let webcamVideoPath: string | undefined;
|
||||||
if (payload.webcam) {
|
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));
|
await fs.writeFile(webcamVideoPath, Buffer.from(payload.webcam.videoData));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -351,6 +490,14 @@ export function registerIpcHandlers(
|
|||||||
return { success: false, message: "Invalid file path" };
|
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);
|
const data = await fs.readFile(normalizedPath);
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -395,6 +542,14 @@ export function registerIpcHandlers(
|
|||||||
return { success: true, samples: [] };
|
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`;
|
const telemetryPath = `${targetVideoPath}.cursor.json`;
|
||||||
try {
|
try {
|
||||||
const content = await fs.readFile(telemetryPath, "utf-8");
|
const content = await fs.readFile(telemetryPath, "utf-8");
|
||||||
@@ -472,11 +627,13 @@ export function registerIpcHandlers(
|
|||||||
// Determine file type from extension
|
// Determine file type from extension
|
||||||
const isGif = fileName.toLowerCase().endsWith(".gif");
|
const isGif = fileName.toLowerCase().endsWith(".gif");
|
||||||
const filters = isGif
|
const filters = isGif
|
||||||
? [{ name: "GIF Image", extensions: ["gif"] }]
|
? [{ name: mainT("dialogs", "fileDialogs.gifImage"), extensions: ["gif"] }]
|
||||||
: [{ name: "MP4 Video", extensions: ["mp4"] }];
|
: [{ name: mainT("dialogs", "fileDialogs.mp4Video"), extensions: ["mp4"] }];
|
||||||
|
|
||||||
const result = await dialog.showSaveDialog({
|
const result = await dialog.showSaveDialog({
|
||||||
title: isGif ? "Save Exported GIF" : "Save Exported Video",
|
title: isGif
|
||||||
|
? mainT("dialogs", "fileDialogs.saveGif")
|
||||||
|
: mainT("dialogs", "fileDialogs.saveVideo"),
|
||||||
defaultPath: path.join(app.getPath("downloads"), fileName),
|
defaultPath: path.join(app.getPath("downloads"), fileName),
|
||||||
filters,
|
filters,
|
||||||
properties: ["createDirectory", "showOverwriteConfirmation"],
|
properties: ["createDirectory", "showOverwriteConfirmation"],
|
||||||
@@ -510,11 +667,14 @@ export function registerIpcHandlers(
|
|||||||
ipcMain.handle("open-video-file-picker", async () => {
|
ipcMain.handle("open-video-file-picker", async () => {
|
||||||
try {
|
try {
|
||||||
const result = await dialog.showOpenDialog({
|
const result = await dialog.showOpenDialog({
|
||||||
title: "Select Video File",
|
title: mainT("dialogs", "fileDialogs.selectVideo"),
|
||||||
defaultPath: RECORDINGS_DIR,
|
defaultPath: RECORDINGS_DIR,
|
||||||
filters: [
|
filters: [
|
||||||
{ name: "Video Files", extensions: ["webm", "mp4", "mov", "avi", "mkv"] },
|
{
|
||||||
{ name: "All Files", extensions: ["*"] },
|
name: mainT("dialogs", "fileDialogs.videoFiles"),
|
||||||
|
extensions: ["webm", "mp4", "mov", "avi", "mkv"],
|
||||||
|
},
|
||||||
|
{ name: mainT("dialogs", "fileDialogs.allFiles"), extensions: ["*"] },
|
||||||
],
|
],
|
||||||
properties: ["openFile"],
|
properties: ["openFile"],
|
||||||
});
|
});
|
||||||
@@ -523,10 +683,17 @@ export function registerIpcHandlers(
|
|||||||
return { success: false, canceled: true };
|
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;
|
currentProjectPath = null;
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
path: result.filePaths[0],
|
path: approvedPath,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to open file picker:", error);
|
console.error("Failed to open file picker:", error);
|
||||||
@@ -590,10 +757,13 @@ export function registerIpcHandlers(
|
|||||||
: `${safeName}.${PROJECT_FILE_EXTENSION}`;
|
: `${safeName}.${PROJECT_FILE_EXTENSION}`;
|
||||||
|
|
||||||
const result = await dialog.showSaveDialog({
|
const result = await dialog.showSaveDialog({
|
||||||
title: "Save OpenScreen Project",
|
title: mainT("dialogs", "fileDialogs.saveProject"),
|
||||||
defaultPath: path.join(RECORDINGS_DIR, defaultName),
|
defaultPath: path.join(RECORDINGS_DIR, defaultName),
|
||||||
filters: [
|
filters: [
|
||||||
{ name: "OpenScreen Project", extensions: [PROJECT_FILE_EXTENSION] },
|
{
|
||||||
|
name: mainT("dialogs", "fileDialogs.openscreenProject"),
|
||||||
|
extensions: [PROJECT_FILE_EXTENSION],
|
||||||
|
},
|
||||||
{ name: "JSON", extensions: ["json"] },
|
{ name: "JSON", extensions: ["json"] },
|
||||||
],
|
],
|
||||||
properties: ["createDirectory", "showOverwriteConfirmation"],
|
properties: ["createDirectory", "showOverwriteConfirmation"],
|
||||||
@@ -629,12 +799,15 @@ export function registerIpcHandlers(
|
|||||||
ipcMain.handle("load-project-file", async () => {
|
ipcMain.handle("load-project-file", async () => {
|
||||||
try {
|
try {
|
||||||
const result = await dialog.showOpenDialog({
|
const result = await dialog.showOpenDialog({
|
||||||
title: "Open OpenScreen Project",
|
title: mainT("dialogs", "fileDialogs.openProject"),
|
||||||
defaultPath: RECORDINGS_DIR,
|
defaultPath: RECORDINGS_DIR,
|
||||||
filters: [
|
filters: [
|
||||||
{ name: "OpenScreen Project", extensions: [PROJECT_FILE_EXTENSION] },
|
{
|
||||||
|
name: mainT("dialogs", "fileDialogs.openscreenProject"),
|
||||||
|
extensions: [PROJECT_FILE_EXTENSION],
|
||||||
|
},
|
||||||
{ name: "JSON", extensions: ["json"] },
|
{ name: "JSON", extensions: ["json"] },
|
||||||
{ name: "All Files", extensions: ["*"] },
|
{ name: mainT("dialogs", "fileDialogs.allFiles"), extensions: ["*"] },
|
||||||
],
|
],
|
||||||
properties: ["openFile"],
|
properties: ["openFile"],
|
||||||
});
|
});
|
||||||
@@ -646,19 +819,9 @@ export function registerIpcHandlers(
|
|||||||
const filePath = result.filePaths[0];
|
const filePath = result.filePaths[0];
|
||||||
const content = await fs.readFile(filePath, "utf-8");
|
const content = await fs.readFile(filePath, "utf-8");
|
||||||
const project = JSON.parse(content);
|
const project = JSON.parse(content);
|
||||||
|
const session = await getApprovedProjectSession(project, filePath);
|
||||||
currentProjectPath = filePath;
|
currentProjectPath = filePath;
|
||||||
if (project && typeof project === "object") {
|
setCurrentRecordingSessionState(session);
|
||||||
const rawProject = project as { media?: unknown; videoPath?: unknown };
|
|
||||||
const media =
|
|
||||||
normalizeProjectMedia(rawProject.media) ??
|
|
||||||
(typeof rawProject.videoPath === "string"
|
|
||||||
? {
|
|
||||||
screenVideoPath:
|
|
||||||
normalizeVideoSourcePath(rawProject.videoPath) ?? rawProject.videoPath,
|
|
||||||
}
|
|
||||||
: null);
|
|
||||||
setCurrentRecordingSessionState(media ? { ...media, createdAt: Date.now() } : null);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -683,18 +846,8 @@ export function registerIpcHandlers(
|
|||||||
|
|
||||||
const content = await fs.readFile(currentProjectPath, "utf-8");
|
const content = await fs.readFile(currentProjectPath, "utf-8");
|
||||||
const project = JSON.parse(content);
|
const project = JSON.parse(content);
|
||||||
if (project && typeof project === "object") {
|
const session = await getApprovedProjectSession(project, currentProjectPath);
|
||||||
const rawProject = project as { media?: unknown; videoPath?: unknown };
|
setCurrentRecordingSessionState(session);
|
||||||
const media =
|
|
||||||
normalizeProjectMedia(rawProject.media) ??
|
|
||||||
(typeof rawProject.videoPath === "string"
|
|
||||||
? {
|
|
||||||
screenVideoPath:
|
|
||||||
normalizeVideoSourcePath(rawProject.videoPath) ?? rawProject.videoPath,
|
|
||||||
}
|
|
||||||
: null);
|
|
||||||
setCurrentRecordingSessionState(media ? { ...media, createdAt: Date.now() } : null);
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
path: currentProjectPath,
|
path: currentProjectPath,
|
||||||
@@ -723,12 +876,22 @@ export function registerIpcHandlers(
|
|||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle("set-current-video-path", async (_, path: string) => {
|
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) {
|
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);
|
setCurrentRecordingSessionState(restoredSession);
|
||||||
} else {
|
} else {
|
||||||
setCurrentRecordingSessionState({
|
setCurrentRecordingSessionState({
|
||||||
screenVideoPath: normalizeVideoSourcePath(path) ?? path,
|
screenVideoPath: normalizedPath,
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+25
-14
@@ -12,6 +12,7 @@ import {
|
|||||||
systemPreferences,
|
systemPreferences,
|
||||||
Tray,
|
Tray,
|
||||||
} from "electron";
|
} from "electron";
|
||||||
|
import { mainT, setMainLocale } from "./i18n";
|
||||||
import { registerIpcHandlers } from "./ipc/handlers";
|
import { registerIpcHandlers } from "./ipc/handlers";
|
||||||
import { createEditorWindow, createHudOverlayWindow, createSourceSelectorWindow } from "./windows";
|
import { createEditorWindow, createHudOverlayWindow, createSourceSelectorWindow } from "./windows";
|
||||||
|
|
||||||
@@ -130,20 +131,20 @@ function setupApplicationMenu() {
|
|||||||
|
|
||||||
template.push(
|
template.push(
|
||||||
{
|
{
|
||||||
label: "File",
|
label: mainT("common", "actions.file") || "File",
|
||||||
submenu: [
|
submenu: [
|
||||||
{
|
{
|
||||||
label: "Load Project…",
|
label: mainT("dialogs", "unsavedChanges.loadProject") || "Load Project…",
|
||||||
accelerator: "CmdOrCtrl+O",
|
accelerator: "CmdOrCtrl+O",
|
||||||
click: () => sendEditorMenuAction("menu-load-project"),
|
click: () => sendEditorMenuAction("menu-load-project"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Save Project…",
|
label: mainT("dialogs", "unsavedChanges.saveProject") || "Save Project…",
|
||||||
accelerator: "CmdOrCtrl+S",
|
accelerator: "CmdOrCtrl+S",
|
||||||
click: () => sendEditorMenuAction("menu-save-project"),
|
click: () => sendEditorMenuAction("menu-save-project"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Save Project As…",
|
label: mainT("dialogs", "unsavedChanges.saveProjectAs") || "Save Project As…",
|
||||||
accelerator: "CmdOrCtrl+Shift+S",
|
accelerator: "CmdOrCtrl+Shift+S",
|
||||||
click: () => sendEditorMenuAction("menu-save-project-as"),
|
click: () => sendEditorMenuAction("menu-save-project-as"),
|
||||||
},
|
},
|
||||||
@@ -151,7 +152,7 @@ function setupApplicationMenu() {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Edit",
|
label: mainT("common", "actions.edit") || "Edit",
|
||||||
submenu: [
|
submenu: [
|
||||||
{ role: "undo" },
|
{ role: "undo" },
|
||||||
{ role: "redo" },
|
{ role: "redo" },
|
||||||
@@ -163,7 +164,7 @@ function setupApplicationMenu() {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "View",
|
label: mainT("common", "actions.view") || "View",
|
||||||
submenu: [
|
submenu: [
|
||||||
{ role: "reload" },
|
{ role: "reload" },
|
||||||
{ role: "forceReload" },
|
{ role: "forceReload" },
|
||||||
@@ -177,7 +178,7 @@ function setupApplicationMenu() {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Window",
|
label: mainT("common", "actions.window") || "Window",
|
||||||
submenu: isMac
|
submenu: isMac
|
||||||
? [{ role: "minimize" }, { role: "zoom" }, { type: "separator" }, { role: "front" }]
|
? [{ role: "minimize" }, { role: "zoom" }, { type: "separator" }, { role: "front" }]
|
||||||
: [{ role: "minimize" }, { role: "close" }],
|
: [{ role: "minimize" }, { role: "close" }],
|
||||||
@@ -215,7 +216,7 @@ function updateTrayMenu(recording: boolean = false) {
|
|||||||
const menuTemplate = recording
|
const menuTemplate = recording
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
label: "Stop Recording",
|
label: mainT("common", "actions.stopRecording") || "Stop Recording",
|
||||||
click: () => {
|
click: () => {
|
||||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||||
mainWindow.webContents.send("stop-recording-from-tray");
|
mainWindow.webContents.send("stop-recording-from-tray");
|
||||||
@@ -225,13 +226,13 @@ function updateTrayMenu(recording: boolean = false) {
|
|||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
{
|
{
|
||||||
label: "Open",
|
label: mainT("common", "actions.open") || "Open",
|
||||||
click: () => {
|
click: () => {
|
||||||
showMainWindow();
|
showMainWindow();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Quit",
|
label: mainT("common", "actions.quit") || "Quit",
|
||||||
click: () => {
|
click: () => {
|
||||||
app.quit();
|
app.quit();
|
||||||
},
|
},
|
||||||
@@ -281,12 +282,16 @@ function createEditorWindowWrapper() {
|
|||||||
|
|
||||||
const choice = dialog.showMessageBoxSync(mainWindow!, {
|
const choice = dialog.showMessageBoxSync(mainWindow!, {
|
||||||
type: "warning",
|
type: "warning",
|
||||||
buttons: ["Save & Close", "Discard & Close", "Cancel"],
|
buttons: [
|
||||||
|
mainT("dialogs", "unsavedChanges.saveAndClose"),
|
||||||
|
mainT("dialogs", "unsavedChanges.discardAndClose"),
|
||||||
|
mainT("common", "actions.cancel"),
|
||||||
|
],
|
||||||
defaultId: 0,
|
defaultId: 0,
|
||||||
cancelId: 2,
|
cancelId: 2,
|
||||||
title: "Unsaved Changes",
|
title: mainT("dialogs", "unsavedChanges.title"),
|
||||||
message: "You have unsaved changes.",
|
message: mainT("dialogs", "unsavedChanges.message"),
|
||||||
detail: "Do you want to save your project before closing?",
|
detail: mainT("dialogs", "unsavedChanges.detail"),
|
||||||
});
|
});
|
||||||
|
|
||||||
const windowToClose = mainWindow;
|
const windowToClose = mainWindow;
|
||||||
@@ -354,6 +359,12 @@ app.whenReady().then(async () => {
|
|||||||
ipcMain.on("hud-overlay-close", () => {
|
ipcMain.on("hud-overlay-close", () => {
|
||||||
app.quit();
|
app.quit();
|
||||||
});
|
});
|
||||||
|
ipcMain.handle("set-locale", (_, locale: string) => {
|
||||||
|
setMainLocale(locale);
|
||||||
|
setupApplicationMenu();
|
||||||
|
updateTrayMenu();
|
||||||
|
});
|
||||||
|
|
||||||
createTray();
|
createTray();
|
||||||
updateTrayMenu();
|
updateTrayMenu();
|
||||||
setupApplicationMenu();
|
setupApplicationMenu();
|
||||||
|
|||||||
@@ -115,6 +115,9 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
|||||||
saveShortcuts: (shortcuts: unknown) => {
|
saveShortcuts: (shortcuts: unknown) => {
|
||||||
return ipcRenderer.invoke("save-shortcuts", shortcuts);
|
return ipcRenderer.invoke("save-shortcuts", shortcuts);
|
||||||
},
|
},
|
||||||
|
setLocale: (locale: string) => {
|
||||||
|
return ipcRenderer.invoke("set-locale", locale);
|
||||||
|
},
|
||||||
setMicrophoneExpanded: (expanded: boolean) => {
|
setMicrophoneExpanded: (expanded: boolean) => {
|
||||||
ipcRenderer.send("hud:setMicrophoneExpanded", expanded);
|
ipcRenderer.send("hud:setMicrophoneExpanded", expanded);
|
||||||
},
|
},
|
||||||
|
|||||||
+6
-6
@@ -21,8 +21,8 @@ export function createHudOverlayWindow(): BrowserWindow {
|
|||||||
const primaryDisplay = screen.getPrimaryDisplay();
|
const primaryDisplay = screen.getPrimaryDisplay();
|
||||||
const { workArea } = primaryDisplay;
|
const { workArea } = primaryDisplay;
|
||||||
|
|
||||||
const windowWidth = 500;
|
const windowWidth = 600;
|
||||||
const windowHeight = 155;
|
const windowHeight = 160;
|
||||||
|
|
||||||
const x = Math.floor(workArea.x + (workArea.width - windowWidth) / 2);
|
const x = Math.floor(workArea.x + (workArea.width - windowWidth) / 2);
|
||||||
const y = Math.floor(workArea.y + workArea.height - windowHeight - 5);
|
const y = Math.floor(workArea.y + workArea.height - windowHeight - 5);
|
||||||
@@ -30,10 +30,10 @@ export function createHudOverlayWindow(): BrowserWindow {
|
|||||||
const win = new BrowserWindow({
|
const win = new BrowserWindow({
|
||||||
width: windowWidth,
|
width: windowWidth,
|
||||||
height: windowHeight,
|
height: windowHeight,
|
||||||
minWidth: 500,
|
minWidth: 600,
|
||||||
maxWidth: 500,
|
maxWidth: 600,
|
||||||
minHeight: 155,
|
minHeight: 160,
|
||||||
maxHeight: 155,
|
maxHeight: 160,
|
||||||
x: x,
|
x: x,
|
||||||
y: y,
|
y: y,
|
||||||
frame: false,
|
frame: false,
|
||||||
|
|||||||
Generated
+768
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "openscreen",
|
"name": "openscreen",
|
||||||
"version": "1.2.0",
|
"version": "1.3.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "openscreen",
|
"name": "openscreen",
|
||||||
"version": "1.2.0",
|
"version": "1.3.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fix-webm-duration/fix": "^1.0.1",
|
"@fix-webm-duration/fix": "^1.0.1",
|
||||||
"@pixi/filter-drop-shadow": "^5.2.0",
|
"@pixi/filter-drop-shadow": "^5.2.0",
|
||||||
@@ -52,6 +52,9 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.3.13",
|
"@biomejs/biome": "^2.3.13",
|
||||||
"@playwright/test": "^1.58.2",
|
"@playwright/test": "^1.58.2",
|
||||||
|
"@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/node": "^25.0.3",
|
||||||
"@types/react": "^18.2.64",
|
"@types/react": "^18.2.64",
|
||||||
"@types/react-dom": "^18.2.21",
|
"@types/react-dom": "^18.2.21",
|
||||||
@@ -64,6 +67,7 @@
|
|||||||
"electron-rebuild": "^3.2.9",
|
"electron-rebuild": "^3.2.9",
|
||||||
"fast-check": "^4.5.2",
|
"fast-check": "^4.5.2",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
|
"jsdom": "^29.0.1",
|
||||||
"lint-staged": "^16.3.2",
|
"lint-staged": "^16.3.2",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"tailwindcss": "^3.4.18",
|
"tailwindcss": "^3.4.18",
|
||||||
@@ -73,8 +77,19 @@
|
|||||||
"vite-plugin-electron": "^0.28.6",
|
"vite-plugin-electron": "^0.28.6",
|
||||||
"vite-plugin-electron-renderer": "^0.14.5",
|
"vite-plugin-electron-renderer": "^0.14.5",
|
||||||
"vitest": "^4.0.16"
|
"vitest": "^4.0.16"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "22.22.1",
|
||||||
|
"npm": "10.9.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@adobe/css-tools": {
|
||||||
|
"version": "4.4.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz",
|
||||||
|
"integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@alloc/quick-lru": {
|
"node_modules/@alloc/quick-lru": {
|
||||||
"version": "5.2.0",
|
"version": "5.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
|
||||||
@@ -87,6 +102,67 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@asamuzakjp/css-color": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@csstools/css-calc": "^3.1.1",
|
||||||
|
"@csstools/css-color-parser": "^4.0.2",
|
||||||
|
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||||
|
"@csstools/css-tokenizer": "^4.0.0",
|
||||||
|
"lru-cache": "^11.2.6"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@asamuzakjp/css-color/node_modules/lru-cache": {
|
||||||
|
"version": "11.2.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz",
|
||||||
|
"integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BlueOak-1.0.0",
|
||||||
|
"engines": {
|
||||||
|
"node": "20 || >=22"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@asamuzakjp/dom-selector": {
|
||||||
|
"version": "7.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.0.4.tgz",
|
||||||
|
"integrity": "sha512-jXR6x4AcT3eIrS2fSNAwJpwirOkGcd+E7F7CP3zjdTqz9B/2huHOL8YJZBgekKwLML+u7qB/6P1LXQuMScsx0w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@asamuzakjp/nwsapi": "^2.3.9",
|
||||||
|
"bidi-js": "^1.0.3",
|
||||||
|
"css-tree": "^3.2.1",
|
||||||
|
"is-potential-custom-element-name": "^1.0.1",
|
||||||
|
"lru-cache": "^11.2.7"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": {
|
||||||
|
"version": "11.2.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz",
|
||||||
|
"integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BlueOak-1.0.0",
|
||||||
|
"engines": {
|
||||||
|
"node": "20 || >=22"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@asamuzakjp/nwsapi": {
|
||||||
|
"version": "2.3.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
|
||||||
|
"integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@babel/code-frame": {
|
"node_modules/@babel/code-frame": {
|
||||||
"version": "7.27.1",
|
"version": "7.27.1",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
|
||||||
@@ -561,6 +637,159 @@
|
|||||||
"node": ">=14.21.3"
|
"node": ">=14.21.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@bramus/specificity": {
|
||||||
|
"version": "2.4.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
|
||||||
|
"integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"css-tree": "^3.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"specificity": "bin/cli.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@csstools/color-helpers": {
|
||||||
|
"version": "6.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz",
|
||||||
|
"integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/csstools"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/csstools"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT-0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.19.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@csstools/css-calc": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/csstools"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/csstools"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.19.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||||
|
"@csstools/css-tokenizer": "^4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@csstools/css-color-parser": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/csstools"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/csstools"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@csstools/color-helpers": "^6.0.2",
|
||||||
|
"@csstools/css-calc": "^3.1.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.19.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||||
|
"@csstools/css-tokenizer": "^4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@csstools/css-parser-algorithms": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/csstools"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/csstools"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.19.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@csstools/css-tokenizer": "^4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@csstools/css-syntax-patches-for-csstree": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-BvqN0AMWNAnLk9G8jnUT77D+mUbY/H2b3uDTvg2isJkHaOufUE2R3AOwxWo7VBQKT1lOdwdvorddo2B/lk64+w==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/csstools"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/csstools"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT-0",
|
||||||
|
"peerDependencies": {
|
||||||
|
"css-tree": "^3.2.1"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"css-tree": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@csstools/css-tokenizer": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/csstools"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/csstools"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.19.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@develar/schema-utils": {
|
"node_modules/@develar/schema-utils": {
|
||||||
"version": "2.6.5",
|
"version": "2.6.5",
|
||||||
"resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz",
|
"resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz",
|
||||||
@@ -1821,6 +2050,24 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@exodus/bytes": {
|
||||||
|
"version": "1.15.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz",
|
||||||
|
"integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@noble/hashes": "^1.8.0 || ^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@noble/hashes": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@fix-webm-duration/fix": {
|
"node_modules/@fix-webm-duration/fix": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@fix-webm-duration/fix/-/fix-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@fix-webm-duration/fix/-/fix-1.0.1.tgz",
|
||||||
@@ -4240,6 +4487,96 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@testing-library/dom": {
|
||||||
|
"version": "10.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
||||||
|
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/code-frame": "^7.10.4",
|
||||||
|
"@babel/runtime": "^7.12.5",
|
||||||
|
"@types/aria-query": "^5.0.1",
|
||||||
|
"aria-query": "5.3.0",
|
||||||
|
"dom-accessibility-api": "^0.5.9",
|
||||||
|
"lz-string": "^1.5.0",
|
||||||
|
"picocolors": "1.1.1",
|
||||||
|
"pretty-format": "^27.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@testing-library/jest-dom": {
|
||||||
|
"version": "6.9.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz",
|
||||||
|
"integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@adobe/css-tools": "^4.4.0",
|
||||||
|
"aria-query": "^5.0.0",
|
||||||
|
"css.escape": "^1.5.1",
|
||||||
|
"dom-accessibility-api": "^0.6.3",
|
||||||
|
"picocolors": "^1.1.1",
|
||||||
|
"redent": "^3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14",
|
||||||
|
"npm": ">=6",
|
||||||
|
"yarn": ">=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": {
|
||||||
|
"version": "0.6.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz",
|
||||||
|
"integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@testing-library/react": {
|
||||||
|
"version": "16.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz",
|
||||||
|
"integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.12.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@testing-library/dom": "^10.0.0",
|
||||||
|
"@types/react": "^18.0.0 || ^19.0.0",
|
||||||
|
"@types/react-dom": "^18.0.0 || ^19.0.0",
|
||||||
|
"react": "^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^18.0.0 || ^19.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@testing-library/user-event": {
|
||||||
|
"version": "14.6.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz",
|
||||||
|
"integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12",
|
||||||
|
"npm": ">=6"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@testing-library/dom": ">=7.21.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tokenizer/token": {
|
"node_modules/@tokenizer/token": {
|
||||||
"version": "0.3.0",
|
"version": "0.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz",
|
||||||
@@ -4257,6 +4594,14 @@
|
|||||||
"node": ">= 10"
|
"node": ">= 10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/aria-query": {
|
||||||
|
"version": "5.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
|
||||||
|
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
|
},
|
||||||
"node_modules/@types/babel__core": {
|
"node_modules/@types/babel__core": {
|
||||||
"version": "7.20.5",
|
"version": "7.20.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||||
@@ -5338,6 +5683,16 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/aria-query": {
|
||||||
|
"version": "5.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
|
||||||
|
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"dequal": "^2.0.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/array-union": {
|
"node_modules/array-union": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz",
|
||||||
@@ -5525,6 +5880,16 @@
|
|||||||
"tweetnacl": "^0.14.3"
|
"tweetnacl": "^0.14.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/bidi-js": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"require-from-string": "^2.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/binary-extensions": {
|
"node_modules/binary-extensions": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||||
@@ -6385,6 +6750,27 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/css-tree": {
|
||||||
|
"version": "3.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
|
||||||
|
"integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"mdn-data": "2.27.1",
|
||||||
|
"source-map-js": "^1.2.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/css.escape": {
|
||||||
|
"version": "1.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
|
||||||
|
"integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/cssesc": {
|
"node_modules/cssesc": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
||||||
@@ -6417,6 +6803,20 @@
|
|||||||
"node": ">=0.10"
|
"node": ">=0.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/data-urls": {
|
||||||
|
"version": "7.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
|
||||||
|
"integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"whatwg-mimetype": "^5.0.0",
|
||||||
|
"whatwg-url": "^16.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
@@ -6445,6 +6845,13 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/decimal.js": {
|
||||||
|
"version": "10.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
|
||||||
|
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/decompress-response": {
|
"node_modules/decompress-response": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
|
||||||
@@ -6575,6 +6982,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/dequal": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/detect-libc": {
|
"node_modules/detect-libc": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
@@ -6752,6 +7169,14 @@
|
|||||||
"resize-observer-polyfill": "^1.5.1"
|
"resize-observer-polyfill": "^1.5.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dom-accessibility-api": {
|
||||||
|
"version": "0.5.16",
|
||||||
|
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
||||||
|
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
|
},
|
||||||
"node_modules/dom-walk": {
|
"node_modules/dom-walk": {
|
||||||
"version": "0.1.2",
|
"version": "0.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/dom-walk/-/dom-walk-0.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/dom-walk/-/dom-walk-0.1.2.tgz",
|
||||||
@@ -7181,6 +7606,19 @@
|
|||||||
"once": "^1.4.0"
|
"once": "^1.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/entities": {
|
||||||
|
"version": "6.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
|
||||||
|
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/env-paths": {
|
"node_modules/env-paths": {
|
||||||
"version": "2.2.1",
|
"version": "2.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
|
||||||
@@ -8272,6 +8710,19 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/html-encoding-sniffer": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@exodus/bytes": "^1.6.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/http-cache-semantics": {
|
"node_modules/http-cache-semantics": {
|
||||||
"version": "4.2.0",
|
"version": "4.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
|
||||||
@@ -8667,6 +9118,13 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-potential-custom-element-name": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/is-stream": {
|
"node_modules/is-stream": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz",
|
||||||
@@ -8838,6 +9296,70 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/jsdom": {
|
||||||
|
"version": "29.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.1.tgz",
|
||||||
|
"integrity": "sha512-z6JOK5gRO7aMybVq/y/MlIpKh8JIi68FBKMUtKkK2KH/wMSRlCxQ682d08LB9fYXplyY/UXG8P4XXTScmdjApg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@asamuzakjp/css-color": "^5.0.1",
|
||||||
|
"@asamuzakjp/dom-selector": "^7.0.3",
|
||||||
|
"@bramus/specificity": "^2.4.2",
|
||||||
|
"@csstools/css-syntax-patches-for-csstree": "^1.1.1",
|
||||||
|
"@exodus/bytes": "^1.15.0",
|
||||||
|
"css-tree": "^3.2.1",
|
||||||
|
"data-urls": "^7.0.0",
|
||||||
|
"decimal.js": "^10.6.0",
|
||||||
|
"html-encoding-sniffer": "^6.0.0",
|
||||||
|
"is-potential-custom-element-name": "^1.0.1",
|
||||||
|
"lru-cache": "^11.2.7",
|
||||||
|
"parse5": "^8.0.0",
|
||||||
|
"saxes": "^6.0.0",
|
||||||
|
"symbol-tree": "^3.2.4",
|
||||||
|
"tough-cookie": "^6.0.1",
|
||||||
|
"undici": "^7.24.5",
|
||||||
|
"w3c-xmlserializer": "^5.0.0",
|
||||||
|
"webidl-conversions": "^8.0.1",
|
||||||
|
"whatwg-mimetype": "^5.0.0",
|
||||||
|
"whatwg-url": "^16.0.1",
|
||||||
|
"xml-name-validator": "^5.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || ^22.13.0 || >=24.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"canvas": "^3.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"canvas": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/jsdom/node_modules/lru-cache": {
|
||||||
|
"version": "11.2.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz",
|
||||||
|
"integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BlueOak-1.0.0",
|
||||||
|
"engines": {
|
||||||
|
"node": "20 || >=22"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/jsdom/node_modules/tough-cookie": {
|
||||||
|
"version": "6.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz",
|
||||||
|
"integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"tldts": "^7.0.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/jsesc": {
|
"node_modules/jsesc": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
|
||||||
@@ -9545,6 +10067,17 @@
|
|||||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lz-string": {
|
||||||
|
"version": "1.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
||||||
|
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"bin": {
|
||||||
|
"lz-string": "bin/bin.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lzma-native": {
|
"node_modules/lzma-native": {
|
||||||
"version": "8.0.6",
|
"version": "8.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/lzma-native/-/lzma-native-8.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/lzma-native/-/lzma-native-8.0.6.tgz",
|
||||||
@@ -9662,6 +10195,13 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/mdn-data": {
|
||||||
|
"version": "2.27.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
|
||||||
|
"integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "CC0-1.0"
|
||||||
|
},
|
||||||
"node_modules/mediabunny": {
|
"node_modules/mediabunny": {
|
||||||
"version": "1.25.1",
|
"version": "1.25.1",
|
||||||
"resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.25.1.tgz",
|
"resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.25.1.tgz",
|
||||||
@@ -9786,6 +10326,16 @@
|
|||||||
"dom-walk": "^0.1.0"
|
"dom-walk": "^0.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/min-indent": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/minimatch": {
|
"node_modules/minimatch": {
|
||||||
"version": "9.0.5",
|
"version": "9.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
|
||||||
@@ -10563,6 +11113,19 @@
|
|||||||
"integrity": "sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==",
|
"integrity": "sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/parse5": {
|
||||||
|
"version": "8.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz",
|
||||||
|
"integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"entities": "^6.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/path-is-absolute": {
|
"node_modules/path-is-absolute": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
|
||||||
@@ -11170,6 +11733,44 @@
|
|||||||
"node": "^12.20.0 || >=14"
|
"node": "^12.20.0 || >=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pretty-format": {
|
||||||
|
"version": "27.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
|
||||||
|
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-regex": "^5.0.1",
|
||||||
|
"ansi-styles": "^5.0.0",
|
||||||
|
"react-is": "^17.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pretty-format/node_modules/ansi-styles": {
|
||||||
|
"version": "5.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
|
||||||
|
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pretty-format/node_modules/react-is": {
|
||||||
|
"version": "17.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||||
|
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
|
},
|
||||||
"node_modules/proc-log": {
|
"node_modules/proc-log": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/proc-log/-/proc-log-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/proc-log/-/proc-log-5.0.0.tgz",
|
||||||
@@ -11720,6 +12321,20 @@
|
|||||||
"node": ">=8.10.0"
|
"node": ">=8.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/redent": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"indent-string": "^4.0.0",
|
||||||
|
"strip-indent": "^3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/regenerator-runtime": {
|
"node_modules/regenerator-runtime": {
|
||||||
"version": "0.13.11",
|
"version": "0.13.11",
|
||||||
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
|
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
|
||||||
@@ -11816,6 +12431,16 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/require-from-string": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/require-main-filename": {
|
"node_modules/require-main-filename": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-1.0.1.tgz",
|
||||||
@@ -12081,6 +12706,19 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/saxes": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"xmlchars": "^2.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=v12.22.7"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/scheduler": {
|
"node_modules/scheduler": {
|
||||||
"version": "0.23.2",
|
"version": "0.23.2",
|
||||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
||||||
@@ -12599,6 +13237,19 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/strip-indent": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"min-indent": "^1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/strtok3": {
|
"node_modules/strtok3": {
|
||||||
"version": "6.3.0",
|
"version": "6.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/strtok3/-/strtok3-6.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/strtok3/-/strtok3-6.3.0.tgz",
|
||||||
@@ -12864,6 +13515,13 @@
|
|||||||
"camelcase": "^3.0.0"
|
"camelcase": "^3.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/symbol-tree": {
|
||||||
|
"version": "3.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
|
||||||
|
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tailwind-merge": {
|
"node_modules/tailwind-merge": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.3.1.tgz",
|
||||||
@@ -13213,6 +13871,26 @@
|
|||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tldts": {
|
||||||
|
"version": "7.0.27",
|
||||||
|
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.27.tgz",
|
||||||
|
"integrity": "sha512-I4FZcVFcqCRuT0ph6dCDpPuO4Xgzvh+spkcTr1gK7peIvxWauoloVO0vuy1FQnijT63ss6AsHB6+OIM4aXHbPg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tldts-core": "^7.0.27"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"tldts": "bin/cli.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tldts-core": {
|
||||||
|
"version": "7.0.27",
|
||||||
|
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.27.tgz",
|
||||||
|
"integrity": "sha512-YQ7uPjgWUibIK6DW5lrKujGwUKhLevU4hcGbP5O6TcIUb+oTjJYJVWPS4nZsIHrEEEG6myk/oqAJUEQmpZrHsg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tmp": {
|
"node_modules/tmp": {
|
||||||
"version": "0.2.5",
|
"version": "0.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz",
|
||||||
@@ -13277,6 +13955,19 @@
|
|||||||
"node": ">=0.8"
|
"node": ">=0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tr46": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"punycode": "^2.3.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/truncate-utf8-bytes": {
|
"node_modules/truncate-utf8-bytes": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz",
|
||||||
@@ -13340,6 +14031,16 @@
|
|||||||
"node": ">=14.17"
|
"node": ">=14.17"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/undici": {
|
||||||
|
"version": "7.24.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici/-/undici-7.24.6.tgz",
|
||||||
|
"integrity": "sha512-Xi4agocCbRzt0yYMZGMA6ApD7gvtUFaxm4ZmeacWI4cZxaF6C+8I8QfofC20NAePiB/IcvZmzkJ7XPa471AEtA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.18.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/undici-types": {
|
"node_modules/undici-types": {
|
||||||
"version": "7.16.0",
|
"version": "7.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
||||||
@@ -14277,6 +14978,19 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/w3c-xmlserializer": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"xml-name-validator": "^5.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/wcwidth": {
|
"node_modules/wcwidth": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz",
|
||||||
@@ -14296,6 +15010,41 @@
|
|||||||
"pnpm": ">=8"
|
"pnpm": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/webidl-conversions": {
|
||||||
|
"version": "8.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
|
||||||
|
"integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/whatwg-mimetype": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/whatwg-url": {
|
||||||
|
"version": "16.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
|
||||||
|
"integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@exodus/bytes": "^1.11.0",
|
||||||
|
"tr46": "^6.0.0",
|
||||||
|
"webidl-conversions": "^8.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/which": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
@@ -14401,6 +15150,16 @@
|
|||||||
"xtend": "^4.0.0"
|
"xtend": "^4.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/xml-name-validator": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/xml-parse-from-string": {
|
"node_modules/xml-parse-from-string": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/xml-parse-from-string/-/xml-parse-from-string-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/xml-parse-from-string/-/xml-parse-from-string-1.0.1.tgz",
|
||||||
@@ -14442,6 +15201,13 @@
|
|||||||
"node": ">=8.0"
|
"node": ">=8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/xmlchars": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/xtend": {
|
"node_modules/xtend": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||||
|
|||||||
+11
-1
@@ -1,14 +1,20 @@
|
|||||||
{
|
{
|
||||||
"name": "openscreen",
|
"name": "openscreen",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.2.0",
|
"version": "1.3.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"packageManager": "npm@10.9.4",
|
||||||
|
"engines": {
|
||||||
|
"node": "22.22.1",
|
||||||
|
"npm": "10.9.4"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build && electron-builder",
|
"build": "tsc && vite build && electron-builder",
|
||||||
"lint": "biome check .",
|
"lint": "biome check .",
|
||||||
"lint:fix": "biome check --write .",
|
"lint:fix": "biome check --write .",
|
||||||
"format": "biome format --write .",
|
"format": "biome format --write .",
|
||||||
|
"i18n:check": "node scripts/i18n-check.mjs",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"build:mac": "tsc && vite build && electron-builder --mac",
|
"build:mac": "tsc && vite build && electron-builder --mac",
|
||||||
"build:win": "tsc && vite build && electron-builder --win",
|
"build:win": "tsc && vite build && electron-builder --win",
|
||||||
@@ -64,6 +70,9 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.3.13",
|
"@biomejs/biome": "^2.3.13",
|
||||||
"@playwright/test": "^1.58.2",
|
"@playwright/test": "^1.58.2",
|
||||||
|
"@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/node": "^25.0.3",
|
||||||
"@types/react": "^18.2.64",
|
"@types/react": "^18.2.64",
|
||||||
"@types/react-dom": "^18.2.21",
|
"@types/react-dom": "^18.2.21",
|
||||||
@@ -76,6 +85,7 @@
|
|||||||
"electron-rebuild": "^3.2.9",
|
"electron-rebuild": "^3.2.9",
|
||||||
"fast-check": "^4.5.2",
|
"fast-check": "^4.5.2",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
|
"jsdom": "^29.0.1",
|
||||||
"lint-staged": "^16.3.2",
|
"lint-staged": "^16.3.2",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"tailwindcss": "^3.4.18",
|
"tailwindcss": "^3.4.18",
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Validates that all locale translation files have identical key structures.
|
||||||
|
* Compares zh-CN and es against the en baseline for every namespace.
|
||||||
|
*
|
||||||
|
* Usage: node scripts/i18n-check.mjs
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const LOCALES_DIR = path.resolve("src/i18n/locales");
|
||||||
|
const BASE_LOCALE = "en";
|
||||||
|
const COMPARE_LOCALES = ["zh-CN", "es"];
|
||||||
|
|
||||||
|
function getKeys(obj, prefix = "") {
|
||||||
|
const keys = [];
|
||||||
|
for (const [key, value] of Object.entries(obj)) {
|
||||||
|
const fullKey = prefix ? `${prefix}.${key}` : key;
|
||||||
|
if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||||
|
keys.push(...getKeys(value, fullKey));
|
||||||
|
} else {
|
||||||
|
keys.push(fullKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return keys.sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
let hasErrors = false;
|
||||||
|
|
||||||
|
const baseDir = path.join(LOCALES_DIR, BASE_LOCALE);
|
||||||
|
const namespaces = fs
|
||||||
|
.readdirSync(baseDir)
|
||||||
|
.filter((f) => f.endsWith(".json"))
|
||||||
|
.map((f) => f.replace(".json", ""));
|
||||||
|
|
||||||
|
for (const namespace of namespaces) {
|
||||||
|
const basePath = path.join(baseDir, `${namespace}.json`);
|
||||||
|
const baseData = JSON.parse(fs.readFileSync(basePath, "utf-8"));
|
||||||
|
const baseKeys = getKeys(baseData);
|
||||||
|
|
||||||
|
for (const locale of COMPARE_LOCALES) {
|
||||||
|
const localePath = path.join(LOCALES_DIR, locale, `${namespace}.json`);
|
||||||
|
|
||||||
|
if (!fs.existsSync(localePath)) {
|
||||||
|
console.error(`MISSING: ${locale}/${namespace}.json does not exist`);
|
||||||
|
hasErrors = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const localeData = JSON.parse(fs.readFileSync(localePath, "utf-8"));
|
||||||
|
const localeKeys = getKeys(localeData);
|
||||||
|
|
||||||
|
const missing = baseKeys.filter((k) => !localeKeys.includes(k));
|
||||||
|
const extra = localeKeys.filter((k) => !baseKeys.includes(k));
|
||||||
|
|
||||||
|
if (missing.length > 0) {
|
||||||
|
console.error(`MISSING in ${locale}/${namespace}.json:`);
|
||||||
|
for (const key of missing) {
|
||||||
|
console.error(` - ${key}`);
|
||||||
|
}
|
||||||
|
hasErrors = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (extra.length > 0) {
|
||||||
|
console.error(`EXTRA in ${locale}/${namespace}.json:`);
|
||||||
|
for (const key of extra) {
|
||||||
|
console.error(` + ${key}`);
|
||||||
|
}
|
||||||
|
hasErrors = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasErrors) {
|
||||||
|
console.error("\ni18n check FAILED — translation files are out of sync.");
|
||||||
|
process.exit(1);
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
`i18n check PASSED — all ${COMPARE_LOCALES.length} locales match ${BASE_LOCALE} across ${namespaces.length} namespaces.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
+7
-1
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { LaunchWindow } from "./components/launch/LaunchWindow";
|
import { LaunchWindow } from "./components/launch/LaunchWindow";
|
||||||
import { SourceSelector } from "./components/launch/SourceSelector";
|
import { SourceSelector } from "./components/launch/SourceSelector";
|
||||||
|
import { Toaster } from "./components/ui/sonner";
|
||||||
import { TooltipProvider } from "./components/ui/tooltip";
|
import { TooltipProvider } from "./components/ui/tooltip";
|
||||||
import { ShortcutsConfigDialog } from "./components/video-editor/ShortcutsConfigDialog";
|
import { ShortcutsConfigDialog } from "./components/video-editor/ShortcutsConfigDialog";
|
||||||
import VideoEditor from "./components/video-editor/VideoEditor";
|
import VideoEditor from "./components/video-editor/VideoEditor";
|
||||||
@@ -48,5 +49,10 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
return <TooltipProvider>{content}</TooltipProvider>;
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
{content}
|
||||||
|
<Toaster theme="dark" className="pointer-events-auto" />
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { ChevronDown } from "lucide-react";
|
import { ChevronDown, Languages } from "lucide-react";
|
||||||
import { useEffect, useState } from "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 { FaRegStopCircle } from "react-icons/fa";
|
||||||
import { FaFolderOpen } from "react-icons/fa6";
|
import { FaFolderOpen } from "react-icons/fa6";
|
||||||
import { FiMinus, FiX } from "react-icons/fi";
|
import { FiMinus, FiX } from "react-icons/fi";
|
||||||
import {
|
import {
|
||||||
|
MdCancel,
|
||||||
MdMic,
|
MdMic,
|
||||||
MdMicOff,
|
MdMicOff,
|
||||||
MdMonitor,
|
MdMonitor,
|
||||||
@@ -16,9 +17,15 @@ import {
|
|||||||
MdVolumeUp,
|
MdVolumeUp,
|
||||||
} from "react-icons/md";
|
} from "react-icons/md";
|
||||||
import { RxDragHandleDots2 } from "react-icons/rx";
|
import { RxDragHandleDots2 } from "react-icons/rx";
|
||||||
|
import { useI18n, useScopedT } from "@/contexts/I18nContext";
|
||||||
|
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 { useAudioLevelMeter } from "../../hooks/useAudioLevelMeter";
|
||||||
|
import { useCameraDevices } from "../../hooks/useCameraDevices";
|
||||||
import { useMicrophoneDevices } from "../../hooks/useMicrophoneDevices";
|
import { useMicrophoneDevices } from "../../hooks/useMicrophoneDevices";
|
||||||
import { useScreenRecorder } from "../../hooks/useScreenRecorder";
|
import { useScreenRecorder } from "../../hooks/useScreenRecorder";
|
||||||
|
import { requestCameraAccess } from "../../lib/requestCameraAccess";
|
||||||
import { formatTimePadded } from "../../utils/timeUtils";
|
import { formatTimePadded } from "../../utils/timeUtils";
|
||||||
import { AudioLevelMeter } from "../ui/audio-level-meter";
|
import { AudioLevelMeter } from "../ui/audio-level-meter";
|
||||||
import { Tooltip } from "../ui/tooltip";
|
import { Tooltip } from "../ui/tooltip";
|
||||||
@@ -35,8 +42,11 @@ const ICON_CONFIG = {
|
|||||||
micOff: { icon: MdMicOff, size: ICON_SIZE },
|
micOff: { icon: MdMicOff, size: ICON_SIZE },
|
||||||
webcamOn: { icon: MdVideocam, size: ICON_SIZE },
|
webcamOn: { icon: MdVideocam, size: ICON_SIZE },
|
||||||
webcamOff: { icon: MdVideocamOff, 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 },
|
stop: { icon: FaRegStopCircle, size: ICON_SIZE },
|
||||||
restart: { icon: MdRestartAlt, size: ICON_SIZE },
|
restart: { icon: MdRestartAlt, size: ICON_SIZE },
|
||||||
|
cancel: { icon: MdCancel, size: ICON_SIZE },
|
||||||
record: { icon: BsRecordCircle, size: ICON_SIZE },
|
record: { icon: BsRecordCircle, size: ICON_SIZE },
|
||||||
videoFile: { icon: MdVideoFile, size: ICON_SIZE },
|
videoFile: { icon: MdVideoFile, size: ICON_SIZE },
|
||||||
folder: { icon: FaFolderOpen, size: ICON_SIZE },
|
folder: { icon: FaFolderOpen, size: ICON_SIZE },
|
||||||
@@ -61,10 +71,22 @@ const windowBtnClasses =
|
|||||||
"flex items-center justify-center p-2 rounded-full transition-all duration-150 cursor-pointer opacity-50 hover:opacity-90 hover:bg-white/[0.08]";
|
"flex items-center justify-center p-2 rounded-full transition-all duration-150 cursor-pointer opacity-50 hover:opacity-90 hover:bg-white/[0.08]";
|
||||||
|
|
||||||
export function LaunchWindow() {
|
export function LaunchWindow() {
|
||||||
|
const t = useScopedT("launch");
|
||||||
|
const { locale, setLocale } = useI18n();
|
||||||
|
const [isMac, setIsMac] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getIsMac().then(setIsMac);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
recording,
|
recording,
|
||||||
|
paused,
|
||||||
|
elapsedSeconds,
|
||||||
toggleRecording,
|
toggleRecording,
|
||||||
|
togglePaused,
|
||||||
restartRecording,
|
restartRecording,
|
||||||
|
cancelRecording,
|
||||||
microphoneEnabled,
|
microphoneEnabled,
|
||||||
setMicrophoneEnabled,
|
setMicrophoneEnabled,
|
||||||
microphoneDeviceId,
|
microphoneDeviceId,
|
||||||
@@ -73,42 +95,72 @@ export function LaunchWindow() {
|
|||||||
setSystemAudioEnabled,
|
setSystemAudioEnabled,
|
||||||
webcamEnabled,
|
webcamEnabled,
|
||||||
setWebcamEnabled,
|
setWebcamEnabled,
|
||||||
|
webcamDeviceId,
|
||||||
|
setWebcamDeviceId,
|
||||||
} = useScreenRecorder();
|
} = useScreenRecorder();
|
||||||
const [recordingStart, setRecordingStart] = useState<number | null>(null);
|
|
||||||
const [elapsed, setElapsed] = useState(0);
|
|
||||||
|
|
||||||
const showMicControls = microphoneEnabled && !recording;
|
const showMicControls = microphoneEnabled && !recording;
|
||||||
const { devices, selectedDeviceId, setSelectedDeviceId } =
|
const showWebcamControls = webcamEnabled && !recording;
|
||||||
useMicrophoneDevices(microphoneEnabled);
|
|
||||||
|
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({
|
const { level } = useAudioLevelMeter({
|
||||||
enabled: showMicControls,
|
enabled: showMicControls,
|
||||||
deviceId: microphoneDeviceId,
|
deviceId: microphoneDeviceId,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedDeviceId && selectedDeviceId !== "default") {
|
if (selectedMicId && selectedMicId !== "default") {
|
||||||
setMicrophoneDeviceId(selectedDeviceId);
|
setMicrophoneDeviceId(selectedMicId);
|
||||||
}
|
}
|
||||||
}, [selectedDeviceId, setMicrophoneDeviceId]);
|
}, [selectedMicId, setMicrophoneDeviceId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let timer: NodeJS.Timeout | null = null;
|
if (selectedCameraId) {
|
||||||
if (recording) {
|
setWebcamDeviceId(selectedCameraId);
|
||||||
if (!recordingStart) setRecordingStart(Date.now());
|
|
||||||
timer = setInterval(() => {
|
|
||||||
if (recordingStart) {
|
|
||||||
setElapsed(Math.floor((Date.now() - recordingStart) / 1000));
|
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
} else {
|
|
||||||
setRecordingStart(null);
|
|
||||||
setElapsed(0);
|
|
||||||
if (timer) clearInterval(timer);
|
|
||||||
}
|
}
|
||||||
return () => {
|
}, [selectedCameraId, setWebcamDeviceId]);
|
||||||
if (timer) clearInterval(timer);
|
|
||||||
};
|
useEffect(() => {
|
||||||
}, [recording, recordingStart]);
|
if (!import.meta.env.DEV) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void requestCameraAccess().catch((error) => {
|
||||||
|
console.warn("Failed to trigger camera access request during development:", error);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const [selectedSource, setSelectedSource] = useState("Screen");
|
const [selectedSource, setSelectedSource] = useState("Screen");
|
||||||
const [hasSelectedSource, setHasSelectedSource] = useState(false);
|
const [hasSelectedSource, setHasSelectedSource] = useState(false);
|
||||||
@@ -176,156 +228,309 @@ export function LaunchWindow() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full flex items-end justify-center bg-transparent">
|
<div className={`w-screen h-screen bg-transparent ${styles.electronDrag}`}>
|
||||||
<div className={`flex flex-col items-center gap-2 mx-auto ${styles.electronDrag}`}>
|
{/* Language switcher — top-left, beside traffic lights */}
|
||||||
{/* Mic controls panel */}
|
<div
|
||||||
{showMicControls && (
|
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}`}
|
||||||
<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}`}
|
<Languages size={14} />
|
||||||
>
|
<select
|
||||||
<div className="relative flex-1" style={{ maxWidth: "70%" }}>
|
value={locale}
|
||||||
<select
|
onChange={(e) => setLocale(e.target.value as Locale)}
|
||||||
value={microphoneDeviceId || selectedDeviceId}
|
className="bg-transparent text-[11px] font-medium outline-none cursor-pointer appearance-none pr-1"
|
||||||
onChange={(e) => {
|
style={{ color: "inherit" }}
|
||||||
setSelectedDeviceId(e.target.value);
|
>
|
||||||
setMicrophoneDeviceId(e.target.value);
|
{SUPPORTED_LOCALES.map((loc) => (
|
||||||
}}
|
<option key={loc} value={loc} className="bg-[#1c1c24] text-white">
|
||||||
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"
|
{getLocaleName(loc)}
|
||||||
>
|
</option>
|
||||||
{devices.map((device) => (
|
))}
|
||||||
<option key={device.deviceId} value={device.deviceId}>
|
</select>
|
||||||
{device.label}
|
</div>
|
||||||
</option>
|
|
||||||
))}
|
{/* Device selectors — fixed above HUD bar, viewport-relative, never clipped */}
|
||||||
</select>
|
{(showMicControls || showWebcamControls) && (
|
||||||
<ChevronDown
|
<div
|
||||||
size={14}
|
className={`fixed bottom-[60px] left-1/2 -translate-x-1/2 flex items-center gap-2 animate-mic-panel-in ${styles.electronNoDrag}`}
|
||||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-white/60 pointer-events-none"
|
>
|
||||||
|
{/* 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>
|
</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 ? "Disable system audio" : "Enable system audio"}
|
|
||||||
>
|
|
||||||
{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 ? "Disable microphone" : "Enable microphone"}
|
|
||||||
>
|
|
||||||
{microphoneEnabled
|
|
||||||
? getIcon("micOn", "text-green-400")
|
|
||||||
: getIcon("micOff", "text-white/40")}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`${hudIconBtnClasses} ${webcamEnabled ? "drop-shadow-[0_0_4px_rgba(74,222,128,0.4)]" : ""}`}
|
|
||||||
onClick={() => {
|
|
||||||
void setWebcamEnabled(!webcamEnabled);
|
|
||||||
}}
|
|
||||||
title={webcamEnabled ? "Disable webcam" : "Enable webcam"}
|
|
||||||
>
|
|
||||||
{webcamEnabled
|
|
||||||
? getIcon("webcamOn", "text-green-400")
|
|
||||||
: getIcon("webcamOff", "text-white/40")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Record/Stop group */}
|
|
||||||
<button
|
|
||||||
className={`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="Restart recording">
|
|
||||||
<button
|
|
||||||
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
|
|
||||||
onClick={restartRecording}
|
|
||||||
>
|
|
||||||
{getIcon("restart", "text-white/60")}
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Open video file */}
|
{/* Webcam selector */}
|
||||||
<Tooltip content="Open video file">
|
{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
|
<button
|
||||||
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
|
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
|
||||||
onClick={openVideoFile}
|
onClick={togglePaused}
|
||||||
disabled={recording}
|
|
||||||
>
|
>
|
||||||
{getIcon("videoFile", "text-white/60")}
|
{getIcon(paused ? "resume" : "pause", paused ? "text-amber-400" : "text-white/60")}
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Open project */}
|
{/* Restart recording */}
|
||||||
<Tooltip content="Open project">
|
{recording && (
|
||||||
|
<Tooltip content={t("tooltips.restartRecording")}>
|
||||||
<button
|
<button
|
||||||
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
|
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
|
||||||
onClick={openProjectFile}
|
onClick={restartRecording}
|
||||||
disabled={recording}
|
|
||||||
>
|
>
|
||||||
{getIcon("folder", "text-white/60")}
|
{getIcon("restart", "text-white/60")}
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Window controls */}
|
{/* Cancel recording */}
|
||||||
<div className={`flex items-center gap-0.5 ${styles.electronNoDrag}`}>
|
{recording && (
|
||||||
<button className={windowBtnClasses} title="Hide HUD" onClick={sendHudOverlayHide}>
|
<Tooltip content={t("tooltips.cancelRecording")}>
|
||||||
{getIcon("minimize", "text-white")}
|
<button
|
||||||
|
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
|
||||||
|
onClick={cancelRecording}
|
||||||
|
>
|
||||||
|
{getIcon("cancel", "text-white/60")}
|
||||||
</button>
|
</button>
|
||||||
<button className={windowBtnClasses} title="Close App" onClick={sendHudOverlayClose}>
|
</Tooltip>
|
||||||
{getIcon("close", "text-white")}
|
)}
|
||||||
</button>
|
|
||||||
</div>
|
{/* 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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { MdCheck } from "react-icons/md";
|
import { MdCheck } from "react-icons/md";
|
||||||
|
import { useScopedT } from "@/contexts/I18nContext";
|
||||||
import { Button } from "../ui/button";
|
import { Button } from "../ui/button";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
|
||||||
import styles from "./SourceSelector.module.css";
|
import styles from "./SourceSelector.module.css";
|
||||||
@@ -13,6 +14,8 @@ interface DesktopSource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SourceSelector() {
|
export function SourceSelector() {
|
||||||
|
const t = useScopedT("launch");
|
||||||
|
const tc = useScopedT("common");
|
||||||
const [sources, setSources] = useState<DesktopSource[]>([]);
|
const [sources, setSources] = useState<DesktopSource[]>([]);
|
||||||
const [selectedSource, setSelectedSource] = useState<DesktopSource | null>(null);
|
const [selectedSource, setSelectedSource] = useState<DesktopSource | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -63,7 +66,7 @@ export function SourceSelector() {
|
|||||||
>
|
>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-[#34B27B] mx-auto mb-2" />
|
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-[#34B27B] mx-auto mb-2" />
|
||||||
<p className="text-xs text-zinc-400">Loading sources...</p>
|
<p className="text-xs text-zinc-400">{t("sourceSelector.loading")}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -113,13 +116,13 @@ export function SourceSelector() {
|
|||||||
value="screens"
|
value="screens"
|
||||||
className="data-[state=active]:bg-white/15 data-[state=active]:text-white text-zinc-400 rounded-full text-xs py-1 transition-all"
|
className="data-[state=active]:bg-white/15 data-[state=active]:text-white text-zinc-400 rounded-full text-xs py-1 transition-all"
|
||||||
>
|
>
|
||||||
Screens ({screenSources.length})
|
{t("sourceSelector.screens", { count: String(screenSources.length) })}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="windows"
|
value="windows"
|
||||||
className="data-[state=active]:bg-white/15 data-[state=active]:text-white text-zinc-400 rounded-full text-xs py-1 transition-all"
|
className="data-[state=active]:bg-white/15 data-[state=active]:text-white text-zinc-400 rounded-full text-xs py-1 transition-all"
|
||||||
>
|
>
|
||||||
Windows ({windowSources.length})
|
{t("sourceSelector.windows", { count: String(windowSources.length) })}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
<div className="flex-1 min-h-0">
|
<div className="flex-1 min-h-0">
|
||||||
@@ -146,14 +149,14 @@ export function SourceSelector() {
|
|||||||
onClick={() => window.close()}
|
onClick={() => window.close()}
|
||||||
className="px-5 py-1 text-xs text-zinc-400 hover:text-white hover:bg-white/5 rounded-full"
|
className="px-5 py-1 text-xs text-zinc-400 hover:text-white hover:bg-white/5 rounded-full"
|
||||||
>
|
>
|
||||||
Cancel
|
{tc("actions.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleShare}
|
onClick={handleShare}
|
||||||
disabled={!selectedSource}
|
disabled={!selectedSource}
|
||||||
className="px-5 py-1 text-xs bg-[#34B27B] text-white hover:bg-[#34B27B]/80 disabled:opacity-30 disabled:bg-zinc-700 rounded-full"
|
className="px-5 py-1 text-xs bg-[#34B27B] text-white hover:bg-[#34B27B]/80 disabled:opacity-30 disabled:bg-zinc-700 rounded-full"
|
||||||
>
|
>
|
||||||
Share
|
{tc("actions.share")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { useScopedT } from "@/contexts/I18nContext";
|
||||||
import {
|
import {
|
||||||
addCustomFont,
|
addCustomFont,
|
||||||
type CustomFont,
|
type CustomFont,
|
||||||
@@ -25,6 +26,8 @@ interface AddCustomFontDialogProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function AddCustomFontDialog({ onFontAdded }: AddCustomFontDialogProps) {
|
export function AddCustomFontDialog({ onFontAdded }: AddCustomFontDialogProps) {
|
||||||
|
const t = useScopedT("settings");
|
||||||
|
const tc = useScopedT("common");
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [importUrl, setImportUrl] = useState("");
|
const [importUrl, setImportUrl] = useState("");
|
||||||
const [fontName, setFontName] = useState("");
|
const [fontName, setFontName] = useState("");
|
||||||
@@ -45,17 +48,17 @@ export function AddCustomFontDialog({ onFontAdded }: AddCustomFontDialogProps) {
|
|||||||
const handleAdd = async () => {
|
const handleAdd = async () => {
|
||||||
// Validate inputs
|
// Validate inputs
|
||||||
if (!importUrl.trim()) {
|
if (!importUrl.trim()) {
|
||||||
toast.error("Please enter a Google Fonts import URL");
|
toast.error(t("customFont.errorEmptyUrl"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isValidGoogleFontsUrl(importUrl)) {
|
if (!isValidGoogleFontsUrl(importUrl)) {
|
||||||
toast.error("Please enter a valid Google Fonts URL");
|
toast.error(t("customFont.errorInvalidUrl"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!fontName.trim()) {
|
if (!fontName.trim()) {
|
||||||
toast.error("Please enter a font name");
|
toast.error(t("customFont.errorEmptyName"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,7 +68,7 @@ export function AddCustomFontDialog({ onFontAdded }: AddCustomFontDialogProps) {
|
|||||||
// Extract font family from URL
|
// Extract font family from URL
|
||||||
const fontFamily = parseFontFamilyFromImport(importUrl);
|
const fontFamily = parseFontFamilyFromImport(importUrl);
|
||||||
if (!fontFamily) {
|
if (!fontFamily) {
|
||||||
toast.error("Could not extract font family from URL");
|
toast.error(t("customFont.errorExtractFailed"));
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -86,7 +89,7 @@ export function AddCustomFontDialog({ onFontAdded }: AddCustomFontDialogProps) {
|
|||||||
onFontAdded(newFont);
|
onFontAdded(newFont);
|
||||||
}
|
}
|
||||||
|
|
||||||
toast.success(`Font "${fontName}" added successfully`);
|
toast.success(t("customFont.successMessage", { fontName }));
|
||||||
|
|
||||||
// Reset and close
|
// Reset and close
|
||||||
setImportUrl("");
|
setImportUrl("");
|
||||||
@@ -95,10 +98,10 @@ export function AddCustomFontDialog({ onFontAdded }: AddCustomFontDialogProps) {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to add custom font:", error);
|
console.error("Failed to add custom font:", error);
|
||||||
const errorMessage = error instanceof Error ? error.message : "Failed to load font";
|
const errorMessage = error instanceof Error ? error.message : "Failed to load font";
|
||||||
toast.error("Failed to add font", {
|
toast.error(t("customFont.failedToAdd"), {
|
||||||
description: errorMessage.includes("timeout")
|
description: errorMessage.includes("timeout")
|
||||||
? "Font took too long to load. Please check the URL and try again."
|
? t("customFont.errorTimeout")
|
||||||
: "The font could not be loaded. Please verify the Google Fonts URL is correct.",
|
: t("customFont.errorLoadFailed"),
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -114,12 +117,12 @@ export function AddCustomFontDialog({ onFontAdded }: AddCustomFontDialogProps) {
|
|||||||
className="w-full bg-white/5 border-white/10 text-slate-200 hover:bg-white/10 h-9 text-xs"
|
className="w-full bg-white/5 border-white/10 text-slate-200 hover:bg-white/10 h-9 text-xs"
|
||||||
>
|
>
|
||||||
<Plus className="w-3 h-3 mr-1" />
|
<Plus className="w-3 h-3 mr-1" />
|
||||||
Add Google Font
|
{t("customFont.dialogTitle")}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="bg-[#1a1a1c] border-white/10 text-slate-200">
|
<DialogContent className="bg-[#1a1a1c] border-white/10 text-slate-200">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Add Google Font</DialogTitle>
|
<DialogTitle>{t("customFont.dialogTitle")}</DialogTitle>
|
||||||
<DialogDescription className="text-slate-400">
|
<DialogDescription className="text-slate-400">
|
||||||
Add a custom font from Google Fonts to use in your annotations.
|
Add a custom font from Google Fonts to use in your annotations.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
@@ -128,34 +131,30 @@ export function AddCustomFontDialog({ onFontAdded }: AddCustomFontDialogProps) {
|
|||||||
<div className="space-y-4 mt-4">
|
<div className="space-y-4 mt-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="import-url" className="text-slate-200">
|
<Label htmlFor="import-url" className="text-slate-200">
|
||||||
Google Fonts Import URL
|
{t("customFont.urlLabel")}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="import-url"
|
id="import-url"
|
||||||
placeholder="https://fonts.googleapis.com/css2?family=Roboto&display=swap"
|
placeholder={t("customFont.urlPlaceholder")}
|
||||||
value={importUrl}
|
value={importUrl}
|
||||||
onChange={(e) => handleImportUrlChange(e.target.value)}
|
onChange={(e) => handleImportUrlChange(e.target.value)}
|
||||||
className="bg-white/5 border-white/10 text-slate-200"
|
className="bg-white/5 border-white/10 text-slate-200"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-slate-400">
|
<p className="text-xs text-slate-400">{t("customFont.urlHelp")}</p>
|
||||||
Get this from Google Fonts: Select a font → Click "Get font" → Copy the @import URL
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="font-name" className="text-slate-200">
|
<Label htmlFor="font-name" className="text-slate-200">
|
||||||
Display Name
|
{t("customFont.nameLabel")}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="font-name"
|
id="font-name"
|
||||||
placeholder="My Custom Font"
|
placeholder={t("customFont.namePlaceholder")}
|
||||||
value={fontName}
|
value={fontName}
|
||||||
onChange={(e) => setFontName(e.target.value)}
|
onChange={(e) => setFontName(e.target.value)}
|
||||||
className="bg-white/5 border-white/10 text-slate-200"
|
className="bg-white/5 border-white/10 text-slate-200"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-slate-400">
|
<p className="text-xs text-slate-400">{t("customFont.nameHelp")}</p>
|
||||||
This is how the font will appear in the font selector
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end gap-2 mt-6">
|
<div className="flex justify-end gap-2 mt-6">
|
||||||
@@ -164,14 +163,14 @@ export function AddCustomFontDialog({ onFontAdded }: AddCustomFontDialogProps) {
|
|||||||
onClick={() => setOpen(false)}
|
onClick={() => setOpen(false)}
|
||||||
className="bg-white/5 border-white/10 text-slate-200 hover:bg-white/10"
|
className="bg-white/5 border-white/10 text-slate-200 hover:bg-white/10"
|
||||||
>
|
>
|
||||||
Cancel
|
{tc("actions.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleAdd}
|
onClick={handleAdd}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="bg-blue-600 hover:bg-blue-700 text-white"
|
className="bg-blue-600 hover:bg-blue-700 text-white"
|
||||||
>
|
>
|
||||||
{loading ? "Adding..." : "Add Font"}
|
{loading ? t("customFont.addingButton") : t("customFont.addButton")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
import { Slider } from "@/components/ui/slider";
|
import { Slider } from "@/components/ui/slider";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||||
|
import { useScopedT } from "@/contexts/I18nContext";
|
||||||
import { type CustomFont, getCustomFonts } from "@/lib/customFonts";
|
import { type CustomFont, getCustomFonts } from "@/lib/customFonts";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { AddCustomFontDialog } from "./AddCustomFontDialog";
|
import { AddCustomFontDialog } from "./AddCustomFontDialog";
|
||||||
@@ -43,14 +44,14 @@ interface AnnotationSettingsPanelProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const FONT_FAMILIES = [
|
const FONT_FAMILIES = [
|
||||||
{ value: "system-ui, -apple-system, sans-serif", label: "Classic" },
|
{ value: "system-ui, -apple-system, sans-serif", labelKey: "classic" },
|
||||||
{ value: "Georgia, serif", label: "Editor" },
|
{ value: "Georgia, serif", labelKey: "editor" },
|
||||||
{ value: "Impact, Arial Black, sans-serif", label: "Strong" },
|
{ value: "Impact, Arial Black, sans-serif", labelKey: "strong" },
|
||||||
{ value: "Courier New, monospace", label: "Typewriter" },
|
{ value: "Courier New, monospace", labelKey: "typewriter" },
|
||||||
{ value: "Brush Script MT, cursive", label: "Deco" },
|
{ value: "Brush Script MT, cursive", labelKey: "deco" },
|
||||||
{ value: "Arial, sans-serif", label: "Simple" },
|
{ value: "Arial, sans-serif", labelKey: "simple" },
|
||||||
{ value: "Verdana, sans-serif", label: "Modern" },
|
{ value: "Verdana, sans-serif", labelKey: "modern" },
|
||||||
{ value: "Trebuchet MS, sans-serif", label: "Clean" },
|
{ value: "Trebuchet MS, sans-serif", labelKey: "clean" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const FONT_SIZES = [12, 14, 16, 18, 20, 24, 28, 32, 36, 40, 48, 56, 64, 72, 80, 96, 128];
|
const FONT_SIZES = [12, 14, 16, 18, 20, 24, 28, 32, 36, 40, 48, 56, 64, 72, 80, 96, 128];
|
||||||
@@ -63,9 +64,21 @@ export function AnnotationSettingsPanel({
|
|||||||
onFigureDataChange,
|
onFigureDataChange,
|
||||||
onDelete,
|
onDelete,
|
||||||
}: AnnotationSettingsPanelProps) {
|
}: AnnotationSettingsPanelProps) {
|
||||||
|
const t = useScopedT("settings");
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [customFonts, setCustomFonts] = useState<CustomFont[]>([]);
|
const [customFonts, setCustomFonts] = useState<CustomFont[]>([]);
|
||||||
|
|
||||||
|
const fontStyleLabels: Record<string, string> = {
|
||||||
|
classic: t("fontStyles.classic"),
|
||||||
|
editor: t("fontStyles.editor"),
|
||||||
|
strong: t("fontStyles.strong"),
|
||||||
|
typewriter: t("fontStyles.typewriter"),
|
||||||
|
deco: t("fontStyles.deco"),
|
||||||
|
simple: t("fontStyles.simple"),
|
||||||
|
modern: t("fontStyles.modern"),
|
||||||
|
clean: t("fontStyles.clean"),
|
||||||
|
};
|
||||||
|
|
||||||
// Load custom fonts on mount
|
// Load custom fonts on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCustomFonts(getCustomFonts());
|
setCustomFonts(getCustomFonts());
|
||||||
@@ -99,8 +112,8 @@ export function AnnotationSettingsPanel({
|
|||||||
// Validate file type
|
// Validate file type
|
||||||
const validTypes = ["image/jpeg", "image/jpg", "image/png", "image/gif", "image/webp"];
|
const validTypes = ["image/jpeg", "image/jpg", "image/png", "image/gif", "image/webp"];
|
||||||
if (!validTypes.includes(file.type)) {
|
if (!validTypes.includes(file.type)) {
|
||||||
toast.error("Invalid file type", {
|
toast.error(t("annotation.invalidImageType"), {
|
||||||
description: "Please upload a JPG, PNG, GIF, or WebP image file.",
|
description: t("annotation.imageFormatsOnly"),
|
||||||
});
|
});
|
||||||
event.target.value = "";
|
event.target.value = "";
|
||||||
return;
|
return;
|
||||||
@@ -112,12 +125,12 @@ export function AnnotationSettingsPanel({
|
|||||||
const dataUrl = e.target?.result as string;
|
const dataUrl = e.target?.result as string;
|
||||||
if (dataUrl) {
|
if (dataUrl) {
|
||||||
onContentChange(dataUrl);
|
onContentChange(dataUrl);
|
||||||
toast.success("Image uploaded successfully!");
|
toast.success(t("annotation.imageUploadSuccess"));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
reader.onerror = () => {
|
reader.onerror = () => {
|
||||||
toast.error("Failed to upload image", {
|
toast.error(t("annotation.failedImageUpload"), {
|
||||||
description: "There was an error reading the file.",
|
description: "There was an error reading the file.",
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -130,9 +143,9 @@ export function AnnotationSettingsPanel({
|
|||||||
<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="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="mb-6">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<span className="text-sm font-medium text-slate-200">Annotation Settings</span>
|
<span className="text-sm font-medium text-slate-200">{t("annotation.title")}</span>
|
||||||
<span className="text-[10px] uppercase tracking-wider font-medium text-[#34B27B] bg-[#34B27B]/10 px-2 py-1 rounded-full">
|
<span className="text-[10px] uppercase tracking-wider font-medium text-[#34B27B] bg-[#34B27B]/10 px-2 py-1 rounded-full">
|
||||||
Active
|
{t("annotation.active")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -148,14 +161,14 @@ export function AnnotationSettingsPanel({
|
|||||||
className="data-[state=active]:bg-[#34B27B] data-[state=active]:text-white text-slate-400 py-2 rounded-lg transition-all gap-2"
|
className="data-[state=active]:bg-[#34B27B] data-[state=active]:text-white text-slate-400 py-2 rounded-lg transition-all gap-2"
|
||||||
>
|
>
|
||||||
<Type className="w-4 h-4" />
|
<Type className="w-4 h-4" />
|
||||||
Text
|
{t("annotation.typeText")}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="image"
|
value="image"
|
||||||
className="data-[state=active]:bg-[#34B27B] data-[state=active]:text-white text-slate-400 py-2 rounded-lg transition-all gap-2"
|
className="data-[state=active]:bg-[#34B27B] data-[state=active]:text-white text-slate-400 py-2 rounded-lg transition-all gap-2"
|
||||||
>
|
>
|
||||||
<ImageIcon className="w-4 h-4" />
|
<ImageIcon className="w-4 h-4" />
|
||||||
Image
|
{t("annotation.typeImage")}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="figure"
|
value="figure"
|
||||||
@@ -170,18 +183,20 @@ export function AnnotationSettingsPanel({
|
|||||||
>
|
>
|
||||||
<path d="M4 12h16m0 0l-6-6m6 6l-6 6" strokeLinecap="round" strokeLinejoin="round" />
|
<path d="M4 12h16m0 0l-6-6m6 6l-6 6" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
</svg>
|
</svg>
|
||||||
Arrow
|
{t("annotation.typeArrow")}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
{/* Text Content */}
|
{/* Text Content */}
|
||||||
<TabsContent value="text" className="mt-0 space-y-4">
|
<TabsContent value="text" className="mt-0 space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs font-medium text-slate-200 mb-2 block">Text Content</label>
|
<label className="text-xs font-medium text-slate-200 mb-2 block">
|
||||||
|
{t("annotation.textContent")}
|
||||||
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={annotation.textContent || annotation.content}
|
value={annotation.textContent || annotation.content}
|
||||||
onChange={(e) => onContentChange(e.target.value)}
|
onChange={(e) => onContentChange(e.target.value)}
|
||||||
placeholder="Enter your text..."
|
placeholder={t("annotation.textPlaceholder")}
|
||||||
rows={5}
|
rows={5}
|
||||||
className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-slate-200 text-sm placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-[#34B27B] focus:border-transparent resize-none"
|
className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-slate-200 text-sm placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-[#34B27B] focus:border-transparent resize-none"
|
||||||
/>
|
/>
|
||||||
@@ -193,14 +208,14 @@ export function AnnotationSettingsPanel({
|
|||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs font-medium text-slate-200 mb-2 block">
|
<label className="text-xs font-medium text-slate-200 mb-2 block">
|
||||||
Font Style
|
{t("annotation.fontStyle")}
|
||||||
</label>
|
</label>
|
||||||
<Select
|
<Select
|
||||||
value={annotation.style.fontFamily}
|
value={annotation.style.fontFamily}
|
||||||
onValueChange={(value) => onStyleChange({ fontFamily: value })}
|
onValueChange={(value) => onStyleChange({ fontFamily: value })}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-full bg-white/5 border-white/10 text-slate-200 h-9 text-xs">
|
<SelectTrigger className="w-full bg-white/5 border-white/10 text-slate-200 h-9 text-xs">
|
||||||
<SelectValue placeholder="Select style" />
|
<SelectValue placeholder={t("annotation.selectStyle")} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="bg-[#1a1a1c] border-white/10 text-slate-200 max-h-[300px]">
|
<SelectContent className="bg-[#1a1a1c] border-white/10 text-slate-200 max-h-[300px]">
|
||||||
{FONT_FAMILIES.map((font) => (
|
{FONT_FAMILIES.map((font) => (
|
||||||
@@ -209,13 +224,13 @@ export function AnnotationSettingsPanel({
|
|||||||
value={font.value}
|
value={font.value}
|
||||||
style={{ fontFamily: font.value }}
|
style={{ fontFamily: font.value }}
|
||||||
>
|
>
|
||||||
{font.label}
|
{fontStyleLabels[font.labelKey]}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
{customFonts.length > 0 && (
|
{customFonts.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className="px-2 py-1.5 text-[10px] font-medium text-slate-400 uppercase tracking-wider">
|
<div className="px-2 py-1.5 text-[10px] font-medium text-slate-400 uppercase tracking-wider">
|
||||||
Custom Fonts
|
{t("annotation.customFonts")}
|
||||||
</div>
|
</div>
|
||||||
{customFonts.map((font) => (
|
{customFonts.map((font) => (
|
||||||
<SelectItem
|
<SelectItem
|
||||||
@@ -232,13 +247,15 @@ export function AnnotationSettingsPanel({
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs font-medium text-slate-200 mb-2 block">Size</label>
|
<label className="text-xs font-medium text-slate-200 mb-2 block">
|
||||||
|
{t("annotation.size")}
|
||||||
|
</label>
|
||||||
<Select
|
<Select
|
||||||
value={annotation.style.fontSize.toString()}
|
value={annotation.style.fontSize.toString()}
|
||||||
onValueChange={(value) => onStyleChange({ fontSize: parseInt(value) })}
|
onValueChange={(value) => onStyleChange({ fontSize: parseInt(value) })}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-full bg-white/5 border-white/10 text-slate-200 h-9 text-xs">
|
<SelectTrigger className="w-full bg-white/5 border-white/10 text-slate-200 h-9 text-xs">
|
||||||
<SelectValue placeholder="Size" />
|
<SelectValue placeholder={t("annotation.size")} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="bg-[#1a1a1c] border-white/10 text-slate-200 max-h-[200px]">
|
<SelectContent className="bg-[#1a1a1c] border-white/10 text-slate-200 max-h-[200px]">
|
||||||
{FONT_SIZES.map((size) => (
|
{FONT_SIZES.map((size) => (
|
||||||
@@ -345,7 +362,7 @@ export function AnnotationSettingsPanel({
|
|||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs font-medium text-slate-200 mb-2 block">
|
<label className="text-xs font-medium text-slate-200 mb-2 block">
|
||||||
Text Color
|
{t("annotation.textColor")}
|
||||||
</label>
|
</label>
|
||||||
<Popover>
|
<Popover>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
@@ -379,7 +396,7 @@ export function AnnotationSettingsPanel({
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs font-medium text-slate-200 mb-2 block">
|
<label className="text-xs font-medium text-slate-200 mb-2 block">
|
||||||
Background
|
{t("annotation.background")}
|
||||||
</label>
|
</label>
|
||||||
<Popover>
|
<Popover>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
@@ -395,7 +412,9 @@ export function AnnotationSettingsPanel({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs text-slate-300 truncate flex-1 text-left">
|
<span className="text-xs text-slate-300 truncate flex-1 text-left">
|
||||||
{annotation.style.backgroundColor === "transparent" ? "None" : "Color"}
|
{annotation.style.backgroundColor === "transparent"
|
||||||
|
? t("annotation.none")
|
||||||
|
: t("annotation.color")}
|
||||||
</span>
|
</span>
|
||||||
<ChevronDown className="h-3 w-3 opacity-50" />
|
<ChevronDown className="h-3 w-3 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -423,7 +442,7 @@ export function AnnotationSettingsPanel({
|
|||||||
onStyleChange({ backgroundColor: "transparent" });
|
onStyleChange({ backgroundColor: "transparent" });
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Clear Background
|
{t("annotation.clearBackground")}
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
@@ -447,7 +466,7 @@ export function AnnotationSettingsPanel({
|
|||||||
className="w-full gap-2 bg-white/5 text-slate-200 border-white/10 hover:bg-[#34B27B] hover:text-white hover:border-[#34B27B] transition-all py-8"
|
className="w-full gap-2 bg-white/5 text-slate-200 border-white/10 hover:bg-[#34B27B] hover:text-white hover:border-[#34B27B] transition-all py-8"
|
||||||
>
|
>
|
||||||
<Upload className="w-5 h-5" />
|
<Upload className="w-5 h-5" />
|
||||||
Upload Image
|
{t("annotation.uploadImage")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{annotation.content && annotation.content.startsWith("data:image") && (
|
{annotation.content && annotation.content.startsWith("data:image") && (
|
||||||
@@ -461,14 +480,14 @@ export function AnnotationSettingsPanel({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<p className="text-xs text-slate-500 text-center leading-relaxed">
|
<p className="text-xs text-slate-500 text-center leading-relaxed">
|
||||||
Supported formats: JPG, PNG, GIF, WebP
|
{t("annotation.supportedFormats")}
|
||||||
</p>
|
</p>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="figure" className="mt-0 space-y-4">
|
<TabsContent value="figure" className="mt-0 space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs font-medium text-slate-200 mb-3 block">
|
<label className="text-xs font-medium text-slate-200 mb-3 block">
|
||||||
Arrow Direction
|
{t("annotation.arrowDirection")}
|
||||||
</label>
|
</label>
|
||||||
<div className="grid grid-cols-4 gap-2">
|
<div className="grid grid-cols-4 gap-2">
|
||||||
{(
|
{(
|
||||||
@@ -517,7 +536,9 @@ export function AnnotationSettingsPanel({
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs font-medium text-slate-200 mb-2 block">
|
<label className="text-xs font-medium text-slate-200 mb-2 block">
|
||||||
Stroke Width: {annotation.figureData?.strokeWidth || 4}px
|
{t("annotation.strokeWidth", {
|
||||||
|
width: String(annotation.figureData?.strokeWidth || 4),
|
||||||
|
})}
|
||||||
</label>
|
</label>
|
||||||
<Slider
|
<Slider
|
||||||
value={[annotation.figureData?.strokeWidth || 4]}
|
value={[annotation.figureData?.strokeWidth || 4]}
|
||||||
@@ -536,7 +557,9 @@ export function AnnotationSettingsPanel({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs font-medium text-slate-200 mb-2 block">Arrow Color</label>
|
<label className="text-xs font-medium text-slate-200 mb-2 block">
|
||||||
|
{t("annotation.arrowColor")}
|
||||||
|
</label>
|
||||||
<Popover>
|
<Popover>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
@@ -581,28 +604,18 @@ export function AnnotationSettingsPanel({
|
|||||||
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"
|
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" />
|
<Trash2 className="w-4 h-4" />
|
||||||
Delete Annotation
|
{t("annotation.deleteAnnotation")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div className="mt-6 p-3 bg-white/5 rounded-lg border border-white/5">
|
<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">
|
<div className="flex items-center gap-2 mb-2 text-slate-300">
|
||||||
<Info className="w-3.5 h-3.5" />
|
<Info className="w-3.5 h-3.5" />
|
||||||
<span className="text-xs font-medium">Shortcuts & Tips</span>
|
<span className="text-xs font-medium">{t("annotation.shortcutsAndTips")}</span>
|
||||||
</div>
|
</div>
|
||||||
<ul className="text-[10px] text-slate-400 space-y-1.5 list-disc pl-3 leading-relaxed">
|
<ul className="text-[10px] text-slate-400 space-y-1.5 list-disc pl-3 leading-relaxed">
|
||||||
<li>Move playhead to overlapping annotation section and select an item.</li>
|
<li>{t("annotation.tipMovePlayhead")}</li>
|
||||||
<li>
|
<li>{t("annotation.tipTabCycle")}</li>
|
||||||
Use{" "}
|
<li>{t("annotation.tipShiftTabCycle")}</li>
|
||||||
<kbd className="px-1 py-0.5 bg-white/10 rounded text-slate-300 font-mono">Tab</kbd> to
|
|
||||||
cycle through overlapping items.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Use{" "}
|
|
||||||
<kbd className="px-1 py-0.5 bg-white/10 rounded text-slate-300 font-mono">
|
|
||||||
Shift+Tab
|
|
||||||
</kbd>{" "}
|
|
||||||
to cycle backwards.
|
|
||||||
</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Download, Loader2, X } from "lucide-react";
|
import { Download, Loader2, X } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useScopedT } from "@/contexts/I18nContext";
|
||||||
import type { ExportProgress } from "@/lib/exporter";
|
import type { ExportProgress } from "@/lib/exporter";
|
||||||
|
|
||||||
interface ExportDialogProps {
|
interface ExportDialogProps {
|
||||||
@@ -26,6 +27,7 @@ export function ExportDialog({
|
|||||||
exportedFilePath,
|
exportedFilePath,
|
||||||
onShowInFolder,
|
onShowInFolder,
|
||||||
}: ExportDialogProps) {
|
}: ExportDialogProps) {
|
||||||
|
const t = useScopedT("dialogs");
|
||||||
const [showSuccess, setShowSuccess] = useState(false);
|
const [showSuccess, setShowSuccess] = useState(false);
|
||||||
|
|
||||||
// Reset showSuccess when a new export starts or dialog reopens
|
// Reset showSuccess when a new export starts or dialog reopens
|
||||||
@@ -65,25 +67,25 @@ export function ExportDialog({
|
|||||||
|
|
||||||
// Get status message based on phase
|
// Get status message based on phase
|
||||||
const getStatusMessage = () => {
|
const getStatusMessage = () => {
|
||||||
if (error) return "Please try again";
|
if (error) return t("export.tryAgain");
|
||||||
if (isCompiling || isFinalizing) {
|
if (isCompiling || isFinalizing) {
|
||||||
if (exportFormat === "mp4") {
|
if (exportFormat === "mp4") {
|
||||||
return "Finalizing video export...";
|
return t("export.finalizingVideo");
|
||||||
}
|
}
|
||||||
if (renderProgress !== undefined && renderProgress > 0) {
|
if (renderProgress !== undefined && renderProgress > 0) {
|
||||||
return `Compiling GIF... ${renderProgress}%`;
|
return t("export.compilingGifProgress", { progress: String(renderProgress) });
|
||||||
}
|
}
|
||||||
return "Compiling GIF... This may take a while";
|
return t("export.compilingGifWait");
|
||||||
}
|
}
|
||||||
return "This may take a moment...";
|
return t("export.takeMoment");
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get title based on phase
|
// Get title based on phase
|
||||||
const getTitle = () => {
|
const getTitle = () => {
|
||||||
if (error) return "Export Failed";
|
if (error) return t("export.failed");
|
||||||
if (isFinalizing && exportFormat === "mp4") return "Finalizing Video";
|
if (isFinalizing && exportFormat === "mp4") return t("export.finalizingVideoTitle");
|
||||||
if (isCompiling || isFinalizing) return "Compiling GIF";
|
if (isCompiling || isFinalizing) return t("export.compilingGif");
|
||||||
return `Exporting ${formatLabel}`;
|
return t("export.exportingFormat", { format: formatLabel });
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -101,9 +103,11 @@ export function ExportDialog({
|
|||||||
<Download className="w-6 h-6 text-[#34B27B]" />
|
<Download className="w-6 h-6 text-[#34B27B]" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<span className="text-xl font-bold text-slate-200 block">Export Complete</span>
|
<span className="text-xl font-bold text-slate-200 block">
|
||||||
|
{t("export.complete")}
|
||||||
|
</span>
|
||||||
<span className="text-sm text-slate-400">
|
<span className="text-sm text-slate-400">
|
||||||
Your {formatLabel.toLowerCase()} is ready
|
{t("export.yourFormatReady", { format: formatLabel.toLowerCase() })}
|
||||||
</span>
|
</span>
|
||||||
{exportedFilePath && (
|
{exportedFilePath && (
|
||||||
<Button
|
<Button
|
||||||
@@ -111,7 +115,7 @@ export function ExportDialog({
|
|||||||
onClick={onShowInFolder}
|
onClick={onShowInFolder}
|
||||||
className="mt-2 w-fit px-3 py-1 text-sm rounded-md bg-white/10 hover:bg-white/20 text-slate-200"
|
className="mt-2 w-fit px-3 py-1 text-sm rounded-md bg-white/10 hover:bg-white/20 text-slate-200"
|
||||||
>
|
>
|
||||||
Show in Folder
|
{t("export.showInFolder")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{exportedFilePath && (
|
{exportedFilePath && (
|
||||||
@@ -166,7 +170,11 @@ export function ExportDialog({
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex justify-between text-xs font-medium text-slate-400 uppercase tracking-wider">
|
<div className="flex justify-between text-xs font-medium text-slate-400 uppercase tracking-wider">
|
||||||
<span>{isCompiling || isFinalizing ? "Compiling" : "Rendering Frames"}</span>
|
<span>
|
||||||
|
{isCompiling || isFinalizing
|
||||||
|
? t("export.compiling")
|
||||||
|
: t("export.renderingFrames")}
|
||||||
|
</span>
|
||||||
<span className="font-mono text-slate-200">
|
<span className="font-mono text-slate-200">
|
||||||
{isCompiling || isFinalizing ? (
|
{isCompiling || isFinalizing ? (
|
||||||
renderProgress !== undefined && renderProgress > 0 ? (
|
renderProgress !== undefined && renderProgress > 0 ? (
|
||||||
@@ -174,7 +182,7 @@ export function ExportDialog({
|
|||||||
) : (
|
) : (
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<Loader2 className="w-3 h-3 animate-spin" />
|
<Loader2 className="w-3 h-3 animate-spin" />
|
||||||
Processing...
|
{t("export.processing")}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
@@ -218,19 +226,19 @@ export function ExportDialog({
|
|||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="bg-white/5 rounded-xl p-3 border border-white/5">
|
<div className="bg-white/5 rounded-xl p-3 border border-white/5">
|
||||||
<div className="text-[10px] text-slate-500 uppercase tracking-wider mb-1">
|
<div className="text-[10px] text-slate-500 uppercase tracking-wider mb-1">
|
||||||
{isCompiling || isFinalizing ? "Status" : "Format"}
|
{isCompiling || isFinalizing ? t("export.status") : t("export.format")}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-slate-200 font-medium text-sm">
|
<div className="text-slate-200 font-medium text-sm">
|
||||||
{isFinalizing && exportFormat === "mp4"
|
{isFinalizing && exportFormat === "mp4"
|
||||||
? "Finalizing..."
|
? t("export.finalizing")
|
||||||
: isCompiling || isFinalizing
|
: isCompiling || isFinalizing
|
||||||
? "Compiling..."
|
? t("export.compilingStatus")
|
||||||
: formatLabel}
|
: formatLabel}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white/5 rounded-xl p-3 border border-white/5">
|
<div className="bg-white/5 rounded-xl p-3 border border-white/5">
|
||||||
<div className="text-[10px] text-slate-500 uppercase tracking-wider mb-1">
|
<div className="text-[10px] text-slate-500 uppercase tracking-wider mb-1">
|
||||||
Frames
|
{t("export.frames")}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-slate-200 font-medium text-sm">
|
<div className="text-slate-200 font-medium text-sm">
|
||||||
{progress.currentFrame} / {progress.totalFrames}
|
{progress.currentFrame} / {progress.totalFrames}
|
||||||
@@ -245,7 +253,7 @@ export function ExportDialog({
|
|||||||
variant="destructive"
|
variant="destructive"
|
||||||
className="w-full py-6 bg-red-500/10 text-red-400 border border-red-500/20 hover:bg-red-500/20 hover:border-red-500/30 transition-all rounded-xl"
|
className="w-full py-6 bg-red-500/10 text-red-400 border border-red-500/20 hover:bg-red-500/20 hover:border-red-500/30 transition-all rounded-xl"
|
||||||
>
|
>
|
||||||
Cancel Export
|
{t("export.cancelExport")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -254,7 +262,9 @@ export function ExportDialog({
|
|||||||
|
|
||||||
{showSuccess && (
|
{showSuccess && (
|
||||||
<div className="text-center py-4 animate-in zoom-in-95">
|
<div className="text-center py-4 animate-in zoom-in-95">
|
||||||
<p className="text-lg text-slate-200 font-medium">{formatLabel} saved successfully!</p>
|
<p className="text-lg text-slate-200 font-medium">
|
||||||
|
{t("export.savedSuccessfully", { format: formatLabel })}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Film, Image } from "lucide-react";
|
import { Film, Image } from "lucide-react";
|
||||||
|
import { useScopedT } from "@/contexts/I18nContext";
|
||||||
import type { ExportFormat } from "@/lib/exporter/types";
|
import type { ExportFormat } from "@/lib/exporter/types";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -8,26 +9,9 @@ interface FormatSelectorProps {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FormatOption {
|
const formatOptions: Array<{ value: ExportFormat; icon: React.ReactNode }> = [
|
||||||
value: ExportFormat;
|
{ value: "mp4", icon: <Film className="w-5 h-5" /> },
|
||||||
label: string;
|
{ value: "gif", icon: <Image className="w-5 h-5" /> },
|
||||||
description: string;
|
|
||||||
icon: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatOptions: FormatOption[] = [
|
|
||||||
{
|
|
||||||
value: "mp4",
|
|
||||||
label: "MP4 Video",
|
|
||||||
description: "High quality video file",
|
|
||||||
icon: <Film className="w-5 h-5" />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "gif",
|
|
||||||
label: "GIF Animation",
|
|
||||||
description: "Animated image for sharing",
|
|
||||||
icon: <Image className="w-5 h-5" />,
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export function FormatSelector({
|
export function FormatSelector({
|
||||||
@@ -35,10 +19,18 @@ export function FormatSelector({
|
|||||||
onFormatChange,
|
onFormatChange,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
}: FormatSelectorProps) {
|
}: FormatSelectorProps) {
|
||||||
|
const t = useScopedT("settings");
|
||||||
|
|
||||||
|
const formatLabels: Record<ExportFormat, { label: string; description: string }> = {
|
||||||
|
mp4: { label: t("exportFormat.mp4Video"), description: t("exportFormat.mp4Description") },
|
||||||
|
gif: { label: t("exportFormat.gifAnimation"), description: t("exportFormat.gifDescription") },
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
{formatOptions.map((option) => {
|
{formatOptions.map((option) => {
|
||||||
const isSelected = selectedFormat === option.value;
|
const isSelected = selectedFormat === option.value;
|
||||||
|
const labels = formatLabels[option.value];
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={option.value}
|
key={option.value}
|
||||||
@@ -63,8 +55,8 @@ export function FormatSelector({
|
|||||||
{option.icon}
|
{option.icon}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="font-medium text-sm">{option.label}</div>
|
<div className="font-medium text-sm">{labels.label}</div>
|
||||||
<div className="text-xs text-slate-500 mt-0.5">{option.description}</div>
|
<div className="text-xs text-slate-500 mt-0.5">{labels.description}</div>
|
||||||
</div>
|
</div>
|
||||||
{isSelected && (
|
{isSelected && (
|
||||||
<div className="absolute top-2 right-2 w-2 h-2 rounded-full bg-[#34B27B]" />
|
<div className="absolute top-2 right-2 w-2 h-2 rounded-full bg-[#34B27B]" />
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { HelpCircle, Settings2 } from "lucide-react";
|
import { HelpCircle, Settings2 } from "lucide-react";
|
||||||
|
import { useScopedT } from "@/contexts/I18nContext";
|
||||||
import { useShortcuts } from "@/contexts/ShortcutsContext";
|
import { useShortcuts } from "@/contexts/ShortcutsContext";
|
||||||
import { FIXED_SHORTCUTS, formatBinding, SHORTCUT_ACTIONS, SHORTCUT_LABELS } from "@/lib/shortcuts";
|
import { FIXED_SHORTCUTS, formatBinding, SHORTCUT_ACTIONS } from "@/lib/shortcuts";
|
||||||
|
|
||||||
export function KeyboardShortcutsHelp() {
|
export function KeyboardShortcutsHelp() {
|
||||||
const { shortcuts, isMac, openConfig } = useShortcuts();
|
const { shortcuts, isMac, openConfig } = useShortcuts();
|
||||||
|
const t = useScopedT("shortcuts");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative group">
|
<div className="relative group">
|
||||||
@@ -11,7 +13,7 @@ export function KeyboardShortcutsHelp() {
|
|||||||
|
|
||||||
<div className="absolute right-0 top-full mt-2 w-64 bg-[#09090b] border border-white/10 rounded-lg p-3 opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 shadow-xl z-50">
|
<div className="absolute right-0 top-full mt-2 w-64 bg-[#09090b] border border-white/10 rounded-lg p-3 opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 shadow-xl z-50">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<span className="text-xs font-semibold text-slate-200">Keyboard Shortcuts</span>
|
<span className="text-xs font-semibold text-slate-200">{t("title")}</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={openConfig}
|
onClick={openConfig}
|
||||||
@@ -19,14 +21,14 @@ export function KeyboardShortcutsHelp() {
|
|||||||
className="flex items-center gap-1 text-[10px] text-slate-500 hover:text-[#34B27B] transition-colors"
|
className="flex items-center gap-1 text-[10px] text-slate-500 hover:text-[#34B27B] transition-colors"
|
||||||
>
|
>
|
||||||
<Settings2 className="w-3 h-3" />
|
<Settings2 className="w-3 h-3" />
|
||||||
Customize
|
{t("customize")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1.5 text-[10px]">
|
<div className="space-y-1.5 text-[10px]">
|
||||||
{SHORTCUT_ACTIONS.map((action) => (
|
{SHORTCUT_ACTIONS.map((action) => (
|
||||||
<div key={action} className="flex items-center justify-between">
|
<div key={action} className="flex items-center justify-between">
|
||||||
<span className="text-slate-400">{SHORTCUT_LABELS[action]}</span>
|
<span className="text-slate-400">{t(`actions.${action}`)}</span>
|
||||||
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">
|
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">
|
||||||
{formatBinding(shortcuts[action], isMac)}
|
{formatBinding(shortcuts[action], isMac)}
|
||||||
</kbd>
|
</kbd>
|
||||||
@@ -35,8 +37,10 @@ export function KeyboardShortcutsHelp() {
|
|||||||
|
|
||||||
<div className="pt-1 border-t border-white/5 mt-1 space-y-1.5">
|
<div className="pt-1 border-t border-white/5 mt-1 space-y-1.5">
|
||||||
{FIXED_SHORTCUTS.map((fixed) => (
|
{FIXED_SHORTCUTS.map((fixed) => (
|
||||||
<div key={fixed.label} className="flex items-center justify-between">
|
<div key={fixed.i18nKey} className="flex items-center justify-between">
|
||||||
<span className="text-slate-400">{fixed.label}</span>
|
<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">
|
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">
|
||||||
{isMac
|
{isMac
|
||||||
? fixed.display
|
? fixed.display
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Pause, Play } from "lucide-react";
|
import { Maximize, Minimize, Pause, Play } from "lucide-react";
|
||||||
|
import { useScopedT } from "@/contexts/I18nContext";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Button } from "../ui/button";
|
import { Button } from "../ui/button";
|
||||||
|
|
||||||
@@ -6,6 +7,8 @@ interface PlaybackControlsProps {
|
|||||||
isPlaying: boolean;
|
isPlaying: boolean;
|
||||||
currentTime: number;
|
currentTime: number;
|
||||||
duration: number;
|
duration: number;
|
||||||
|
isFullscreen?: boolean;
|
||||||
|
onToggleFullscreen?: () => void;
|
||||||
onTogglePlayPause: () => void;
|
onTogglePlayPause: () => void;
|
||||||
onSeek: (time: number) => void;
|
onSeek: (time: number) => void;
|
||||||
}
|
}
|
||||||
@@ -14,9 +17,13 @@ export default function PlaybackControls({
|
|||||||
isPlaying,
|
isPlaying,
|
||||||
currentTime,
|
currentTime,
|
||||||
duration,
|
duration,
|
||||||
|
isFullscreen = false,
|
||||||
|
onToggleFullscreen,
|
||||||
onTogglePlayPause,
|
onTogglePlayPause,
|
||||||
onSeek,
|
onSeek,
|
||||||
}: PlaybackControlsProps) {
|
}: PlaybackControlsProps) {
|
||||||
|
const t = useScopedT("common");
|
||||||
|
|
||||||
function formatTime(seconds: number) {
|
function formatTime(seconds: number) {
|
||||||
if (!isFinite(seconds) || isNaN(seconds) || seconds < 0) return "0:00";
|
if (!isFinite(seconds) || isNaN(seconds) || seconds < 0) return "0:00";
|
||||||
const mins = Math.floor(seconds / 60);
|
const mins = Math.floor(seconds / 60);
|
||||||
@@ -41,7 +48,7 @@ export default function PlaybackControls({
|
|||||||
? "bg-white/10 text-white hover:bg-white/20"
|
? "bg-white/10 text-white hover:bg-white/20"
|
||||||
: "bg-white text-black hover:bg-white/90 hover:scale-105 shadow-[0_0_15px_rgba(255,255,255,0.3)]",
|
: "bg-white text-black hover:bg-white/90 hover:scale-105 shadow-[0_0_15px_rgba(255,255,255,0.3)]",
|
||||||
)}
|
)}
|
||||||
aria-label={isPlaying ? "Pause" : "Play"}
|
aria-label={isPlaying ? t("playback.pause") : t("playback.play")}
|
||||||
>
|
>
|
||||||
{isPlaying ? (
|
{isPlaying ? (
|
||||||
<Pause className="w-3.5 h-3.5 fill-current" />
|
<Pause className="w-3.5 h-3.5 fill-current" />
|
||||||
@@ -84,6 +91,21 @@ export default function PlaybackControls({
|
|||||||
<span className="text-[9px] font-medium text-slate-500 tabular-nums w-[30px]">
|
<span className="text-[9px] font-medium text-slate-500 tabular-nums w-[30px]">
|
||||||
{formatTime(duration)}
|
{formatTime(duration)}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
{onToggleFullscreen && (
|
||||||
|
<Button
|
||||||
|
onClick={onToggleFullscreen}
|
||||||
|
size="icon"
|
||||||
|
className="w-7 h-7 rounded-full transition-all duration-200 border border-transparent hover:bg-white/10 text-white hover:border-white/10 shrink-0 shadow-none ml-0.5"
|
||||||
|
aria-label={isFullscreen ? t("playback.exitFullscreen") : t("playback.fullscreen")}
|
||||||
|
>
|
||||||
|
{isFullscreen ? (
|
||||||
|
<Minimize className="w-3.5 h-3.5" />
|
||||||
|
) : (
|
||||||
|
<Maximize className="w-3.5 h-3.5" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,11 +4,9 @@ import {
|
|||||||
Crop,
|
Crop,
|
||||||
Download,
|
Download,
|
||||||
Film,
|
Film,
|
||||||
FolderOpen,
|
|
||||||
Image,
|
Image,
|
||||||
Lock,
|
Lock,
|
||||||
Palette,
|
Palette,
|
||||||
Save,
|
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Star,
|
Star,
|
||||||
Trash2,
|
Trash2,
|
||||||
@@ -25,14 +23,23 @@ import {
|
|||||||
AccordionTrigger,
|
AccordionTrigger,
|
||||||
} from "@/components/ui/accordion";
|
} from "@/components/ui/accordion";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
import { Slider } from "@/components/ui/slider";
|
import { Slider } from "@/components/ui/slider";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { useScopedT } from "@/contexts/I18nContext";
|
||||||
import { getAssetPath } from "@/lib/assetPath";
|
import { getAssetPath } from "@/lib/assetPath";
|
||||||
|
import { WEBCAM_LAYOUT_PRESETS } from "@/lib/compositeLayout";
|
||||||
import type { ExportFormat, ExportQuality, GifFrameRate, GifSizePreset } from "@/lib/exporter";
|
import type { ExportFormat, ExportQuality, GifFrameRate, GifSizePreset } from "@/lib/exporter";
|
||||||
import { GIF_FRAME_RATES, GIF_SIZE_PRESETS } from "@/lib/exporter";
|
import { GIF_FRAME_RATES, GIF_SIZE_PRESETS } from "@/lib/exporter";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { type AspectRatio } from "@/utils/aspectRatioUtils";
|
import { type AspectRatio, isPortraitAspectRatio } from "@/utils/aspectRatioUtils";
|
||||||
import { getTestId } from "@/utils/getTestId";
|
import { getTestId } from "@/utils/getTestId";
|
||||||
import { AnnotationSettingsPanel } from "./AnnotationSettingsPanel";
|
import { AnnotationSettingsPanel } from "./AnnotationSettingsPanel";
|
||||||
import { CropControl } from "./CropControl";
|
import { CropControl } from "./CropControl";
|
||||||
@@ -43,7 +50,10 @@ import type {
|
|||||||
CropRegion,
|
CropRegion,
|
||||||
FigureData,
|
FigureData,
|
||||||
PlaybackSpeed,
|
PlaybackSpeed,
|
||||||
|
WebcamLayoutPreset,
|
||||||
|
WebcamMaskShape,
|
||||||
ZoomDepth,
|
ZoomDepth,
|
||||||
|
ZoomFocusMode,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { SPEED_OPTIONS } from "./types";
|
import { SPEED_OPTIONS } from "./types";
|
||||||
|
|
||||||
@@ -84,6 +94,9 @@ interface SettingsPanelProps {
|
|||||||
onWallpaperChange: (path: string) => void;
|
onWallpaperChange: (path: string) => void;
|
||||||
selectedZoomDepth?: ZoomDepth | null;
|
selectedZoomDepth?: ZoomDepth | null;
|
||||||
onZoomDepthChange?: (depth: ZoomDepth) => void;
|
onZoomDepthChange?: (depth: ZoomDepth) => void;
|
||||||
|
selectedZoomFocusMode?: ZoomFocusMode | null;
|
||||||
|
onZoomFocusModeChange?: (mode: ZoomFocusMode) => void;
|
||||||
|
hasCursorTelemetry?: boolean;
|
||||||
selectedZoomId?: string | null;
|
selectedZoomId?: string | null;
|
||||||
onZoomDelete?: (id: string) => void;
|
onZoomDelete?: (id: string) => void;
|
||||||
selectedTrimId?: string | null;
|
selectedTrimId?: string | null;
|
||||||
@@ -118,9 +131,9 @@ interface SettingsPanelProps {
|
|||||||
gifSizePreset?: GifSizePreset;
|
gifSizePreset?: GifSizePreset;
|
||||||
onGifSizePresetChange?: (preset: GifSizePreset) => void;
|
onGifSizePresetChange?: (preset: GifSizePreset) => void;
|
||||||
gifOutputDimensions?: { width: number; height: number };
|
gifOutputDimensions?: { width: number; height: number };
|
||||||
onSaveProject?: () => void;
|
|
||||||
onLoadProject?: () => void;
|
|
||||||
onExport?: () => void;
|
onExport?: () => void;
|
||||||
|
unsavedExport?: { arrayBuffer: ArrayBuffer; fileName: string; format: string } | null;
|
||||||
|
onSaveUnsavedExport?: () => void;
|
||||||
selectedAnnotationId?: string | null;
|
selectedAnnotationId?: string | null;
|
||||||
annotationRegions?: AnnotationRegion[];
|
annotationRegions?: AnnotationRegion[];
|
||||||
onAnnotationContentChange?: (id: string, content: string) => void;
|
onAnnotationContentChange?: (id: string, content: string) => void;
|
||||||
@@ -132,6 +145,11 @@ interface SettingsPanelProps {
|
|||||||
selectedSpeedValue?: PlaybackSpeed | null;
|
selectedSpeedValue?: PlaybackSpeed | null;
|
||||||
onSpeedChange?: (speed: PlaybackSpeed) => void;
|
onSpeedChange?: (speed: PlaybackSpeed) => void;
|
||||||
onSpeedDelete?: (id: string) => void;
|
onSpeedDelete?: (id: string) => void;
|
||||||
|
hasWebcam?: boolean;
|
||||||
|
webcamLayoutPreset?: WebcamLayoutPreset;
|
||||||
|
onWebcamLayoutPresetChange?: (preset: WebcamLayoutPreset) => void;
|
||||||
|
webcamMaskShape?: import("./types").WebcamMaskShape;
|
||||||
|
onWebcamMaskShapeChange?: (shape: import("./types").WebcamMaskShape) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default SettingsPanel;
|
export default SettingsPanel;
|
||||||
@@ -150,6 +168,9 @@ export function SettingsPanel({
|
|||||||
onWallpaperChange,
|
onWallpaperChange,
|
||||||
selectedZoomDepth,
|
selectedZoomDepth,
|
||||||
onZoomDepthChange,
|
onZoomDepthChange,
|
||||||
|
selectedZoomFocusMode,
|
||||||
|
onZoomFocusModeChange,
|
||||||
|
hasCursorTelemetry = false,
|
||||||
selectedZoomId,
|
selectedZoomId,
|
||||||
onZoomDelete,
|
onZoomDelete,
|
||||||
selectedTrimId,
|
selectedTrimId,
|
||||||
@@ -183,9 +204,9 @@ export function SettingsPanel({
|
|||||||
gifSizePreset = "medium",
|
gifSizePreset = "medium",
|
||||||
onGifSizePresetChange,
|
onGifSizePresetChange,
|
||||||
gifOutputDimensions = { width: 1280, height: 720 },
|
gifOutputDimensions = { width: 1280, height: 720 },
|
||||||
onSaveProject,
|
|
||||||
onLoadProject,
|
|
||||||
onExport,
|
onExport,
|
||||||
|
unsavedExport,
|
||||||
|
onSaveUnsavedExport,
|
||||||
selectedAnnotationId,
|
selectedAnnotationId,
|
||||||
annotationRegions = [],
|
annotationRegions = [],
|
||||||
onAnnotationContentChange,
|
onAnnotationContentChange,
|
||||||
@@ -197,7 +218,13 @@ export function SettingsPanel({
|
|||||||
selectedSpeedValue,
|
selectedSpeedValue,
|
||||||
onSpeedChange,
|
onSpeedChange,
|
||||||
onSpeedDelete,
|
onSpeedDelete,
|
||||||
|
hasWebcam = false,
|
||||||
|
webcamLayoutPreset = "picture-in-picture",
|
||||||
|
onWebcamLayoutPresetChange,
|
||||||
|
webcamMaskShape = "rectangle",
|
||||||
|
onWebcamMaskShapeChange,
|
||||||
}: SettingsPanelProps) {
|
}: SettingsPanelProps) {
|
||||||
|
const t = useScopedT("settings");
|
||||||
const [wallpaperPaths, setWallpaperPaths] = useState<string[]>([]);
|
const [wallpaperPaths, setWallpaperPaths] = useState<string[]>([]);
|
||||||
const [customImages, setCustomImages] = useState<string[]>([]);
|
const [customImages, setCustomImages] = useState<string[]>([]);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -363,8 +390,8 @@ export function SettingsPanel({
|
|||||||
// Validate file type - only allow JPG/JPEG
|
// Validate file type - only allow JPG/JPEG
|
||||||
const validTypes = ["image/jpeg", "image/jpg"];
|
const validTypes = ["image/jpeg", "image/jpg"];
|
||||||
if (!validTypes.includes(file.type)) {
|
if (!validTypes.includes(file.type)) {
|
||||||
toast.error("Invalid file type", {
|
toast.error(t("imageUpload.invalidFileType"), {
|
||||||
description: "Please upload a JPG or JPEG image file.",
|
description: t("imageUpload.jpgOnly"),
|
||||||
});
|
});
|
||||||
event.target.value = "";
|
event.target.value = "";
|
||||||
return;
|
return;
|
||||||
@@ -377,13 +404,13 @@ export function SettingsPanel({
|
|||||||
if (dataUrl) {
|
if (dataUrl) {
|
||||||
setCustomImages((prev) => [...prev, dataUrl]);
|
setCustomImages((prev) => [...prev, dataUrl]);
|
||||||
onWallpaperChange(dataUrl);
|
onWallpaperChange(dataUrl);
|
||||||
toast.success("Custom image uploaded successfully!");
|
toast.success(t("imageUpload.uploadSuccess"));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
reader.onerror = () => {
|
reader.onerror = () => {
|
||||||
toast.error("Failed to upload image", {
|
toast.error(t("imageUpload.failedToUpload"), {
|
||||||
description: "There was an error reading the file.",
|
description: t("imageUpload.errorReading"),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -449,7 +476,7 @@ export function SettingsPanel({
|
|||||||
<div className="flex-1 overflow-y-auto custom-scrollbar p-4 pb-0">
|
<div className="flex-1 overflow-y-auto custom-scrollbar p-4 pb-0">
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<span className="text-sm font-medium text-slate-200">Zoom Level</span>
|
<span className="text-sm font-medium text-slate-200">{t("zoom.level")}</span>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{zoomEnabled && selectedZoomDepth && (
|
{zoomEnabled && selectedZoomDepth && (
|
||||||
<span className="text-[10px] uppercase tracking-wider font-medium text-[#34B27B] bg-[#34B27B]/10 px-2 py-0.5 rounded-full">
|
<span className="text-[10px] uppercase tracking-wider font-medium text-[#34B27B] bg-[#34B27B]/10 px-2 py-0.5 rounded-full">
|
||||||
@@ -483,9 +510,42 @@ export function SettingsPanel({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
{!zoomEnabled && (
|
{!zoomEnabled && (
|
||||||
<p className="text-[10px] text-slate-500 mt-2 text-center">
|
<p className="text-[10px] text-slate-500 mt-2 text-center">{t("zoom.selectRegion")}</p>
|
||||||
Select a zoom region to adjust
|
)}
|
||||||
</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 && (
|
{zoomEnabled && (
|
||||||
<Button
|
<Button
|
||||||
@@ -495,7 +555,7 @@ export function SettingsPanel({
|
|||||||
className="mt-2 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 h-8 text-xs"
|
className="mt-2 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 h-8 text-xs"
|
||||||
>
|
>
|
||||||
<Trash2 className="w-3 h-3" />
|
<Trash2 className="w-3 h-3" />
|
||||||
Delete Zoom
|
{t("zoom.deleteZoom")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -509,14 +569,14 @@ export function SettingsPanel({
|
|||||||
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 h-8 text-xs"
|
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 h-8 text-xs"
|
||||||
>
|
>
|
||||||
<Trash2 className="w-3 h-3" />
|
<Trash2 className="w-3 h-3" />
|
||||||
Delete Trim Region
|
{t("trim.deleteRegion")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<span className="text-sm font-medium text-slate-200">Playback Speed</span>
|
<span className="text-sm font-medium text-slate-200">{t("speed.playbackSpeed")}</span>
|
||||||
{selectedSpeedId && selectedSpeedValue && (
|
{selectedSpeedId && selectedSpeedValue && (
|
||||||
<span className="text-[10px] uppercase tracking-wider font-medium text-[#d97706] bg-[#d97706]/10 px-2 py-0.5 rounded-full">
|
<span className="text-[10px] uppercase tracking-wider font-medium text-[#d97706] bg-[#d97706]/10 px-2 py-0.5 rounded-full">
|
||||||
{SPEED_OPTIONS.find((o) => o.speed === selectedSpeedValue)?.label ??
|
{SPEED_OPTIONS.find((o) => o.speed === selectedSpeedValue)?.label ??
|
||||||
@@ -550,9 +610,7 @@ export function SettingsPanel({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
{!selectedSpeedId && (
|
{!selectedSpeedId && (
|
||||||
<p className="text-[10px] text-slate-500 mt-2 text-center">
|
<p className="text-[10px] text-slate-500 mt-2 text-center">{t("speed.selectRegion")}</p>
|
||||||
Select a speed region to adjust
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
{selectedSpeedId && (
|
{selectedSpeedId && (
|
||||||
<Button
|
<Button
|
||||||
@@ -562,23 +620,154 @@ export function SettingsPanel({
|
|||||||
className="mt-2 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 h-8 text-xs"
|
className="mt-2 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 h-8 text-xs"
|
||||||
>
|
>
|
||||||
<Trash2 className="w-3 h-3" />
|
<Trash2 className="w-3 h-3" />
|
||||||
Delete Speed Region
|
{t("speed.deleteRegion")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Accordion type="multiple" defaultValue={["effects", "background"]} className="space-y-1">
|
<Accordion
|
||||||
|
type="multiple"
|
||||||
|
defaultValue={hasWebcam ? ["layout", "effects", "background"] : ["effects", "background"]}
|
||||||
|
className="space-y-1"
|
||||||
|
>
|
||||||
|
{hasWebcam && (
|
||||||
|
<AccordionItem
|
||||||
|
value="layout"
|
||||||
|
className="border-white/5 rounded-xl bg-white/[0.02] px-3"
|
||||||
|
>
|
||||||
|
<AccordionTrigger className="py-2.5 hover:no-underline">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Sparkles className="w-4 h-4 text-[#34B27B]" />
|
||||||
|
<span className="text-xs font-medium">{t("layout.title")}</span>
|
||||||
|
</div>
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent className="pb-3">
|
||||||
|
<div className="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.preset")}
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
value={webcamLayoutPreset}
|
||||||
|
onValueChange={(value: WebcamLayoutPreset) =>
|
||||||
|
onWebcamLayoutPresetChange?.(value)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 bg-black/20 border-white/10 text-xs">
|
||||||
|
<SelectValue placeholder={t("layout.selectPreset")} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{WEBCAM_LAYOUT_PRESETS.filter(
|
||||||
|
(preset) =>
|
||||||
|
preset.value === "picture-in-picture" ||
|
||||||
|
isPortraitAspectRatio(aspectRatio),
|
||||||
|
).map((preset) => (
|
||||||
|
<SelectItem key={preset.value} value={preset.value} className="text-xs">
|
||||||
|
{preset.value === "picture-in-picture"
|
||||||
|
? t("layout.pictureInPicture")
|
||||||
|
: t("layout.verticalStack")}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
)}
|
||||||
|
|
||||||
<AccordionItem value="effects" className="border-white/5 rounded-xl bg-white/[0.02] px-3">
|
<AccordionItem value="effects" className="border-white/5 rounded-xl bg-white/[0.02] px-3">
|
||||||
<AccordionTrigger className="py-2.5 hover:no-underline">
|
<AccordionTrigger className="py-2.5 hover:no-underline">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Sparkles className="w-4 h-4 text-[#34B27B]" />
|
<Sparkles className="w-4 h-4 text-[#34B27B]" />
|
||||||
<span className="text-xs font-medium">Video Effects</span>
|
<span className="text-xs font-medium">{t("effects.title")}</span>
|
||||||
</div>
|
</div>
|
||||||
</AccordionTrigger>
|
</AccordionTrigger>
|
||||||
<AccordionContent className="pb-3">
|
<AccordionContent className="pb-3">
|
||||||
<div className="grid grid-cols-2 gap-2 mb-3">
|
<div className="grid grid-cols-2 gap-2 mb-3">
|
||||||
<div className="flex items-center justify-between p-2 rounded-lg bg-white/5 border border-white/5">
|
<div className="flex items-center justify-between p-2 rounded-lg bg-white/5 border border-white/5">
|
||||||
<div className="text-[10px] font-medium text-slate-300">Blur BG</div>
|
<div className="text-[10px] font-medium text-slate-300">
|
||||||
|
{t("effects.blurBg")}
|
||||||
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
checked={showBlur}
|
checked={showBlur}
|
||||||
onCheckedChange={onBlurChange}
|
onCheckedChange={onBlurChange}
|
||||||
@@ -590,9 +779,11 @@ export function SettingsPanel({
|
|||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<div className="p-2 rounded-lg bg-white/5 border border-white/5">
|
<div className="p-2 rounded-lg bg-white/5 border border-white/5">
|
||||||
<div className="flex items-center justify-between mb-1">
|
<div className="flex items-center justify-between mb-1">
|
||||||
<div className="text-[10px] font-medium text-slate-300">Motion Blur</div>
|
<div className="text-[10px] font-medium text-slate-300">
|
||||||
|
{t("effects.motionBlur")}
|
||||||
|
</div>
|
||||||
<span className="text-[10px] text-slate-500 font-mono">
|
<span className="text-[10px] text-slate-500 font-mono">
|
||||||
{motionBlurAmount === 0 ? "off" : motionBlurAmount.toFixed(2)}
|
{motionBlurAmount === 0 ? t("effects.off") : motionBlurAmount.toFixed(2)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Slider
|
<Slider
|
||||||
@@ -607,7 +798,9 @@ export function SettingsPanel({
|
|||||||
</div>
|
</div>
|
||||||
<div className="p-2 rounded-lg bg-white/5 border border-white/5">
|
<div className="p-2 rounded-lg bg-white/5 border border-white/5">
|
||||||
<div className="flex items-center justify-between mb-1">
|
<div className="flex items-center justify-between mb-1">
|
||||||
<div className="text-[10px] font-medium text-slate-300">Shadow</div>
|
<div className="text-[10px] font-medium text-slate-300">
|
||||||
|
{t("effects.shadow")}
|
||||||
|
</div>
|
||||||
<span className="text-[10px] text-slate-500 font-mono">
|
<span className="text-[10px] text-slate-500 font-mono">
|
||||||
{Math.round(shadowIntensity * 100)}%
|
{Math.round(shadowIntensity * 100)}%
|
||||||
</span>
|
</span>
|
||||||
@@ -624,7 +817,9 @@ export function SettingsPanel({
|
|||||||
</div>
|
</div>
|
||||||
<div className="p-2 rounded-lg bg-white/5 border border-white/5">
|
<div className="p-2 rounded-lg bg-white/5 border border-white/5">
|
||||||
<div className="flex items-center justify-between mb-1">
|
<div className="flex items-center justify-between mb-1">
|
||||||
<div className="text-[10px] font-medium text-slate-300">Roundness</div>
|
<div className="text-[10px] font-medium text-slate-300">
|
||||||
|
{t("effects.roundness")}
|
||||||
|
</div>
|
||||||
<span className="text-[10px] text-slate-500 font-mono">{borderRadius}px</span>
|
<span className="text-[10px] text-slate-500 font-mono">{borderRadius}px</span>
|
||||||
</div>
|
</div>
|
||||||
<Slider
|
<Slider
|
||||||
@@ -637,18 +832,25 @@ export function SettingsPanel({
|
|||||||
className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3"
|
className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-2 rounded-lg bg-white/5 border border-white/5">
|
<div
|
||||||
|
className={`p-2 rounded-lg bg-white/5 border border-white/5 ${webcamLayoutPreset === "vertical-stack" ? "opacity-40 pointer-events-none" : ""}`}
|
||||||
|
>
|
||||||
<div className="flex items-center justify-between mb-1">
|
<div className="flex items-center justify-between mb-1">
|
||||||
<div className="text-[10px] font-medium text-slate-300">Padding</div>
|
<div className="text-[10px] font-medium text-slate-300">
|
||||||
<span className="text-[10px] text-slate-500 font-mono">{padding}%</span>
|
{t("effects.padding")}
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-slate-500 font-mono">
|
||||||
|
{webcamLayoutPreset === "vertical-stack" ? "—" : `${padding}%`}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Slider
|
<Slider
|
||||||
value={[padding]}
|
value={[webcamLayoutPreset === "vertical-stack" ? 0 : padding]}
|
||||||
onValueChange={(values) => onPaddingChange?.(values[0])}
|
onValueChange={(values) => onPaddingChange?.(values[0])}
|
||||||
onValueCommit={() => onPaddingCommit?.()}
|
onValueCommit={() => onPaddingCommit?.()}
|
||||||
min={0}
|
min={0}
|
||||||
max={100}
|
max={100}
|
||||||
step={1}
|
step={1}
|
||||||
|
disabled={webcamLayoutPreset === "vertical-stack"}
|
||||||
className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3"
|
className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -660,7 +862,7 @@ export function SettingsPanel({
|
|||||||
className="w-full mt-2 gap-1.5 bg-white/5 text-slate-200 border-white/10 hover:bg-white/10 hover:border-white/20 hover:text-white text-[10px] h-8 transition-all"
|
className="w-full mt-2 gap-1.5 bg-white/5 text-slate-200 border-white/10 hover:bg-white/10 hover:border-white/20 hover:text-white text-[10px] h-8 transition-all"
|
||||||
>
|
>
|
||||||
<Crop className="w-3 h-3" />
|
<Crop className="w-3 h-3" />
|
||||||
Crop Video
|
{t("crop.cropVideo")}
|
||||||
</Button>
|
</Button>
|
||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
@@ -672,7 +874,7 @@ export function SettingsPanel({
|
|||||||
<AccordionTrigger className="py-2.5 hover:no-underline">
|
<AccordionTrigger className="py-2.5 hover:no-underline">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Palette className="w-4 h-4 text-[#34B27B]" />
|
<Palette className="w-4 h-4 text-[#34B27B]" />
|
||||||
<span className="text-xs font-medium">Background</span>
|
<span className="text-xs font-medium">{t("background.title")}</span>
|
||||||
</div>
|
</div>
|
||||||
</AccordionTrigger>
|
</AccordionTrigger>
|
||||||
<AccordionContent className="pb-3">
|
<AccordionContent className="pb-3">
|
||||||
@@ -682,19 +884,19 @@ export function SettingsPanel({
|
|||||||
value="image"
|
value="image"
|
||||||
className="data-[state=active]:bg-[#34B27B] data-[state=active]:text-white text-slate-400 text-[10px] py-1 rounded-md transition-all"
|
className="data-[state=active]:bg-[#34B27B] data-[state=active]:text-white text-slate-400 text-[10px] py-1 rounded-md transition-all"
|
||||||
>
|
>
|
||||||
Image
|
{t("background.image")}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="color"
|
value="color"
|
||||||
className="data-[state=active]:bg-[#34B27B] data-[state=active]:text-white text-slate-400 text-[10px] py-1 rounded-md transition-all"
|
className="data-[state=active]:bg-[#34B27B] data-[state=active]:text-white text-slate-400 text-[10px] py-1 rounded-md transition-all"
|
||||||
>
|
>
|
||||||
Color
|
{t("background.color")}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="gradient"
|
value="gradient"
|
||||||
className="data-[state=active]:bg-[#34B27B] data-[state=active]:text-white text-slate-400 text-[10px] py-1 rounded-md transition-all"
|
className="data-[state=active]:bg-[#34B27B] data-[state=active]:text-white text-slate-400 text-[10px] py-1 rounded-md transition-all"
|
||||||
>
|
>
|
||||||
Gradient
|
{t("background.gradient")}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
@@ -713,7 +915,7 @@ export function SettingsPanel({
|
|||||||
className="w-full gap-2 bg-white/5 text-slate-200 border-white/10 hover:bg-[#34B27B] hover:text-white hover:border-[#34B27B] transition-all h-7 text-[10px]"
|
className="w-full gap-2 bg-white/5 text-slate-200 border-white/10 hover:bg-[#34B27B] hover:text-white hover:border-[#34B27B] transition-all h-7 text-[10px]"
|
||||||
>
|
>
|
||||||
<Upload className="w-3 h-3" />
|
<Upload className="w-3 h-3" />
|
||||||
Upload Custom
|
{t("background.uploadCustom")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div className="grid grid-cols-7 gap-1.5">
|
<div className="grid grid-cols-7 gap-1.5">
|
||||||
@@ -814,7 +1016,7 @@ export function SettingsPanel({
|
|||||||
: "border-white/10 hover:border-[#34B27B]/40 opacity-80 hover:opacity-100 bg-white/5",
|
: "border-white/10 hover:border-[#34B27B]/40 opacity-80 hover:opacity-100 bg-white/5",
|
||||||
)}
|
)}
|
||||||
style={{ background: g }}
|
style={{ background: g }}
|
||||||
aria-label={`Gradient ${idx + 1}`}
|
aria-label={t("background.gradientLabel", { index: idx + 1 })}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setGradient(g);
|
setGradient(g);
|
||||||
onWallpaperChange(g);
|
onWallpaperChange(g);
|
||||||
@@ -840,10 +1042,8 @@ export function SettingsPanel({
|
|||||||
<div className="fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-[60] bg-[#09090b] rounded-2xl shadow-2xl border border-white/10 p-8 w-[90vw] max-w-5xl max-h-[90vh] overflow-auto animate-in zoom-in-95 duration-200">
|
<div className="fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-[60] bg-[#09090b] rounded-2xl shadow-2xl border border-white/10 p-8 w-[90vw] max-w-5xl max-h-[90vh] overflow-auto animate-in zoom-in-95 duration-200">
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-xl font-bold text-slate-200">Crop Video</span>
|
<span className="text-xl font-bold text-slate-200">{t("crop.cropVideo")}</span>
|
||||||
<p className="text-sm text-slate-400 mt-2">
|
<p className="text-sm text-slate-400 mt-2">{t("crop.dragInstruction")}</p>
|
||||||
Drag on each side to adjust the crop area
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -885,7 +1085,7 @@ export function SettingsPanel({
|
|||||||
|
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<label className="text-[10px] font-medium text-slate-400 uppercase tracking-wider">
|
<label className="text-[10px] font-medium text-slate-400 uppercase tracking-wider">
|
||||||
Ratio
|
{t("crop.ratio")}
|
||||||
</label>
|
</label>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<select
|
<select
|
||||||
@@ -894,7 +1094,7 @@ export function SettingsPanel({
|
|||||||
className="h-8 rounded-md border border-white/10 bg-[#1a1a1f] px-2 text-xs text-slate-200 outline-none focus:border-[#34B27B]/50 cursor-pointer"
|
className="h-8 rounded-md border border-white/10 bg-[#1a1a1f] px-2 text-xs text-slate-200 outline-none focus:border-[#34B27B]/50 cursor-pointer"
|
||||||
>
|
>
|
||||||
<option value="" className="bg-[#1a1a1f] text-slate-200">
|
<option value="" className="bg-[#1a1a1f] text-slate-200">
|
||||||
Free
|
{t("crop.free")}
|
||||||
</option>
|
</option>
|
||||||
<option value="16:9" className="bg-[#1a1a1f] text-slate-200">
|
<option value="16:9" className="bg-[#1a1a1f] text-slate-200">
|
||||||
16:9
|
16:9
|
||||||
@@ -924,7 +1124,9 @@ export function SettingsPanel({
|
|||||||
? "border-[#34B27B]/50 bg-[#34B27B]/10 text-[#34B27B]"
|
? "border-[#34B27B]/50 bg-[#34B27B]/10 text-[#34B27B]"
|
||||||
: "border-white/10 bg-white/5 text-slate-400 hover:text-slate-200",
|
: "border-white/10 bg-white/5 text-slate-400 hover:text-slate-200",
|
||||||
)}
|
)}
|
||||||
title={cropAspectLocked ? "Unlock aspect ratio" : "Lock aspect ratio"}
|
title={
|
||||||
|
cropAspectLocked ? t("crop.unlockAspectRatio") : t("crop.lockAspectRatio")
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{cropAspectLocked ? (
|
{cropAspectLocked ? (
|
||||||
<Lock className="w-3.5 h-3.5" />
|
<Lock className="w-3.5 h-3.5" />
|
||||||
@@ -946,7 +1148,7 @@ export function SettingsPanel({
|
|||||||
size="lg"
|
size="lg"
|
||||||
className="bg-[#34B27B] hover:bg-[#34B27B]/90 text-white"
|
className="bg-[#34B27B] hover:bg-[#34B27B]/90 text-white"
|
||||||
>
|
>
|
||||||
Done
|
{t("crop.done")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -966,7 +1168,7 @@ export function SettingsPanel({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Film className="w-3.5 h-3.5" />
|
<Film className="w-3.5 h-3.5" />
|
||||||
MP4
|
{t("exportFormat.mp4")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
data-testid={getTestId("gif-format-button")}
|
data-testid={getTestId("gif-format-button")}
|
||||||
@@ -979,7 +1181,7 @@ export function SettingsPanel({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Image className="w-3.5 h-3.5" />
|
<Image className="w-3.5 h-3.5" />
|
||||||
GIF
|
{t("exportFormat.gif")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -994,7 +1196,7 @@ export function SettingsPanel({
|
|||||||
: "text-slate-400 hover:text-slate-200",
|
: "text-slate-400 hover:text-slate-200",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
Low
|
{t("exportQuality.low")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => onExportQualityChange?.("good")}
|
onClick={() => onExportQualityChange?.("good")}
|
||||||
@@ -1005,7 +1207,7 @@ export function SettingsPanel({
|
|||||||
: "text-slate-400 hover:text-slate-200",
|
: "text-slate-400 hover:text-slate-200",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
Medium
|
{t("exportQuality.medium")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => onExportQualityChange?.("source")}
|
onClick={() => onExportQualityChange?.("source")}
|
||||||
@@ -1016,7 +1218,7 @@ export function SettingsPanel({
|
|||||||
: "text-slate-400 hover:text-slate-200",
|
: "text-slate-400 hover:text-slate-200",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
High
|
{t("exportQuality.high")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1063,7 +1265,7 @@ export function SettingsPanel({
|
|||||||
{gifOutputDimensions.width} × {gifOutputDimensions.height}px
|
{gifOutputDimensions.width} × {gifOutputDimensions.height}px
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-[10px] text-slate-400">Loop</span>
|
<span className="text-[10px] text-slate-400">{t("gifSettings.loop")}</span>
|
||||||
<Switch
|
<Switch
|
||||||
checked={gifLoop}
|
checked={gifLoop}
|
||||||
onCheckedChange={onGifLoopChange}
|
onCheckedChange={onGifLoopChange}
|
||||||
@@ -1074,27 +1276,17 @@ export function SettingsPanel({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-2 mb-2">
|
{unsavedExport && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
size="lg"
|
||||||
onClick={onLoadProject}
|
onClick={onSaveUnsavedExport}
|
||||||
className="h-8 text-[10px] font-medium gap-1.5 bg-white/5 border-white/10 text-slate-300 hover:bg-white/10"
|
className="w-full mb-2 py-5 text-sm font-semibold flex items-center justify-center gap-2 bg-indigo-500 text-white rounded-xl shadow-lg shadow-indigo-500/20 hover:bg-indigo-500/90 hover:scale-[1.02] active:scale-[0.98] transition-all duration-200"
|
||||||
>
|
>
|
||||||
<FolderOpen className="w-3.5 h-3.5" />
|
<Download className="w-4 h-4" />
|
||||||
Load Project
|
{t("export.chooseSaveLocation")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
)}
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={onSaveProject}
|
|
||||||
className="h-8 text-[10px] font-medium gap-1.5 bg-white/5 border-white/10 text-slate-300 hover:bg-white/10"
|
|
||||||
>
|
|
||||||
<Save className="w-3.5 h-3.5" />
|
|
||||||
Save Project
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
data-testid={getTestId("export-button")}
|
data-testid={getTestId("export-button")}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1103,7 +1295,7 @@ export function SettingsPanel({
|
|||||||
className="w-full py-5 text-sm font-semibold flex items-center justify-center gap-2 bg-[#34B27B] text-white rounded-xl shadow-lg shadow-[#34B27B]/20 hover:bg-[#34B27B]/90 hover:scale-[1.02] active:scale-[0.98] transition-all duration-200"
|
className="w-full py-5 text-sm font-semibold flex items-center justify-center gap-2 bg-[#34B27B] text-white rounded-xl shadow-lg shadow-[#34B27B]/20 hover:bg-[#34B27B]/90 hover:scale-[1.02] active:scale-[0.98] transition-all duration-200"
|
||||||
>
|
>
|
||||||
<Download className="w-4 h-4" />
|
<Download className="w-4 h-4" />
|
||||||
Export {exportFormat === "gif" ? "GIF" : "Video"}
|
{exportFormat === "gif" ? t("export.gifButton") : t("export.videoButton")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div className="flex gap-2 mt-3">
|
<div className="flex gap-2 mt-3">
|
||||||
@@ -1117,7 +1309,7 @@ export function SettingsPanel({
|
|||||||
className="flex-1 flex items-center justify-center gap-1.5 text-[10px] text-slate-500 hover:text-slate-300 py-1.5 transition-colors"
|
className="flex-1 flex items-center justify-center gap-1.5 text-[10px] text-slate-500 hover:text-slate-300 py-1.5 transition-colors"
|
||||||
>
|
>
|
||||||
<Bug className="w-3 h-3 text-[#34B27B]" />
|
<Bug className="w-3 h-3 text-[#34B27B]" />
|
||||||
Report Bug
|
{t("links.reportBug")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1127,7 +1319,7 @@ export function SettingsPanel({
|
|||||||
className="flex-1 flex items-center justify-center gap-1.5 text-[10px] text-slate-500 hover:text-slate-300 py-1.5 transition-colors"
|
className="flex-1 flex items-center justify-center gap-1.5 text-[10px] text-slate-500 hover:text-slate-300 py-1.5 transition-colors"
|
||||||
>
|
>
|
||||||
<Star className="w-3 h-3 text-yellow-400" />
|
<Star className="w-3 h-3 text-yellow-400" />
|
||||||
Star on GitHub
|
{t("links.starOnGithub")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
|
import { useScopedT } from "@/contexts/I18nContext";
|
||||||
import { useShortcuts } from "@/contexts/ShortcutsContext";
|
import { useShortcuts } from "@/contexts/ShortcutsContext";
|
||||||
import {
|
import {
|
||||||
DEFAULT_SHORTCUTS,
|
DEFAULT_SHORTCUTS,
|
||||||
@@ -16,7 +17,6 @@ import {
|
|||||||
findConflict,
|
findConflict,
|
||||||
formatBinding,
|
formatBinding,
|
||||||
SHORTCUT_ACTIONS,
|
SHORTCUT_ACTIONS,
|
||||||
SHORTCUT_LABELS,
|
|
||||||
type ShortcutAction,
|
type ShortcutAction,
|
||||||
type ShortcutBinding,
|
type ShortcutBinding,
|
||||||
type ShortcutConflict,
|
type ShortcutConflict,
|
||||||
@@ -28,6 +28,8 @@ const MODIFIER_KEYS = new Set(["Control", "Shift", "Alt", "Meta"]);
|
|||||||
export function ShortcutsConfigDialog() {
|
export function ShortcutsConfigDialog() {
|
||||||
const { shortcuts, isMac, isConfigOpen, closeConfig, setShortcuts, persistShortcuts } =
|
const { shortcuts, isMac, isConfigOpen, closeConfig, setShortcuts, persistShortcuts } =
|
||||||
useShortcuts();
|
useShortcuts();
|
||||||
|
const t = useScopedT("shortcuts");
|
||||||
|
const tc = useScopedT("common");
|
||||||
|
|
||||||
const [draft, setDraft] = useState<ShortcutsConfig>(shortcuts);
|
const [draft, setDraft] = useState<ShortcutsConfig>(shortcuts);
|
||||||
const [captureFor, setCaptureFor] = useState<ShortcutAction | null>(null);
|
const [captureFor, setCaptureFor] = useState<ShortcutAction | null>(null);
|
||||||
@@ -70,7 +72,7 @@ export function ShortcutsConfigDialog() {
|
|||||||
setCaptureFor(null);
|
setCaptureFor(null);
|
||||||
|
|
||||||
if (found?.type === "fixed") {
|
if (found?.type === "fixed") {
|
||||||
toast.error(`This shortcut is reserved for "${found.label}" and cannot be reassigned.`);
|
toast.error(t("reservedShortcut", { label: found.label }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,7 +86,7 @@ export function ShortcutsConfigDialog() {
|
|||||||
|
|
||||||
window.addEventListener("keydown", handleCapture, { capture: true });
|
window.addEventListener("keydown", handleCapture, { capture: true });
|
||||||
return () => window.removeEventListener("keydown", handleCapture, { capture: true });
|
return () => window.removeEventListener("keydown", handleCapture, { capture: true });
|
||||||
}, [captureFor, draft]);
|
}, [captureFor, draft, t]);
|
||||||
|
|
||||||
const handleSwap = useCallback(() => {
|
const handleSwap = useCallback(() => {
|
||||||
if (!conflict || conflict.conflictWith.type !== "configurable") return;
|
if (!conflict || conflict.conflictWith.type !== "configurable") return;
|
||||||
@@ -102,14 +104,14 @@ export function ShortcutsConfigDialog() {
|
|||||||
const handleSave = useCallback(async () => {
|
const handleSave = useCallback(async () => {
|
||||||
setShortcuts(draft);
|
setShortcuts(draft);
|
||||||
await persistShortcuts(draft);
|
await persistShortcuts(draft);
|
||||||
toast.success("Keyboard shortcuts saved");
|
toast.success(t("savedToast"));
|
||||||
closeConfig();
|
closeConfig();
|
||||||
}, [draft, setShortcuts, persistShortcuts, closeConfig]);
|
}, [draft, setShortcuts, persistShortcuts, closeConfig, t]);
|
||||||
|
|
||||||
const handleReset = useCallback(() => {
|
const handleReset = useCallback(() => {
|
||||||
setDraft({ ...DEFAULT_SHORTCUTS });
|
setDraft({ ...DEFAULT_SHORTCUTS });
|
||||||
toast.info("Reset to default shortcuts — click Save to apply");
|
toast.info(t("resetToast"));
|
||||||
}, []);
|
}, [t]);
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
setCaptureFor(null);
|
setCaptureFor(null);
|
||||||
@@ -128,13 +130,13 @@ export function ShortcutsConfigDialog() {
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2 text-sm">
|
<DialogTitle className="flex items-center gap-2 text-sm">
|
||||||
<Keyboard className="w-4 h-4 text-[#34B27B]" />
|
<Keyboard className="w-4 h-4 text-[#34B27B]" />
|
||||||
Keyboard Shortcuts
|
{t("title")}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<p className="text-[10px] text-slate-500 mb-2 uppercase tracking-wide font-semibold">
|
<p className="text-[10px] text-slate-500 mb-2 uppercase tracking-wide font-semibold">
|
||||||
Configurable
|
{t("configurable")}
|
||||||
</p>
|
</p>
|
||||||
{SHORTCUT_ACTIONS.map((action) => {
|
{SHORTCUT_ACTIONS.map((action) => {
|
||||||
const isCapturing = captureFor === action;
|
const isCapturing = captureFor === action;
|
||||||
@@ -142,14 +144,14 @@ export function ShortcutsConfigDialog() {
|
|||||||
return (
|
return (
|
||||||
<div key={action}>
|
<div key={action}>
|
||||||
<div className="flex items-center justify-between py-1.5 px-1 border-b border-white/5">
|
<div className="flex items-center justify-between py-1.5 px-1 border-b border-white/5">
|
||||||
<span className="text-sm text-slate-300">{SHORTCUT_LABELS[action]}</span>
|
<span className="text-sm text-slate-300">{t(`actions.${action}`)}</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setConflict(null);
|
setConflict(null);
|
||||||
setCaptureFor(isCapturing ? null : action);
|
setCaptureFor(isCapturing ? null : action);
|
||||||
}}
|
}}
|
||||||
title={isCapturing ? "Press Esc to cancel" : "Click to change"}
|
title={isCapturing ? t("pressEscToCancel") : t("clickToChange")}
|
||||||
className={[
|
className={[
|
||||||
"px-2 py-1 rounded text-xs font-mono border transition-all min-w-[90px] text-center select-none",
|
"px-2 py-1 rounded text-xs font-mono border transition-all min-w-[90px] text-center select-none",
|
||||||
isCapturing
|
isCapturing
|
||||||
@@ -159,14 +161,14 @@ export function ShortcutsConfigDialog() {
|
|||||||
: "bg-white/5 border-white/10 text-slate-200 hover:border-[#34B27B]/50 hover:text-[#34B27B] cursor-pointer",
|
: "bg-white/5 border-white/10 text-slate-200 hover:border-[#34B27B]/50 hover:text-[#34B27B] cursor-pointer",
|
||||||
].join(" ")}
|
].join(" ")}
|
||||||
>
|
>
|
||||||
{isCapturing ? "Press a key…" : formatBinding(draft[action], isMac)}
|
{isCapturing ? t("pressKey") : formatBinding(draft[action], isMac)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{hasConflict && conflict?.conflictWith.type === "configurable" && (
|
{hasConflict && conflict?.conflictWith.type === "configurable" && (
|
||||||
<div className="flex items-center justify-between px-1 py-1.5 mb-0.5 bg-amber-500/10 border border-amber-500/20 rounded text-xs">
|
<div className="flex items-center justify-between px-1 py-1.5 mb-0.5 bg-amber-500/10 border border-amber-500/20 rounded text-xs">
|
||||||
<span className="text-amber-400">
|
<span className="text-amber-400">
|
||||||
⚠ Already used by{" "}
|
⚠{" "}
|
||||||
<strong>{SHORTCUT_LABELS[conflict.conflictWith.action]}</strong>
|
{t("alreadyUsedBy", { action: t(`actions.${conflict.conflictWith.action}`) })}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex gap-1.5">
|
<div className="flex gap-1.5">
|
||||||
<button
|
<button
|
||||||
@@ -174,14 +176,14 @@ export function ShortcutsConfigDialog() {
|
|||||||
onClick={handleSwap}
|
onClick={handleSwap}
|
||||||
className="px-2 py-0.5 bg-amber-500/20 hover:bg-amber-500/30 border border-amber-500/40 rounded text-amber-300 font-medium transition-colors"
|
className="px-2 py-0.5 bg-amber-500/20 hover:bg-amber-500/30 border border-amber-500/40 rounded text-amber-300 font-medium transition-colors"
|
||||||
>
|
>
|
||||||
Swap
|
{t("swap")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleCancelConflict}
|
onClick={handleCancelConflict}
|
||||||
className="px-2 py-0.5 bg-white/5 hover:bg-white/10 border border-white/10 rounded text-slate-400 transition-colors"
|
className="px-2 py-0.5 bg-white/5 hover:bg-white/10 border border-white/10 rounded text-slate-400 transition-colors"
|
||||||
>
|
>
|
||||||
Cancel
|
{tc("actions.cancel")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -193,14 +195,16 @@ export function ShortcutsConfigDialog() {
|
|||||||
|
|
||||||
<div className="space-y-0.5 mt-2">
|
<div className="space-y-0.5 mt-2">
|
||||||
<p className="text-[10px] text-slate-500 mb-2 uppercase tracking-wide font-semibold">
|
<p className="text-[10px] text-slate-500 mb-2 uppercase tracking-wide font-semibold">
|
||||||
Fixed
|
{t("fixed")}
|
||||||
</p>
|
</p>
|
||||||
{FIXED_SHORTCUTS.map(({ label, display }) => (
|
{FIXED_SHORTCUTS.map(({ i18nKey, label, display }) => (
|
||||||
<div
|
<div
|
||||||
key={label}
|
key={i18nKey}
|
||||||
className="flex items-center justify-between py-1.5 px-1 border-b border-white/5 last:border-0"
|
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">
|
<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}
|
{display}
|
||||||
</kbd>
|
</kbd>
|
||||||
@@ -208,10 +212,7 @@ export function ShortcutsConfigDialog() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-[10px] text-slate-500 mt-1">
|
<p className="text-[10px] text-slate-500 mt-1">{t("helpText")}</p>
|
||||||
Click a shortcut then press the new key combination. Press{" "}
|
|
||||||
<span className="font-mono border border-white/10 rounded px-1">Esc</span> to cancel.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<DialogFooter className="flex gap-2 sm:justify-between mt-2">
|
<DialogFooter className="flex gap-2 sm:justify-between mt-2">
|
||||||
<Button
|
<Button
|
||||||
@@ -221,18 +222,18 @@ export function ShortcutsConfigDialog() {
|
|||||||
onClick={handleReset}
|
onClick={handleReset}
|
||||||
>
|
>
|
||||||
<RotateCcw className="w-3 h-3" />
|
<RotateCcw className="w-3 h-3" />
|
||||||
Reset to defaults
|
{t("resetToDefaults")}
|
||||||
</Button>
|
</Button>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button variant="ghost" size="sm" onClick={handleClose}>
|
<Button variant="ghost" size="sm" onClick={handleClose}>
|
||||||
Cancel
|
{tc("actions.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
className="bg-[#34B27B] hover:bg-[#2d9e6c] text-white"
|
className="bg-[#34B27B] hover:bg-[#2d9e6c] text-white"
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
>
|
>
|
||||||
Save
|
{tc("actions.save")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
|
import { useScopedT } from "@/contexts/I18nContext";
|
||||||
|
|
||||||
export function TutorialHelp() {
|
export function TutorialHelp() {
|
||||||
|
const t = useScopedT("dialogs");
|
||||||
return (
|
return (
|
||||||
<Dialog>
|
<Dialog>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
@@ -19,33 +21,33 @@ export function TutorialHelp() {
|
|||||||
className="h-7 px-2 text-xs text-slate-400 hover:text-slate-200 hover:bg-white/10 transition-all gap-1.5"
|
className="h-7 px-2 text-xs text-slate-400 hover:text-slate-200 hover:bg-white/10 transition-all gap-1.5"
|
||||||
>
|
>
|
||||||
<HelpCircle className="w-3.5 h-3.5" />
|
<HelpCircle className="w-3.5 h-3.5" />
|
||||||
<span className="font-medium">How trimming works</span>
|
<span className="font-medium">{t("tutorial.triggerLabel")}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="max-w-2xl bg-[#09090b] border-white/10 [&>button]:text-slate-400 [&>button:hover]:text-white">
|
<DialogContent className="max-w-2xl bg-[#09090b] border-white/10 [&>button]:text-slate-400 [&>button:hover]:text-white">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="text-xl font-semibold text-slate-200 flex items-center gap-2">
|
<DialogTitle className="text-xl font-semibold text-slate-200 flex items-center gap-2">
|
||||||
<Scissors className="w-5 h-5 text-[#ef4444]" /> How Trimming Works
|
<Scissors className="w-5 h-5 text-[#ef4444]" /> {t("tutorial.title")}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription className="text-slate-400">
|
<DialogDescription className="text-slate-400">
|
||||||
Understanding how to cut out unwanted parts of your video.
|
{t("tutorial.description")}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="mt-4 space-y-8">
|
<div className="mt-4 space-y-8">
|
||||||
{/* Explanation */}
|
{/* Explanation */}
|
||||||
<div className="bg-white/5 rounded-lg p-4 border border-white/5">
|
<div className="bg-white/5 rounded-lg p-4 border border-white/5">
|
||||||
<p className="text-slate-300 leading-relaxed">
|
<p className="text-slate-300 leading-relaxed">
|
||||||
The Trim tool works by defining the segments you want to
|
{t("tutorial.explanationBefore")}
|
||||||
<span className="text-[#ef4444] font-bold"> remove</span>. Any part of the timeline
|
<span className="text-[#ef4444] font-bold"> {t("tutorial.remove")}</span>
|
||||||
that is
|
{t("tutorial.explanationMiddle")}
|
||||||
<span className="text-[#ef4444] font-bold"> covered</span> by a red trim segment will
|
<span className="text-[#ef4444] font-bold"> {t("tutorial.covered")}</span>
|
||||||
be cut out when you export.
|
{t("tutorial.explanationAfter")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{/* Visual Illustration */}
|
{/* Visual Illustration */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h3 className="text-sm font-medium text-slate-400 uppercase tracking-wider">
|
<h3 className="text-sm font-medium text-slate-400 uppercase tracking-wider">
|
||||||
Visual Example
|
{t("tutorial.visualExample")}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="relative h-24 bg-[#000] rounded-lg border border-white/10 flex items-center px-4 overflow-hidden select-none">
|
<div className="relative h-24 bg-[#000] rounded-lg border border-white/10 flex items-center px-4 overflow-hidden select-none">
|
||||||
{/* Background track (Kept parts) */}
|
{/* Background track (Kept parts) */}
|
||||||
@@ -58,7 +60,7 @@ export function TutorialHelp() {
|
|||||||
style={{ width: "20%" }}
|
style={{ width: "20%" }}
|
||||||
>
|
>
|
||||||
<span className="text-[10px] font-bold text-[#ef4444] bg-black/50 px-1 rounded">
|
<span className="text-[10px] font-bold text-[#ef4444] bg-black/50 px-1 rounded">
|
||||||
REMOVED
|
{t("tutorial.removed")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{/* Removed Segment 2 */}
|
{/* Removed Segment 2 */}
|
||||||
@@ -67,13 +69,19 @@ export function TutorialHelp() {
|
|||||||
style={{ width: "15%" }}
|
style={{ width: "15%" }}
|
||||||
>
|
>
|
||||||
<span className="text-[10px] font-bold text-[#ef4444] bg-black/50 px-1 rounded">
|
<span className="text-[10px] font-bold text-[#ef4444] bg-black/50 px-1 rounded">
|
||||||
REMOVED
|
{t("tutorial.removed")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{/* Labels for kept parts */}
|
{/* Labels for kept parts */}
|
||||||
<div className="absolute left-[5%] text-[10px] text-slate-400 font-medium">Kept</div>
|
<div className="absolute left-[5%] text-[10px] text-slate-400 font-medium">
|
||||||
<div className="absolute left-[50%] text-[10px] text-slate-400 font-medium">Kept</div>
|
{t("tutorial.kept")}
|
||||||
<div className="absolute left-[90%] text-[10px] text-slate-400 font-medium">Kept</div>
|
</div>
|
||||||
|
<div className="absolute left-[50%] text-[10px] text-slate-400 font-medium">
|
||||||
|
{t("tutorial.kept")}
|
||||||
|
</div>
|
||||||
|
<div className="absolute left-[90%] text-[10px] text-slate-400 font-medium">
|
||||||
|
{t("tutorial.kept")}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-center mt-2">
|
<div className="flex justify-center mt-2">
|
||||||
<ArrowRight className="w-4 h-4 text-slate-600 rotate-90" />
|
<ArrowRight className="w-4 h-4 text-slate-600 rotate-90" />
|
||||||
@@ -84,38 +92,38 @@ export function TutorialHelp() {
|
|||||||
className="h-8 bg-slate-700 rounded flex items-center justify-center opacity-80"
|
className="h-8 bg-slate-700 rounded flex items-center justify-center opacity-80"
|
||||||
style={{ width: "30%" }}
|
style={{ width: "30%" }}
|
||||||
>
|
>
|
||||||
<span className="text-[10px] text-white font-medium">Part 1</span>
|
<span className="text-[10px] text-white font-medium">{t("tutorial.part1")}</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="h-8 bg-slate-700 rounded flex items-center justify-center opacity-80"
|
className="h-8 bg-slate-700 rounded flex items-center justify-center opacity-80"
|
||||||
style={{ width: "30%" }}
|
style={{ width: "30%" }}
|
||||||
>
|
>
|
||||||
<span className="text-[10px] text-white font-medium">Part 2</span>
|
<span className="text-[10px] text-white font-medium">{t("tutorial.part2")}</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="h-8 bg-slate-700 rounded flex items-center justify-center opacity-80"
|
className="h-8 bg-slate-700 rounded flex items-center justify-center opacity-80"
|
||||||
style={{ width: "30%" }}
|
style={{ width: "30%" }}
|
||||||
>
|
>
|
||||||
<span className="text-[10px] text-white font-medium">Part 3</span>
|
<span className="text-[10px] text-white font-medium">{t("tutorial.part3")}</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="absolute right-4 text-xs text-slate-400">Final Video</span>
|
<span className="absolute right-4 text-xs text-slate-400">
|
||||||
|
{t("tutorial.finalVideo")}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Steps */}
|
{/* Steps */}
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="p-3 rounded bg-white/5 border border-white/5">
|
<div className="p-3 rounded bg-white/5 border border-white/5">
|
||||||
<div className="text-[#ef4444] font-bold mb-1">1. Add Trim</div>
|
<div className="text-[#ef4444] font-bold mb-1">{t("tutorial.step1Title")}</div>
|
||||||
<p className="text-xs text-slate-400">
|
<p className="text-xs text-slate-400">
|
||||||
Press
|
{t("tutorial.step1DescriptionBefore")}
|
||||||
<kbd className="bg-white/10 px-1 rounded text-slate-300">T</kbd>
|
<kbd className="bg-white/10 px-1 rounded text-slate-300">T</kbd>
|
||||||
or click the scissors icon to mark a section for removal.
|
{t("tutorial.step1DescriptionAfter")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3 rounded bg-white/5 border border-white/5">
|
<div className="p-3 rounded bg-white/5 border border-white/5">
|
||||||
<div className="text-[#ef4444] font-bold mb-1">2. Adjust</div>
|
<div className="text-[#ef4444] font-bold mb-1">{t("tutorial.step2Title")}</div>
|
||||||
<p className="text-xs text-slate-400">
|
<p className="text-xs text-slate-400">{t("tutorial.step2Description")}</p>
|
||||||
Drag the edges of the red region to cover exactly what you want to cut out.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import type { Span } from "dnd-timeline";
|
import type { Span } from "dnd-timeline";
|
||||||
|
import { FolderOpen, Languages, Save } from "lucide-react";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels";
|
import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
import { useI18n, useScopedT } from "@/contexts/I18nContext";
|
||||||
import { useShortcuts } from "@/contexts/ShortcutsContext";
|
import { useShortcuts } from "@/contexts/ShortcutsContext";
|
||||||
import { INITIAL_EDITOR_STATE, useEditorHistory } from "@/hooks/useEditorHistory";
|
import { INITIAL_EDITOR_STATE, useEditorHistory } from "@/hooks/useEditorHistory";
|
||||||
|
import { type Locale, SUPPORTED_LOCALES } from "@/i18n/config";
|
||||||
|
import { getLocaleName } from "@/i18n/loader";
|
||||||
import {
|
import {
|
||||||
calculateOutputDimensions,
|
calculateOutputDimensions,
|
||||||
type ExportFormat,
|
type ExportFormat,
|
||||||
@@ -17,15 +20,23 @@ import {
|
|||||||
type GifSizePreset,
|
type GifSizePreset,
|
||||||
VideoExporter,
|
VideoExporter,
|
||||||
} from "@/lib/exporter";
|
} from "@/lib/exporter";
|
||||||
|
import { computeFrameStepTime } from "@/lib/frameStep";
|
||||||
import type { ProjectMedia } from "@/lib/recordingSession";
|
import type { ProjectMedia } from "@/lib/recordingSession";
|
||||||
import { matchesShortcut } from "@/lib/shortcuts";
|
import { matchesShortcut } from "@/lib/shortcuts";
|
||||||
import { getAspectRatioValue, getNativeAspectRatioValue } from "@/utils/aspectRatioUtils";
|
import { loadUserPreferences, saveUserPreferences } from "@/lib/userPreferences";
|
||||||
|
import {
|
||||||
|
getAspectRatioValue,
|
||||||
|
getNativeAspectRatioValue,
|
||||||
|
isPortraitAspectRatio,
|
||||||
|
} from "@/utils/aspectRatioUtils";
|
||||||
import { ExportDialog } from "./ExportDialog";
|
import { ExportDialog } from "./ExportDialog";
|
||||||
import PlaybackControls from "./PlaybackControls";
|
import PlaybackControls from "./PlaybackControls";
|
||||||
import {
|
import {
|
||||||
createProjectData,
|
createProjectData,
|
||||||
|
createProjectSnapshot,
|
||||||
deriveNextId,
|
deriveNextId,
|
||||||
fromFileUrl,
|
fromFileUrl,
|
||||||
|
hasProjectUnsavedChanges,
|
||||||
normalizeProjectEditor,
|
normalizeProjectEditor,
|
||||||
resolveProjectMedia,
|
resolveProjectMedia,
|
||||||
toFileUrl,
|
toFileUrl,
|
||||||
@@ -49,6 +60,7 @@ import {
|
|||||||
type TrimRegion,
|
type TrimRegion,
|
||||||
type ZoomDepth,
|
type ZoomDepth,
|
||||||
type ZoomFocus,
|
type ZoomFocus,
|
||||||
|
type ZoomFocusMode,
|
||||||
type ZoomRegion,
|
type ZoomRegion,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import VideoPlayback, { VideoPlaybackRef } from "./VideoPlayback";
|
import VideoPlayback, { VideoPlaybackRef } from "./VideoPlayback";
|
||||||
@@ -76,6 +88,9 @@ export default function VideoEditor() {
|
|||||||
borderRadius,
|
borderRadius,
|
||||||
padding,
|
padding,
|
||||||
aspectRatio,
|
aspectRatio,
|
||||||
|
webcamLayoutPreset,
|
||||||
|
webcamMaskShape,
|
||||||
|
webcamPosition,
|
||||||
} = editorState;
|
} = editorState;
|
||||||
|
|
||||||
// ── Non-undoable state
|
// ── Non-undoable state
|
||||||
@@ -89,6 +104,10 @@ export default function VideoEditor() {
|
|||||||
const [isPlaying, setIsPlaying] = useState(false);
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
const [currentTime, setCurrentTime] = useState(0);
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
const [duration, setDuration] = 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 [cursorTelemetry, setCursorTelemetry] = useState<CursorTelemetryPoint[]>([]);
|
||||||
const [selectedZoomId, setSelectedZoomId] = useState<string | null>(null);
|
const [selectedZoomId, setSelectedZoomId] = useState<string | null>(null);
|
||||||
const [selectedTrimId, setSelectedTrimId] = useState<string | null>(null);
|
const [selectedTrimId, setSelectedTrimId] = useState<string | null>(null);
|
||||||
@@ -105,13 +124,25 @@ export default function VideoEditor() {
|
|||||||
const [gifSizePreset, setGifSizePreset] = useState<GifSizePreset>("medium");
|
const [gifSizePreset, setGifSizePreset] = useState<GifSizePreset>("medium");
|
||||||
const [exportedFilePath, setExportedFilePath] = useState<string | null>(null);
|
const [exportedFilePath, setExportedFilePath] = useState<string | null>(null);
|
||||||
const [lastSavedSnapshot, setLastSavedSnapshot] = useState<string | null>(null);
|
const [lastSavedSnapshot, setLastSavedSnapshot] = useState<string | null>(null);
|
||||||
|
const [unsavedExport, setUnsavedExport] = useState<{
|
||||||
|
arrayBuffer: ArrayBuffer;
|
||||||
|
fileName: string;
|
||||||
|
format: string;
|
||||||
|
} | null>(null);
|
||||||
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
|
|
||||||
|
const playerContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const videoPlaybackRef = useRef<VideoPlaybackRef>(null);
|
const videoPlaybackRef = useRef<VideoPlaybackRef>(null);
|
||||||
|
|
||||||
const nextZoomIdRef = useRef(1);
|
const nextZoomIdRef = useRef(1);
|
||||||
const nextTrimIdRef = useRef(1);
|
const nextTrimIdRef = useRef(1);
|
||||||
const nextSpeedIdRef = useRef(1);
|
const nextSpeedIdRef = useRef(1);
|
||||||
|
|
||||||
const { shortcuts, isMac } = useShortcuts();
|
const { shortcuts, isMac } = useShortcuts();
|
||||||
|
const t = useScopedT("editor");
|
||||||
|
const ts = useScopedT("settings");
|
||||||
|
const { locale, setLocale } = useI18n();
|
||||||
|
|
||||||
const nextAnnotationIdRef = useRef(1);
|
const nextAnnotationIdRef = useRef(1);
|
||||||
const nextAnnotationZIndexRef = useRef(1);
|
const nextAnnotationZIndexRef = useRef(1);
|
||||||
const exporterRef = useRef<VideoExporter | null>(null);
|
const exporterRef = useRef<VideoExporter | null>(null);
|
||||||
@@ -173,6 +204,9 @@ export default function VideoEditor() {
|
|||||||
speedRegions: normalizedEditor.speedRegions,
|
speedRegions: normalizedEditor.speedRegions,
|
||||||
annotationRegions: normalizedEditor.annotationRegions,
|
annotationRegions: normalizedEditor.annotationRegions,
|
||||||
aspectRatio: normalizedEditor.aspectRatio,
|
aspectRatio: normalizedEditor.aspectRatio,
|
||||||
|
webcamLayoutPreset: normalizedEditor.webcamLayoutPreset,
|
||||||
|
webcamMaskShape: normalizedEditor.webcamMaskShape,
|
||||||
|
webcamPosition: normalizedEditor.webcamPosition,
|
||||||
});
|
});
|
||||||
setExportQuality(normalizedEditor.exportQuality);
|
setExportQuality(normalizedEditor.exportQuality);
|
||||||
setExportFormat(normalizedEditor.exportFormat);
|
setExportFormat(normalizedEditor.exportFormat);
|
||||||
@@ -208,13 +242,11 @@ export default function VideoEditor() {
|
|||||||
) + 1;
|
) + 1;
|
||||||
|
|
||||||
setLastSavedSnapshot(
|
setLastSavedSnapshot(
|
||||||
JSON.stringify(
|
createProjectSnapshot(
|
||||||
createProjectData(
|
webcamSourcePath
|
||||||
webcamSourcePath
|
? { screenVideoPath: sourcePath, webcamVideoPath: webcamSourcePath }
|
||||||
? { screenVideoPath: sourcePath, webcamVideoPath: webcamSourcePath }
|
: { screenVideoPath: sourcePath },
|
||||||
: { screenVideoPath: sourcePath },
|
normalizedEditor,
|
||||||
normalizedEditor,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return true;
|
return true;
|
||||||
@@ -226,27 +258,28 @@ export default function VideoEditor() {
|
|||||||
if (!currentProjectMedia) {
|
if (!currentProjectMedia) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return JSON.stringify(
|
return createProjectSnapshot(currentProjectMedia, {
|
||||||
createProjectData(currentProjectMedia, {
|
wallpaper,
|
||||||
wallpaper,
|
shadowIntensity,
|
||||||
shadowIntensity,
|
showBlur,
|
||||||
showBlur,
|
motionBlurAmount,
|
||||||
motionBlurAmount,
|
borderRadius,
|
||||||
borderRadius,
|
padding,
|
||||||
padding,
|
cropRegion,
|
||||||
cropRegion,
|
zoomRegions,
|
||||||
zoomRegions,
|
trimRegions,
|
||||||
trimRegions,
|
speedRegions,
|
||||||
speedRegions,
|
annotationRegions,
|
||||||
annotationRegions,
|
aspectRatio,
|
||||||
aspectRatio,
|
webcamLayoutPreset,
|
||||||
exportQuality,
|
webcamMaskShape,
|
||||||
exportFormat,
|
webcamPosition,
|
||||||
gifFrameRate,
|
exportQuality,
|
||||||
gifLoop,
|
exportFormat,
|
||||||
gifSizePreset,
|
gifFrameRate,
|
||||||
}),
|
gifLoop,
|
||||||
);
|
gifSizePreset,
|
||||||
|
});
|
||||||
}, [
|
}, [
|
||||||
currentProjectMedia,
|
currentProjectMedia,
|
||||||
wallpaper,
|
wallpaper,
|
||||||
@@ -261,6 +294,9 @@ export default function VideoEditor() {
|
|||||||
speedRegions,
|
speedRegions,
|
||||||
annotationRegions,
|
annotationRegions,
|
||||||
aspectRatio,
|
aspectRatio,
|
||||||
|
webcamLayoutPreset,
|
||||||
|
webcamMaskShape,
|
||||||
|
webcamPosition,
|
||||||
exportQuality,
|
exportQuality,
|
||||||
exportFormat,
|
exportFormat,
|
||||||
gifFrameRate,
|
gifFrameRate,
|
||||||
@@ -268,12 +304,7 @@ export default function VideoEditor() {
|
|||||||
gifSizePreset,
|
gifSizePreset,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const hasUnsavedChanges = Boolean(
|
const hasUnsavedChanges = hasProjectUnsavedChanges(currentProjectSnapshot, lastSavedSnapshot);
|
||||||
currentProjectPath &&
|
|
||||||
currentProjectSnapshot &&
|
|
||||||
lastSavedSnapshot &&
|
|
||||||
currentProjectSnapshot !== lastSavedSnapshot,
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function loadInitialData() {
|
async function loadInitialData() {
|
||||||
@@ -301,7 +332,14 @@ export default function VideoEditor() {
|
|||||||
setWebcamVideoSourcePath(webcamSourcePath);
|
setWebcamVideoSourcePath(webcamSourcePath);
|
||||||
setWebcamVideoPath(webcamSourcePath ? toFileUrl(webcamSourcePath) : null);
|
setWebcamVideoPath(webcamSourcePath ? toFileUrl(webcamSourcePath) : null);
|
||||||
setCurrentProjectPath(null);
|
setCurrentProjectPath(null);
|
||||||
setLastSavedSnapshot(null);
|
setLastSavedSnapshot(
|
||||||
|
createProjectSnapshot(
|
||||||
|
webcamSourcePath
|
||||||
|
? { screenVideoPath: sourcePath, webcamVideoPath: webcamSourcePath }
|
||||||
|
: { screenVideoPath: sourcePath },
|
||||||
|
INITIAL_EDITOR_STATE,
|
||||||
|
),
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -313,7 +351,9 @@ export default function VideoEditor() {
|
|||||||
setWebcamVideoSourcePath(null);
|
setWebcamVideoSourcePath(null);
|
||||||
setWebcamVideoPath(null);
|
setWebcamVideoPath(null);
|
||||||
setCurrentProjectPath(null);
|
setCurrentProjectPath(null);
|
||||||
setLastSavedSnapshot(null);
|
setLastSavedSnapshot(
|
||||||
|
createProjectSnapshot({ screenVideoPath: sourcePath }, INITIAL_EDITOR_STATE),
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
setError("No video to load. Please record or select a video.");
|
setError("No video to load. Please record or select a video.");
|
||||||
}
|
}
|
||||||
@@ -327,15 +367,37 @@ export default function VideoEditor() {
|
|||||||
loadInitialData();
|
loadInitialData();
|
||||||
}, [applyLoadedProject]);
|
}, [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(
|
const saveProject = useCallback(
|
||||||
async (forceSaveAs: boolean) => {
|
async (forceSaveAs: boolean) => {
|
||||||
if (!videoPath) {
|
if (!videoPath) {
|
||||||
toast.error("No video loaded");
|
toast.error(t("errors.noVideoLoaded"));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!currentProjectMedia) {
|
if (!currentProjectMedia) {
|
||||||
toast.error("Unable to determine source video path");
|
toast.error(t("errors.unableToDetermineSourcePath"));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -352,6 +414,9 @@ export default function VideoEditor() {
|
|||||||
speedRegions,
|
speedRegions,
|
||||||
annotationRegions,
|
annotationRegions,
|
||||||
aspectRatio,
|
aspectRatio,
|
||||||
|
webcamLayoutPreset,
|
||||||
|
webcamMaskShape,
|
||||||
|
webcamPosition,
|
||||||
exportQuality,
|
exportQuality,
|
||||||
exportFormat,
|
exportFormat,
|
||||||
gifFrameRate,
|
gifFrameRate,
|
||||||
@@ -372,12 +437,12 @@ export default function VideoEditor() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (result.canceled) {
|
if (result.canceled) {
|
||||||
toast.info("Project save canceled");
|
toast.info(t("project.saveCanceled"));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
toast.error(result.message || "Failed to save project");
|
toast.error(result.message || t("project.failedToSave"));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -386,7 +451,7 @@ export default function VideoEditor() {
|
|||||||
}
|
}
|
||||||
setLastSavedSnapshot(projectSnapshot);
|
setLastSavedSnapshot(projectSnapshot);
|
||||||
|
|
||||||
toast.success(`Project saved to ${result.path}`);
|
toast.success(t("project.savedTo", { path: result.path ?? "" }));
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
@@ -404,12 +469,16 @@ export default function VideoEditor() {
|
|||||||
speedRegions,
|
speedRegions,
|
||||||
annotationRegions,
|
annotationRegions,
|
||||||
aspectRatio,
|
aspectRatio,
|
||||||
|
webcamLayoutPreset,
|
||||||
|
webcamMaskShape,
|
||||||
|
webcamPosition,
|
||||||
exportQuality,
|
exportQuality,
|
||||||
exportFormat,
|
exportFormat,
|
||||||
gifFrameRate,
|
gifFrameRate,
|
||||||
gifLoop,
|
gifLoop,
|
||||||
gifSizePreset,
|
gifSizePreset,
|
||||||
videoPath,
|
videoPath,
|
||||||
|
t,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -510,6 +579,21 @@ export default function VideoEditor() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const toggleFullscreen = useCallback(() => {
|
||||||
|
setIsFullscreen((prev) => !prev);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isFullscreen) return;
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
setIsFullscreen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [isFullscreen]);
|
||||||
|
|
||||||
function handleSeek(time: number) {
|
function handleSeek(time: number) {
|
||||||
const video = videoPlaybackRef.current?.video;
|
const video = videoPlaybackRef.current?.video;
|
||||||
if (!video) return;
|
if (!video) return;
|
||||||
@@ -641,6 +725,18 @@ export default function VideoEditor() {
|
|||||||
[selectedZoomId, pushState],
|
[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(
|
const handleZoomDelete = useCallback(
|
||||||
(id: string) => {
|
(id: string) => {
|
||||||
pushState((prev) => ({ zoomRegions: prev.zoomRegions.filter((r) => r.id !== id) }));
|
pushState((prev) => ({ zoomRegions: prev.zoomRegions.filter((r) => r.id !== id) }));
|
||||||
@@ -879,6 +975,40 @@ export default function VideoEditor() {
|
|||||||
return;
|
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 =
|
const isInput =
|
||||||
e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement;
|
e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement;
|
||||||
|
|
||||||
@@ -961,6 +1091,27 @@ export default function VideoEditor() {
|
|||||||
[handleShowExportedFile],
|
[handleShowExportedFile],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleSaveUnsavedExport = useCallback(async () => {
|
||||||
|
if (!unsavedExport) return;
|
||||||
|
try {
|
||||||
|
const saveResult = await window.electronAPI.saveExportedVideo(
|
||||||
|
unsavedExport.arrayBuffer,
|
||||||
|
unsavedExport.fileName,
|
||||||
|
);
|
||||||
|
if (saveResult.canceled) {
|
||||||
|
toast.info("Export canceled");
|
||||||
|
} else if (saveResult.success && saveResult.path) {
|
||||||
|
setUnsavedExport(null);
|
||||||
|
handleExportSaved(unsavedExport.format === "gif" ? "GIF" : "Video", saveResult.path);
|
||||||
|
} else {
|
||||||
|
toast.error(saveResult.message || "Failed to save export");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error saving unsaved export:", error);
|
||||||
|
toast.error("Failed to save exported video");
|
||||||
|
}
|
||||||
|
}, [unsavedExport, handleExportSaved]);
|
||||||
|
|
||||||
const handleExport = useCallback(
|
const handleExport = useCallback(
|
||||||
async (settings: ExportSettings) => {
|
async (settings: ExportSettings) => {
|
||||||
if (!videoPath) {
|
if (!videoPath) {
|
||||||
@@ -1021,8 +1172,12 @@ export default function VideoEditor() {
|
|||||||
videoPadding: padding,
|
videoPadding: padding,
|
||||||
cropRegion,
|
cropRegion,
|
||||||
annotationRegions,
|
annotationRegions,
|
||||||
|
webcamLayoutPreset,
|
||||||
|
webcamMaskShape,
|
||||||
|
webcamPosition,
|
||||||
previewWidth,
|
previewWidth,
|
||||||
previewHeight,
|
previewHeight,
|
||||||
|
cursorTelemetry,
|
||||||
onProgress: (progress: ExportProgress) => {
|
onProgress: (progress: ExportProgress) => {
|
||||||
setExportProgress(progress);
|
setExportProgress(progress);
|
||||||
},
|
},
|
||||||
@@ -1039,8 +1194,10 @@ export default function VideoEditor() {
|
|||||||
const saveResult = await window.electronAPI.saveExportedVideo(arrayBuffer, fileName);
|
const saveResult = await window.electronAPI.saveExportedVideo(arrayBuffer, fileName);
|
||||||
|
|
||||||
if (saveResult.canceled) {
|
if (saveResult.canceled) {
|
||||||
|
setUnsavedExport({ arrayBuffer, fileName, format: "gif" });
|
||||||
toast.info("Export canceled");
|
toast.info("Export canceled");
|
||||||
} else if (saveResult.success && saveResult.path) {
|
} else if (saveResult.success && saveResult.path) {
|
||||||
|
setUnsavedExport(null);
|
||||||
handleExportSaved("GIF", saveResult.path);
|
handleExportSaved("GIF", saveResult.path);
|
||||||
} else {
|
} else {
|
||||||
setExportError(saveResult.message || "Failed to save GIF");
|
setExportError(saveResult.message || "Failed to save GIF");
|
||||||
@@ -1148,8 +1305,12 @@ export default function VideoEditor() {
|
|||||||
padding,
|
padding,
|
||||||
cropRegion,
|
cropRegion,
|
||||||
annotationRegions,
|
annotationRegions,
|
||||||
|
webcamLayoutPreset,
|
||||||
|
webcamMaskShape,
|
||||||
|
webcamPosition,
|
||||||
previewWidth,
|
previewWidth,
|
||||||
previewHeight,
|
previewHeight,
|
||||||
|
cursorTelemetry,
|
||||||
onProgress: (progress: ExportProgress) => {
|
onProgress: (progress: ExportProgress) => {
|
||||||
setExportProgress(progress);
|
setExportProgress(progress);
|
||||||
},
|
},
|
||||||
@@ -1166,8 +1327,10 @@ export default function VideoEditor() {
|
|||||||
const saveResult = await window.electronAPI.saveExportedVideo(arrayBuffer, fileName);
|
const saveResult = await window.electronAPI.saveExportedVideo(arrayBuffer, fileName);
|
||||||
|
|
||||||
if (saveResult.canceled) {
|
if (saveResult.canceled) {
|
||||||
|
setUnsavedExport({ arrayBuffer, fileName, format: "mp4" });
|
||||||
toast.info("Export canceled");
|
toast.info("Export canceled");
|
||||||
} else if (saveResult.success && saveResult.path) {
|
} else if (saveResult.success && saveResult.path) {
|
||||||
|
setUnsavedExport(null);
|
||||||
handleExportSaved("Video", saveResult.path);
|
handleExportSaved("Video", saveResult.path);
|
||||||
} else {
|
} else {
|
||||||
setExportError(saveResult.message || "Failed to save video");
|
setExportError(saveResult.message || "Failed to save video");
|
||||||
@@ -1212,8 +1375,12 @@ export default function VideoEditor() {
|
|||||||
annotationRegions,
|
annotationRegions,
|
||||||
isPlaying,
|
isPlaying,
|
||||||
aspectRatio,
|
aspectRatio,
|
||||||
|
webcamLayoutPreset,
|
||||||
|
webcamMaskShape,
|
||||||
|
webcamPosition,
|
||||||
exportQuality,
|
exportQuality,
|
||||||
handleExportSaved,
|
handleExportSaved,
|
||||||
|
cursorTelemetry,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1319,210 +1486,279 @@ export default function VideoEditor() {
|
|||||||
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"
|
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}
|
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
|
||||||
>
|
>
|
||||||
<div className="flex-1" />
|
<div
|
||||||
|
className="flex-1 flex items-center gap-1"
|
||||||
|
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
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 ${isMac ? "ml-14" : "ml-2"}`}
|
||||||
|
>
|
||||||
|
<Languages size={14} />
|
||||||
|
<select
|
||||||
|
value={locale}
|
||||||
|
onChange={(e) => setLocale(e.target.value as Locale)}
|
||||||
|
className="bg-transparent text-[11px] font-medium outline-none cursor-pointer appearance-none pr-1"
|
||||||
|
style={{ color: "inherit" }}
|
||||||
|
>
|
||||||
|
{SUPPORTED_LOCALES.map((loc) => (
|
||||||
|
<option key={loc} value={loc} className="bg-[#09090b] text-white">
|
||||||
|
{getLocaleName(loc)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleLoadProject}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<FolderOpen size={14} />
|
||||||
|
{ts("project.load")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSaveProject}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<Save size={14} />
|
||||||
|
{ts("project.save")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 p-5 gap-4 flex min-h-0 relative">
|
<div className="flex-1 p-5 gap-4 flex min-h-0 relative">
|
||||||
{/* Left Column - Video & Timeline */}
|
{/* Left Column - Video & Timeline */}
|
||||||
<PanelGroup direction="horizontal" className="gap-3">
|
<div className="flex-[7] flex flex-col gap-3 min-w-0 h-full">
|
||||||
<Panel>
|
<PanelGroup direction="vertical" className="gap-3">
|
||||||
<PanelGroup direction="vertical" className="gap-3">
|
{/* Top section: video preview and controls */}
|
||||||
{/* Top section: video preview and controls */}
|
<Panel defaultSize={70} maxSize={70} minSize={40}>
|
||||||
<Panel defaultSize={70} maxSize={70} minSize={40}>
|
<div
|
||||||
<div className="w-full h-full flex flex-col items-center justify-center bg-black/40 rounded-2xl border border-white/5 shadow-2xl overflow-hidden">
|
ref={playerContainerRef}
|
||||||
{/* Video preview */}
|
className={
|
||||||
<div className="w-full flex justify-center items-center flex-auto mt-1.5">
|
isFullscreen
|
||||||
<div
|
? "fixed inset-0 z-[99999] w-full h-full flex flex-col items-center justify-center bg-[#09090b]"
|
||||||
className="relative flex justify-center items-center w-auto h-full max-w-full box-border"
|
: "w-full h-full flex flex-col items-center justify-center bg-black/40 rounded-2xl border border-white/5 shadow-2xl overflow-hidden relative"
|
||||||
style={{
|
}
|
||||||
aspectRatio:
|
>
|
||||||
aspectRatio === "native"
|
{/* Video preview */}
|
||||||
? getNativeAspectRatioValue(
|
<div className="w-full flex justify-center items-center flex-auto mt-1.5">
|
||||||
videoPlaybackRef.current?.video?.videoWidth || 1920,
|
<div
|
||||||
videoPlaybackRef.current?.video?.videoHeight || 1080,
|
className="relative flex justify-center items-center w-auto h-full max-w-full box-border"
|
||||||
cropRegion,
|
style={{
|
||||||
)
|
aspectRatio:
|
||||||
: getAspectRatioValue(aspectRatio),
|
aspectRatio === "native"
|
||||||
}}
|
? getNativeAspectRatioValue(
|
||||||
>
|
videoPlaybackRef.current?.video?.videoWidth || 1920,
|
||||||
<VideoPlayback
|
videoPlaybackRef.current?.video?.videoHeight || 1080,
|
||||||
key={`${videoPath || "no-video"}:${webcamVideoPath || "no-webcam"}`}
|
cropRegion,
|
||||||
aspectRatio={aspectRatio}
|
)
|
||||||
ref={videoPlaybackRef}
|
: getAspectRatioValue(aspectRatio),
|
||||||
videoPath={videoPath || ""}
|
}}
|
||||||
webcamVideoPath={webcamVideoPath || undefined}
|
>
|
||||||
onDurationChange={setDuration}
|
<VideoPlayback
|
||||||
onTimeUpdate={setCurrentTime}
|
key={`${videoPath || "no-video"}:${webcamVideoPath || "no-webcam"}`}
|
||||||
currentTime={currentTime}
|
aspectRatio={aspectRatio}
|
||||||
onPlayStateChange={setIsPlaying}
|
ref={videoPlaybackRef}
|
||||||
onError={setError}
|
videoPath={videoPath || ""}
|
||||||
wallpaper={wallpaper}
|
webcamVideoPath={webcamVideoPath || undefined}
|
||||||
zoomRegions={zoomRegions}
|
webcamLayoutPreset={webcamLayoutPreset}
|
||||||
selectedZoomId={selectedZoomId}
|
webcamMaskShape={webcamMaskShape}
|
||||||
onSelectZoom={handleSelectZoom}
|
webcamPosition={webcamPosition}
|
||||||
onZoomFocusChange={handleZoomFocusChange}
|
onWebcamPositionChange={(pos) => updateState({ webcamPosition: pos })}
|
||||||
onZoomFocusDragEnd={commitState}
|
onWebcamPositionDragEnd={commitState}
|
||||||
isPlaying={isPlaying}
|
onDurationChange={setDuration}
|
||||||
showShadow={shadowIntensity > 0}
|
onTimeUpdate={setCurrentTime}
|
||||||
shadowIntensity={shadowIntensity}
|
currentTime={currentTime}
|
||||||
showBlur={showBlur}
|
onPlayStateChange={setIsPlaying}
|
||||||
motionBlurAmount={motionBlurAmount}
|
onError={setError}
|
||||||
borderRadius={borderRadius}
|
wallpaper={wallpaper}
|
||||||
padding={padding}
|
zoomRegions={zoomRegions}
|
||||||
cropRegion={cropRegion}
|
selectedZoomId={selectedZoomId}
|
||||||
trimRegions={trimRegions}
|
onSelectZoom={handleSelectZoom}
|
||||||
speedRegions={speedRegions}
|
onZoomFocusChange={handleZoomFocusChange}
|
||||||
annotationRegions={annotationRegions}
|
onZoomFocusDragEnd={commitState}
|
||||||
selectedAnnotationId={selectedAnnotationId}
|
isPlaying={isPlaying}
|
||||||
onSelectAnnotation={handleSelectAnnotation}
|
showShadow={shadowIntensity > 0}
|
||||||
onAnnotationPositionChange={handleAnnotationPositionChange}
|
shadowIntensity={shadowIntensity}
|
||||||
onAnnotationSizeChange={handleAnnotationSizeChange}
|
showBlur={showBlur}
|
||||||
/>
|
motionBlurAmount={motionBlurAmount}
|
||||||
</div>
|
borderRadius={borderRadius}
|
||||||
</div>
|
padding={padding}
|
||||||
{/* Playback controls */}
|
cropRegion={cropRegion}
|
||||||
<div className="w-full flex justify-center items-center h-12 flex-shrink-0 px-3 py-1.5 my-1.5">
|
trimRegions={trimRegions}
|
||||||
<div className="w-full max-w-[700px]">
|
speedRegions={speedRegions}
|
||||||
<PlaybackControls
|
annotationRegions={annotationRegions}
|
||||||
isPlaying={isPlaying}
|
selectedAnnotationId={selectedAnnotationId}
|
||||||
currentTime={currentTime}
|
onSelectAnnotation={handleSelectAnnotation}
|
||||||
duration={duration}
|
onAnnotationPositionChange={handleAnnotationPositionChange}
|
||||||
onTogglePlayPause={togglePlayPause}
|
onAnnotationSizeChange={handleAnnotationSizeChange}
|
||||||
onSeek={handleSeek}
|
cursorTelemetry={cursorTelemetry}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Panel>
|
{/* Playback controls */}
|
||||||
|
<div className="w-full flex justify-center items-center h-12 flex-shrink-0 px-3 py-1.5 my-1.5">
|
||||||
<PanelResizeHandle className="bg-[#09090b]/80 hover:bg-[#09090b] transition-colors rounded-full flex items-center justify-center">
|
<div className="w-full max-w-[700px]">
|
||||||
<div className="w-8 h-1 bg-white/20 rounded-full"></div>
|
<PlaybackControls
|
||||||
</PanelResizeHandle>
|
isPlaying={isPlaying}
|
||||||
|
currentTime={currentTime}
|
||||||
{/* Timeline section */}
|
duration={duration}
|
||||||
<Panel defaultSize={30} maxSize={60} minSize={30}>
|
isFullscreen={isFullscreen}
|
||||||
<div className="h-full bg-[#09090b] rounded-2xl border border-white/5 shadow-lg overflow-hidden flex flex-col">
|
onToggleFullscreen={toggleFullscreen}
|
||||||
<TimelineEditor
|
onTogglePlayPause={togglePlayPause}
|
||||||
videoDuration={duration}
|
onSeek={handleSeek}
|
||||||
currentTime={currentTime}
|
/>
|
||||||
onSeek={handleSeek}
|
</div>
|
||||||
cursorTelemetry={cursorTelemetry}
|
|
||||||
zoomRegions={zoomRegions}
|
|
||||||
onZoomAdded={handleZoomAdded}
|
|
||||||
onZoomSuggested={handleZoomSuggested}
|
|
||||||
onZoomSpanChange={handleZoomSpanChange}
|
|
||||||
onZoomDelete={handleZoomDelete}
|
|
||||||
selectedZoomId={selectedZoomId}
|
|
||||||
onSelectZoom={handleSelectZoom}
|
|
||||||
trimRegions={trimRegions}
|
|
||||||
onTrimAdded={handleTrimAdded}
|
|
||||||
onTrimSpanChange={handleTrimSpanChange}
|
|
||||||
onTrimDelete={handleTrimDelete}
|
|
||||||
selectedTrimId={selectedTrimId}
|
|
||||||
onSelectTrim={handleSelectTrim}
|
|
||||||
speedRegions={speedRegions}
|
|
||||||
onSpeedAdded={handleSpeedAdded}
|
|
||||||
onSpeedSpanChange={handleSpeedSpanChange}
|
|
||||||
onSpeedDelete={handleSpeedDelete}
|
|
||||||
selectedSpeedId={selectedSpeedId}
|
|
||||||
onSelectSpeed={handleSelectSpeed}
|
|
||||||
annotationRegions={annotationRegions}
|
|
||||||
onAnnotationAdded={handleAnnotationAdded}
|
|
||||||
onAnnotationSpanChange={handleAnnotationSpanChange}
|
|
||||||
onAnnotationDelete={handleAnnotationDelete}
|
|
||||||
selectedAnnotationId={selectedAnnotationId}
|
|
||||||
onSelectAnnotation={handleSelectAnnotation}
|
|
||||||
aspectRatio={aspectRatio}
|
|
||||||
onAspectRatioChange={(ar) => pushState({ aspectRatio: ar })}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</Panel>
|
</div>
|
||||||
</PanelGroup>
|
</Panel>
|
||||||
</Panel>
|
|
||||||
|
|
||||||
<PanelResizeHandle className="h-full bg-[#09090b]/80 hover:bg-[#09090b] transition-colors rounded-full flex items-center justify-center">
|
<PanelResizeHandle className="bg-[#09090b]/80 hover:bg-[#09090b] transition-colors rounded-full flex items-center justify-center">
|
||||||
<div className="w-1 h-8 bg-white/20 rounded-full"></div>
|
<div className="w-8 h-1 bg-white/20 rounded-full"></div>
|
||||||
</PanelResizeHandle>
|
</PanelResizeHandle>
|
||||||
|
|
||||||
{/* Right section: settings panel */}
|
{/* Timeline section */}
|
||||||
<Panel defaultSize={30} minSize={25} maxSize={50}>
|
<Panel defaultSize={30} maxSize={60} minSize={30}>
|
||||||
<SettingsPanel
|
<div className="h-full bg-[#09090b] rounded-2xl border border-white/5 shadow-lg overflow-hidden flex flex-col">
|
||||||
selected={wallpaper}
|
<TimelineEditor
|
||||||
onWallpaperChange={(w) => pushState({ wallpaper: w })}
|
videoDuration={duration}
|
||||||
selectedZoomDepth={
|
currentTime={currentTime}
|
||||||
selectedZoomId ? zoomRegions.find((z) => z.id === selectedZoomId)?.depth : null
|
onSeek={handleSeek}
|
||||||
}
|
cursorTelemetry={cursorTelemetry}
|
||||||
onZoomDepthChange={(depth) => selectedZoomId && handleZoomDepthChange(depth)}
|
zoomRegions={zoomRegions}
|
||||||
selectedZoomId={selectedZoomId}
|
onZoomAdded={handleZoomAdded}
|
||||||
onZoomDelete={handleZoomDelete}
|
onZoomSuggested={handleZoomSuggested}
|
||||||
selectedTrimId={selectedTrimId}
|
onZoomSpanChange={handleZoomSpanChange}
|
||||||
onTrimDelete={handleTrimDelete}
|
onZoomDelete={handleZoomDelete}
|
||||||
shadowIntensity={shadowIntensity}
|
selectedZoomId={selectedZoomId}
|
||||||
onShadowChange={(v) => updateState({ shadowIntensity: v })}
|
onSelectZoom={handleSelectZoom}
|
||||||
onShadowCommit={commitState}
|
trimRegions={trimRegions}
|
||||||
showBlur={showBlur}
|
onTrimAdded={handleTrimAdded}
|
||||||
onBlurChange={(v) => pushState({ showBlur: v })}
|
onTrimSpanChange={handleTrimSpanChange}
|
||||||
motionBlurAmount={motionBlurAmount}
|
onTrimDelete={handleTrimDelete}
|
||||||
onMotionBlurChange={(v) => updateState({ motionBlurAmount: v })}
|
selectedTrimId={selectedTrimId}
|
||||||
onMotionBlurCommit={commitState}
|
onSelectTrim={handleSelectTrim}
|
||||||
borderRadius={borderRadius}
|
speedRegions={speedRegions}
|
||||||
onBorderRadiusChange={(v) => updateState({ borderRadius: v })}
|
onSpeedAdded={handleSpeedAdded}
|
||||||
onBorderRadiusCommit={commitState}
|
onSpeedSpanChange={handleSpeedSpanChange}
|
||||||
padding={padding}
|
onSpeedDelete={handleSpeedDelete}
|
||||||
onPaddingChange={(v) => updateState({ padding: v })}
|
selectedSpeedId={selectedSpeedId}
|
||||||
onPaddingCommit={commitState}
|
onSelectSpeed={handleSelectSpeed}
|
||||||
cropRegion={cropRegion}
|
annotationRegions={annotationRegions}
|
||||||
onCropChange={(r) => pushState({ cropRegion: r })}
|
onAnnotationAdded={handleAnnotationAdded}
|
||||||
aspectRatio={aspectRatio}
|
onAnnotationSpanChange={handleAnnotationSpanChange}
|
||||||
videoElement={videoPlaybackRef.current?.video || null}
|
onAnnotationDelete={handleAnnotationDelete}
|
||||||
exportQuality={exportQuality}
|
selectedAnnotationId={selectedAnnotationId}
|
||||||
onExportQualityChange={setExportQuality}
|
onSelectAnnotation={handleSelectAnnotation}
|
||||||
exportFormat={exportFormat}
|
aspectRatio={aspectRatio}
|
||||||
onExportFormatChange={setExportFormat}
|
onAspectRatioChange={(ar) =>
|
||||||
gifFrameRate={gifFrameRate}
|
pushState({
|
||||||
onGifFrameRateChange={setGifFrameRate}
|
aspectRatio: ar,
|
||||||
gifLoop={gifLoop}
|
webcamLayoutPreset:
|
||||||
onGifLoopChange={setGifLoop}
|
!isPortraitAspectRatio(ar) && webcamLayoutPreset === "vertical-stack"
|
||||||
gifSizePreset={gifSizePreset}
|
? "picture-in-picture"
|
||||||
onGifSizePresetChange={setGifSizePreset}
|
: webcamLayoutPreset,
|
||||||
gifOutputDimensions={calculateOutputDimensions(
|
})
|
||||||
videoPlaybackRef.current?.video?.videoWidth || 1920,
|
}
|
||||||
videoPlaybackRef.current?.video?.videoHeight || 1080,
|
/>
|
||||||
gifSizePreset,
|
</div>
|
||||||
GIF_SIZE_PRESETS,
|
</Panel>
|
||||||
aspectRatio === "native"
|
</PanelGroup>
|
||||||
? getNativeAspectRatioValue(
|
</div>
|
||||||
videoPlaybackRef.current?.video?.videoWidth || 1920,
|
|
||||||
videoPlaybackRef.current?.video?.videoHeight || 1080,
|
{/* Right section: settings panel */}
|
||||||
cropRegion,
|
<div className="flex-[3] min-w-[280px] max-w-[420px] h-full">
|
||||||
)
|
<SettingsPanel
|
||||||
: getAspectRatioValue(aspectRatio),
|
selected={wallpaper}
|
||||||
)}
|
onWallpaperChange={(w) => pushState({ wallpaper: w })}
|
||||||
onExport={handleOpenExportDialog}
|
selectedZoomDepth={
|
||||||
selectedAnnotationId={selectedAnnotationId}
|
selectedZoomId ? zoomRegions.find((z) => z.id === selectedZoomId)?.depth : null
|
||||||
annotationRegions={annotationRegions}
|
}
|
||||||
onAnnotationContentChange={handleAnnotationContentChange}
|
onZoomDepthChange={(depth) => selectedZoomId && handleZoomDepthChange(depth)}
|
||||||
onAnnotationTypeChange={handleAnnotationTypeChange}
|
selectedZoomFocusMode={
|
||||||
onAnnotationStyleChange={handleAnnotationStyleChange}
|
selectedZoomId
|
||||||
onAnnotationFigureDataChange={handleAnnotationFigureDataChange}
|
? (zoomRegions.find((z) => z.id === selectedZoomId)?.focusMode ?? "manual")
|
||||||
onAnnotationDelete={handleAnnotationDelete}
|
: null
|
||||||
onSaveProject={handleSaveProject}
|
}
|
||||||
onLoadProject={handleLoadProject}
|
onZoomFocusModeChange={(mode) => selectedZoomId && handleZoomFocusModeChange(mode)}
|
||||||
selectedSpeedId={selectedSpeedId}
|
hasCursorTelemetry={cursorTelemetry.length > 0}
|
||||||
selectedSpeedValue={
|
selectedZoomId={selectedZoomId}
|
||||||
selectedSpeedId
|
onZoomDelete={handleZoomDelete}
|
||||||
? (speedRegions.find((r) => r.id === selectedSpeedId)?.speed ?? null)
|
selectedTrimId={selectedTrimId}
|
||||||
: null
|
onTrimDelete={handleTrimDelete}
|
||||||
}
|
shadowIntensity={shadowIntensity}
|
||||||
onSpeedChange={handleSpeedChange}
|
onShadowChange={(v) => updateState({ shadowIntensity: v })}
|
||||||
onSpeedDelete={handleSpeedDelete}
|
onShadowCommit={commitState}
|
||||||
/>
|
showBlur={showBlur}
|
||||||
</Panel>
|
onBlurChange={(v) => pushState({ showBlur: v })}
|
||||||
</PanelGroup>
|
motionBlurAmount={motionBlurAmount}
|
||||||
|
onMotionBlurChange={(v) => updateState({ motionBlurAmount: v })}
|
||||||
|
onMotionBlurCommit={commitState}
|
||||||
|
borderRadius={borderRadius}
|
||||||
|
onBorderRadiusChange={(v) => updateState({ borderRadius: v })}
|
||||||
|
onBorderRadiusCommit={commitState}
|
||||||
|
padding={padding}
|
||||||
|
onPaddingChange={(v) => updateState({ padding: v })}
|
||||||
|
onPaddingCommit={commitState}
|
||||||
|
cropRegion={cropRegion}
|
||||||
|
onCropChange={(r) => pushState({ cropRegion: r })}
|
||||||
|
aspectRatio={aspectRatio}
|
||||||
|
hasWebcam={Boolean(webcamVideoPath)}
|
||||||
|
webcamLayoutPreset={webcamLayoutPreset}
|
||||||
|
onWebcamLayoutPresetChange={(preset) =>
|
||||||
|
pushState({
|
||||||
|
webcamLayoutPreset: preset,
|
||||||
|
webcamPosition: preset === "vertical-stack" ? null : webcamPosition,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
webcamMaskShape={webcamMaskShape}
|
||||||
|
onWebcamMaskShapeChange={(shape) => pushState({ webcamMaskShape: shape })}
|
||||||
|
videoElement={videoPlaybackRef.current?.video || null}
|
||||||
|
exportQuality={exportQuality}
|
||||||
|
onExportQualityChange={setExportQuality}
|
||||||
|
exportFormat={exportFormat}
|
||||||
|
onExportFormatChange={setExportFormat}
|
||||||
|
gifFrameRate={gifFrameRate}
|
||||||
|
onGifFrameRateChange={setGifFrameRate}
|
||||||
|
gifLoop={gifLoop}
|
||||||
|
onGifLoopChange={setGifLoop}
|
||||||
|
gifSizePreset={gifSizePreset}
|
||||||
|
onGifSizePresetChange={setGifSizePreset}
|
||||||
|
gifOutputDimensions={calculateOutputDimensions(
|
||||||
|
videoPlaybackRef.current?.video?.videoWidth || 1920,
|
||||||
|
videoPlaybackRef.current?.video?.videoHeight || 1080,
|
||||||
|
gifSizePreset,
|
||||||
|
GIF_SIZE_PRESETS,
|
||||||
|
aspectRatio === "native"
|
||||||
|
? getNativeAspectRatioValue(
|
||||||
|
videoPlaybackRef.current?.video?.videoWidth || 1920,
|
||||||
|
videoPlaybackRef.current?.video?.videoHeight || 1080,
|
||||||
|
cropRegion,
|
||||||
|
)
|
||||||
|
: getAspectRatioValue(aspectRatio),
|
||||||
|
)}
|
||||||
|
onExport={handleOpenExportDialog}
|
||||||
|
selectedAnnotationId={selectedAnnotationId}
|
||||||
|
annotationRegions={annotationRegions}
|
||||||
|
onAnnotationContentChange={handleAnnotationContentChange}
|
||||||
|
onAnnotationTypeChange={handleAnnotationTypeChange}
|
||||||
|
onAnnotationStyleChange={handleAnnotationStyleChange}
|
||||||
|
onAnnotationFigureDataChange={handleAnnotationFigureDataChange}
|
||||||
|
onAnnotationDelete={handleAnnotationDelete}
|
||||||
|
selectedSpeedId={selectedSpeedId}
|
||||||
|
selectedSpeedValue={
|
||||||
|
selectedSpeedId
|
||||||
|
? (speedRegions.find((r) => r.id === selectedSpeedId)?.speed ?? null)
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
onSpeedChange={handleSpeedChange}
|
||||||
|
onSpeedDelete={handleSpeedDelete}
|
||||||
|
unsavedExport={unsavedExport}
|
||||||
|
onSaveUnsavedExport={handleSaveUnsavedExport}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Toaster theme="dark" className="pointer-events-auto" />
|
|
||||||
|
|
||||||
<ExportDialog
|
<ExportDialog
|
||||||
isOpen={showExportDialog}
|
isOpen={showExportDialog}
|
||||||
onClose={() => setShowExportDialog(false)}
|
onClose={() => setShowExportDialog(false)}
|
||||||
|
|||||||
@@ -19,7 +19,13 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { getAssetPath } from "@/lib/assetPath";
|
import { getAssetPath } from "@/lib/assetPath";
|
||||||
import { computeWebcamOverlayLayout, type WebcamOverlayLayout } from "@/lib/webcamOverlay";
|
import {
|
||||||
|
getWebcamLayoutCssBoxShadow,
|
||||||
|
type Size,
|
||||||
|
type StyledRenderRect,
|
||||||
|
type WebcamLayoutPreset,
|
||||||
|
} from "@/lib/compositeLayout";
|
||||||
|
import { getCssClipPath } from "@/lib/webcamMaskShapes";
|
||||||
import {
|
import {
|
||||||
type AspectRatio,
|
type AspectRatio,
|
||||||
formatAspectRatioForCSS,
|
formatAspectRatioForCSS,
|
||||||
@@ -36,10 +42,14 @@ import {
|
|||||||
type ZoomRegion,
|
type ZoomRegion,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import {
|
import {
|
||||||
|
AUTO_FOLLOW_RAMP_DISTANCE,
|
||||||
|
AUTO_FOLLOW_SMOOTHING_FACTOR,
|
||||||
|
AUTO_FOLLOW_SMOOTHING_FACTOR_MAX,
|
||||||
DEFAULT_FOCUS,
|
DEFAULT_FOCUS,
|
||||||
ZOOM_SCALE_DEADZONE,
|
ZOOM_SCALE_DEADZONE,
|
||||||
ZOOM_TRANSLATION_DEADZONE_PX,
|
ZOOM_TRANSLATION_DEADZONE_PX,
|
||||||
} from "./videoPlayback/constants";
|
} from "./videoPlayback/constants";
|
||||||
|
import { adaptiveSmoothFactor, smoothCursorFocus } from "./videoPlayback/cursorFollowUtils";
|
||||||
import { clampFocusToStage as clampFocusToStageUtil } from "./videoPlayback/focusUtils";
|
import { clampFocusToStage as clampFocusToStageUtil } from "./videoPlayback/focusUtils";
|
||||||
import { layoutVideoContent as layoutVideoContentUtil } from "./videoPlayback/layoutUtils";
|
import { layoutVideoContent as layoutVideoContentUtil } from "./videoPlayback/layoutUtils";
|
||||||
import { clamp01 } from "./videoPlayback/mathUtils";
|
import { clamp01 } from "./videoPlayback/mathUtils";
|
||||||
@@ -57,6 +67,11 @@ import {
|
|||||||
interface VideoPlaybackProps {
|
interface VideoPlaybackProps {
|
||||||
videoPath: string;
|
videoPath: string;
|
||||||
webcamVideoPath?: string;
|
webcamVideoPath?: string;
|
||||||
|
webcamLayoutPreset: WebcamLayoutPreset;
|
||||||
|
webcamMaskShape?: import("./types").WebcamMaskShape;
|
||||||
|
webcamPosition?: { cx: number; cy: number } | null;
|
||||||
|
onWebcamPositionChange?: (position: { cx: number; cy: number }) => void;
|
||||||
|
onWebcamPositionDragEnd?: () => void;
|
||||||
onDurationChange: (duration: number) => void;
|
onDurationChange: (duration: number) => void;
|
||||||
onTimeUpdate: (time: number) => void;
|
onTimeUpdate: (time: number) => void;
|
||||||
currentTime: number;
|
currentTime: number;
|
||||||
@@ -84,6 +99,7 @@ interface VideoPlaybackProps {
|
|||||||
onSelectAnnotation?: (id: string | null) => void;
|
onSelectAnnotation?: (id: string | null) => void;
|
||||||
onAnnotationPositionChange?: (id: string, position: { x: number; y: number }) => void;
|
onAnnotationPositionChange?: (id: string, position: { x: number; y: number }) => void;
|
||||||
onAnnotationSizeChange?: (id: string, size: { width: number; height: number }) => void;
|
onAnnotationSizeChange?: (id: string, size: { width: number; height: number }) => void;
|
||||||
|
cursorTelemetry?: import("./types").CursorTelemetryPoint[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VideoPlaybackRef {
|
export interface VideoPlaybackRef {
|
||||||
@@ -101,6 +117,11 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
|||||||
{
|
{
|
||||||
videoPath,
|
videoPath,
|
||||||
webcamVideoPath,
|
webcamVideoPath,
|
||||||
|
webcamLayoutPreset,
|
||||||
|
webcamMaskShape,
|
||||||
|
webcamPosition,
|
||||||
|
onWebcamPositionChange,
|
||||||
|
onWebcamPositionDragEnd,
|
||||||
onDurationChange,
|
onDurationChange,
|
||||||
onTimeUpdate,
|
onTimeUpdate,
|
||||||
currentTime,
|
currentTime,
|
||||||
@@ -128,13 +149,13 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
|||||||
onSelectAnnotation,
|
onSelectAnnotation,
|
||||||
onAnnotationPositionChange,
|
onAnnotationPositionChange,
|
||||||
onAnnotationSizeChange,
|
onAnnotationSizeChange,
|
||||||
|
cursorTelemetry = [],
|
||||||
},
|
},
|
||||||
ref,
|
ref,
|
||||||
) => {
|
) => {
|
||||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||||
const webcamVideoRef = useRef<HTMLVideoElement | null>(null);
|
const webcamVideoRef = useRef<HTMLVideoElement | null>(null);
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const stageRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
const appRef = useRef<Application | null>(null);
|
const appRef = useRef<Application | null>(null);
|
||||||
const videoSpriteRef = useRef<Sprite | null>(null);
|
const videoSpriteRef = useRef<Sprite | null>(null);
|
||||||
const videoContainerRef = useRef<Container | null>(null);
|
const videoContainerRef = useRef<Container | null>(null);
|
||||||
@@ -144,13 +165,11 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
|||||||
const [videoReady, setVideoReady] = useState(false);
|
const [videoReady, setVideoReady] = useState(false);
|
||||||
const overlayRef = useRef<HTMLDivElement | null>(null);
|
const overlayRef = useRef<HTMLDivElement | null>(null);
|
||||||
const focusIndicatorRef = useRef<HTMLDivElement | null>(null);
|
const focusIndicatorRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [webcamLayout, setWebcamLayout] = useState<WebcamOverlayLayout | null>(null);
|
const [webcamLayout, setWebcamLayout] = useState<StyledRenderRect | null>(null);
|
||||||
const [webcamDimensions, setWebcamDimensions] = useState<{
|
const [webcamDimensions, setWebcamDimensions] = useState<Size | null>(null);
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
} | null>(null);
|
|
||||||
const currentTimeRef = useRef(0);
|
const currentTimeRef = useRef(0);
|
||||||
const zoomRegionsRef = useRef<ZoomRegion[]>([]);
|
const zoomRegionsRef = useRef<ZoomRegion[]>([]);
|
||||||
|
const cursorTelemetryRef = useRef<import("./types").CursorTelemetryPoint[]>([]);
|
||||||
const selectedZoomIdRef = useRef<string | null>(null);
|
const selectedZoomIdRef = useRef<string | null>(null);
|
||||||
const animationStateRef = useRef({
|
const animationStateRef = useRef({
|
||||||
scale: 1,
|
scale: 1,
|
||||||
@@ -164,6 +183,8 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
|||||||
const blurFilterRef = useRef<BlurFilter | null>(null);
|
const blurFilterRef = useRef<BlurFilter | null>(null);
|
||||||
const motionBlurFilterRef = useRef<MotionBlurFilter | null>(null);
|
const motionBlurFilterRef = useRef<MotionBlurFilter | null>(null);
|
||||||
const isDraggingFocusRef = useRef(false);
|
const isDraggingFocusRef = useRef(false);
|
||||||
|
const isDraggingWebcamRef = useRef(false);
|
||||||
|
const webcamDragOffsetRef = useRef({ dx: 0, dy: 0 });
|
||||||
const stageSizeRef = useRef({ width: 0, height: 0 });
|
const stageSizeRef = useRef({ width: 0, height: 0 });
|
||||||
const videoSizeRef = useRef({ width: 0, height: 0 });
|
const videoSizeRef = useRef({ width: 0, height: 0 });
|
||||||
const baseScaleRef = useRef(1);
|
const baseScaleRef = useRef(1);
|
||||||
@@ -183,6 +204,8 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
|||||||
const onTimeUpdateRef = useRef(onTimeUpdate);
|
const onTimeUpdateRef = useRef(onTimeUpdate);
|
||||||
const onPlayStateChangeRef = useRef(onPlayStateChange);
|
const onPlayStateChangeRef = useRef(onPlayStateChange);
|
||||||
const videoReadyRafRef = useRef<number | null>(null);
|
const videoReadyRafRef = useRef<number | null>(null);
|
||||||
|
const smoothedAutoFocusRef = useRef<ZoomFocus | null>(null);
|
||||||
|
const prevTargetProgressRef = useRef(0);
|
||||||
|
|
||||||
const clampFocusToStage = useCallback((focus: ZoomFocus, depth: ZoomDepth) => {
|
const clampFocusToStage = useCallback((focus: ZoomFocus, depth: ZoomDepth) => {
|
||||||
return clampFocusToStageUtil(focus, depth, stageSizeRef.current);
|
return clampFocusToStageUtil(focus, depth, stageSizeRef.current);
|
||||||
@@ -258,6 +281,10 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
|||||||
lockedVideoDimensions: lockedVideoDimensionsRef.current,
|
lockedVideoDimensions: lockedVideoDimensionsRef.current,
|
||||||
borderRadius,
|
borderRadius,
|
||||||
padding,
|
padding,
|
||||||
|
webcamDimensions,
|
||||||
|
webcamLayoutPreset,
|
||||||
|
webcamPosition,
|
||||||
|
webcamMaskShape,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result) {
|
if (result) {
|
||||||
@@ -267,6 +294,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
|||||||
baseOffsetRef.current = result.baseOffset;
|
baseOffsetRef.current = result.baseOffset;
|
||||||
baseMaskRef.current = result.maskRect;
|
baseMaskRef.current = result.maskRect;
|
||||||
cropBoundsRef.current = result.cropBounds;
|
cropBoundsRef.current = result.cropBounds;
|
||||||
|
setWebcamLayout(result.webcamRect);
|
||||||
|
|
||||||
// Reset camera container to identity
|
// Reset camera container to identity
|
||||||
cameraContainer.scale.set(1);
|
cameraContainer.scale.set(1);
|
||||||
@@ -279,7 +307,16 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
|||||||
|
|
||||||
updateOverlayForRegion(activeRegion);
|
updateOverlayForRegion(activeRegion);
|
||||||
}
|
}
|
||||||
}, [updateOverlayForRegion, cropRegion, borderRadius, padding]);
|
}, [
|
||||||
|
updateOverlayForRegion,
|
||||||
|
cropRegion,
|
||||||
|
borderRadius,
|
||||||
|
padding,
|
||||||
|
webcamDimensions,
|
||||||
|
webcamLayoutPreset,
|
||||||
|
webcamPosition,
|
||||||
|
webcamMaskShape,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
layoutVideoContentRef.current = layoutVideoContent;
|
layoutVideoContentRef.current = layoutVideoContent;
|
||||||
@@ -356,6 +393,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
|||||||
if (!regionId) return;
|
if (!regionId) return;
|
||||||
const region = zoomRegionsRef.current.find((r) => r.id === regionId);
|
const region = zoomRegionsRef.current.find((r) => r.id === regionId);
|
||||||
if (!region) return;
|
if (!region) return;
|
||||||
|
if (region.focusMode === "auto") return;
|
||||||
onSelectZoom(region.id);
|
onSelectZoom(region.id);
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
isDraggingFocusRef.current = true;
|
isDraggingFocusRef.current = true;
|
||||||
@@ -388,10 +426,61 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
|||||||
endFocusDrag(event);
|
endFocusDrag(event);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── Webcam PiP drag handlers ──
|
||||||
|
|
||||||
|
const handleWebcamPointerDown = (event: React.PointerEvent<HTMLVideoElement>) => {
|
||||||
|
if (isPlayingRef.current) return;
|
||||||
|
if (webcamLayoutPreset !== "picture-in-picture") return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
isDraggingWebcamRef.current = true;
|
||||||
|
event.currentTarget.setPointerCapture(event.pointerId);
|
||||||
|
|
||||||
|
const webcamEl = event.currentTarget;
|
||||||
|
const webcamRect = webcamEl.getBoundingClientRect();
|
||||||
|
webcamDragOffsetRef.current = {
|
||||||
|
dx: event.clientX - (webcamRect.left + webcamRect.width / 2),
|
||||||
|
dy: event.clientY - (webcamRect.top + webcamRect.height / 2),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleWebcamPointerMove = (event: React.PointerEvent<HTMLVideoElement>) => {
|
||||||
|
if (!isDraggingWebcamRef.current) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
|
const containerEl = containerRef.current;
|
||||||
|
if (!containerEl || !onWebcamPositionChange) return;
|
||||||
|
|
||||||
|
const containerRect = containerEl.getBoundingClientRect();
|
||||||
|
const cx = clamp01(
|
||||||
|
(event.clientX - webcamDragOffsetRef.current.dx - containerRect.left) / containerRect.width,
|
||||||
|
);
|
||||||
|
const cy = clamp01(
|
||||||
|
(event.clientY - webcamDragOffsetRef.current.dy - containerRect.top) / containerRect.height,
|
||||||
|
);
|
||||||
|
onWebcamPositionChange({ cx, cy });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleWebcamPointerUp = (event: React.PointerEvent<HTMLVideoElement>) => {
|
||||||
|
if (!isDraggingWebcamRef.current) return;
|
||||||
|
isDraggingWebcamRef.current = false;
|
||||||
|
try {
|
||||||
|
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||||
|
} catch {
|
||||||
|
// Pointer may already be released.
|
||||||
|
}
|
||||||
|
onWebcamPositionDragEnd?.();
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
zoomRegionsRef.current = zoomRegions;
|
zoomRegionsRef.current = zoomRegions;
|
||||||
}, [zoomRegions]);
|
}, [zoomRegions]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
cursorTelemetryRef.current = cursorTelemetry;
|
||||||
|
}, [cursorTelemetry]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
selectedZoomIdRef.current = selectedZoomId;
|
selectedZoomIdRef.current = selectedZoomId;
|
||||||
}, [selectedZoomId]);
|
}, [selectedZoomId]);
|
||||||
@@ -760,10 +849,16 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
|||||||
};
|
};
|
||||||
|
|
||||||
const ticker = () => {
|
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(
|
const { region, strength, blendedScale, transition } = findDominantRegion(
|
||||||
zoomRegionsRef.current,
|
zoomRegionsRef.current,
|
||||||
currentTimeRef.current,
|
currentTimeRef.current,
|
||||||
{ connectZooms: true },
|
{ connectZooms: true, cursorTelemetry: cursorTelemetryRef.current, viewportRatio },
|
||||||
);
|
);
|
||||||
|
|
||||||
const defaultFocus = DEFAULT_FOCUS;
|
const defaultFocus = DEFAULT_FOCUS;
|
||||||
@@ -784,6 +879,47 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
|||||||
targetFocus = regionFocus;
|
targetFocus = regionFocus;
|
||||||
targetProgress = strength;
|
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)
|
// Handle connected zoom transitions (pan between adjacent zoom regions)
|
||||||
if (transition) {
|
if (transition) {
|
||||||
const startTransform = computeZoomTransform({
|
const startTransform = computeZoomTransform({
|
||||||
@@ -910,6 +1046,10 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
|||||||
};
|
};
|
||||||
|
|
||||||
const [resolvedWallpaper, setResolvedWallpaper] = useState<string | null>(null);
|
const [resolvedWallpaper, setResolvedWallpaper] = useState<string | null>(null);
|
||||||
|
const webcamCssBoxShadow = useMemo(
|
||||||
|
() => getWebcamLayoutCssBoxShadow(webcamLayoutPreset),
|
||||||
|
[webcamLayoutPreset],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const webcamVideo = webcamVideoRef.current;
|
const webcamVideo = webcamVideoRef.current;
|
||||||
@@ -934,34 +1074,6 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
|||||||
};
|
};
|
||||||
}, [webcamVideoPath]);
|
}, [webcamVideoPath]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const stage = stageRef.current;
|
|
||||||
if (!stage || !webcamDimensions) {
|
|
||||||
setWebcamLayout(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateLayout = () => {
|
|
||||||
const layout = computeWebcamOverlayLayout({
|
|
||||||
stageWidth: stage.clientWidth,
|
|
||||||
stageHeight: stage.clientHeight,
|
|
||||||
videoWidth: webcamDimensions.width,
|
|
||||||
videoHeight: webcamDimensions.height,
|
|
||||||
});
|
|
||||||
setWebcamLayout(layout);
|
|
||||||
};
|
|
||||||
|
|
||||||
updateLayout();
|
|
||||||
|
|
||||||
if (typeof ResizeObserver === "undefined") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const observer = new ResizeObserver(updateLayout);
|
|
||||||
observer.observe(stage);
|
|
||||||
return () => observer.disconnect();
|
|
||||||
}, [webcamDimensions]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const webcamVideo = webcamVideoRef.current;
|
const webcamVideo = webcamVideoRef.current;
|
||||||
if (!webcamVideo || !webcamVideoPath) {
|
if (!webcamVideo || !webcamVideoPath) {
|
||||||
@@ -1075,7 +1187,6 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={stageRef}
|
|
||||||
className="relative rounded-sm overflow-hidden"
|
className="relative rounded-sm overflow-hidden"
|
||||||
style={{
|
style={{
|
||||||
width: "100%",
|
width: "100%",
|
||||||
@@ -1109,27 +1220,47 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
|||||||
: "none",
|
: "none",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{webcamVideoPath && (
|
{webcamVideoPath &&
|
||||||
<video
|
(() => {
|
||||||
ref={webcamVideoRef}
|
const clipPath = getCssClipPath(webcamLayout?.maskShape ?? "rectangle");
|
||||||
src={webcamVideoPath}
|
const useClipPath = !!clipPath;
|
||||||
className="absolute object-cover pointer-events-none"
|
return (
|
||||||
style={{
|
<div
|
||||||
left: webcamLayout?.x ?? 0,
|
className="absolute"
|
||||||
top: webcamLayout?.y ?? 0,
|
style={{
|
||||||
width: webcamLayout?.width ?? 0,
|
left: webcamLayout?.x ?? 0,
|
||||||
height: webcamLayout?.height ?? 0,
|
top: webcamLayout?.y ?? 0,
|
||||||
borderRadius: webcamLayout?.borderRadius ?? 0,
|
width: webcamLayout?.width ?? 0,
|
||||||
boxShadow: "0 12px 36px rgba(0,0,0,0.35), 0 4px 12px rgba(0,0,0,0.22)",
|
height: webcamLayout?.height ?? 0,
|
||||||
zIndex: 20,
|
zIndex: 20,
|
||||||
opacity: webcamLayout ? 1 : 0,
|
opacity: webcamLayout ? 1 : 0,
|
||||||
backgroundColor: "#000",
|
filter:
|
||||||
}}
|
useClipPath && webcamCssBoxShadow !== "none"
|
||||||
muted
|
? `drop-shadow(${webcamCssBoxShadow})`
|
||||||
preload="metadata"
|
: undefined,
|
||||||
playsInline
|
}}
|
||||||
/>
|
>
|
||||||
)}
|
<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 */}
|
{/* Only render overlay after PIXI and video are fully initialized */}
|
||||||
{pixiReady && videoReady && (
|
{pixiReady && videoReady && (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
createProjectData,
|
createProjectData,
|
||||||
|
createProjectSnapshot,
|
||||||
|
hasProjectUnsavedChanges,
|
||||||
|
normalizeProjectEditor,
|
||||||
PROJECT_VERSION,
|
PROJECT_VERSION,
|
||||||
resolveProjectMedia,
|
resolveProjectMedia,
|
||||||
validateProjectData,
|
validateProjectData,
|
||||||
@@ -39,6 +42,8 @@ describe("projectPersistence media compatibility", () => {
|
|||||||
speedRegions: [],
|
speedRegions: [],
|
||||||
annotationRegions: [],
|
annotationRegions: [],
|
||||||
aspectRatio: "16:9",
|
aspectRatio: "16:9",
|
||||||
|
webcamLayoutPreset: "picture-in-picture",
|
||||||
|
webcamMaskShape: "circle",
|
||||||
exportQuality: "good",
|
exportQuality: "good",
|
||||||
exportFormat: "mp4",
|
exportFormat: "mp4",
|
||||||
gifFrameRate: 15,
|
gifFrameRate: 15,
|
||||||
@@ -54,4 +59,47 @@ describe("projectPersistence media compatibility", () => {
|
|||||||
});
|
});
|
||||||
expect(validateProjectData(project)).toBe(true);
|
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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,9 +11,15 @@ import {
|
|||||||
DEFAULT_CROP_REGION,
|
DEFAULT_CROP_REGION,
|
||||||
DEFAULT_FIGURE_DATA,
|
DEFAULT_FIGURE_DATA,
|
||||||
DEFAULT_PLAYBACK_SPEED,
|
DEFAULT_PLAYBACK_SPEED,
|
||||||
|
DEFAULT_WEBCAM_LAYOUT_PRESET,
|
||||||
|
DEFAULT_WEBCAM_MASK_SHAPE,
|
||||||
|
DEFAULT_WEBCAM_POSITION,
|
||||||
DEFAULT_ZOOM_DEPTH,
|
DEFAULT_ZOOM_DEPTH,
|
||||||
type SpeedRegion,
|
type SpeedRegion,
|
||||||
type TrimRegion,
|
type TrimRegion,
|
||||||
|
type WebcamLayoutPreset,
|
||||||
|
type WebcamMaskShape,
|
||||||
|
type WebcamPosition,
|
||||||
type ZoomRegion,
|
type ZoomRegion,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
@@ -39,6 +45,9 @@ export interface ProjectEditorState {
|
|||||||
speedRegions: SpeedRegion[];
|
speedRegions: SpeedRegion[];
|
||||||
annotationRegions: AnnotationRegion[];
|
annotationRegions: AnnotationRegion[];
|
||||||
aspectRatio: AspectRatio;
|
aspectRatio: AspectRatio;
|
||||||
|
webcamLayoutPreset: WebcamLayoutPreset;
|
||||||
|
webcamMaskShape: WebcamMaskShape;
|
||||||
|
webcamPosition: WebcamPosition | null;
|
||||||
exportQuality: ExportQuality;
|
exportQuality: ExportQuality;
|
||||||
exportFormat: ExportFormat;
|
exportFormat: ExportFormat;
|
||||||
gifFrameRate: GifFrameRate;
|
gifFrameRate: GifFrameRate;
|
||||||
@@ -183,6 +192,7 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
|
|||||||
cx: clamp(isFiniteNumber(region.focus?.cx) ? region.focus.cx : 0.5, 0, 1),
|
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),
|
cy: clamp(isFiniteNumber(region.focus?.cy) ? region.focus.cy : 0.5, 0, 1),
|
||||||
},
|
},
|
||||||
|
focusMode: region.focusMode === "auto" ? "auto" : "manual",
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
: [];
|
: [];
|
||||||
@@ -341,6 +351,28 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
|
|||||||
annotationRegions: normalizedAnnotationRegions,
|
annotationRegions: normalizedAnnotationRegions,
|
||||||
aspectRatio:
|
aspectRatio:
|
||||||
editor.aspectRatio && validAspectRatios.has(editor.aspectRatio) ? editor.aspectRatio : "16:9",
|
editor.aspectRatio && validAspectRatios.has(editor.aspectRatio) ? editor.aspectRatio : "16:9",
|
||||||
|
webcamLayoutPreset:
|
||||||
|
editor.webcamLayoutPreset === "vertical-stack" ||
|
||||||
|
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,
|
||||||
|
webcamPosition:
|
||||||
|
editor.webcamPosition &&
|
||||||
|
typeof editor.webcamPosition === "object" &&
|
||||||
|
isFiniteNumber((editor.webcamPosition as WebcamPosition).cx) &&
|
||||||
|
isFiniteNumber((editor.webcamPosition as WebcamPosition).cy)
|
||||||
|
? {
|
||||||
|
cx: clamp((editor.webcamPosition as WebcamPosition).cx, 0, 1),
|
||||||
|
cy: clamp((editor.webcamPosition as WebcamPosition).cy, 0, 1),
|
||||||
|
}
|
||||||
|
: DEFAULT_WEBCAM_POSITION,
|
||||||
exportQuality:
|
exportQuality:
|
||||||
editor.exportQuality === "medium" || editor.exportQuality === "source"
|
editor.exportQuality === "medium" || editor.exportQuality === "source"
|
||||||
? editor.exportQuality
|
? editor.exportQuality
|
||||||
@@ -373,3 +405,19 @@ export function createProjectData(
|
|||||||
editor,
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { useScopedT } from "@/contexts/I18nContext";
|
||||||
import { useShortcuts } from "@/contexts/ShortcutsContext";
|
import { useShortcuts } from "@/contexts/ShortcutsContext";
|
||||||
import { matchesShortcut } from "@/lib/shortcuts";
|
import { matchesShortcut } from "@/lib/shortcuts";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -169,6 +170,33 @@ function createInitialRange(totalMs: number): Range {
|
|||||||
return { start: 0, end: FALLBACK_RANGE_MS };
|
return { start: 0, end: FALLBACK_RANGE_MS };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clampVisibleRange(candidate: Range, totalMs: number): Range {
|
||||||
|
if (totalMs <= 0) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
const span = Math.max(candidate.end - candidate.start, 1);
|
||||||
|
|
||||||
|
if (span >= totalMs) {
|
||||||
|
return { start: 0, end: totalMs };
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = Math.max(0, Math.min(candidate.start, totalMs - span));
|
||||||
|
return { start, end: start + span };
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeWheelDelta(delta: number, deltaMode: number, pageSizePx: number): number {
|
||||||
|
if (deltaMode === WheelEvent.DOM_DELTA_LINE) {
|
||||||
|
return delta * 16;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deltaMode === WheelEvent.DOM_DELTA_PAGE) {
|
||||||
|
return delta * pageSizePx;
|
||||||
|
}
|
||||||
|
|
||||||
|
return delta;
|
||||||
|
}
|
||||||
|
|
||||||
function formatTimeLabel(milliseconds: number, intervalMs: number) {
|
function formatTimeLabel(milliseconds: number, intervalMs: number) {
|
||||||
const totalSeconds = milliseconds / 1000;
|
const totalSeconds = milliseconds / 1000;
|
||||||
const hours = Math.floor(totalSeconds / 3600);
|
const hours = Math.floor(totalSeconds / 3600);
|
||||||
@@ -204,18 +232,21 @@ function PlaybackCursor({
|
|||||||
currentTimeMs,
|
currentTimeMs,
|
||||||
videoDurationMs,
|
videoDurationMs,
|
||||||
onSeek,
|
onSeek,
|
||||||
|
onRangeChange,
|
||||||
timelineRef,
|
timelineRef,
|
||||||
keyframes = [],
|
keyframes = [],
|
||||||
}: {
|
}: {
|
||||||
currentTimeMs: number;
|
currentTimeMs: number;
|
||||||
videoDurationMs: number;
|
videoDurationMs: number;
|
||||||
onSeek?: (time: number) => void;
|
onSeek?: (time: number) => void;
|
||||||
|
onRangeChange?: (updater: (previous: Range) => Range) => void;
|
||||||
timelineRef: React.RefObject<HTMLDivElement>;
|
timelineRef: React.RefObject<HTMLDivElement>;
|
||||||
keyframes?: { id: string; time: number }[];
|
keyframes?: { id: string; time: number }[];
|
||||||
}) {
|
}) {
|
||||||
const { sidebarWidth, direction, range, valueToPixels, pixelsToValue } = useTimelineContext();
|
const { sidebarWidth, direction, range, valueToPixels, pixelsToValue } = useTimelineContext();
|
||||||
const sideProperty = direction === "rtl" ? "right" : "left";
|
const sideProperty = direction === "rtl" ? "right" : "left";
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const [dragPreviewTimeMs, setDragPreviewTimeMs] = useState<number | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isDragging) return;
|
if (!isDragging) return;
|
||||||
@@ -225,6 +256,7 @@ function PlaybackCursor({
|
|||||||
|
|
||||||
const rect = timelineRef.current.getBoundingClientRect();
|
const rect = timelineRef.current.getBoundingClientRect();
|
||||||
const clickX = e.clientX - rect.left - sidebarWidth;
|
const clickX = e.clientX - rect.left - sidebarWidth;
|
||||||
|
const contentWidth = Math.max(rect.width - sidebarWidth, 1);
|
||||||
|
|
||||||
// Allow dragging outside to 0 or max, but clamp the value
|
// Allow dragging outside to 0 or max, but clamp the value
|
||||||
const relativeMs = pixelsToValue(clickX);
|
const relativeMs = pixelsToValue(clickX);
|
||||||
@@ -243,11 +275,51 @@ function PlaybackCursor({
|
|||||||
absoluteMs = nearbyKeyframe.time;
|
absoluteMs = nearbyKeyframe.time;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setDragPreviewTimeMs(absoluteMs);
|
||||||
|
|
||||||
|
const visibleMs = range.end - range.start;
|
||||||
|
if (onRangeChange && visibleMs > 0 && videoDurationMs > visibleMs) {
|
||||||
|
const msPerPixel = visibleMs / contentWidth;
|
||||||
|
const overflowLeftPx = Math.max(0, -clickX);
|
||||||
|
const overflowRightPx = Math.max(0, clickX - contentWidth);
|
||||||
|
|
||||||
|
if (overflowLeftPx > 0 && range.start > 0) {
|
||||||
|
const shiftMs = overflowLeftPx * msPerPixel;
|
||||||
|
onRangeChange((previous) => {
|
||||||
|
const nextRange = clampVisibleRange(
|
||||||
|
{
|
||||||
|
start: previous.start - shiftMs,
|
||||||
|
end: previous.end - shiftMs,
|
||||||
|
},
|
||||||
|
videoDurationMs,
|
||||||
|
);
|
||||||
|
return nextRange.start === previous.start && nextRange.end === previous.end
|
||||||
|
? previous
|
||||||
|
: nextRange;
|
||||||
|
});
|
||||||
|
} else if (overflowRightPx > 0 && range.end < videoDurationMs) {
|
||||||
|
const shiftMs = overflowRightPx * msPerPixel;
|
||||||
|
onRangeChange((previous) => {
|
||||||
|
const nextRange = clampVisibleRange(
|
||||||
|
{
|
||||||
|
start: previous.start + shiftMs,
|
||||||
|
end: previous.end + shiftMs,
|
||||||
|
},
|
||||||
|
videoDurationMs,
|
||||||
|
);
|
||||||
|
return nextRange.start === previous.start && nextRange.end === previous.end
|
||||||
|
? previous
|
||||||
|
: nextRange;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onSeek(absoluteMs / 1000);
|
onSeek(absoluteMs / 1000);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMouseUp = () => {
|
const handleMouseUp = () => {
|
||||||
setIsDragging(false);
|
setIsDragging(false);
|
||||||
|
setDragPreviewTimeMs(null);
|
||||||
document.body.style.cursor = "";
|
document.body.style.cursor = "";
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -263,6 +335,7 @@ function PlaybackCursor({
|
|||||||
}, [
|
}, [
|
||||||
isDragging,
|
isDragging,
|
||||||
onSeek,
|
onSeek,
|
||||||
|
onRangeChange,
|
||||||
timelineRef,
|
timelineRef,
|
||||||
sidebarWidth,
|
sidebarWidth,
|
||||||
range.start,
|
range.start,
|
||||||
@@ -272,11 +345,14 @@ function PlaybackCursor({
|
|||||||
keyframes,
|
keyframes,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (videoDurationMs <= 0 || currentTimeMs < 0) {
|
const displayTimeMs =
|
||||||
|
isDragging && dragPreviewTimeMs !== null ? dragPreviewTimeMs : currentTimeMs;
|
||||||
|
|
||||||
|
if (videoDurationMs <= 0 || displayTimeMs < 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const clampedTime = Math.min(currentTimeMs, videoDurationMs);
|
const clampedTime = Math.min(displayTimeMs, videoDurationMs);
|
||||||
|
|
||||||
if (clampedTime < range.start || clampedTime > range.end) {
|
if (clampedTime < range.start || clampedTime > range.end) {
|
||||||
return null;
|
return null;
|
||||||
@@ -299,6 +375,7 @@ function PlaybackCursor({
|
|||||||
}}
|
}}
|
||||||
onMouseDown={(e) => {
|
onMouseDown={(e) => {
|
||||||
e.stopPropagation(); // Prevent timeline click
|
e.stopPropagation(); // Prevent timeline click
|
||||||
|
setDragPreviewTimeMs(currentTimeMs);
|
||||||
setIsDragging(true);
|
setIsDragging(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -444,6 +521,7 @@ function Timeline({
|
|||||||
videoDurationMs,
|
videoDurationMs,
|
||||||
currentTimeMs,
|
currentTimeMs,
|
||||||
onSeek,
|
onSeek,
|
||||||
|
onRangeChange,
|
||||||
onSelectZoom,
|
onSelectZoom,
|
||||||
onSelectTrim,
|
onSelectTrim,
|
||||||
onSelectAnnotation,
|
onSelectAnnotation,
|
||||||
@@ -458,6 +536,7 @@ function Timeline({
|
|||||||
videoDurationMs: number;
|
videoDurationMs: number;
|
||||||
currentTimeMs: number;
|
currentTimeMs: number;
|
||||||
onSeek?: (time: number) => void;
|
onSeek?: (time: number) => void;
|
||||||
|
onRangeChange?: (updater: (previous: Range) => Range) => void;
|
||||||
onSelectZoom?: (id: string | null) => void;
|
onSelectZoom?: (id: string | null) => void;
|
||||||
onSelectTrim?: (id: string | null) => void;
|
onSelectTrim?: (id: string | null) => void;
|
||||||
onSelectAnnotation?: (id: string | null) => void;
|
onSelectAnnotation?: (id: string | null) => void;
|
||||||
@@ -468,6 +547,7 @@ function Timeline({
|
|||||||
selectedSpeedId?: string | null;
|
selectedSpeedId?: string | null;
|
||||||
keyframes?: { id: string; time: number }[];
|
keyframes?: { id: string; time: number }[];
|
||||||
}) {
|
}) {
|
||||||
|
const t = useScopedT("timeline");
|
||||||
const { setTimelineRef, style, sidebarWidth, range, pixelsToValue } = useTimelineContext();
|
const { setTimelineRef, style, sidebarWidth, range, pixelsToValue } = useTimelineContext();
|
||||||
const localTimelineRef = useRef<HTMLDivElement | null>(null);
|
const localTimelineRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
@@ -514,6 +594,46 @@ function Timeline({
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleTimelineWheel = useCallback(
|
||||||
|
(event: React.WheelEvent<HTMLDivElement>) => {
|
||||||
|
if (!onRangeChange || event.ctrlKey || event.metaKey || videoDurationMs <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibleMs = range.end - range.start;
|
||||||
|
if (visibleMs <= 0 || videoDurationMs <= visibleMs) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dominantDelta =
|
||||||
|
Math.abs(event.deltaX) > Math.abs(event.deltaY) ? event.deltaX : event.deltaY;
|
||||||
|
if (dominantDelta === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const pageWidthPx = Math.max(event.currentTarget.clientWidth - sidebarWidth, 1);
|
||||||
|
const normalizedDeltaPx = normalizeWheelDelta(dominantDelta, event.deltaMode, pageWidthPx);
|
||||||
|
const shiftMs = pixelsToValue(normalizedDeltaPx);
|
||||||
|
|
||||||
|
onRangeChange((previous) => {
|
||||||
|
const nextRange = clampVisibleRange(
|
||||||
|
{
|
||||||
|
start: previous.start + shiftMs,
|
||||||
|
end: previous.end + shiftMs,
|
||||||
|
},
|
||||||
|
videoDurationMs,
|
||||||
|
);
|
||||||
|
|
||||||
|
return nextRange.start === previous.start && nextRange.end === previous.end
|
||||||
|
? previous
|
||||||
|
: nextRange;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[onRangeChange, videoDurationMs, range.end, range.start, sidebarWidth, pixelsToValue],
|
||||||
|
);
|
||||||
|
|
||||||
const zoomItems = items.filter((item) => item.rowId === ZOOM_ROW_ID);
|
const zoomItems = items.filter((item) => item.rowId === ZOOM_ROW_ID);
|
||||||
const trimItems = items.filter((item) => item.rowId === TRIM_ROW_ID);
|
const trimItems = items.filter((item) => item.rowId === TRIM_ROW_ID);
|
||||||
const annotationItems = items.filter((item) => item.rowId === ANNOTATION_ROW_ID);
|
const annotationItems = items.filter((item) => item.rowId === ANNOTATION_ROW_ID);
|
||||||
@@ -525,6 +645,7 @@ function Timeline({
|
|||||||
style={style}
|
style={style}
|
||||||
className="select-none bg-[#09090b] min-h-[140px] relative cursor-pointer group"
|
className="select-none bg-[#09090b] min-h-[140px] relative cursor-pointer group"
|
||||||
onClick={handleTimelineClick}
|
onClick={handleTimelineClick}
|
||||||
|
onWheel={handleTimelineWheel}
|
||||||
>
|
>
|
||||||
<div className="absolute inset-0 bg-[linear-gradient(to_right,#ffffff03_1px,transparent_1px)] bg-[length:20px_100%] pointer-events-none" />
|
<div className="absolute inset-0 bg-[linear-gradient(to_right,#ffffff03_1px,transparent_1px)] bg-[length:20px_100%] pointer-events-none" />
|
||||||
<TimelineAxis videoDurationMs={videoDurationMs} currentTimeMs={currentTimeMs} />
|
<TimelineAxis videoDurationMs={videoDurationMs} currentTimeMs={currentTimeMs} />
|
||||||
@@ -532,11 +653,12 @@ function Timeline({
|
|||||||
currentTimeMs={currentTimeMs}
|
currentTimeMs={currentTimeMs}
|
||||||
videoDurationMs={videoDurationMs}
|
videoDurationMs={videoDurationMs}
|
||||||
onSeek={onSeek}
|
onSeek={onSeek}
|
||||||
|
onRangeChange={onRangeChange}
|
||||||
timelineRef={localTimelineRef}
|
timelineRef={localTimelineRef}
|
||||||
keyframes={keyframes}
|
keyframes={keyframes}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Row id={ZOOM_ROW_ID} isEmpty={zoomItems.length === 0} hint="Press Z to add zoom">
|
<Row id={ZOOM_ROW_ID} isEmpty={zoomItems.length === 0} hint={t("hints.pressZoom")}>
|
||||||
{zoomItems.map((item) => (
|
{zoomItems.map((item) => (
|
||||||
<Item
|
<Item
|
||||||
id={item.id}
|
id={item.id}
|
||||||
@@ -553,7 +675,7 @@ function Timeline({
|
|||||||
))}
|
))}
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
<Row id={TRIM_ROW_ID} isEmpty={trimItems.length === 0} hint="Press T to add trim">
|
<Row id={TRIM_ROW_ID} isEmpty={trimItems.length === 0} hint={t("hints.pressTrim")}>
|
||||||
{trimItems.map((item) => (
|
{trimItems.map((item) => (
|
||||||
<Item
|
<Item
|
||||||
id={item.id}
|
id={item.id}
|
||||||
@@ -572,7 +694,7 @@ function Timeline({
|
|||||||
<Row
|
<Row
|
||||||
id={ANNOTATION_ROW_ID}
|
id={ANNOTATION_ROW_ID}
|
||||||
isEmpty={annotationItems.length === 0}
|
isEmpty={annotationItems.length === 0}
|
||||||
hint="Press A to add annotation"
|
hint={t("hints.pressAnnotation")}
|
||||||
>
|
>
|
||||||
{annotationItems.map((item) => (
|
{annotationItems.map((item) => (
|
||||||
<Item
|
<Item
|
||||||
@@ -589,7 +711,7 @@ function Timeline({
|
|||||||
))}
|
))}
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
<Row id={SPEED_ROW_ID} isEmpty={speedItems.length === 0} hint="Press S to add speed">
|
<Row id={SPEED_ROW_ID} isEmpty={speedItems.length === 0} hint={t("hints.pressSpeed")}>
|
||||||
{speedItems.map((item) => (
|
{speedItems.map((item) => (
|
||||||
<Item
|
<Item
|
||||||
id={item.id}
|
id={item.id}
|
||||||
@@ -642,6 +764,7 @@ export default function TimelineEditor({
|
|||||||
aspectRatio,
|
aspectRatio,
|
||||||
onAspectRatioChange,
|
onAspectRatioChange,
|
||||||
}: TimelineEditorProps) {
|
}: TimelineEditorProps) {
|
||||||
|
const t = useScopedT("timeline");
|
||||||
const totalMs = useMemo(() => Math.max(0, Math.round(videoDuration * 1000)), [videoDuration]);
|
const totalMs = useMemo(() => Math.max(0, Math.round(videoDuration * 1000)), [videoDuration]);
|
||||||
const currentTimeMs = useMemo(() => Math.round(currentTime * 1000), [currentTime]);
|
const currentTimeMs = useMemo(() => Math.round(currentTime * 1000), [currentTime]);
|
||||||
const timelineScale = useMemo(() => calculateTimelineScale(videoDuration), [videoDuration]);
|
const timelineScale = useMemo(() => calculateTimelineScale(videoDuration), [videoDuration]);
|
||||||
@@ -657,17 +780,15 @@ export default function TimelineEditor({
|
|||||||
const [keyframes, setKeyframes] = useState<{ id: string; time: number }[]>([]);
|
const [keyframes, setKeyframes] = useState<{ id: string; time: number }[]>([]);
|
||||||
const [selectedKeyframeId, setSelectedKeyframeId] = useState<string | null>(null);
|
const [selectedKeyframeId, setSelectedKeyframeId] = useState<string | null>(null);
|
||||||
const [scrollLabels, setScrollLabels] = useState({
|
const [scrollLabels, setScrollLabels] = useState({
|
||||||
pan: "Shift + Ctrl + Scroll",
|
pan: "Scroll",
|
||||||
zoom: "Ctrl + Scroll",
|
zoom: "Ctrl + Scroll",
|
||||||
});
|
});
|
||||||
const timelineContainerRef = useRef<HTMLDivElement>(null);
|
const timelineContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const { shortcuts: keyShortcuts, isMac } = useShortcuts();
|
const { shortcuts: keyShortcuts, isMac } = useShortcuts();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
formatShortcut(["shift", "mod", "Scroll"]).then((pan) => {
|
formatShortcut(["mod", "Scroll"]).then((zoom) => {
|
||||||
formatShortcut(["mod", "Scroll"]).then((zoom) => {
|
setScrollLabels({ pan: "Scroll", zoom });
|
||||||
setScrollLabels({ pan, zoom });
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -848,15 +969,15 @@ export default function TimelineEditor({
|
|||||||
(region) => startPos >= region.startMs && startPos < region.endMs,
|
(region) => startPos >= region.startMs && startPos < region.endMs,
|
||||||
);
|
);
|
||||||
if (isOverlapping || gapToNext <= 0) {
|
if (isOverlapping || gapToNext <= 0) {
|
||||||
toast.error("Cannot place zoom here", {
|
toast.error(t("errors.cannotPlaceZoom"), {
|
||||||
description: "Zoom already exists at this location or not enough space available.",
|
description: t("errors.zoomExistsAtLocation"),
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const actualDuration = Math.min(defaultRegionDurationMs, gapToNext);
|
const actualDuration = Math.min(defaultRegionDurationMs, gapToNext);
|
||||||
onZoomAdded({ start: startPos, end: startPos + actualDuration });
|
onZoomAdded({ start: startPos, end: startPos + actualDuration });
|
||||||
}, [videoDuration, totalMs, currentTimeMs, zoomRegions, onZoomAdded, defaultRegionDurationMs]);
|
}, [videoDuration, totalMs, currentTimeMs, zoomRegions, onZoomAdded, defaultRegionDurationMs, t]);
|
||||||
|
|
||||||
const handleSuggestZooms = useCallback(() => {
|
const handleSuggestZooms = useCallback(() => {
|
||||||
if (!videoDuration || videoDuration === 0 || totalMs === 0) {
|
if (!videoDuration || videoDuration === 0 || totalMs === 0) {
|
||||||
@@ -864,13 +985,13 @@ export default function TimelineEditor({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!onZoomSuggested) {
|
if (!onZoomSuggested) {
|
||||||
toast.error("Zoom suggestion handler unavailable");
|
toast.error(t("errors.zoomSuggestionUnavailable"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cursorTelemetry.length < 2) {
|
if (cursorTelemetry.length < 2) {
|
||||||
toast.info("No cursor telemetry available", {
|
toast.info(t("errors.noCursorTelemetry"), {
|
||||||
description: "Record a screencast first to generate cursor-based suggestions.",
|
description: t("errors.noCursorTelemetryDescription"),
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -887,8 +1008,8 @@ export default function TimelineEditor({
|
|||||||
const normalizedSamples = normalizeCursorTelemetry(cursorTelemetry, totalMs);
|
const normalizedSamples = normalizeCursorTelemetry(cursorTelemetry, totalMs);
|
||||||
|
|
||||||
if (normalizedSamples.length < 2) {
|
if (normalizedSamples.length < 2) {
|
||||||
toast.info("No usable cursor telemetry", {
|
toast.info(t("errors.noUsableTelemetry"), {
|
||||||
description: "The recording does not include enough cursor movement data.",
|
description: t("errors.noUsableTelemetryDescription"),
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -896,8 +1017,8 @@ export default function TimelineEditor({
|
|||||||
const dwellCandidates = detectZoomDwellCandidates(normalizedSamples);
|
const dwellCandidates = detectZoomDwellCandidates(normalizedSamples);
|
||||||
|
|
||||||
if (dwellCandidates.length === 0) {
|
if (dwellCandidates.length === 0) {
|
||||||
toast.info("No clear cursor dwell moments found", {
|
toast.info(t("errors.noDwellMoments"), {
|
||||||
description: "Try a recording with slower cursor pauses on important actions.",
|
description: t("errors.noDwellMomentsDescription"),
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -934,13 +1055,17 @@ export default function TimelineEditor({
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (addedCount === 0) {
|
if (addedCount === 0) {
|
||||||
toast.info("No auto-zoom slots available", {
|
toast.info(t("errors.noAutoZoomSlots"), {
|
||||||
description: "Detected dwell points overlap existing zoom regions.",
|
description: t("errors.noAutoZoomSlotsDescription"),
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
toast.success(`Added ${addedCount} cursor-based zoom suggestion${addedCount === 1 ? "" : "s"}`);
|
toast.success(
|
||||||
|
addedCount === 1
|
||||||
|
? t("success.addedZoomSuggestions", { count: String(addedCount) })
|
||||||
|
: t("success.addedZoomSuggestionsPlural", { count: String(addedCount) }),
|
||||||
|
);
|
||||||
}, [
|
}, [
|
||||||
videoDuration,
|
videoDuration,
|
||||||
totalMs,
|
totalMs,
|
||||||
@@ -948,6 +1073,7 @@ export default function TimelineEditor({
|
|||||||
zoomRegions,
|
zoomRegions,
|
||||||
onZoomSuggested,
|
onZoomSuggested,
|
||||||
cursorTelemetry,
|
cursorTelemetry,
|
||||||
|
t,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const handleAddTrim = useCallback(() => {
|
const handleAddTrim = useCallback(() => {
|
||||||
@@ -972,15 +1098,15 @@ export default function TimelineEditor({
|
|||||||
(region) => startPos >= region.startMs && startPos < region.endMs,
|
(region) => startPos >= region.startMs && startPos < region.endMs,
|
||||||
);
|
);
|
||||||
if (isOverlapping || gapToNext <= 0) {
|
if (isOverlapping || gapToNext <= 0) {
|
||||||
toast.error("Cannot place trim here", {
|
toast.error(t("errors.cannotPlaceTrim"), {
|
||||||
description: "Trim already exists at this location or not enough space available.",
|
description: t("errors.trimExistsAtLocation"),
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const actualDuration = Math.min(defaultRegionDurationMs, gapToNext);
|
const actualDuration = Math.min(defaultRegionDurationMs, gapToNext);
|
||||||
onTrimAdded({ start: startPos, end: startPos + actualDuration });
|
onTrimAdded({ start: startPos, end: startPos + actualDuration });
|
||||||
}, [videoDuration, totalMs, currentTimeMs, trimRegions, onTrimAdded, defaultRegionDurationMs]);
|
}, [videoDuration, totalMs, currentTimeMs, trimRegions, onTrimAdded, defaultRegionDurationMs, t]);
|
||||||
|
|
||||||
const handleAddSpeed = useCallback(() => {
|
const handleAddSpeed = useCallback(() => {
|
||||||
if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onSpeedAdded) {
|
if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onSpeedAdded) {
|
||||||
@@ -1004,15 +1130,23 @@ export default function TimelineEditor({
|
|||||||
(region) => startPos >= region.startMs && startPos < region.endMs,
|
(region) => startPos >= region.startMs && startPos < region.endMs,
|
||||||
);
|
);
|
||||||
if (isOverlapping || gapToNext <= 0) {
|
if (isOverlapping || gapToNext <= 0) {
|
||||||
toast.error("Cannot place speed here", {
|
toast.error(t("errors.cannotPlaceSpeed"), {
|
||||||
description: "Speed region already exists at this location or not enough space available.",
|
description: t("errors.speedExistsAtLocation"),
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const actualDuration = Math.min(defaultRegionDurationMs, gapToNext);
|
const actualDuration = Math.min(defaultRegionDurationMs, gapToNext);
|
||||||
onSpeedAdded({ start: startPos, end: startPos + actualDuration });
|
onSpeedAdded({ start: startPos, end: startPos + actualDuration });
|
||||||
}, [videoDuration, totalMs, currentTimeMs, speedRegions, onSpeedAdded, defaultRegionDurationMs]);
|
}, [
|
||||||
|
videoDuration,
|
||||||
|
totalMs,
|
||||||
|
currentTimeMs,
|
||||||
|
speedRegions,
|
||||||
|
onSpeedAdded,
|
||||||
|
defaultRegionDurationMs,
|
||||||
|
t,
|
||||||
|
]);
|
||||||
|
|
||||||
const handleAddAnnotation = useCallback(() => {
|
const handleAddAnnotation = useCallback(() => {
|
||||||
if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onAnnotationAdded) {
|
if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onAnnotationAdded) {
|
||||||
@@ -1135,7 +1269,7 @@ export default function TimelineEditor({
|
|||||||
id: region.id,
|
id: region.id,
|
||||||
rowId: ZOOM_ROW_ID,
|
rowId: ZOOM_ROW_ID,
|
||||||
span: { start: region.startMs, end: region.endMs },
|
span: { start: region.startMs, end: region.endMs },
|
||||||
label: `Zoom ${index + 1}`,
|
label: t("labels.zoomItem", { index: String(index + 1) }),
|
||||||
zoomDepth: region.depth,
|
zoomDepth: region.depth,
|
||||||
variant: "zoom",
|
variant: "zoom",
|
||||||
}));
|
}));
|
||||||
@@ -1144,7 +1278,7 @@ export default function TimelineEditor({
|
|||||||
id: region.id,
|
id: region.id,
|
||||||
rowId: TRIM_ROW_ID,
|
rowId: TRIM_ROW_ID,
|
||||||
span: { start: region.startMs, end: region.endMs },
|
span: { start: region.startMs, end: region.endMs },
|
||||||
label: `Trim ${index + 1}`,
|
label: t("labels.trimItem", { index: String(index + 1) }),
|
||||||
variant: "trim",
|
variant: "trim",
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -1153,12 +1287,12 @@ export default function TimelineEditor({
|
|||||||
|
|
||||||
if (region.type === "text") {
|
if (region.type === "text") {
|
||||||
// Show text preview
|
// Show text preview
|
||||||
const preview = region.content.trim() || "Empty text";
|
const preview = region.content.trim() || t("labels.emptyText");
|
||||||
label = preview.length > 20 ? `${preview.substring(0, 20)}...` : preview;
|
label = preview.length > 20 ? `${preview.substring(0, 20)}...` : preview;
|
||||||
} else if (region.type === "image") {
|
} else if (region.type === "image") {
|
||||||
label = "Image";
|
label = t("labels.imageItem");
|
||||||
} else {
|
} else {
|
||||||
label = "Annotation";
|
label = t("labels.annotationItem");
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -1174,13 +1308,13 @@ export default function TimelineEditor({
|
|||||||
id: region.id,
|
id: region.id,
|
||||||
rowId: SPEED_ROW_ID,
|
rowId: SPEED_ROW_ID,
|
||||||
span: { start: region.startMs, end: region.endMs },
|
span: { start: region.startMs, end: region.endMs },
|
||||||
label: `Speed ${index + 1}`,
|
label: t("labels.speedItem", { index: String(index + 1) }),
|
||||||
speedValue: region.speed,
|
speedValue: region.speed,
|
||||||
variant: "speed",
|
variant: "speed",
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return [...zooms, ...trims, ...annotations, ...speeds];
|
return [...zooms, ...trims, ...annotations, ...speeds];
|
||||||
}, [zoomRegions, trimRegions, annotationRegions, speedRegions]);
|
}, [zoomRegions, trimRegions, annotationRegions, speedRegions, t]);
|
||||||
|
|
||||||
// Flat list of all non-annotation region spans for neighbour-clamping during drag/resize
|
// Flat list of all non-annotation region spans for neighbour-clamping during drag/resize
|
||||||
const allRegionSpans = useMemo(() => {
|
const allRegionSpans = useMemo(() => {
|
||||||
@@ -1222,8 +1356,8 @@ export default function TimelineEditor({
|
|||||||
<Plus className="w-6 h-6 text-slate-600" />
|
<Plus className="w-6 h-6 text-slate-600" />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<p className="text-sm font-medium text-slate-300">No Video Loaded</p>
|
<p className="text-sm font-medium text-slate-300">{t("emptyState.noVideo")}</p>
|
||||||
<p className="text-xs text-slate-500 mt-1">Drag and drop a video to start editing</p>
|
<p className="text-xs text-slate-500 mt-1">{t("emptyState.dragAndDrop")}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -1238,7 +1372,7 @@ export default function TimelineEditor({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7 text-slate-400 hover:text-[#34B27B] hover:bg-[#34B27B]/10 transition-all"
|
className="h-7 w-7 text-slate-400 hover:text-[#34B27B] hover:bg-[#34B27B]/10 transition-all"
|
||||||
title="Add Zoom (Z)"
|
title={t("buttons.addZoom")}
|
||||||
>
|
>
|
||||||
<ZoomIn className="w-4 h-4" />
|
<ZoomIn className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1247,7 +1381,7 @@ export default function TimelineEditor({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7 text-slate-400 hover:text-[#34B27B] hover:bg-[#34B27B]/10 transition-all"
|
className="h-7 w-7 text-slate-400 hover:text-[#34B27B] hover:bg-[#34B27B]/10 transition-all"
|
||||||
title="Suggest Zooms from Cursor"
|
title={t("buttons.suggestZooms")}
|
||||||
>
|
>
|
||||||
<WandSparkles className="w-4 h-4" />
|
<WandSparkles className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1256,7 +1390,7 @@ export default function TimelineEditor({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7 text-slate-400 hover:text-[#ef4444] hover:bg-[#ef4444]/10 transition-all"
|
className="h-7 w-7 text-slate-400 hover:text-[#ef4444] hover:bg-[#ef4444]/10 transition-all"
|
||||||
title="Add Trim (T)"
|
title={t("buttons.addTrim")}
|
||||||
>
|
>
|
||||||
<Scissors className="w-4 h-4" />
|
<Scissors className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1265,7 +1399,7 @@ export default function TimelineEditor({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7 text-slate-400 hover:text-[#B4A046] hover:bg-[#B4A046]/10 transition-all"
|
className="h-7 w-7 text-slate-400 hover:text-[#B4A046] hover:bg-[#B4A046]/10 transition-all"
|
||||||
title="Add Annotation (A)"
|
title={t("buttons.addAnnotation")}
|
||||||
>
|
>
|
||||||
<MessageSquare className="w-4 h-4" />
|
<MessageSquare className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1274,7 +1408,7 @@ export default function TimelineEditor({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7 text-slate-400 hover:text-[#d97706] hover:bg-[#d97706]/10 transition-all"
|
className="h-7 w-7 text-slate-400 hover:text-[#d97706] hover:bg-[#d97706]/10 transition-all"
|
||||||
title="Add Speed (S)"
|
title={t("buttons.addSpeed")}
|
||||||
>
|
>
|
||||||
<Gauge className="w-4 h-4" />
|
<Gauge className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1313,13 +1447,13 @@ export default function TimelineEditor({
|
|||||||
<kbd className="px-1.5 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-sans">
|
<kbd className="px-1.5 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-sans">
|
||||||
{scrollLabels.pan}
|
{scrollLabels.pan}
|
||||||
</kbd>
|
</kbd>
|
||||||
<span>Pan</span>
|
<span>{t("labels.pan")}</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center gap-1.5">
|
<span className="flex items-center gap-1.5">
|
||||||
<kbd className="px-1.5 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-sans">
|
<kbd className="px-1.5 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-sans">
|
||||||
{scrollLabels.zoom}
|
{scrollLabels.zoom}
|
||||||
</kbd>
|
</kbd>
|
||||||
<span>Zoom</span>
|
<span>{t("labels.zoom")}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1351,6 +1485,7 @@ export default function TimelineEditor({
|
|||||||
videoDurationMs={totalMs}
|
videoDurationMs={totalMs}
|
||||||
currentTimeMs={currentTimeMs}
|
currentTimeMs={currentTimeMs}
|
||||||
onSeek={onSeek}
|
onSeek={onSeek}
|
||||||
|
onRangeChange={setRange}
|
||||||
onSelectZoom={onSelectZoom}
|
onSelectZoom={onSelectZoom}
|
||||||
onSelectTrim={onSelectTrim}
|
onSelectTrim={onSelectTrim}
|
||||||
onSelectAnnotation={onSelectAnnotation}
|
onSelectAnnotation={onSelectAnnotation}
|
||||||
|
|||||||
@@ -1,4 +1,21 @@
|
|||||||
|
import type { WebcamLayoutPreset } from "@/lib/compositeLayout";
|
||||||
|
|
||||||
export type ZoomDepth = 1 | 2 | 3 | 4 | 5 | 6;
|
export type ZoomDepth = 1 | 2 | 3 | 4 | 5 | 6;
|
||||||
|
export type ZoomFocusMode = "manual" | "auto";
|
||||||
|
export type { WebcamLayoutPreset };
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_WEBCAM_POSITION: WebcamPosition | null = null;
|
||||||
|
|
||||||
export interface ZoomFocus {
|
export interface ZoomFocus {
|
||||||
cx: number; // normalized horizontal center (0-1)
|
cx: number; // normalized horizontal center (0-1)
|
||||||
@@ -11,6 +28,7 @@ export interface ZoomRegion {
|
|||||||
endMs: number;
|
endMs: number;
|
||||||
depth: ZoomDepth;
|
depth: ZoomDepth;
|
||||||
focus: ZoomFocus;
|
focus: ZoomFocus;
|
||||||
|
focusMode?: ZoomFocusMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CursorTelemetryPoint {
|
export interface CursorTelemetryPoint {
|
||||||
|
|||||||
@@ -8,3 +8,6 @@ export const VIEWPORT_SCALE = 0.8;
|
|||||||
export const SMOOTHING_FACTOR = 0.12;
|
export const SMOOTHING_FACTOR = 0.12;
|
||||||
export const ZOOM_TRANSLATION_DEADZONE_PX = 1.25;
|
export const ZOOM_TRANSLATION_DEADZONE_PX = 1.25;
|
||||||
export const ZOOM_SCALE_DEADZONE = 0.002;
|
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);
|
return getFocusBoundsForScale(zoomScale);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getFocusBoundsForScale(zoomScale: number) {
|
interface ViewportRatio {
|
||||||
const marginX = 1 / (2 * zoomScale);
|
widthRatio: number;
|
||||||
const marginY = 1 / (2 * zoomScale);
|
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 {
|
return {
|
||||||
minX: marginX,
|
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 = {
|
const baseFocus = {
|
||||||
cx: clamp(focus.cx, 0, 1),
|
cx: clamp(focus.cx, 0, 1),
|
||||||
cy: clamp(focus.cy, 0, 1),
|
cy: clamp(focus.cy, 0, 1),
|
||||||
};
|
};
|
||||||
const bounds = getFocusBoundsForScale(zoomScale);
|
const bounds = getFocusBoundsForScale(zoomScale, viewportRatio);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
cx: clamp(baseFocus.cx, bounds.minX, bounds.maxX),
|
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 = {
|
const baseFocus = {
|
||||||
cx: clamp(focus.cx, 0, 1),
|
cx: clamp(focus.cx, 0, 1),
|
||||||
cy: clamp(focus.cy, 0, 1),
|
cy: clamp(focus.cy, 0, 1),
|
||||||
};
|
};
|
||||||
const bounds = getFocusBoundsForScale(zoomScale);
|
const bounds = getFocusBoundsForScale(zoomScale, viewportRatio);
|
||||||
const horizontalRange = bounds.maxX - bounds.minX;
|
const horizontalRange = bounds.maxX - bounds.minX;
|
||||||
const verticalRange = bounds.maxY - bounds.minY;
|
const verticalRange = bounds.maxY - bounds.minY;
|
||||||
const horizontalSoftness = Math.min(0.12, horizontalRange * 0.35);
|
const horizontalSoftness = Math.min(0.12, horizontalRange * 0.35);
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
import { Application, Graphics, Sprite } from "pixi.js";
|
import { Application, Graphics, Sprite } from "pixi.js";
|
||||||
import type { CropRegion } from "../types";
|
import {
|
||||||
|
computeCompositeLayout,
|
||||||
|
type RenderRect,
|
||||||
|
type Size,
|
||||||
|
type StyledRenderRect,
|
||||||
|
type WebcamLayoutPreset,
|
||||||
|
} from "@/lib/compositeLayout";
|
||||||
|
import type { CropRegion, WebcamMaskShape } from "../types";
|
||||||
|
|
||||||
interface LayoutParams {
|
interface LayoutParams {
|
||||||
container: HTMLDivElement;
|
container: HTMLDivElement;
|
||||||
@@ -11,6 +18,10 @@ interface LayoutParams {
|
|||||||
lockedVideoDimensions?: { width: number; height: number } | null;
|
lockedVideoDimensions?: { width: number; height: number } | null;
|
||||||
borderRadius?: number;
|
borderRadius?: number;
|
||||||
padding?: number;
|
padding?: number;
|
||||||
|
webcamDimensions?: Size | null;
|
||||||
|
webcamLayoutPreset?: WebcamLayoutPreset;
|
||||||
|
webcamPosition?: { cx: number; cy: number } | null;
|
||||||
|
webcamMaskShape?: WebcamMaskShape;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LayoutResult {
|
interface LayoutResult {
|
||||||
@@ -18,7 +29,8 @@ interface LayoutResult {
|
|||||||
videoSize: { width: number; height: number };
|
videoSize: { width: number; height: number };
|
||||||
baseScale: number;
|
baseScale: number;
|
||||||
baseOffset: { x: number; y: number };
|
baseOffset: { x: number; y: number };
|
||||||
maskRect: { x: number; y: number; width: number; height: number };
|
maskRect: RenderRect;
|
||||||
|
webcamRect: StyledRenderRect | null;
|
||||||
cropBounds: { startX: number; endX: number; startY: number; endY: number };
|
cropBounds: { startX: number; endX: number; startY: number; endY: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,6 +45,10 @@ export function layoutVideoContent(params: LayoutParams): LayoutResult | null {
|
|||||||
lockedVideoDimensions,
|
lockedVideoDimensions,
|
||||||
borderRadius = 0,
|
borderRadius = 0,
|
||||||
padding = 0,
|
padding = 0,
|
||||||
|
webcamDimensions,
|
||||||
|
webcamLayoutPreset,
|
||||||
|
webcamPosition,
|
||||||
|
webcamMaskShape,
|
||||||
} = params;
|
} = params;
|
||||||
|
|
||||||
const videoWidth = lockedVideoDimensions?.width || videoElement.videoWidth;
|
const videoWidth = lockedVideoDimensions?.width || videoElement.videoWidth;
|
||||||
@@ -67,15 +83,35 @@ export function layoutVideoContent(params: LayoutParams): LayoutResult | null {
|
|||||||
|
|
||||||
// Calculate scale to fit the cropped area in the viewport
|
// Calculate scale to fit the cropped area in the viewport
|
||||||
// Padding is a percentage (0-100), where 50 matches the original VIEWPORT_SCALE of 0.8
|
// Padding is a percentage (0-100), where 50 matches the original VIEWPORT_SCALE of 0.8
|
||||||
const paddingScale = 1.0 - (padding / 100) * 0.4;
|
// Vertical stack ignores padding — it's full-bleed
|
||||||
|
const effectivePadding = webcamLayoutPreset === "vertical-stack" ? 0 : padding;
|
||||||
|
const paddingScale = 1.0 - (effectivePadding / 100) * 0.4;
|
||||||
const maxDisplayWidth = width * paddingScale;
|
const maxDisplayWidth = width * paddingScale;
|
||||||
const maxDisplayHeight = height * paddingScale;
|
const maxDisplayHeight = height * paddingScale;
|
||||||
|
|
||||||
const scale = Math.min(
|
const compositeLayout = computeCompositeLayout({
|
||||||
maxDisplayWidth / croppedVideoWidth,
|
canvasSize: { width, height },
|
||||||
maxDisplayHeight / croppedVideoHeight,
|
maxContentSize: { width: maxDisplayWidth, height: maxDisplayHeight },
|
||||||
1,
|
screenSize: { width: croppedVideoWidth, height: croppedVideoHeight },
|
||||||
);
|
webcamSize: webcamDimensions,
|
||||||
|
layoutPreset: webcamLayoutPreset,
|
||||||
|
webcamPosition,
|
||||||
|
webcamMaskShape,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!compositeLayout) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const screenRect = compositeLayout.screenRect;
|
||||||
|
|
||||||
|
// Cover mode: scale to fill the rect (may crop), otherwise fit-to-width
|
||||||
|
let scale: number;
|
||||||
|
if (compositeLayout.screenCover) {
|
||||||
|
scale = Math.max(screenRect.width / croppedVideoWidth, screenRect.height / croppedVideoHeight);
|
||||||
|
} else {
|
||||||
|
scale = screenRect.width / croppedVideoWidth;
|
||||||
|
}
|
||||||
|
|
||||||
videoSprite.scale.set(scale);
|
videoSprite.scale.set(scale);
|
||||||
|
|
||||||
@@ -83,31 +119,25 @@ export function layoutVideoContent(params: LayoutParams): LayoutResult | null {
|
|||||||
const fullVideoDisplayWidth = videoWidth * scale;
|
const fullVideoDisplayWidth = videoWidth * scale;
|
||||||
const fullVideoDisplayHeight = videoHeight * scale;
|
const fullVideoDisplayHeight = videoHeight * scale;
|
||||||
|
|
||||||
// Calculate display size of just the cropped region
|
// Position the video so the cropped region is centered within the screenRect
|
||||||
const croppedDisplayWidth = croppedVideoWidth * scale;
|
const croppedDisplayWidth = croppedVideoWidth * scale;
|
||||||
const croppedDisplayHeight = croppedVideoHeight * scale;
|
const croppedDisplayHeight = croppedVideoHeight * scale;
|
||||||
|
const offsetX = screenRect.x + (screenRect.width - croppedDisplayWidth) / 2;
|
||||||
// Center the cropped region in the container
|
const offsetY = screenRect.y + (screenRect.height - croppedDisplayHeight) / 2;
|
||||||
const centerOffsetX = (width - croppedDisplayWidth) / 2;
|
const spriteX = offsetX - crop.x * fullVideoDisplayWidth;
|
||||||
const centerOffsetY = (height - croppedDisplayHeight) / 2;
|
const spriteY = offsetY - crop.y * fullVideoDisplayHeight;
|
||||||
|
|
||||||
// Position the full video sprite so that when we apply the mask,
|
|
||||||
// the cropped region appears centered
|
|
||||||
// The crop starts at (crop.x * videoWidth, crop.y * videoHeight) in video coordinates
|
|
||||||
// In display coordinates, that's (crop.x * fullVideoDisplayWidth, crop.y * fullVideoDisplayHeight)
|
|
||||||
// We want that point to be at centerOffsetX, centerOffsetY
|
|
||||||
const spriteX = centerOffsetX - crop.x * fullVideoDisplayWidth;
|
|
||||||
const spriteY = centerOffsetY - crop.y * fullVideoDisplayHeight;
|
|
||||||
|
|
||||||
videoSprite.position.set(spriteX, spriteY);
|
videoSprite.position.set(spriteX, spriteY);
|
||||||
|
|
||||||
// Create a mask that only shows the cropped region (centered in container)
|
// Apply border radius — mask clips the video to the screenRect
|
||||||
const maskX = centerOffsetX;
|
|
||||||
const maskY = centerOffsetY;
|
|
||||||
|
|
||||||
// Apply border radius
|
|
||||||
maskGraphics.clear();
|
maskGraphics.clear();
|
||||||
maskGraphics.roundRect(maskX, maskY, croppedDisplayWidth, croppedDisplayHeight, borderRadius);
|
maskGraphics.roundRect(
|
||||||
|
screenRect.x,
|
||||||
|
screenRect.y,
|
||||||
|
screenRect.width,
|
||||||
|
screenRect.height,
|
||||||
|
compositeLayout.screenCover ? 0 : borderRadius,
|
||||||
|
);
|
||||||
maskGraphics.fill({ color: 0xffffff });
|
maskGraphics.fill({ color: 0xffffff });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -115,7 +145,8 @@ export function layoutVideoContent(params: LayoutParams): LayoutResult | null {
|
|||||||
videoSize: { width: croppedVideoWidth, height: croppedVideoHeight },
|
videoSize: { width: croppedVideoWidth, height: croppedVideoHeight },
|
||||||
baseScale: scale,
|
baseScale: scale,
|
||||||
baseOffset: { x: spriteX, y: spriteY },
|
baseOffset: { x: spriteX, y: spriteY },
|
||||||
maskRect: { x: maskX, y: maskY, width: croppedDisplayWidth, height: croppedDisplayHeight },
|
maskRect: compositeLayout.screenRect,
|
||||||
|
webcamRect: compositeLayout.webcamRect,
|
||||||
cropBounds: { startX: cropStartX, endX: cropEndX, startY: cropStartY, endY: cropEndY },
|
cropBounds: { startX: cropStartX, endX: cropEndX, startY: cropStartY, endY: cropEndY },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ interface OverlayUpdateParams {
|
|||||||
export function updateOverlayIndicator(params: OverlayUpdateParams) {
|
export function updateOverlayIndicator(params: OverlayUpdateParams) {
|
||||||
const { overlayEl, indicatorEl, region, focusOverride, videoSize, baseScale, isPlaying } = params;
|
const { overlayEl, indicatorEl, region, focusOverride, videoSize, baseScale, isPlaying } = params;
|
||||||
|
|
||||||
if (!region) {
|
if (!region || region.focusMode === "auto") {
|
||||||
indicatorEl.style.display = "none";
|
indicatorEl.style.display = "none";
|
||||||
overlayEl.style.pointerEvents = "none";
|
overlayEl.style.pointerEvents = "none";
|
||||||
return;
|
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 { ZOOM_DEPTH_SCALES } from "../types";
|
||||||
import { TRANSITION_WINDOW_MS, ZOOM_IN_TRANSITION_WINDOW_MS } from "./constants";
|
import { TRANSITION_WINDOW_MS, ZOOM_IN_TRANSITION_WINDOW_MS } from "./constants";
|
||||||
|
import { interpolateCursorAt } from "./cursorFollowUtils";
|
||||||
import { clampFocusToScale } from "./focusUtils";
|
import { clampFocusToScale } from "./focusUtils";
|
||||||
import { clamp01, cubicBezier, easeOutScreenStudio } from "./mathUtils";
|
import { clamp01, cubicBezier, easeOutScreenStudio } from "./mathUtils";
|
||||||
|
|
||||||
@@ -10,6 +11,8 @@ const ZOOM_IN_OVERLAP_MS = 500;
|
|||||||
|
|
||||||
type DominantRegionOptions = {
|
type DominantRegionOptions = {
|
||||||
connectZooms?: boolean;
|
connectZooms?: boolean;
|
||||||
|
cursorTelemetry?: CursorTelemetryPoint[];
|
||||||
|
viewportRatio?: ViewportRatio;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ConnectedRegionPair = {
|
type ConnectedRegionPair = {
|
||||||
@@ -64,8 +67,33 @@ function getLinearFocus(start: ZoomFocus, end: ZoomFocus, amount: number): ZoomF
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function getResolvedFocus(region: ZoomRegion, zoomScale: number): ZoomFocus {
|
interface ViewportRatio {
|
||||||
return clampFocusToScale(region.focus, zoomScale);
|
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[]) {
|
function getConnectedRegionPairs(regions: ZoomRegion[]) {
|
||||||
@@ -96,6 +124,8 @@ function getActiveRegion(
|
|||||||
regions: ZoomRegion[],
|
regions: ZoomRegion[],
|
||||||
timeMs: number,
|
timeMs: number,
|
||||||
connectedPairs: ConnectedRegionPair[],
|
connectedPairs: ConnectedRegionPair[],
|
||||||
|
cursorTelemetry?: CursorTelemetryPoint[],
|
||||||
|
viewportRatio?: ViewportRatio,
|
||||||
) {
|
) {
|
||||||
const activeRegions = regions
|
const activeRegions = regions
|
||||||
.map((region) => {
|
.map((region) => {
|
||||||
@@ -130,21 +160,32 @@ function getActiveRegion(
|
|||||||
return {
|
return {
|
||||||
region: {
|
region: {
|
||||||
...activeRegion,
|
...activeRegion,
|
||||||
focus: getResolvedFocus(activeRegion, activeScale),
|
focus: getResolvedFocus(activeRegion, activeScale, timeMs, cursorTelemetry, viewportRatio),
|
||||||
},
|
},
|
||||||
strength: activeRegions[0].strength,
|
strength: activeRegions[0].strength,
|
||||||
blendedScale: null,
|
blendedScale: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function getConnectedRegionHold(timeMs: number, connectedPairs: ConnectedRegionPair[]) {
|
function getConnectedRegionHold(
|
||||||
|
timeMs: number,
|
||||||
|
connectedPairs: ConnectedRegionPair[],
|
||||||
|
cursorTelemetry?: CursorTelemetryPoint[],
|
||||||
|
viewportRatio?: ViewportRatio,
|
||||||
|
) {
|
||||||
for (const pair of connectedPairs) {
|
for (const pair of connectedPairs) {
|
||||||
if (timeMs > pair.transitionEnd && timeMs < pair.nextRegion.startMs) {
|
if (timeMs > pair.transitionEnd && timeMs < pair.nextRegion.startMs) {
|
||||||
const nextScale = ZOOM_DEPTH_SCALES[pair.nextRegion.depth];
|
const nextScale = ZOOM_DEPTH_SCALES[pair.nextRegion.depth];
|
||||||
return {
|
return {
|
||||||
region: {
|
region: {
|
||||||
...pair.nextRegion,
|
...pair.nextRegion,
|
||||||
focus: getResolvedFocus(pair.nextRegion, nextScale),
|
focus: getResolvedFocus(
|
||||||
|
pair.nextRegion,
|
||||||
|
nextScale,
|
||||||
|
timeMs,
|
||||||
|
cursorTelemetry,
|
||||||
|
viewportRatio,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
strength: 1,
|
strength: 1,
|
||||||
blendedScale: null,
|
blendedScale: null,
|
||||||
@@ -155,7 +196,12 @@ function getConnectedRegionHold(timeMs: number, connectedPairs: ConnectedRegionP
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getConnectedRegionTransition(connectedPairs: ConnectedRegionPair[], timeMs: number) {
|
function getConnectedRegionTransition(
|
||||||
|
connectedPairs: ConnectedRegionPair[],
|
||||||
|
timeMs: number,
|
||||||
|
cursorTelemetry?: CursorTelemetryPoint[],
|
||||||
|
viewportRatio?: ViewportRatio,
|
||||||
|
) {
|
||||||
for (const pair of connectedPairs) {
|
for (const pair of connectedPairs) {
|
||||||
const { currentRegion, nextRegion, transitionStart, transitionEnd } = pair;
|
const { currentRegion, nextRegion, transitionStart, transitionEnd } = pair;
|
||||||
|
|
||||||
@@ -169,8 +215,23 @@ function getConnectedRegionTransition(connectedPairs: ConnectedRegionPair[], tim
|
|||||||
const currentScale = ZOOM_DEPTH_SCALES[currentRegion.depth];
|
const currentScale = ZOOM_DEPTH_SCALES[currentRegion.depth];
|
||||||
const nextScale = ZOOM_DEPTH_SCALES[nextRegion.depth];
|
const nextScale = ZOOM_DEPTH_SCALES[nextRegion.depth];
|
||||||
const transitionScale = lerp(currentScale, nextScale, transitionProgress);
|
const transitionScale = lerp(currentScale, nextScale, transitionProgress);
|
||||||
const currentFocus = getResolvedFocus(currentRegion, currentScale);
|
// Both regions share the same timeMs, so interpolate cursor once and reuse.
|
||||||
const nextFocus = getResolvedFocus(nextRegion, nextScale);
|
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);
|
const transitionFocus = getLinearFocus(currentFocus, nextFocus, transitionProgress);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -204,20 +265,22 @@ export function findDominantRegion(
|
|||||||
transition: ConnectedPanTransition | null;
|
transition: ConnectedPanTransition | null;
|
||||||
} {
|
} {
|
||||||
const connectedPairs = options.connectZooms ? getConnectedRegionPairs(regions) : [];
|
const connectedPairs = options.connectZooms ? getConnectedRegionPairs(regions) : [];
|
||||||
|
const telemetry = options.cursorTelemetry;
|
||||||
|
const vr = options.viewportRatio;
|
||||||
|
|
||||||
if (options.connectZooms) {
|
if (options.connectZooms) {
|
||||||
const connectedTransition = getConnectedRegionTransition(connectedPairs, timeMs);
|
const connectedTransition = getConnectedRegionTransition(connectedPairs, timeMs, telemetry, vr);
|
||||||
if (connectedTransition) {
|
if (connectedTransition) {
|
||||||
return connectedTransition;
|
return connectedTransition;
|
||||||
}
|
}
|
||||||
|
|
||||||
const connectedHold = getConnectedRegionHold(timeMs, connectedPairs);
|
const connectedHold = getConnectedRegionHold(timeMs, connectedPairs, telemetry, vr);
|
||||||
if (connectedHold) {
|
if (connectedHold) {
|
||||||
return { ...connectedHold, transition: null };
|
return { ...connectedHold, transition: null };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeRegion = getActiveRegion(regions, timeMs, connectedPairs);
|
const activeRegion = getActiveRegion(regions, timeMs, connectedPairs, telemetry, vr);
|
||||||
return activeRegion
|
return activeRegion
|
||||||
? { ...activeRegion, transition: null }
|
? { ...activeRegion, transition: null }
|
||||||
: { region: null, strength: 0, blendedScale: null, transition: null };
|
: { region: null, strength: 0, blendedScale: null, transition: null };
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
type ReactNode,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import {
|
||||||
|
DEFAULT_LOCALE,
|
||||||
|
type I18nNamespace,
|
||||||
|
LOCALE_STORAGE_KEY,
|
||||||
|
type Locale,
|
||||||
|
SUPPORTED_LOCALES,
|
||||||
|
} from "@/i18n/config";
|
||||||
|
import { translate } from "@/i18n/loader";
|
||||||
|
|
||||||
|
type TranslateVars = Record<string, string | number>;
|
||||||
|
|
||||||
|
interface I18nContextValue {
|
||||||
|
locale: Locale;
|
||||||
|
setLocale: (locale: Locale) => void;
|
||||||
|
t: (qualifiedKey: string, vars?: TranslateVars) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const I18nContext = createContext<I18nContextValue | null>(null);
|
||||||
|
|
||||||
|
export function useI18n(): I18nContextValue {
|
||||||
|
const ctx = useContext(I18nContext);
|
||||||
|
if (!ctx) throw new Error("useI18n must be used within <I18nProvider>");
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useScopedT(namespace: I18nNamespace) {
|
||||||
|
const { locale } = useI18n();
|
||||||
|
return useCallback(
|
||||||
|
(key: string, vars?: TranslateVars): string => translate(locale, namespace, key, vars),
|
||||||
|
[locale, namespace],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSupportedLocale(value: string): value is Locale {
|
||||||
|
return (SUPPORTED_LOCALES as readonly string[]).includes(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInitialLocale(): Locale {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(LOCALE_STORAGE_KEY);
|
||||||
|
if (stored && isSupportedLocale(stored)) return stored;
|
||||||
|
} catch {
|
||||||
|
// localStorage may be unavailable
|
||||||
|
}
|
||||||
|
return DEFAULT_LOCALE;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function I18nProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [locale, setLocaleState] = useState<Locale>(getInitialLocale);
|
||||||
|
|
||||||
|
const setLocale = useCallback((newLocale: Locale) => {
|
||||||
|
setLocaleState(newLocale);
|
||||||
|
try {
|
||||||
|
localStorage.setItem(LOCALE_STORAGE_KEY, newLocale);
|
||||||
|
} catch {
|
||||||
|
// localStorage may be unavailable
|
||||||
|
}
|
||||||
|
document.documentElement.lang = newLocale;
|
||||||
|
// Notify Electron main process
|
||||||
|
window.electronAPI?.setLocale?.(newLocale);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.lang = locale;
|
||||||
|
}, [locale]);
|
||||||
|
|
||||||
|
const t = useCallback(
|
||||||
|
(qualifiedKey: string, vars?: TranslateVars): string => {
|
||||||
|
const dotIndex = qualifiedKey.indexOf(".");
|
||||||
|
if (dotIndex === -1) return qualifiedKey;
|
||||||
|
const namespace = qualifiedKey.slice(0, dotIndex) as I18nNamespace;
|
||||||
|
const key = qualifiedKey.slice(dotIndex + 1);
|
||||||
|
return translate(locale, namespace, key, vars);
|
||||||
|
},
|
||||||
|
[locale],
|
||||||
|
);
|
||||||
|
|
||||||
|
const value = useMemo<I18nContextValue>(() => ({ locale, setLocale, t }), [locale, setLocale, t]);
|
||||||
|
|
||||||
|
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -4,9 +4,17 @@ import type {
|
|||||||
CropRegion,
|
CropRegion,
|
||||||
SpeedRegion,
|
SpeedRegion,
|
||||||
TrimRegion,
|
TrimRegion,
|
||||||
|
WebcamLayoutPreset,
|
||||||
|
WebcamMaskShape,
|
||||||
|
WebcamPosition,
|
||||||
ZoomRegion,
|
ZoomRegion,
|
||||||
} from "@/components/video-editor/types";
|
} from "@/components/video-editor/types";
|
||||||
import { DEFAULT_CROP_REGION } from "@/components/video-editor/types";
|
import {
|
||||||
|
DEFAULT_CROP_REGION,
|
||||||
|
DEFAULT_WEBCAM_LAYOUT_PRESET,
|
||||||
|
DEFAULT_WEBCAM_MASK_SHAPE,
|
||||||
|
DEFAULT_WEBCAM_POSITION,
|
||||||
|
} from "@/components/video-editor/types";
|
||||||
import type { AspectRatio } from "@/utils/aspectRatioUtils";
|
import type { AspectRatio } from "@/utils/aspectRatioUtils";
|
||||||
|
|
||||||
// Undoable state — selection IDs are intentionally excluded (undoing a
|
// Undoable state — selection IDs are intentionally excluded (undoing a
|
||||||
@@ -24,6 +32,9 @@ export interface EditorState {
|
|||||||
borderRadius: number;
|
borderRadius: number;
|
||||||
padding: number;
|
padding: number;
|
||||||
aspectRatio: AspectRatio;
|
aspectRatio: AspectRatio;
|
||||||
|
webcamLayoutPreset: WebcamLayoutPreset;
|
||||||
|
webcamMaskShape: WebcamMaskShape;
|
||||||
|
webcamPosition: WebcamPosition | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const INITIAL_EDITOR_STATE: EditorState = {
|
export const INITIAL_EDITOR_STATE: EditorState = {
|
||||||
@@ -39,6 +50,9 @@ export const INITIAL_EDITOR_STATE: EditorState = {
|
|||||||
borderRadius: 0,
|
borderRadius: 0,
|
||||||
padding: 50,
|
padding: 50,
|
||||||
aspectRatio: "16:9",
|
aspectRatio: "16:9",
|
||||||
|
webcamLayoutPreset: DEFAULT_WEBCAM_LAYOUT_PRESET,
|
||||||
|
webcamMaskShape: DEFAULT_WEBCAM_MASK_SHAPE,
|
||||||
|
webcamPosition: DEFAULT_WEBCAM_POSITION,
|
||||||
};
|
};
|
||||||
|
|
||||||
type StateUpdate = Partial<EditorState> | ((prev: EditorState) => Partial<EditorState>);
|
type StateUpdate = Partial<EditorState> | ((prev: EditorState) => Partial<EditorState>);
|
||||||
|
|||||||
+166
-46
@@ -1,6 +1,8 @@
|
|||||||
import { fixWebmDuration } from "@fix-webm-duration/fix";
|
import { fixWebmDuration } from "@fix-webm-duration/fix";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { useScopedT } from "@/contexts/I18nContext";
|
||||||
|
import { requestCameraAccess } from "@/lib/requestCameraAccess";
|
||||||
|
|
||||||
const TARGET_FRAME_RATE = 60;
|
const TARGET_FRAME_RATE = 60;
|
||||||
const MIN_FRAME_RATE = 30;
|
const MIN_FRAME_RATE = 30;
|
||||||
@@ -39,12 +41,18 @@ const WEBCAM_TARGET_FRAME_RATE = 30;
|
|||||||
|
|
||||||
type UseScreenRecorderReturn = {
|
type UseScreenRecorderReturn = {
|
||||||
recording: boolean;
|
recording: boolean;
|
||||||
|
paused: boolean;
|
||||||
|
elapsedSeconds: number;
|
||||||
toggleRecording: () => void;
|
toggleRecording: () => void;
|
||||||
|
togglePaused: () => void;
|
||||||
restartRecording: () => void;
|
restartRecording: () => void;
|
||||||
|
cancelRecording: () => void;
|
||||||
microphoneEnabled: boolean;
|
microphoneEnabled: boolean;
|
||||||
setMicrophoneEnabled: (enabled: boolean) => void;
|
setMicrophoneEnabled: (enabled: boolean) => void;
|
||||||
microphoneDeviceId: string | undefined;
|
microphoneDeviceId: string | undefined;
|
||||||
setMicrophoneDeviceId: (deviceId: string | undefined) => void;
|
setMicrophoneDeviceId: (deviceId: string | undefined) => void;
|
||||||
|
webcamDeviceId: string | undefined;
|
||||||
|
setWebcamDeviceId: (deviceId: string | undefined) => void;
|
||||||
systemAudioEnabled: boolean;
|
systemAudioEnabled: boolean;
|
||||||
setSystemAudioEnabled: (enabled: boolean) => void;
|
setSystemAudioEnabled: (enabled: boolean) => void;
|
||||||
webcamEnabled: boolean;
|
webcamEnabled: boolean;
|
||||||
@@ -79,9 +87,13 @@ function createRecorderHandle(stream: MediaStream, options: MediaRecorderOptions
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useScreenRecorder(): UseScreenRecorderReturn {
|
export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||||
|
const t = useScopedT("editor");
|
||||||
const [recording, setRecording] = useState(false);
|
const [recording, setRecording] = useState(false);
|
||||||
|
const [paused, setPaused] = useState(false);
|
||||||
|
const [elapsedSeconds, setElapsedSeconds] = useState(0);
|
||||||
const [microphoneEnabled, setMicrophoneEnabled] = useState(false);
|
const [microphoneEnabled, setMicrophoneEnabled] = useState(false);
|
||||||
const [microphoneDeviceId, setMicrophoneDeviceId] = useState<string | undefined>(undefined);
|
const [microphoneDeviceId, setMicrophoneDeviceId] = useState<string | undefined>(undefined);
|
||||||
|
const [webcamDeviceId, setWebcamDeviceId] = useState<string | undefined>(undefined);
|
||||||
const [systemAudioEnabled, setSystemAudioEnabled] = useState(false);
|
const [systemAudioEnabled, setSystemAudioEnabled] = useState(false);
|
||||||
const [webcamEnabled, setWebcamEnabledState] = useState(false);
|
const [webcamEnabled, setWebcamEnabledState] = useState(false);
|
||||||
const screenRecorder = useRef<RecorderHandle | null>(null);
|
const screenRecorder = useRef<RecorderHandle | null>(null);
|
||||||
@@ -91,13 +103,20 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
|||||||
const microphoneStream = useRef<MediaStream | null>(null);
|
const microphoneStream = useRef<MediaStream | null>(null);
|
||||||
const webcamStream = useRef<MediaStream | null>(null);
|
const webcamStream = useRef<MediaStream | null>(null);
|
||||||
const mixingContext = useRef<AudioContext | null>(null);
|
const mixingContext = useRef<AudioContext | null>(null);
|
||||||
const startTime = useRef<number>(0);
|
|
||||||
const recordingId = 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 finalizingRecordingId = useRef<number | null>(null);
|
||||||
const allowAutoFinalize = useRef(false);
|
const allowAutoFinalize = useRef(false);
|
||||||
const discardRecordingId = useRef<number | null>(null);
|
const discardRecordingId = useRef<number | null>(null);
|
||||||
const restarting = useRef(false);
|
const restarting = useRef(false);
|
||||||
|
|
||||||
|
const getRecordingDurationMs = useCallback(() => {
|
||||||
|
const segmentDuration =
|
||||||
|
segmentStartedAt.current === null ? 0 : Date.now() - segmentStartedAt.current;
|
||||||
|
return accumulatedDurationMs.current + segmentDuration;
|
||||||
|
}, []);
|
||||||
|
|
||||||
const selectMimeType = () => {
|
const selectMimeType = () => {
|
||||||
const preferred = [
|
const preferred = [
|
||||||
"video/webm;codecs=av1",
|
"video/webm;codecs=av1",
|
||||||
@@ -151,37 +170,29 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const setWebcamEnabled = useCallback(async (enabled: boolean) => {
|
const setWebcamEnabled = useCallback(
|
||||||
if (!enabled) {
|
async (enabled: boolean) => {
|
||||||
setWebcamEnabledState(false);
|
if (!enabled) {
|
||||||
return true;
|
setWebcamEnabledState(false);
|
||||||
}
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
const accessResult = await window.electronAPI.requestCameraAccess();
|
const accessResult = await requestCameraAccess();
|
||||||
if (!accessResult.success) {
|
if (!accessResult.success) {
|
||||||
toast.error("Failed to request camera access.");
|
toast.error(t("recording.failedCameraAccess"));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!accessResult.granted) {
|
if (!accessResult.granted) {
|
||||||
toast.error("Camera access is blocked. Enable it in system settings to use the webcam.");
|
toast.error(t("recording.cameraBlocked"));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
|
||||||
const probeStream = await navigator.mediaDevices.getUserMedia({
|
|
||||||
audio: false,
|
|
||||||
video: true,
|
|
||||||
});
|
|
||||||
probeStream.getTracks().forEach((track) => track.stop());
|
|
||||||
setWebcamEnabledState(true);
|
setWebcamEnabledState(true);
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
},
|
||||||
console.warn("Failed to preflight webcam access:", error);
|
[t],
|
||||||
toast.error("Camera access denied. Webcam overlay will stay disabled.");
|
);
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const finalizeRecording = useCallback(
|
const finalizeRecording = useCallback(
|
||||||
(
|
(
|
||||||
@@ -204,6 +215,10 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
|||||||
|
|
||||||
teardownMedia();
|
teardownMedia();
|
||||||
setRecording(false);
|
setRecording(false);
|
||||||
|
setPaused(false);
|
||||||
|
setElapsedSeconds(0);
|
||||||
|
accumulatedDurationMs.current = 0;
|
||||||
|
segmentStartedAt.current = null;
|
||||||
window.electronAPI?.setRecordingState(false);
|
window.electronAPI?.setRecordingState(false);
|
||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
@@ -275,7 +290,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const activeWebcamRecorder = webcamRecorder.current;
|
const activeWebcamRecorder = webcamRecorder.current;
|
||||||
const duration = Date.now() - startTime.current;
|
const duration = getRecordingDurationMs();
|
||||||
const activeRecordingId = recordingId.current;
|
const activeRecordingId = recordingId.current;
|
||||||
|
|
||||||
finalizeRecording(
|
finalizeRecording(
|
||||||
@@ -285,7 +300,10 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
|||||||
activeRecordingId,
|
activeRecordingId,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (activeScreenRecorder.recorder.state === "recording") {
|
if (
|
||||||
|
activeScreenRecorder.recorder.state === "recording" ||
|
||||||
|
activeScreenRecorder.recorder.state === "paused"
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
activeScreenRecorder.recorder.stop();
|
activeScreenRecorder.recorder.stop();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -293,7 +311,10 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (activeWebcamRecorder) {
|
if (activeWebcamRecorder) {
|
||||||
if (activeWebcamRecorder.recorder.state === "recording") {
|
if (
|
||||||
|
activeWebcamRecorder.recorder.state === "recording" ||
|
||||||
|
activeWebcamRecorder.recorder.state === "paused"
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
activeWebcamRecorder.recorder.stop();
|
activeWebcamRecorder.recorder.stop();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -318,14 +339,20 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
|||||||
restarting.current = false;
|
restarting.current = false;
|
||||||
discardRecordingId.current = null;
|
discardRecordingId.current = null;
|
||||||
|
|
||||||
if (screenRecorder.current?.recorder.state === "recording") {
|
if (
|
||||||
|
screenRecorder.current?.recorder.state === "recording" ||
|
||||||
|
screenRecorder.current?.recorder.state === "paused"
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
screenRecorder.current.recorder.stop();
|
screenRecorder.current.recorder.stop();
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore recorder teardown errors during cleanup.
|
// Ignore recorder teardown errors during cleanup.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (webcamRecorder.current?.recorder.state === "recording") {
|
if (
|
||||||
|
webcamRecorder.current?.recorder.state === "recording" ||
|
||||||
|
webcamRecorder.current?.recorder.state === "paused"
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
webcamRecorder.current.recorder.stop();
|
webcamRecorder.current.recorder.stop();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -342,7 +369,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
|||||||
try {
|
try {
|
||||||
const selectedSource = await window.electronAPI.getSelectedSource();
|
const selectedSource = await window.electronAPI.getSelectedSource();
|
||||||
if (!selectedSource) {
|
if (!selectedSource) {
|
||||||
alert("Please select a source to record");
|
alert(t("recording.selectSource"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -372,7 +399,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
|||||||
} as unknown as MediaStreamConstraints);
|
} as unknown as MediaStreamConstraints);
|
||||||
} catch (audioErr) {
|
} catch (audioErr) {
|
||||||
console.warn("System audio capture failed, falling back to video-only:", audioErr);
|
console.warn("System audio capture failed, falling back to video-only:", audioErr);
|
||||||
toast.error("System audio not available. Recording without system audio.");
|
toast.error(t("recording.systemAudioUnavailable"));
|
||||||
screenMediaStream = await navigator.mediaDevices.getUserMedia({
|
screenMediaStream = await navigator.mediaDevices.getUserMedia({
|
||||||
audio: false,
|
audio: false,
|
||||||
video: videoConstraints,
|
video: videoConstraints,
|
||||||
@@ -405,7 +432,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
|||||||
});
|
});
|
||||||
} catch (audioError) {
|
} catch (audioError) {
|
||||||
console.warn("Failed to get microphone access:", audioError);
|
console.warn("Failed to get microphone access:", audioError);
|
||||||
toast.error("Microphone access denied. Recording will continue without audio.");
|
toast.error(t("recording.microphoneDenied"));
|
||||||
setMicrophoneEnabled(false);
|
setMicrophoneEnabled(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -414,11 +441,18 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
|||||||
try {
|
try {
|
||||||
webcamStream.current = await navigator.mediaDevices.getUserMedia({
|
webcamStream.current = await navigator.mediaDevices.getUserMedia({
|
||||||
audio: false,
|
audio: false,
|
||||||
video: {
|
video: webcamDeviceId
|
||||||
width: { ideal: WEBCAM_TARGET_WIDTH },
|
? {
|
||||||
height: { ideal: WEBCAM_TARGET_HEIGHT },
|
deviceId: { exact: webcamDeviceId },
|
||||||
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 },
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
width: { ideal: WEBCAM_TARGET_WIDTH },
|
||||||
|
height: { ideal: WEBCAM_TARGET_HEIGHT },
|
||||||
|
frameRate: { ideal: WEBCAM_TARGET_FRAME_RATE, max: WEBCAM_TARGET_FRAME_RATE },
|
||||||
|
},
|
||||||
});
|
});
|
||||||
} catch (cameraError) {
|
} catch (cameraError) {
|
||||||
console.warn("Failed to get webcam access:", cameraError);
|
console.warn("Failed to get webcam access:", cameraError);
|
||||||
@@ -427,7 +461,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
|||||||
webcamStream.current = null;
|
webcamStream.current = null;
|
||||||
}
|
}
|
||||||
setWebcamEnabledState(false);
|
setWebcamEnabledState(false);
|
||||||
toast.error("Camera access denied. Recording will continue without webcam.");
|
toast.error(t("recording.cameraDenied"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -513,9 +547,12 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
|||||||
}
|
}
|
||||||
|
|
||||||
recordingId.current = Date.now();
|
recordingId.current = Date.now();
|
||||||
startTime.current = recordingId.current;
|
accumulatedDurationMs.current = 0;
|
||||||
|
segmentStartedAt.current = Date.now();
|
||||||
allowAutoFinalize.current = true;
|
allowAutoFinalize.current = true;
|
||||||
setRecording(true);
|
setRecording(true);
|
||||||
|
setPaused(false);
|
||||||
|
setElapsedSeconds(0);
|
||||||
window.electronAPI?.setRecordingState(true);
|
window.electronAPI?.setRecordingState(true);
|
||||||
|
|
||||||
const activeScreenRecorder = screenRecorder.current;
|
const activeScreenRecorder = screenRecorder.current;
|
||||||
@@ -531,7 +568,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
|||||||
finalizeRecording(
|
finalizeRecording(
|
||||||
activeScreenRecorder,
|
activeScreenRecorder,
|
||||||
activeWebcamRecorder ?? null,
|
activeWebcamRecorder ?? null,
|
||||||
Math.max(0, Date.now() - startTime.current),
|
Math.max(0, getRecordingDurationMs()),
|
||||||
activeRecordingId,
|
activeRecordingId,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -542,17 +579,61 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
|||||||
console.error("Failed to start recording:", error);
|
console.error("Failed to start recording:", error);
|
||||||
const errorMsg = error instanceof Error ? error.message : "Failed to start recording";
|
const errorMsg = error instanceof Error ? error.message : "Failed to start recording";
|
||||||
if (errorMsg.includes("Permission denied") || errorMsg.includes("NotAllowedError")) {
|
if (errorMsg.includes("Permission denied") || errorMsg.includes("NotAllowedError")) {
|
||||||
toast.error("Recording permission denied. Please allow screen recording.");
|
toast.error(t("recording.permissionDenied"));
|
||||||
} else {
|
} else {
|
||||||
toast.error(errorMsg);
|
toast.error(errorMsg);
|
||||||
}
|
}
|
||||||
setRecording(false);
|
setRecording(false);
|
||||||
|
setPaused(false);
|
||||||
|
setElapsedSeconds(0);
|
||||||
|
accumulatedDurationMs.current = 0;
|
||||||
|
segmentStartedAt.current = null;
|
||||||
screenRecorder.current = null;
|
screenRecorder.current = null;
|
||||||
webcamRecorder.current = null;
|
webcamRecorder.current = null;
|
||||||
teardownMedia();
|
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 = () => {
|
const toggleRecording = () => {
|
||||||
recording ? stopRecording.current() : startRecording();
|
recording ? stopRecording.current() : startRecording();
|
||||||
};
|
};
|
||||||
@@ -561,13 +642,14 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
|||||||
if (restarting.current) return;
|
if (restarting.current) return;
|
||||||
|
|
||||||
const activeScreenRecorder = screenRecorder.current;
|
const activeScreenRecorder = screenRecorder.current;
|
||||||
if (!activeScreenRecorder || activeScreenRecorder.recorder.state !== "recording") return;
|
if (!activeScreenRecorder || activeScreenRecorder.recorder.state === "inactive") return;
|
||||||
|
|
||||||
const activeWebcamRecorder = webcamRecorder.current;
|
const activeWebcamRecorder = webcamRecorder.current;
|
||||||
const activeRecordingId = recordingId.current;
|
const activeRecordingId = recordingId.current;
|
||||||
|
|
||||||
restarting.current = true;
|
restarting.current = true;
|
||||||
discardRecordingId.current = activeRecordingId;
|
discardRecordingId.current = activeRecordingId;
|
||||||
|
allowAutoFinalize.current = false;
|
||||||
|
|
||||||
const stopPromises = [
|
const stopPromises = [
|
||||||
new Promise<void>((resolve) => {
|
new Promise<void>((resolve) => {
|
||||||
@@ -575,7 +657,10 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
|||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
if (activeWebcamRecorder?.recorder.state === "recording") {
|
if (
|
||||||
|
activeWebcamRecorder?.recorder.state === "recording" ||
|
||||||
|
activeWebcamRecorder?.recorder.state === "paused"
|
||||||
|
) {
|
||||||
stopPromises.push(
|
stopPromises.push(
|
||||||
new Promise<void>((resolve) => {
|
new Promise<void>((resolve) => {
|
||||||
activeWebcamRecorder.recorder.addEventListener("stop", () => resolve(), {
|
activeWebcamRecorder.recorder.addEventListener("stop", () => resolve(), {
|
||||||
@@ -595,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 {
|
return {
|
||||||
recording,
|
recording,
|
||||||
|
paused,
|
||||||
|
elapsedSeconds,
|
||||||
toggleRecording,
|
toggleRecording,
|
||||||
|
togglePaused,
|
||||||
restartRecording,
|
restartRecording,
|
||||||
|
cancelRecording,
|
||||||
microphoneEnabled,
|
microphoneEnabled,
|
||||||
setMicrophoneEnabled,
|
setMicrophoneEnabled,
|
||||||
microphoneDeviceId,
|
microphoneDeviceId,
|
||||||
setMicrophoneDeviceId,
|
setMicrophoneDeviceId,
|
||||||
|
webcamDeviceId,
|
||||||
|
setWebcamDeviceId,
|
||||||
systemAudioEnabled,
|
systemAudioEnabled,
|
||||||
setSystemAudioEnabled,
|
setSystemAudioEnabled,
|
||||||
webcamEnabled,
|
webcamEnabled,
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
export const DEFAULT_LOCALE = "en" as const;
|
||||||
|
export const SUPPORTED_LOCALES = ["en", "zh-CN", "es"] as const;
|
||||||
|
export const I18N_NAMESPACES = [
|
||||||
|
"common",
|
||||||
|
"dialogs",
|
||||||
|
"editor",
|
||||||
|
"launch",
|
||||||
|
"settings",
|
||||||
|
"shortcuts",
|
||||||
|
"timeline",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type Locale = (typeof SUPPORTED_LOCALES)[number];
|
||||||
|
export type I18nNamespace = (typeof I18N_NAMESPACES)[number];
|
||||||
|
|
||||||
|
export const LOCALE_STORAGE_KEY = "openscreen-locale";
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { DEFAULT_LOCALE, type I18nNamespace, type Locale } from "./config";
|
||||||
|
|
||||||
|
type MessageMap = Record<string, unknown>;
|
||||||
|
|
||||||
|
const modules = import.meta.glob("./locales/**/*.json", { eager: true }) as Record<
|
||||||
|
string,
|
||||||
|
{ default: MessageMap }
|
||||||
|
>;
|
||||||
|
|
||||||
|
const messages: Record<string, Record<string, MessageMap>> = {};
|
||||||
|
|
||||||
|
for (const [path, mod] of Object.entries(modules)) {
|
||||||
|
// path looks like "./locales/en/common.json"
|
||||||
|
const parts = path.replace("./locales/", "").replace(".json", "").split("/");
|
||||||
|
const locale = parts[0];
|
||||||
|
const namespace = parts[1];
|
||||||
|
if (!messages[locale]) messages[locale] = {};
|
||||||
|
messages[locale][namespace] = mod.default;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMessageValue(obj: unknown, dotPath: string): string | undefined {
|
||||||
|
const keys = dotPath.split(".");
|
||||||
|
let current: unknown = obj;
|
||||||
|
for (const key of keys) {
|
||||||
|
if (current == null || typeof current !== "object") return undefined;
|
||||||
|
current = (current as Record<string, unknown>)[key];
|
||||||
|
}
|
||||||
|
return typeof current === "string" ? current : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function interpolate(str: string, vars?: Record<string, string | number>): string {
|
||||||
|
if (!vars) return str;
|
||||||
|
return str.replace(/\{\{(\w+)\}\}/g, (_, key: string) => String(vars[key] ?? `{{${key}}}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMessages(locale: Locale, namespace: I18nNamespace): MessageMap {
|
||||||
|
return messages[locale]?.[namespace] ?? {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLocaleName(locale: Locale): string {
|
||||||
|
return getMessageValue(messages[locale]?.common, "locale.name") ?? locale;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLocaleShort(locale: Locale): string {
|
||||||
|
return getMessageValue(messages[locale]?.common, "locale.short") ?? locale;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function translate(
|
||||||
|
locale: Locale,
|
||||||
|
namespace: I18nNamespace,
|
||||||
|
key: string,
|
||||||
|
vars?: Record<string, string | number>,
|
||||||
|
): string {
|
||||||
|
const value =
|
||||||
|
getMessageValue(messages[locale]?.[namespace], key) ??
|
||||||
|
getMessageValue(messages[DEFAULT_LOCALE]?.[namespace], key);
|
||||||
|
|
||||||
|
if (value == null) return `${namespace}.${key}`;
|
||||||
|
return interpolate(value, vars);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"actions": {
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"save": "Save",
|
||||||
|
"delete": "Delete",
|
||||||
|
"close": "Close",
|
||||||
|
"share": "Share",
|
||||||
|
"done": "Done",
|
||||||
|
"open": "Open",
|
||||||
|
"upload": "Upload",
|
||||||
|
"export": "Export",
|
||||||
|
"file": "File",
|
||||||
|
"edit": "Edit",
|
||||||
|
"view": "View",
|
||||||
|
"window": "Window",
|
||||||
|
"quit": "Quit",
|
||||||
|
"stopRecording": "Stop Recording"
|
||||||
|
},
|
||||||
|
"playback": {
|
||||||
|
"play": "Play",
|
||||||
|
"pause": "Pause",
|
||||||
|
"fullscreen": "Fullscreen",
|
||||||
|
"exitFullscreen": "Exit Fullscreen"
|
||||||
|
},
|
||||||
|
"locale": {
|
||||||
|
"name": "English",
|
||||||
|
"short": "EN"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
{
|
||||||
|
"export": {
|
||||||
|
"complete": "Export Complete",
|
||||||
|
"yourFormatReady": "Your {{format}} is ready",
|
||||||
|
"showInFolder": "Show in Folder",
|
||||||
|
"finalizingVideo": "Finalizing video export...",
|
||||||
|
"compilingGifProgress": "Compiling GIF... {{progress}}%",
|
||||||
|
"compilingGifWait": "Compiling GIF... This may take a while",
|
||||||
|
"takeMoment": "This may take a moment...",
|
||||||
|
"failed": "Export Failed",
|
||||||
|
"tryAgain": "Please try again",
|
||||||
|
"finalizingVideoTitle": "Finalizing Video",
|
||||||
|
"compilingGif": "Compiling GIF",
|
||||||
|
"exportingFormat": "Exporting {{format}}",
|
||||||
|
"compiling": "Compiling",
|
||||||
|
"renderingFrames": "Rendering Frames",
|
||||||
|
"processing": "Processing...",
|
||||||
|
"finalizing": "Finalizing...",
|
||||||
|
"compilingStatus": "Compiling...",
|
||||||
|
"status": "Status",
|
||||||
|
"format": "Format",
|
||||||
|
"frames": "Frames",
|
||||||
|
"cancelExport": "Cancel Export",
|
||||||
|
"savedSuccessfully": "{{format}} saved successfully!"
|
||||||
|
},
|
||||||
|
"tutorial": {
|
||||||
|
"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.",
|
||||||
|
"visualExample": "Visual Example",
|
||||||
|
"removed": "REMOVED",
|
||||||
|
"kept": "Kept",
|
||||||
|
"part1": "Part 1",
|
||||||
|
"part2": "Part 2",
|
||||||
|
"part3": "Part 3",
|
||||||
|
"finalVideo": "Final Video",
|
||||||
|
"step1Title": "1. Add Trim",
|
||||||
|
"step1Description": "Press T 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."
|
||||||
|
},
|
||||||
|
"unsavedChanges": {
|
||||||
|
"title": "Unsaved Changes",
|
||||||
|
"message": "You have unsaved changes.",
|
||||||
|
"detail": "Do you want to save your project before closing?",
|
||||||
|
"saveAndClose": "Save & Close",
|
||||||
|
"discardAndClose": "Discard & Close",
|
||||||
|
"loadProject": "Load Project…",
|
||||||
|
"saveProject": "Save Project…",
|
||||||
|
"saveProjectAs": "Save Project As…"
|
||||||
|
},
|
||||||
|
"fileDialogs": {
|
||||||
|
"saveGif": "Save Exported GIF",
|
||||||
|
"saveVideo": "Save Exported Video",
|
||||||
|
"selectVideo": "Select Video File",
|
||||||
|
"saveProject": "Save OpenScreen Project",
|
||||||
|
"openProject": "Open OpenScreen Project",
|
||||||
|
"gifImage": "GIF Image",
|
||||||
|
"mp4Video": "MP4 Video",
|
||||||
|
"videoFiles": "Video Files",
|
||||||
|
"openscreenProject": "OpenScreen Project",
|
||||||
|
"allFiles": "All Files"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"errors": {
|
||||||
|
"noVideoLoaded": "No video loaded",
|
||||||
|
"videoNotReady": "Video not ready",
|
||||||
|
"unableToDetermineSourcePath": "Unable to determine source video path",
|
||||||
|
"failedToSaveGif": "Failed to save GIF",
|
||||||
|
"gifExportFailed": "GIF export failed",
|
||||||
|
"failedToSaveVideo": "Failed to save video",
|
||||||
|
"exportFailed": "Export failed",
|
||||||
|
"exportFailedWithError": "Export failed: {{error}}",
|
||||||
|
"failedToSaveExport": "Failed to save export",
|
||||||
|
"failedToSaveExportedVideo": "Failed to save exported video",
|
||||||
|
"failedToRevealInFolder": "Error revealing in folder: {{error}}"
|
||||||
|
},
|
||||||
|
"export": {
|
||||||
|
"canceled": "Export canceled",
|
||||||
|
"exportedSuccessfully": "{{format}} exported successfully"
|
||||||
|
},
|
||||||
|
"project": {
|
||||||
|
"saveCanceled": "Project save canceled",
|
||||||
|
"failedToSave": "Failed to save project",
|
||||||
|
"savedTo": "Project saved to {{path}}",
|
||||||
|
"failedToLoad": "Failed to load project",
|
||||||
|
"invalidFormat": "Invalid project file format",
|
||||||
|
"loadedFrom": "Project loaded from {{path}}"
|
||||||
|
},
|
||||||
|
"recording": {
|
||||||
|
"failedCameraAccess": "Failed to request camera access.",
|
||||||
|
"cameraBlocked": "Camera access is blocked. Enable it in system settings to use the webcam.",
|
||||||
|
"systemAudioUnavailable": "System audio not available. Recording without system audio.",
|
||||||
|
"microphoneDenied": "Microphone access denied. Recording will continue without audio.",
|
||||||
|
"cameraDenied": "Camera access denied. Recording will continue without webcam.",
|
||||||
|
"permissionDenied": "Recording permission denied. Please allow screen recording."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"tooltips": {
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"audio": {
|
||||||
|
"enableSystemAudio": "Enable system audio",
|
||||||
|
"disableSystemAudio": "Disable system audio",
|
||||||
|
"enableMicrophone": "Enable microphone",
|
||||||
|
"disableMicrophone": "Disable microphone",
|
||||||
|
"defaultMicrophone": "Default Microphone"
|
||||||
|
},
|
||||||
|
"webcam": {
|
||||||
|
"enableWebcam": "Enable webcam",
|
||||||
|
"disableWebcam": "Disable webcam",
|
||||||
|
"defaultCamera": "Default Camera",
|
||||||
|
"searching": "Searching...",
|
||||||
|
"noneFound": "No camera found",
|
||||||
|
"unavailable": "Camera unavailable"
|
||||||
|
},
|
||||||
|
"sourceSelector": {
|
||||||
|
"loading": "Loading sources...",
|
||||||
|
"screens": "Screens ({{count}})",
|
||||||
|
"windows": "Windows ({{count}})",
|
||||||
|
"defaultSourceName": "Screen"
|
||||||
|
},
|
||||||
|
"recording": {
|
||||||
|
"selectSource": "Please select a source to record"
|
||||||
|
},
|
||||||
|
"language": "Language"
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
{
|
||||||
|
"zoom": {
|
||||||
|
"level": "Zoom Level",
|
||||||
|
"selectRegion": "Select a zoom region to adjust",
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"trim": {
|
||||||
|
"deleteRegion": "Delete Trim Region"
|
||||||
|
},
|
||||||
|
"layout": {
|
||||||
|
"title": "Layout",
|
||||||
|
"preset": "Preset",
|
||||||
|
"selectPreset": "Select preset",
|
||||||
|
"pictureInPicture": "Picture in Picture",
|
||||||
|
"verticalStack": "Vertical Stack",
|
||||||
|
"webcamShape": "Camera Shape"
|
||||||
|
},
|
||||||
|
"effects": {
|
||||||
|
"title": "Video Effects",
|
||||||
|
"blurBg": "Blur BG",
|
||||||
|
"motionBlur": "Motion Blur",
|
||||||
|
"off": "off",
|
||||||
|
"shadow": "Shadow",
|
||||||
|
"roundness": "Roundness",
|
||||||
|
"padding": "Padding"
|
||||||
|
},
|
||||||
|
"background": {
|
||||||
|
"title": "Background",
|
||||||
|
"image": "Image",
|
||||||
|
"color": "Color",
|
||||||
|
"gradient": "Gradient",
|
||||||
|
"uploadCustom": "Upload Custom",
|
||||||
|
"gradientLabel": "Gradient {{index}}"
|
||||||
|
},
|
||||||
|
"crop": {
|
||||||
|
"title": "Crop",
|
||||||
|
"cropVideo": "Crop Video",
|
||||||
|
"dragInstruction": "Drag on each side to adjust the crop area",
|
||||||
|
"ratio": "Ratio",
|
||||||
|
"free": "Free",
|
||||||
|
"done": "Done",
|
||||||
|
"lockAspectRatio": "Lock aspect ratio",
|
||||||
|
"unlockAspectRatio": "Unlock aspect ratio"
|
||||||
|
},
|
||||||
|
"exportFormat": {
|
||||||
|
"mp4": "MP4",
|
||||||
|
"gif": "GIF",
|
||||||
|
"mp4Video": "MP4 Video",
|
||||||
|
"mp4Description": "High quality video file",
|
||||||
|
"gifAnimation": "GIF Animation",
|
||||||
|
"gifDescription": "Animated image for sharing"
|
||||||
|
},
|
||||||
|
"exportQuality": {
|
||||||
|
"title": "Export Quality",
|
||||||
|
"low": "Low",
|
||||||
|
"medium": "Medium",
|
||||||
|
"high": "High"
|
||||||
|
},
|
||||||
|
"gifSettings": {
|
||||||
|
"frameRate": "GIF Frame Rate",
|
||||||
|
"size": "GIF Size",
|
||||||
|
"loop": "Loop GIF"
|
||||||
|
},
|
||||||
|
"project": {
|
||||||
|
"save": "Save Project",
|
||||||
|
"load": "Load Project"
|
||||||
|
},
|
||||||
|
"export": {
|
||||||
|
"videoButton": "Export Video",
|
||||||
|
"gifButton": "Export GIF",
|
||||||
|
"chooseSaveLocation": "Choose Save Location"
|
||||||
|
},
|
||||||
|
"links": {
|
||||||
|
"reportBug": "Report Bug",
|
||||||
|
"starOnGithub": "Star on GitHub"
|
||||||
|
},
|
||||||
|
"imageUpload": {
|
||||||
|
"invalidFileType": "Invalid file type",
|
||||||
|
"jpgOnly": "Please upload a JPG or JPEG image file.",
|
||||||
|
"uploadSuccess": "Custom image uploaded successfully!",
|
||||||
|
"failedToUpload": "Failed to upload image",
|
||||||
|
"errorReading": "There was an error reading the file."
|
||||||
|
},
|
||||||
|
"annotation": {
|
||||||
|
"title": "Annotation Settings",
|
||||||
|
"active": "Active",
|
||||||
|
"typeText": "Text",
|
||||||
|
"typeImage": "Image",
|
||||||
|
"typeArrow": "Arrow",
|
||||||
|
"textContent": "Text Content",
|
||||||
|
"textPlaceholder": "Enter your text...",
|
||||||
|
"fontStyle": "Font Style",
|
||||||
|
"selectStyle": "Select style",
|
||||||
|
"size": "Size",
|
||||||
|
"customFonts": "Custom Fonts",
|
||||||
|
"textColor": "Text Color",
|
||||||
|
"background": "Background",
|
||||||
|
"none": "None",
|
||||||
|
"color": "Color",
|
||||||
|
"clearBackground": "Clear Background",
|
||||||
|
"uploadImage": "Upload Image",
|
||||||
|
"supportedFormats": "Supported formats: JPG, PNG, GIF, WebP",
|
||||||
|
"arrowDirection": "Arrow Direction",
|
||||||
|
"strokeWidth": "Stroke Width: {{width}}px",
|
||||||
|
"arrowColor": "Arrow Color",
|
||||||
|
"deleteAnnotation": "Delete Annotation",
|
||||||
|
"shortcutsAndTips": "Shortcuts & Tips",
|
||||||
|
"tipMovePlayhead": "Move playhead to overlapping annotation section and select an item.",
|
||||||
|
"tipTabCycle": "Use Tab to cycle through overlapping items.",
|
||||||
|
"tipShiftTabCycle": "Use Shift+Tab to cycle backwards.",
|
||||||
|
"invalidImageType": "Invalid file type",
|
||||||
|
"imageFormatsOnly": "Please upload a JPG, PNG, GIF, or WebP image file.",
|
||||||
|
"imageUploadSuccess": "Image uploaded successfully!",
|
||||||
|
"failedImageUpload": "Failed to upload image"
|
||||||
|
},
|
||||||
|
"fontStyles": {
|
||||||
|
"classic": "Classic",
|
||||||
|
"editor": "Editor",
|
||||||
|
"strong": "Strong",
|
||||||
|
"typewriter": "Typewriter",
|
||||||
|
"deco": "Deco",
|
||||||
|
"simple": "Simple",
|
||||||
|
"modern": "Modern",
|
||||||
|
"clean": "Clean"
|
||||||
|
},
|
||||||
|
"customFont": {
|
||||||
|
"dialogTitle": "Add Google Font",
|
||||||
|
"urlLabel": "Google Fonts Import URL",
|
||||||
|
"urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
|
||||||
|
"urlHelp": "Get this from Google Fonts: Select a font → Click \"Get font\" → Copy the @import URL",
|
||||||
|
"nameLabel": "Display Name",
|
||||||
|
"namePlaceholder": "My Custom Font",
|
||||||
|
"nameHelp": "This is how the font will appear in the font selector",
|
||||||
|
"addButton": "Add Font",
|
||||||
|
"addingButton": "Adding...",
|
||||||
|
"errorEmptyUrl": "Please enter a Google Fonts import URL",
|
||||||
|
"errorInvalidUrl": "Please enter a valid Google Fonts URL",
|
||||||
|
"errorEmptyName": "Please enter a font name",
|
||||||
|
"errorExtractFailed": "Could not extract font family from URL",
|
||||||
|
"successMessage": "Font \"{{fontName}}\" added successfully",
|
||||||
|
"failedToAdd": "Failed to add font",
|
||||||
|
"errorTimeout": "Font took too long to load. Please check the URL and try again.",
|
||||||
|
"errorLoadFailed": "The font could not be loaded. Please verify the Google Fonts URL is correct."
|
||||||
|
},
|
||||||
|
"language": {
|
||||||
|
"title": "Language"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"title": "Keyboard Shortcuts",
|
||||||
|
"customize": "Customize",
|
||||||
|
"configurable": "Configurable",
|
||||||
|
"fixed": "Fixed",
|
||||||
|
"pressKey": "Press a key…",
|
||||||
|
"clickToChange": "Click to change",
|
||||||
|
"pressEscToCancel": "Press Esc to cancel",
|
||||||
|
"helpText": "Click a shortcut then press the new key combination. Press Esc to cancel.",
|
||||||
|
"resetToDefaults": "Reset to defaults",
|
||||||
|
"alreadyUsedBy": "Already used by {{action}}",
|
||||||
|
"swap": "Swap",
|
||||||
|
"reservedShortcut": "This shortcut is reserved for \"{{label}}\" and cannot be reassigned.",
|
||||||
|
"savedToast": "Keyboard shortcuts saved",
|
||||||
|
"resetToast": "Reset to default shortcuts — click Save to apply",
|
||||||
|
"actions": {
|
||||||
|
"addZoom": "Add Zoom",
|
||||||
|
"addTrim": "Add Trim",
|
||||||
|
"addSpeed": "Add Speed",
|
||||||
|
"addAnnotation": "Add Annotation",
|
||||||
|
"addKeyframe": "Add Keyframe",
|
||||||
|
"deleteSelected": "Delete Selected",
|
||||||
|
"playPause": "Play / Pause"
|
||||||
|
},
|
||||||
|
"fixedActions": {
|
||||||
|
"undo": "Undo",
|
||||||
|
"redo": "Redo",
|
||||||
|
"cycleAnnotationsForward": "Cycle Annotations Forward",
|
||||||
|
"cycleAnnotationsBackward": "Cycle Annotations Backward",
|
||||||
|
"deleteSelectedAlt": "Delete Selected (alt)",
|
||||||
|
"panTimeline": "Pan Timeline",
|
||||||
|
"zoomTimeline": "Zoom Timeline",
|
||||||
|
"frameBack": "Frame Back",
|
||||||
|
"frameForward": "Frame Forward"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"buttons": {
|
||||||
|
"addZoom": "Add Zoom (Z)",
|
||||||
|
"suggestZooms": "Suggest Zooms from Cursor",
|
||||||
|
"addTrim": "Add Trim (T)",
|
||||||
|
"addAnnotation": "Add Annotation (A)",
|
||||||
|
"addSpeed": "Add Speed (S)"
|
||||||
|
},
|
||||||
|
"hints": {
|
||||||
|
"pressZoom": "Press Z to add zoom",
|
||||||
|
"pressTrim": "Press T to add trim",
|
||||||
|
"pressAnnotation": "Press A to add annotation",
|
||||||
|
"pressSpeed": "Press S to add speed"
|
||||||
|
},
|
||||||
|
"labels": {
|
||||||
|
"pan": "Pan",
|
||||||
|
"zoom": "Zoom",
|
||||||
|
"zoomItem": "Zoom {{index}}",
|
||||||
|
"trimItem": "Trim {{index}}",
|
||||||
|
"speedItem": "Speed {{index}}",
|
||||||
|
"annotationItem": "Annotation",
|
||||||
|
"imageItem": "Image",
|
||||||
|
"emptyText": "Empty text"
|
||||||
|
},
|
||||||
|
"emptyState": {
|
||||||
|
"noVideo": "No Video Loaded",
|
||||||
|
"dragAndDrop": "Drag and drop a video to start editing"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"cannotPlaceZoom": "Cannot place zoom here",
|
||||||
|
"zoomExistsAtLocation": "Zoom already exists at this location or not enough space available.",
|
||||||
|
"zoomSuggestionUnavailable": "Zoom suggestion handler unavailable",
|
||||||
|
"noCursorTelemetry": "No cursor telemetry available",
|
||||||
|
"noCursorTelemetryDescription": "Record a screencast first to generate cursor-based suggestions.",
|
||||||
|
"noUsableTelemetry": "No usable cursor telemetry",
|
||||||
|
"noUsableTelemetryDescription": "The recording does not include enough cursor movement data.",
|
||||||
|
"noDwellMoments": "No clear cursor dwell moments found",
|
||||||
|
"noDwellMomentsDescription": "Try a recording with slower cursor pauses on important actions.",
|
||||||
|
"noAutoZoomSlots": "No auto-zoom slots available",
|
||||||
|
"noAutoZoomSlotsDescription": "Detected dwell points overlap existing zoom regions.",
|
||||||
|
"cannotPlaceTrim": "Cannot place trim here",
|
||||||
|
"trimExistsAtLocation": "Trim already exists at this location or not enough space available.",
|
||||||
|
"cannotPlaceSpeed": "Cannot place speed here",
|
||||||
|
"speedExistsAtLocation": "Speed region already exists at this location or not enough space available."
|
||||||
|
},
|
||||||
|
"success": {
|
||||||
|
"addedZoomSuggestions": "Added {{count}} cursor-based zoom suggestion",
|
||||||
|
"addedZoomSuggestionsPlural": "Added {{count}} cursor-based zoom suggestions"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"actions": {
|
||||||
|
"cancel": "Cancelar",
|
||||||
|
"save": "Guardar",
|
||||||
|
"delete": "Eliminar",
|
||||||
|
"close": "Cerrar",
|
||||||
|
"share": "Compartir",
|
||||||
|
"done": "Listo",
|
||||||
|
"open": "Abrir",
|
||||||
|
"upload": "Subir",
|
||||||
|
"export": "Exportar",
|
||||||
|
"file": "Archivo",
|
||||||
|
"edit": "Editar",
|
||||||
|
"view": "Vista",
|
||||||
|
"window": "Ventana",
|
||||||
|
"quit": "Salir",
|
||||||
|
"stopRecording": "Detener grabación"
|
||||||
|
},
|
||||||
|
"playback": {
|
||||||
|
"play": "Reproducir",
|
||||||
|
"pause": "Pausar",
|
||||||
|
"fullscreen": "Pantalla completa",
|
||||||
|
"exitFullscreen": "Salir de pantalla completa"
|
||||||
|
},
|
||||||
|
"locale": {
|
||||||
|
"name": "Español",
|
||||||
|
"short": "ES"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
{
|
||||||
|
"export": {
|
||||||
|
"complete": "Exportación completada",
|
||||||
|
"yourFormatReady": "Tu {{format}} está listo",
|
||||||
|
"showInFolder": "Mostrar en carpeta",
|
||||||
|
"finalizingVideo": "Finalizando exportación de video...",
|
||||||
|
"compilingGifProgress": "Compilando GIF... {{progress}}%",
|
||||||
|
"compilingGifWait": "Compilando GIF... Esto puede tardar un rato",
|
||||||
|
"takeMoment": "Esto puede tardar un momento...",
|
||||||
|
"failed": "La exportación falló",
|
||||||
|
"tryAgain": "Por favor intenta de nuevo",
|
||||||
|
"finalizingVideoTitle": "Finalizando video",
|
||||||
|
"compilingGif": "Compilando GIF",
|
||||||
|
"exportingFormat": "Exportando {{format}}",
|
||||||
|
"compiling": "Compilando",
|
||||||
|
"renderingFrames": "Renderizando cuadros",
|
||||||
|
"processing": "Procesando...",
|
||||||
|
"finalizing": "Finalizando...",
|
||||||
|
"compilingStatus": "Compilando...",
|
||||||
|
"status": "Estado",
|
||||||
|
"format": "Formato",
|
||||||
|
"frames": "Cuadros",
|
||||||
|
"cancelExport": "Cancelar exportación",
|
||||||
|
"savedSuccessfully": "¡{{format}} guardado exitosamente!"
|
||||||
|
},
|
||||||
|
"tutorial": {
|
||||||
|
"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.",
|
||||||
|
"visualExample": "Ejemplo visual",
|
||||||
|
"removed": "ELIMINADO",
|
||||||
|
"kept": "Conservado",
|
||||||
|
"part1": "Parte 1",
|
||||||
|
"part2": "Parte 2",
|
||||||
|
"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.",
|
||||||
|
"step2Title": "2. Ajustar",
|
||||||
|
"step2Description": "Arrastra los bordes de la región roja para cubrir exactamente lo que deseas eliminar."
|
||||||
|
},
|
||||||
|
"unsavedChanges": {
|
||||||
|
"title": "Cambios sin guardar",
|
||||||
|
"message": "Tienes cambios sin guardar.",
|
||||||
|
"detail": "¿Deseas guardar tu proyecto antes de cerrar?",
|
||||||
|
"saveAndClose": "Guardar y cerrar",
|
||||||
|
"discardAndClose": "Descartar y cerrar",
|
||||||
|
"loadProject": "Cargar proyecto…",
|
||||||
|
"saveProject": "Guardar proyecto…",
|
||||||
|
"saveProjectAs": "Guardar proyecto como…"
|
||||||
|
},
|
||||||
|
"fileDialogs": {
|
||||||
|
"saveGif": "Guardar GIF exportado",
|
||||||
|
"saveVideo": "Guardar video exportado",
|
||||||
|
"selectVideo": "Seleccionar archivo de video",
|
||||||
|
"saveProject": "Guardar proyecto OpenScreen",
|
||||||
|
"openProject": "Abrir proyecto OpenScreen",
|
||||||
|
"gifImage": "Imagen GIF",
|
||||||
|
"mp4Video": "Video MP4",
|
||||||
|
"videoFiles": "Archivos de video",
|
||||||
|
"openscreenProject": "Proyecto OpenScreen",
|
||||||
|
"allFiles": "Todos los archivos"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"errors": {
|
||||||
|
"noVideoLoaded": "No hay video cargado",
|
||||||
|
"videoNotReady": "El video no está listo",
|
||||||
|
"unableToDetermineSourcePath": "No se pudo determinar la ruta del video de origen",
|
||||||
|
"failedToSaveGif": "Error al guardar el GIF",
|
||||||
|
"gifExportFailed": "La exportación de GIF falló",
|
||||||
|
"failedToSaveVideo": "Error al guardar el video",
|
||||||
|
"exportFailed": "La exportación falló",
|
||||||
|
"exportFailedWithError": "La exportación falló: {{error}}",
|
||||||
|
"failedToSaveExport": "Error al guardar la exportación",
|
||||||
|
"failedToSaveExportedVideo": "Error al guardar el video exportado",
|
||||||
|
"failedToRevealInFolder": "Error al mostrar en la carpeta: {{error}}"
|
||||||
|
},
|
||||||
|
"export": {
|
||||||
|
"canceled": "Exportación cancelada",
|
||||||
|
"exportedSuccessfully": "{{format}} exportado exitosamente"
|
||||||
|
},
|
||||||
|
"project": {
|
||||||
|
"saveCanceled": "Guardado de proyecto cancelado",
|
||||||
|
"failedToSave": "Error al guardar el proyecto",
|
||||||
|
"savedTo": "Proyecto guardado en {{path}}",
|
||||||
|
"failedToLoad": "Error al cargar el proyecto",
|
||||||
|
"invalidFormat": "Formato de archivo de proyecto no válido",
|
||||||
|
"loadedFrom": "Proyecto cargado desde {{path}}"
|
||||||
|
},
|
||||||
|
"recording": {
|
||||||
|
"failedCameraAccess": "Error al solicitar acceso a la cámara.",
|
||||||
|
"cameraBlocked": "El acceso a la cámara está bloqueado. Actívalo en la configuración del sistema para usar la cámara web.",
|
||||||
|
"systemAudioUnavailable": "Audio del sistema no disponible. Grabando sin audio del sistema.",
|
||||||
|
"microphoneDenied": "Acceso al micrófono denegado. La grabación continuará sin audio.",
|
||||||
|
"cameraDenied": "Acceso a la cámara denegado. La grabación continuará sin cámara web.",
|
||||||
|
"permissionDenied": "Permiso de grabación denegado. Por favor permite la grabación de pantalla."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"tooltips": {
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"audio": {
|
||||||
|
"enableSystemAudio": "Activar audio del sistema",
|
||||||
|
"disableSystemAudio": "Desactivar audio del sistema",
|
||||||
|
"enableMicrophone": "Activar micrófono",
|
||||||
|
"disableMicrophone": "Desactivar micrófono",
|
||||||
|
"defaultMicrophone": "Micrófono predeterminado"
|
||||||
|
},
|
||||||
|
"webcam": {
|
||||||
|
"enableWebcam": "Activar 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...",
|
||||||
|
"screens": "Pantallas ({{count}})",
|
||||||
|
"windows": "Ventanas ({{count}})",
|
||||||
|
"defaultSourceName": "Pantalla"
|
||||||
|
},
|
||||||
|
"recording": {
|
||||||
|
"selectSource": "Por favor selecciona una fuente para grabar"
|
||||||
|
},
|
||||||
|
"language": "Idioma"
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
{
|
||||||
|
"zoom": {
|
||||||
|
"level": "Nivel de zoom",
|
||||||
|
"selectRegion": "Selecciona una región de zoom para ajustar",
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"trim": {
|
||||||
|
"deleteRegion": "Eliminar región de recorte"
|
||||||
|
},
|
||||||
|
"layout": {
|
||||||
|
"title": "Diseño",
|
||||||
|
"preset": "Predefinido",
|
||||||
|
"selectPreset": "Seleccionar predefinido",
|
||||||
|
"pictureInPicture": "Imagen en imagen",
|
||||||
|
"verticalStack": "Apilado vertical",
|
||||||
|
"webcamShape": "Forma de cámara"
|
||||||
|
},
|
||||||
|
"effects": {
|
||||||
|
"title": "Efectos de video",
|
||||||
|
"blurBg": "Desenfocar fondo",
|
||||||
|
"motionBlur": "Desenfoque de movimiento",
|
||||||
|
"off": "desactivado",
|
||||||
|
"shadow": "Sombra",
|
||||||
|
"roundness": "Redondez",
|
||||||
|
"padding": "Relleno"
|
||||||
|
},
|
||||||
|
"background": {
|
||||||
|
"title": "Fondo",
|
||||||
|
"image": "Imagen",
|
||||||
|
"color": "Color",
|
||||||
|
"gradient": "Degradado",
|
||||||
|
"uploadCustom": "Subir personalizado",
|
||||||
|
"gradientLabel": "Degradado {{index}}"
|
||||||
|
},
|
||||||
|
"crop": {
|
||||||
|
"title": "Recortar",
|
||||||
|
"cropVideo": "Recortar video",
|
||||||
|
"dragInstruction": "Arrastra cada lado para ajustar el área de recorte",
|
||||||
|
"ratio": "Proporción",
|
||||||
|
"free": "Libre",
|
||||||
|
"done": "Listo",
|
||||||
|
"lockAspectRatio": "Bloquear relación de aspecto",
|
||||||
|
"unlockAspectRatio": "Desbloquear relación de aspecto"
|
||||||
|
},
|
||||||
|
"exportFormat": {
|
||||||
|
"mp4": "MP4",
|
||||||
|
"gif": "GIF",
|
||||||
|
"mp4Video": "Video MP4",
|
||||||
|
"mp4Description": "Archivo de video de alta calidad",
|
||||||
|
"gifAnimation": "Animación GIF",
|
||||||
|
"gifDescription": "Imagen animada para compartir"
|
||||||
|
},
|
||||||
|
"exportQuality": {
|
||||||
|
"title": "Calidad de exportación",
|
||||||
|
"low": "Baja",
|
||||||
|
"medium": "Media",
|
||||||
|
"high": "Alta"
|
||||||
|
},
|
||||||
|
"gifSettings": {
|
||||||
|
"frameRate": "Velocidad de cuadros del GIF",
|
||||||
|
"size": "Tamaño del GIF",
|
||||||
|
"loop": "Repetir GIF"
|
||||||
|
},
|
||||||
|
"project": {
|
||||||
|
"save": "Guardar proyecto",
|
||||||
|
"load": "Cargar proyecto"
|
||||||
|
},
|
||||||
|
"export": {
|
||||||
|
"videoButton": "Exportar video",
|
||||||
|
"gifButton": "Exportar GIF",
|
||||||
|
"chooseSaveLocation": "Elegir ubicación de guardado"
|
||||||
|
},
|
||||||
|
"links": {
|
||||||
|
"reportBug": "Reportar error",
|
||||||
|
"starOnGithub": "Dar estrella en GitHub"
|
||||||
|
},
|
||||||
|
"imageUpload": {
|
||||||
|
"invalidFileType": "Tipo de archivo no válido",
|
||||||
|
"jpgOnly": "Por favor sube un archivo de imagen JPG o JPEG.",
|
||||||
|
"uploadSuccess": "¡Imagen personalizada subida exitosamente!",
|
||||||
|
"failedToUpload": "Error al subir la imagen",
|
||||||
|
"errorReading": "Hubo un error al leer el archivo."
|
||||||
|
},
|
||||||
|
"annotation": {
|
||||||
|
"title": "Configuración de anotaciones",
|
||||||
|
"active": "Activo",
|
||||||
|
"typeText": "Texto",
|
||||||
|
"typeImage": "Imagen",
|
||||||
|
"typeArrow": "Flecha",
|
||||||
|
"textContent": "Contenido de texto",
|
||||||
|
"textPlaceholder": "Escribe tu texto...",
|
||||||
|
"fontStyle": "Estilo de fuente",
|
||||||
|
"selectStyle": "Seleccionar estilo",
|
||||||
|
"size": "Tamaño",
|
||||||
|
"customFonts": "Fuentes personalizadas",
|
||||||
|
"textColor": "Color de texto",
|
||||||
|
"background": "Fondo",
|
||||||
|
"none": "Ninguno",
|
||||||
|
"color": "Color",
|
||||||
|
"clearBackground": "Quitar fondo",
|
||||||
|
"uploadImage": "Subir imagen",
|
||||||
|
"supportedFormats": "Formatos compatibles: JPG, PNG, GIF, WebP",
|
||||||
|
"arrowDirection": "Dirección de la flecha",
|
||||||
|
"strokeWidth": "Grosor del trazo: {{width}}px",
|
||||||
|
"arrowColor": "Color de la flecha",
|
||||||
|
"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.",
|
||||||
|
"tipTabCycle": "Usa Tab para recorrer los elementos superpuestos.",
|
||||||
|
"tipShiftTabCycle": "Usa Shift+Tab para recorrer hacia atrás.",
|
||||||
|
"invalidImageType": "Tipo de archivo no válido",
|
||||||
|
"imageFormatsOnly": "Por favor sube un archivo de imagen JPG, PNG, GIF o WebP.",
|
||||||
|
"imageUploadSuccess": "¡Imagen subida exitosamente!",
|
||||||
|
"failedImageUpload": "Error al subir la imagen"
|
||||||
|
},
|
||||||
|
"fontStyles": {
|
||||||
|
"classic": "Clásico",
|
||||||
|
"editor": "Editor",
|
||||||
|
"strong": "Fuerte",
|
||||||
|
"typewriter": "Máquina de escribir",
|
||||||
|
"deco": "Deco",
|
||||||
|
"simple": "Simple",
|
||||||
|
"modern": "Moderno",
|
||||||
|
"clean": "Limpio"
|
||||||
|
},
|
||||||
|
"customFont": {
|
||||||
|
"dialogTitle": "Agregar fuente de Google",
|
||||||
|
"urlLabel": "URL de importación de Google Fonts",
|
||||||
|
"urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
|
||||||
|
"urlHelp": "Obtén esto de Google Fonts: Selecciona una fuente → Haz clic en \"Get font\" → Copia la URL de @import",
|
||||||
|
"nameLabel": "Nombre para mostrar",
|
||||||
|
"namePlaceholder": "Mi fuente personalizada",
|
||||||
|
"nameHelp": "Así aparecerá la fuente en el selector de fuentes",
|
||||||
|
"addButton": "Agregar fuente",
|
||||||
|
"addingButton": "Agregando...",
|
||||||
|
"errorEmptyUrl": "Por favor ingresa una URL de importación de Google Fonts",
|
||||||
|
"errorInvalidUrl": "Por favor ingresa una URL válida de Google Fonts",
|
||||||
|
"errorEmptyName": "Por favor ingresa un nombre de fuente",
|
||||||
|
"errorExtractFailed": "No se pudo extraer la familia de fuentes de la URL",
|
||||||
|
"successMessage": "Fuente \"{{fontName}}\" agregada exitosamente",
|
||||||
|
"failedToAdd": "Error al agregar la fuente",
|
||||||
|
"errorTimeout": "La fuente tardó demasiado en cargarse. Por favor verifica la URL e intenta de nuevo.",
|
||||||
|
"errorLoadFailed": "No se pudo cargar la fuente. Por favor verifica que la URL de Google Fonts sea correcta."
|
||||||
|
},
|
||||||
|
"language": {
|
||||||
|
"title": "Idioma"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"title": "Atajos de teclado",
|
||||||
|
"customize": "Personalizar",
|
||||||
|
"configurable": "Configurables",
|
||||||
|
"fixed": "Fijos",
|
||||||
|
"pressKey": "Presiona una tecla…",
|
||||||
|
"clickToChange": "Haz clic para cambiar",
|
||||||
|
"pressEscToCancel": "Presiona Esc para cancelar",
|
||||||
|
"helpText": "Haz clic en un atajo y luego presiona la nueva combinación de teclas. Presiona Esc para cancelar.",
|
||||||
|
"resetToDefaults": "Restablecer valores predeterminados",
|
||||||
|
"alreadyUsedBy": "Ya está en uso por {{action}}",
|
||||||
|
"swap": "Intercambiar",
|
||||||
|
"reservedShortcut": "Este atajo está reservado para \"{{label}}\" y no se puede reasignar.",
|
||||||
|
"savedToast": "Atajos de teclado guardados",
|
||||||
|
"resetToast": "Restablecido a los atajos predeterminados — haz clic en Guardar para aplicar",
|
||||||
|
"actions": {
|
||||||
|
"addZoom": "Agregar zoom",
|
||||||
|
"addTrim": "Agregar recorte",
|
||||||
|
"addSpeed": "Agregar velocidad",
|
||||||
|
"addAnnotation": "Agregar anotación",
|
||||||
|
"addKeyframe": "Agregar fotograma clave",
|
||||||
|
"deleteSelected": "Eliminar seleccionado",
|
||||||
|
"playPause": "Reproducir / Pausar"
|
||||||
|
},
|
||||||
|
"fixedActions": {
|
||||||
|
"undo": "Deshacer",
|
||||||
|
"redo": "Rehacer",
|
||||||
|
"cycleAnnotationsForward": "Recorrer anotaciones hacia adelante",
|
||||||
|
"cycleAnnotationsBackward": "Recorrer anotaciones hacia atrás",
|
||||||
|
"deleteSelectedAlt": "Eliminar seleccionado (alt)",
|
||||||
|
"panTimeline": "Desplazar línea de tiempo",
|
||||||
|
"zoomTimeline": "Zoom en línea de tiempo",
|
||||||
|
"frameBack": "Fotograma anterior",
|
||||||
|
"frameForward": "Fotograma siguiente"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"buttons": {
|
||||||
|
"addZoom": "Agregar zoom (Z)",
|
||||||
|
"suggestZooms": "Sugerir zooms desde el cursor",
|
||||||
|
"addTrim": "Agregar recorte (T)",
|
||||||
|
"addAnnotation": "Agregar anotación (A)",
|
||||||
|
"addSpeed": "Agregar velocidad (S)"
|
||||||
|
},
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"labels": {
|
||||||
|
"pan": "Desplazar",
|
||||||
|
"zoom": "Zoom",
|
||||||
|
"zoomItem": "Zoom {{index}}",
|
||||||
|
"trimItem": "Recorte {{index}}",
|
||||||
|
"speedItem": "Velocidad {{index}}",
|
||||||
|
"annotationItem": "Anotación",
|
||||||
|
"imageItem": "Imagen",
|
||||||
|
"emptyText": "Texto vacío"
|
||||||
|
},
|
||||||
|
"emptyState": {
|
||||||
|
"noVideo": "No hay video cargado",
|
||||||
|
"dragAndDrop": "Arrastra y suelta un video para comenzar a editar"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"cannotPlaceZoom": "No se puede colocar el zoom aquí",
|
||||||
|
"zoomExistsAtLocation": "Ya existe un zoom en esta ubicación o no hay suficiente espacio disponible.",
|
||||||
|
"zoomSuggestionUnavailable": "El controlador de sugerencias de zoom no está disponible",
|
||||||
|
"noCursorTelemetry": "No hay telemetría de cursor disponible",
|
||||||
|
"noCursorTelemetryDescription": "Graba una captura de pantalla primero para generar sugerencias basadas en el cursor.",
|
||||||
|
"noUsableTelemetry": "No hay telemetría de cursor utilizable",
|
||||||
|
"noUsableTelemetryDescription": "La grabación no incluye suficientes datos de movimiento del cursor.",
|
||||||
|
"noDwellMoments": "No se encontraron momentos claros de pausa del cursor",
|
||||||
|
"noDwellMomentsDescription": "Intenta una grabación con pausas más lentas del cursor en acciones importantes.",
|
||||||
|
"noAutoZoomSlots": "No hay espacios de auto-zoom disponibles",
|
||||||
|
"noAutoZoomSlotsDescription": "Los puntos de pausa detectados se superponen con regiones de zoom existentes.",
|
||||||
|
"cannotPlaceTrim": "No se puede colocar el recorte aquí",
|
||||||
|
"trimExistsAtLocation": "Ya existe un recorte en esta ubicación o no hay suficiente espacio disponible.",
|
||||||
|
"cannotPlaceSpeed": "No se puede colocar la velocidad aquí",
|
||||||
|
"speedExistsAtLocation": "Ya existe una región de velocidad en esta ubicación o no hay suficiente espacio disponible."
|
||||||
|
},
|
||||||
|
"success": {
|
||||||
|
"addedZoomSuggestions": "Se agregó {{count}} sugerencia de zoom basada en el cursor",
|
||||||
|
"addedZoomSuggestionsPlural": "Se agregaron {{count}} sugerencias de zoom basadas en el cursor"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"actions": {
|
||||||
|
"cancel": "取消",
|
||||||
|
"save": "保存",
|
||||||
|
"delete": "删除",
|
||||||
|
"close": "关闭",
|
||||||
|
"share": "分享",
|
||||||
|
"done": "完成",
|
||||||
|
"open": "打开",
|
||||||
|
"upload": "上传",
|
||||||
|
"export": "导出",
|
||||||
|
"file": "文件",
|
||||||
|
"edit": "编辑",
|
||||||
|
"view": "视图",
|
||||||
|
"window": "窗口",
|
||||||
|
"quit": "退出",
|
||||||
|
"stopRecording": "停止录制"
|
||||||
|
},
|
||||||
|
"playback": {
|
||||||
|
"play": "播放",
|
||||||
|
"pause": "暂停",
|
||||||
|
"fullscreen": "全屏",
|
||||||
|
"exitFullscreen": "退出全屏"
|
||||||
|
},
|
||||||
|
"locale": {
|
||||||
|
"name": "中文",
|
||||||
|
"short": "中文"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
{
|
||||||
|
"export": {
|
||||||
|
"complete": "导出完成",
|
||||||
|
"yourFormatReady": "您的 {{format}} 已准备就绪",
|
||||||
|
"showInFolder": "在文件夹中显示",
|
||||||
|
"finalizingVideo": "正在完成视频导出...",
|
||||||
|
"compilingGifProgress": "正在编译 GIF... {{progress}}%",
|
||||||
|
"compilingGifWait": "正在编译 GIF... 这可能需要一些时间",
|
||||||
|
"takeMoment": "这可能需要一点时间...",
|
||||||
|
"failed": "导出失败",
|
||||||
|
"tryAgain": "请重试",
|
||||||
|
"finalizingVideoTitle": "正在完成视频",
|
||||||
|
"compilingGif": "正在编译 GIF",
|
||||||
|
"exportingFormat": "正在导出 {{format}}",
|
||||||
|
"compiling": "编译中",
|
||||||
|
"renderingFrames": "渲染帧",
|
||||||
|
"processing": "处理中...",
|
||||||
|
"finalizing": "正在完成...",
|
||||||
|
"compilingStatus": "编译中...",
|
||||||
|
"status": "状态",
|
||||||
|
"format": "格式",
|
||||||
|
"frames": "帧",
|
||||||
|
"cancelExport": "取消导出",
|
||||||
|
"savedSuccessfully": "{{format}} 保存成功!"
|
||||||
|
},
|
||||||
|
"tutorial": {
|
||||||
|
"triggerLabel": "剪辑功能说明",
|
||||||
|
"title": "剪辑功能说明",
|
||||||
|
"description": "了解如何剪掉视频中不需要的部分。",
|
||||||
|
"explanation": "剪辑工具通过定义您要",
|
||||||
|
"explanationRemove": "移除",
|
||||||
|
"explanationCovered": "覆盖",
|
||||||
|
"explanationEnd": "的片段来工作。被红色剪辑区域覆盖的部分将在导出时被剪掉。",
|
||||||
|
"visualExample": "示例演示",
|
||||||
|
"removed": "已移除",
|
||||||
|
"kept": "保留",
|
||||||
|
"part1": "第 1 部分",
|
||||||
|
"part2": "第 2 部分",
|
||||||
|
"part3": "第 3 部分",
|
||||||
|
"finalVideo": "最终视频",
|
||||||
|
"step1Title": "1. 添加剪辑",
|
||||||
|
"step1Description": "按 T 或点击剪刀图标来标记要移除的片段。",
|
||||||
|
"step2Title": "2. 调整",
|
||||||
|
"step2Description": "拖动红色区域的边缘,精确覆盖您要剪掉的部分。"
|
||||||
|
},
|
||||||
|
"unsavedChanges": {
|
||||||
|
"title": "未保存的更改",
|
||||||
|
"message": "您有未保存的更改。",
|
||||||
|
"detail": "是否在关闭前保存项目?",
|
||||||
|
"saveAndClose": "保存并关闭",
|
||||||
|
"discardAndClose": "放弃并关闭",
|
||||||
|
"loadProject": "加载项目…",
|
||||||
|
"saveProject": "保存项目…",
|
||||||
|
"saveProjectAs": "项目另存为…"
|
||||||
|
},
|
||||||
|
"fileDialogs": {
|
||||||
|
"saveGif": "保存导出的 GIF",
|
||||||
|
"saveVideo": "保存导出的视频",
|
||||||
|
"selectVideo": "选择视频文件",
|
||||||
|
"saveProject": "保存 OpenScreen 项目",
|
||||||
|
"openProject": "打开 OpenScreen 项目",
|
||||||
|
"gifImage": "GIF 图片",
|
||||||
|
"mp4Video": "MP4 视频",
|
||||||
|
"videoFiles": "视频文件",
|
||||||
|
"openscreenProject": "OpenScreen 项目",
|
||||||
|
"allFiles": "所有文件"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"errors": {
|
||||||
|
"noVideoLoaded": "未加载视频",
|
||||||
|
"videoNotReady": "视频未就绪",
|
||||||
|
"unableToDetermineSourcePath": "无法确定源视频路径",
|
||||||
|
"failedToSaveGif": "保存 GIF 失败",
|
||||||
|
"gifExportFailed": "GIF 导出失败",
|
||||||
|
"failedToSaveVideo": "保存视频失败",
|
||||||
|
"exportFailed": "导出失败",
|
||||||
|
"exportFailedWithError": "导出失败:{{error}}",
|
||||||
|
"failedToSaveExport": "保存导出文件失败",
|
||||||
|
"failedToSaveExportedVideo": "保存导出的视频失败",
|
||||||
|
"failedToRevealInFolder": "在文件夹中显示时出错:{{error}}"
|
||||||
|
},
|
||||||
|
"export": {
|
||||||
|
"canceled": "导出已取消",
|
||||||
|
"exportedSuccessfully": "{{format}} 导出成功"
|
||||||
|
},
|
||||||
|
"project": {
|
||||||
|
"saveCanceled": "项目保存已取消",
|
||||||
|
"failedToSave": "保存项目失败",
|
||||||
|
"savedTo": "项目已保存至 {{path}}",
|
||||||
|
"failedToLoad": "加载项目失败",
|
||||||
|
"invalidFormat": "无效的项目文件格式",
|
||||||
|
"loadedFrom": "项目已从 {{path}} 加载"
|
||||||
|
},
|
||||||
|
"recording": {
|
||||||
|
"failedCameraAccess": "请求摄像头权限失败。",
|
||||||
|
"cameraBlocked": "摄像头权限已被阻止。请在系统设置中启用以使用摄像头。",
|
||||||
|
"systemAudioUnavailable": "系统音频不可用。将在无系统音频的情况下录制。",
|
||||||
|
"microphoneDenied": "麦克风权限被拒绝。录制将继续,但不包含音频。",
|
||||||
|
"cameraDenied": "摄像头权限被拒绝。录制将继续,但不包含摄像头画面。",
|
||||||
|
"permissionDenied": "录屏权限被拒绝。请允许屏幕录制。"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"tooltips": {
|
||||||
|
"hideHUD": "隐藏控制面板",
|
||||||
|
"closeApp": "关闭应用",
|
||||||
|
"restartRecording": "重新开始录制",
|
||||||
|
"cancelRecording": "取消录制",
|
||||||
|
"pauseRecording": "暂停录制",
|
||||||
|
"resumeRecording": "继续录制",
|
||||||
|
"openVideoFile": "打开视频文件",
|
||||||
|
"openProject": "打开项目"
|
||||||
|
},
|
||||||
|
"audio": {
|
||||||
|
"enableSystemAudio": "启用系统音频",
|
||||||
|
"disableSystemAudio": "禁用系统音频",
|
||||||
|
"enableMicrophone": "启用麦克风",
|
||||||
|
"disableMicrophone": "禁用麦克风",
|
||||||
|
"defaultMicrophone": "默认麦克风"
|
||||||
|
},
|
||||||
|
"webcam": {
|
||||||
|
"enableWebcam": "启用摄像头",
|
||||||
|
"disableWebcam": "禁用摄像头",
|
||||||
|
"defaultCamera": "默认摄像头",
|
||||||
|
"searching": "正在搜索...",
|
||||||
|
"noneFound": "未找到摄像头",
|
||||||
|
"unavailable": "摄像头不可用"
|
||||||
|
},
|
||||||
|
"sourceSelector": {
|
||||||
|
"loading": "正在加载源...",
|
||||||
|
"screens": "屏幕 ({{count}})",
|
||||||
|
"windows": "窗口 ({{count}})",
|
||||||
|
"defaultSourceName": "屏幕"
|
||||||
|
},
|
||||||
|
"recording": {
|
||||||
|
"selectSource": "请选择要录制的源"
|
||||||
|
},
|
||||||
|
"language": "语言"
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
{
|
||||||
|
"zoom": {
|
||||||
|
"level": "缩放级别",
|
||||||
|
"selectRegion": "选择要调整的缩放区域",
|
||||||
|
"deleteZoom": "删除缩放",
|
||||||
|
"focusMode": {
|
||||||
|
"title": "对焦模式",
|
||||||
|
"manual": "手动",
|
||||||
|
"auto": "自动",
|
||||||
|
"autoDescription": "摄像头跟随录制时的光标位置"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"speed": {
|
||||||
|
"playbackSpeed": "播放速度",
|
||||||
|
"selectRegion": "选择要调整的速度区域",
|
||||||
|
"deleteRegion": "删除速度区域"
|
||||||
|
},
|
||||||
|
"trim": {
|
||||||
|
"deleteRegion": "删除剪辑区域"
|
||||||
|
},
|
||||||
|
"layout": {
|
||||||
|
"title": "布局",
|
||||||
|
"preset": "预设",
|
||||||
|
"selectPreset": "选择预设",
|
||||||
|
"pictureInPicture": "画中画",
|
||||||
|
"verticalStack": "垂直堆叠",
|
||||||
|
"webcamShape": "摄像头形状"
|
||||||
|
},
|
||||||
|
"effects": {
|
||||||
|
"title": "视频效果",
|
||||||
|
"blurBg": "模糊背景",
|
||||||
|
"motionBlur": "运动模糊",
|
||||||
|
"off": "关",
|
||||||
|
"shadow": "阴影",
|
||||||
|
"roundness": "圆角",
|
||||||
|
"padding": "内边距"
|
||||||
|
},
|
||||||
|
"background": {
|
||||||
|
"title": "背景",
|
||||||
|
"image": "图片",
|
||||||
|
"color": "颜色",
|
||||||
|
"gradient": "渐变",
|
||||||
|
"uploadCustom": "上传自定义",
|
||||||
|
"gradientLabel": "渐变 {{index}}"
|
||||||
|
},
|
||||||
|
"crop": {
|
||||||
|
"title": "裁剪",
|
||||||
|
"cropVideo": "裁剪视频",
|
||||||
|
"dragInstruction": "拖动每一侧来调整裁剪区域",
|
||||||
|
"ratio": "比例",
|
||||||
|
"free": "自由",
|
||||||
|
"done": "完成",
|
||||||
|
"lockAspectRatio": "锁定宽高比",
|
||||||
|
"unlockAspectRatio": "解锁宽高比"
|
||||||
|
},
|
||||||
|
"exportFormat": {
|
||||||
|
"mp4": "MP4",
|
||||||
|
"gif": "GIF",
|
||||||
|
"mp4Video": "MP4 视频",
|
||||||
|
"mp4Description": "高质量视频文件",
|
||||||
|
"gifAnimation": "GIF 动画",
|
||||||
|
"gifDescription": "可分享的动态图片"
|
||||||
|
},
|
||||||
|
"exportQuality": {
|
||||||
|
"title": "导出质量",
|
||||||
|
"low": "低",
|
||||||
|
"medium": "中",
|
||||||
|
"high": "高"
|
||||||
|
},
|
||||||
|
"gifSettings": {
|
||||||
|
"frameRate": "GIF 帧率",
|
||||||
|
"size": "GIF 尺寸",
|
||||||
|
"loop": "循环 GIF"
|
||||||
|
},
|
||||||
|
"project": {
|
||||||
|
"save": "保存项目",
|
||||||
|
"load": "加载项目"
|
||||||
|
},
|
||||||
|
"export": {
|
||||||
|
"videoButton": "导出视频",
|
||||||
|
"gifButton": "导出 GIF",
|
||||||
|
"chooseSaveLocation": "选择保存位置"
|
||||||
|
},
|
||||||
|
"links": {
|
||||||
|
"reportBug": "报告错误",
|
||||||
|
"starOnGithub": "在 GitHub 上加星"
|
||||||
|
},
|
||||||
|
"imageUpload": {
|
||||||
|
"invalidFileType": "无效的文件类型",
|
||||||
|
"jpgOnly": "请上传 JPG 或 JPEG 格式的图片文件。",
|
||||||
|
"uploadSuccess": "自定义图片上传成功!",
|
||||||
|
"failedToUpload": "上传图片失败",
|
||||||
|
"errorReading": "读取文件时出错。"
|
||||||
|
},
|
||||||
|
"annotation": {
|
||||||
|
"title": "标注设置",
|
||||||
|
"active": "活动",
|
||||||
|
"typeText": "文本",
|
||||||
|
"typeImage": "图片",
|
||||||
|
"typeArrow": "箭头",
|
||||||
|
"textContent": "文本内容",
|
||||||
|
"textPlaceholder": "输入您的文本...",
|
||||||
|
"fontStyle": "字体样式",
|
||||||
|
"selectStyle": "选择样式",
|
||||||
|
"size": "大小",
|
||||||
|
"customFonts": "自定义字体",
|
||||||
|
"textColor": "文本颜色",
|
||||||
|
"background": "背景",
|
||||||
|
"none": "无",
|
||||||
|
"color": "颜色",
|
||||||
|
"clearBackground": "清除背景",
|
||||||
|
"uploadImage": "上传图片",
|
||||||
|
"supportedFormats": "支持的格式:JPG、PNG、GIF、WebP",
|
||||||
|
"arrowDirection": "箭头方向",
|
||||||
|
"strokeWidth": "描边宽度:{{width}}px",
|
||||||
|
"arrowColor": "箭头颜色",
|
||||||
|
"deleteAnnotation": "删除标注",
|
||||||
|
"shortcutsAndTips": "快捷键与提示",
|
||||||
|
"tipMovePlayhead": "将播放头移动到重叠的标注区域并选择一个项目。",
|
||||||
|
"tipTabCycle": "使用 Tab 键在重叠项目之间循环切换。",
|
||||||
|
"tipShiftTabCycle": "使用 Shift+Tab 反向循环切换。",
|
||||||
|
"invalidImageType": "无效的文件类型",
|
||||||
|
"imageFormatsOnly": "请上传 JPG、PNG、GIF 或 WebP 格式的图片文件。",
|
||||||
|
"imageUploadSuccess": "图片上传成功!",
|
||||||
|
"failedImageUpload": "上传图片失败"
|
||||||
|
},
|
||||||
|
"fontStyles": {
|
||||||
|
"classic": "经典",
|
||||||
|
"editor": "编辑器",
|
||||||
|
"strong": "粗体",
|
||||||
|
"typewriter": "打字机",
|
||||||
|
"deco": "装饰",
|
||||||
|
"simple": "简约",
|
||||||
|
"modern": "现代",
|
||||||
|
"clean": "简洁"
|
||||||
|
},
|
||||||
|
"customFont": {
|
||||||
|
"dialogTitle": "添加 Google 字体",
|
||||||
|
"urlLabel": "Google Fonts 导入 URL",
|
||||||
|
"urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
|
||||||
|
"urlHelp": "从 Google Fonts 获取:选择字体 → 点击 \"Get font\" → 复制 @import URL",
|
||||||
|
"nameLabel": "显示名称",
|
||||||
|
"namePlaceholder": "我的自定义字体",
|
||||||
|
"nameHelp": "这是字体在字体选择器中显示的名称",
|
||||||
|
"addButton": "添加字体",
|
||||||
|
"addingButton": "添加中...",
|
||||||
|
"errorEmptyUrl": "请输入 Google Fonts 导入 URL",
|
||||||
|
"errorInvalidUrl": "请输入有效的 Google Fonts URL",
|
||||||
|
"errorEmptyName": "请输入字体名称",
|
||||||
|
"errorExtractFailed": "无法从 URL 中提取字体系列",
|
||||||
|
"successMessage": "字体 \"{{fontName}}\" 添加成功",
|
||||||
|
"failedToAdd": "添加字体失败",
|
||||||
|
"errorTimeout": "字体加载时间过长。请检查 URL 并重试。",
|
||||||
|
"errorLoadFailed": "无法加载该字体。请确认 Google Fonts URL 是否正确。"
|
||||||
|
},
|
||||||
|
"language": {
|
||||||
|
"title": "语言"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"title": "键盘快捷键",
|
||||||
|
"customize": "自定义",
|
||||||
|
"configurable": "可配置",
|
||||||
|
"fixed": "固定",
|
||||||
|
"pressKey": "请按下按键…",
|
||||||
|
"clickToChange": "点击以更改",
|
||||||
|
"pressEscToCancel": "按 Esc 取消",
|
||||||
|
"helpText": "点击一个快捷键,然后按下新的组合键。按 Esc 取消。",
|
||||||
|
"resetToDefaults": "恢复默认设置",
|
||||||
|
"alreadyUsedBy": "已被 \"{{action}}\" 使用",
|
||||||
|
"swap": "交换",
|
||||||
|
"reservedShortcut": "此快捷键已保留给 \"{{label}}\",无法重新分配。",
|
||||||
|
"savedToast": "键盘快捷键已保存",
|
||||||
|
"resetToast": "已恢复默认快捷键 — 点击保存以应用",
|
||||||
|
"actions": {
|
||||||
|
"addZoom": "添加缩放",
|
||||||
|
"addTrim": "添加剪辑",
|
||||||
|
"addSpeed": "添加速度",
|
||||||
|
"addAnnotation": "添加标注",
|
||||||
|
"addKeyframe": "添加关键帧",
|
||||||
|
"deleteSelected": "删除所选",
|
||||||
|
"playPause": "播放 / 暂停"
|
||||||
|
},
|
||||||
|
"fixedActions": {
|
||||||
|
"undo": "撤销",
|
||||||
|
"redo": "重做",
|
||||||
|
"cycleAnnotationsForward": "向前切换标注",
|
||||||
|
"cycleAnnotationsBackward": "向后切换标注",
|
||||||
|
"deleteSelectedAlt": "删除所选(替代)",
|
||||||
|
"panTimeline": "平移时间轴",
|
||||||
|
"zoomTimeline": "缩放时间轴",
|
||||||
|
"frameBack": "上一帧",
|
||||||
|
"frameForward": "下一帧"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"buttons": {
|
||||||
|
"addZoom": "添加缩放 (Z)",
|
||||||
|
"suggestZooms": "根据光标建议缩放",
|
||||||
|
"addTrim": "添加剪辑 (T)",
|
||||||
|
"addAnnotation": "添加标注 (A)",
|
||||||
|
"addSpeed": "添加速度 (S)"
|
||||||
|
},
|
||||||
|
"hints": {
|
||||||
|
"pressZoom": "按 Z 添加缩放",
|
||||||
|
"pressTrim": "按 T 添加剪辑",
|
||||||
|
"pressAnnotation": "按 A 添加标注",
|
||||||
|
"pressSpeed": "按 S 添加速度"
|
||||||
|
},
|
||||||
|
"labels": {
|
||||||
|
"pan": "平移",
|
||||||
|
"zoom": "缩放",
|
||||||
|
"zoomItem": "缩放 {{index}}",
|
||||||
|
"trimItem": "剪辑 {{index}}",
|
||||||
|
"speedItem": "速度 {{index}}",
|
||||||
|
"annotationItem": "标注",
|
||||||
|
"imageItem": "图片",
|
||||||
|
"emptyText": "空文本"
|
||||||
|
},
|
||||||
|
"emptyState": {
|
||||||
|
"noVideo": "未加载视频",
|
||||||
|
"dragAndDrop": "拖放视频以开始编辑"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"cannotPlaceZoom": "无法在此处放置缩放",
|
||||||
|
"zoomExistsAtLocation": "此位置已存在缩放或没有足够的空间。",
|
||||||
|
"zoomSuggestionUnavailable": "缩放建议处理器不可用",
|
||||||
|
"noCursorTelemetry": "无可用的光标遥测数据",
|
||||||
|
"noCursorTelemetryDescription": "请先录制一段屏幕录像以生成基于光标的建议。",
|
||||||
|
"noUsableTelemetry": "无可用的光标遥测数据",
|
||||||
|
"noUsableTelemetryDescription": "录制内容没有包含足够的光标移动数据。",
|
||||||
|
"noDwellMoments": "未找到明确的光标停留时刻",
|
||||||
|
"noDwellMomentsDescription": "请尝试在重要操作上进行较慢光标停留的录制。",
|
||||||
|
"noAutoZoomSlots": "无可用的自动缩放位置",
|
||||||
|
"noAutoZoomSlotsDescription": "检测到的停留点与现有缩放区域重叠。",
|
||||||
|
"cannotPlaceTrim": "无法在此处放置剪辑",
|
||||||
|
"trimExistsAtLocation": "此位置已存在剪辑或没有足够的空间。",
|
||||||
|
"cannotPlaceSpeed": "无法在此处放置速度",
|
||||||
|
"speedExistsAtLocation": "此位置已存在速度区域或没有足够的空间。"
|
||||||
|
},
|
||||||
|
"success": {
|
||||||
|
"addedZoomSuggestions": "已添加 {{count}} 个基于光标的缩放建议",
|
||||||
|
"addedZoomSuggestionsPlural": "已添加 {{count}} 个基于光标的缩放建议"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { computeCompositeLayout } from "./compositeLayout";
|
||||||
|
|
||||||
|
describe("computeCompositeLayout", () => {
|
||||||
|
it("anchors the overlay in the lower-right corner", () => {
|
||||||
|
const layout = computeCompositeLayout({
|
||||||
|
canvasSize: { width: 1920, height: 1080 },
|
||||||
|
screenSize: { width: 1920, height: 1080 },
|
||||||
|
webcamSize: { width: 1280, height: 720 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(layout).not.toBeNull();
|
||||||
|
expect(layout!.webcamRect).not.toBeNull();
|
||||||
|
expect(layout!.webcamRect!.x + layout!.webcamRect!.width).toBeLessThanOrEqual(1920);
|
||||||
|
expect(layout!.webcamRect!.y + layout!.webcamRect!.height).toBeLessThanOrEqual(1080);
|
||||||
|
expect(layout!.webcamRect!.x).toBeGreaterThan(1920 / 2);
|
||||||
|
expect(layout!.webcamRect!.y).toBeGreaterThan(1080 / 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the overlay within the configured stage fraction while preserving aspect ratio", () => {
|
||||||
|
const layout = computeCompositeLayout({
|
||||||
|
canvasSize: { width: 1280, height: 720 },
|
||||||
|
screenSize: { width: 1280, height: 720 },
|
||||||
|
webcamSize: { width: 1920, height: 1080 },
|
||||||
|
});
|
||||||
|
|
||||||
|
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(
|
||||||
|
Math.abs(layout!.webcamRect!.width * 1080 - layout!.webcamRect!.height * 1920),
|
||||||
|
).toBeLessThanOrEqual(1920);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses cover-style full-width stacking in vertical stack mode", () => {
|
||||||
|
const layout = computeCompositeLayout({
|
||||||
|
canvasSize: { width: 1920, height: 1080 },
|
||||||
|
maxContentSize: { width: 1536, height: 864 },
|
||||||
|
screenSize: { width: 1920, height: 1080 },
|
||||||
|
webcamSize: { width: 1280, height: 720 },
|
||||||
|
layoutPreset: "vertical-stack",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(layout).not.toBeNull();
|
||||||
|
expect(layout?.screenRect).toEqual({
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 1920,
|
||||||
|
height: 0,
|
||||||
|
});
|
||||||
|
expect(layout?.webcamRect).toEqual({
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
borderRadius: 0,
|
||||||
|
});
|
||||||
|
expect(layout?.screenCover).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fills the canvas with the screen when vertical stack has no webcam", () => {
|
||||||
|
const layout = computeCompositeLayout({
|
||||||
|
canvasSize: { width: 1920, height: 1080 },
|
||||||
|
maxContentSize: { width: 1536, height: 864 },
|
||||||
|
screenSize: { width: 1920, height: 1080 },
|
||||||
|
layoutPreset: "vertical-stack",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(layout).not.toBeNull();
|
||||||
|
expect(layout?.screenRect).toEqual({
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
export interface RenderRect {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StyledRenderRect extends RenderRect {
|
||||||
|
borderRadius: number;
|
||||||
|
maskShape?: import("@/components/video-editor/types").WebcamMaskShape;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Size {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WebcamLayoutPreset = "picture-in-picture" | "vertical-stack";
|
||||||
|
|
||||||
|
export interface WebcamLayoutShadow {
|
||||||
|
color: string;
|
||||||
|
blur: number;
|
||||||
|
offsetX: number;
|
||||||
|
offsetY: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BorderRadiusRule {
|
||||||
|
max: number;
|
||||||
|
min: number;
|
||||||
|
fraction: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OverlayTransform {
|
||||||
|
type: "overlay";
|
||||||
|
maxStageFraction: number;
|
||||||
|
marginFraction: number;
|
||||||
|
minMargin: number;
|
||||||
|
minSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StackTransform {
|
||||||
|
type: "stack";
|
||||||
|
gap: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WebcamLayoutPresetDefinition {
|
||||||
|
label: string;
|
||||||
|
transform: OverlayTransform | StackTransform;
|
||||||
|
borderRadius: BorderRadiusRule;
|
||||||
|
shadow: WebcamLayoutShadow | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WebcamCompositeLayout {
|
||||||
|
screenRect: RenderRect;
|
||||||
|
webcamRect: StyledRenderRect | null;
|
||||||
|
/** When true, the video should be scaled to cover screenRect (cropping overflow). */
|
||||||
|
screenCover?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_STAGE_FRACTION = 0.18;
|
||||||
|
const MARGIN_FRACTION = 0.02;
|
||||||
|
const MAX_BORDER_RADIUS = 24;
|
||||||
|
const WEBCAM_LAYOUT_PRESET_MAP: Record<WebcamLayoutPreset, WebcamLayoutPresetDefinition> = {
|
||||||
|
"picture-in-picture": {
|
||||||
|
label: "Picture in Picture",
|
||||||
|
transform: {
|
||||||
|
type: "overlay",
|
||||||
|
maxStageFraction: MAX_STAGE_FRACTION,
|
||||||
|
marginFraction: MARGIN_FRACTION,
|
||||||
|
minMargin: 0,
|
||||||
|
minSize: 0,
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
max: MAX_BORDER_RADIUS,
|
||||||
|
min: 12,
|
||||||
|
fraction: 0.12,
|
||||||
|
},
|
||||||
|
shadow: {
|
||||||
|
color: "rgba(0,0,0,0.35)",
|
||||||
|
blur: 24,
|
||||||
|
offsetX: 0,
|
||||||
|
offsetY: 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"vertical-stack": {
|
||||||
|
label: "Vertical Stack",
|
||||||
|
transform: {
|
||||||
|
type: "stack",
|
||||||
|
gap: 0,
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
max: 0,
|
||||||
|
min: 0,
|
||||||
|
fraction: 0,
|
||||||
|
},
|
||||||
|
shadow: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WEBCAM_LAYOUT_PRESETS = Object.entries(WEBCAM_LAYOUT_PRESET_MAP).map(
|
||||||
|
([value, preset]) => ({
|
||||||
|
value: value as WebcamLayoutPreset,
|
||||||
|
label: preset.label,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export function getWebcamLayoutPresetDefinition(
|
||||||
|
preset: WebcamLayoutPreset = "picture-in-picture",
|
||||||
|
): WebcamLayoutPresetDefinition {
|
||||||
|
return WEBCAM_LAYOUT_PRESET_MAP[preset];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWebcamLayoutCssBoxShadow(
|
||||||
|
preset: WebcamLayoutPreset = "picture-in-picture",
|
||||||
|
): string {
|
||||||
|
const shadow = getWebcamLayoutPresetDefinition(preset).shadow;
|
||||||
|
return shadow
|
||||||
|
? `${shadow.offsetX}px ${shadow.offsetY}px ${shadow.blur}px ${shadow.color}`
|
||||||
|
: "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeCompositeLayout(params: {
|
||||||
|
canvasSize: Size;
|
||||||
|
maxContentSize?: Size;
|
||||||
|
screenSize: Size;
|
||||||
|
webcamSize?: Size | null;
|
||||||
|
layoutPreset?: WebcamLayoutPreset;
|
||||||
|
webcamPosition?: { cx: number; cy: number } | null;
|
||||||
|
webcamMaskShape?: import("@/components/video-editor/types").WebcamMaskShape;
|
||||||
|
}): WebcamCompositeLayout | null {
|
||||||
|
const {
|
||||||
|
canvasSize,
|
||||||
|
maxContentSize = canvasSize,
|
||||||
|
screenSize,
|
||||||
|
webcamSize,
|
||||||
|
layoutPreset = "picture-in-picture",
|
||||||
|
webcamPosition,
|
||||||
|
webcamMaskShape = "rectangle",
|
||||||
|
} = params;
|
||||||
|
const { width: canvasWidth, height: canvasHeight } = canvasSize;
|
||||||
|
const { width: screenWidth, height: screenHeight } = screenSize;
|
||||||
|
const webcamWidth = webcamSize?.width;
|
||||||
|
const webcamHeight = webcamSize?.height;
|
||||||
|
const preset = getWebcamLayoutPresetDefinition(layoutPreset);
|
||||||
|
|
||||||
|
if (canvasWidth <= 0 || canvasHeight <= 0 || screenWidth <= 0 || screenHeight <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preset.transform.type === "stack") {
|
||||||
|
if (!webcamWidth || !webcamHeight || webcamWidth <= 0 || webcamHeight <= 0) {
|
||||||
|
// No webcam — screen fills the entire canvas (cover mode)
|
||||||
|
return {
|
||||||
|
screenRect: { x: 0, y: 0, width: canvasWidth, height: canvasHeight },
|
||||||
|
webcamRect: null,
|
||||||
|
screenCover: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Webcam: full width at the bottom, maintaining its aspect ratio
|
||||||
|
const webcamAspect = webcamWidth / webcamHeight;
|
||||||
|
const resolvedWebcamWidth = canvasWidth;
|
||||||
|
const resolvedWebcamHeight = Math.round(canvasWidth / webcamAspect);
|
||||||
|
|
||||||
|
// Screen: fills remaining space at the top (cover mode — may crop sides)
|
||||||
|
const screenRectHeight = canvasHeight - resolvedWebcamHeight;
|
||||||
|
|
||||||
|
return {
|
||||||
|
screenRect: {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: canvasWidth,
|
||||||
|
height: Math.max(0, screenRectHeight),
|
||||||
|
},
|
||||||
|
webcamRect: {
|
||||||
|
x: 0,
|
||||||
|
y: Math.max(0, screenRectHeight),
|
||||||
|
width: resolvedWebcamWidth,
|
||||||
|
height: resolvedWebcamHeight,
|
||||||
|
borderRadius: 0,
|
||||||
|
},
|
||||||
|
screenCover: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const transform = preset.transform;
|
||||||
|
const screenRect = centerRect({
|
||||||
|
canvasSize,
|
||||||
|
size: screenSize,
|
||||||
|
maxSize: maxContentSize,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!webcamWidth || !webcamHeight || webcamWidth <= 0 || webcamHeight <= 0) {
|
||||||
|
return { screenRect, webcamRect: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const margin = Math.max(
|
||||||
|
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);
|
||||||
|
const scale = Math.min(maxWidth / webcamWidth, maxHeight / webcamHeight);
|
||||||
|
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;
|
||||||
|
|
||||||
|
if (webcamPosition) {
|
||||||
|
// Custom position: cx/cy represent the center of the webcam as a fraction of the canvas
|
||||||
|
webcamX = Math.round(webcamPosition.cx * canvasWidth - width / 2);
|
||||||
|
webcamY = Math.round(webcamPosition.cy * canvasHeight - height / 2);
|
||||||
|
// Clamp to stay within canvas bounds
|
||||||
|
webcamX = Math.max(0, Math.min(canvasWidth - width, webcamX));
|
||||||
|
webcamY = Math.max(0, Math.min(canvasHeight - height, webcamY));
|
||||||
|
} else {
|
||||||
|
// Default: bottom-right with margin
|
||||||
|
webcamX = Math.max(0, Math.round(canvasWidth - margin - width));
|
||||||
|
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: {
|
||||||
|
x: webcamX,
|
||||||
|
y: webcamY,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
borderRadius,
|
||||||
|
maskShape: webcamMaskShape,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function centerRect(params: { canvasSize: Size; size: Size; maxSize: Size }): RenderRect {
|
||||||
|
const { canvasSize, size, maxSize } = params;
|
||||||
|
const { width: canvasWidth, height: canvasHeight } = canvasSize;
|
||||||
|
const { width, height } = size;
|
||||||
|
const { width: maxWidth, height: maxHeight } = maxSize;
|
||||||
|
const scale = Math.min(maxWidth / width, maxHeight / height, 1);
|
||||||
|
const resolvedWidth = Math.round(width * scale);
|
||||||
|
const resolvedHeight = Math.round(height * scale);
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: Math.max(0, Math.floor((canvasWidth - resolvedWidth) / 2)),
|
||||||
|
y: Math.max(0, Math.floor((canvasHeight - resolvedHeight) / 2)),
|
||||||
|
width: resolvedWidth,
|
||||||
|
height: resolvedHeight,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -12,15 +12,23 @@ import type {
|
|||||||
AnnotationRegion,
|
AnnotationRegion,
|
||||||
CropRegion,
|
CropRegion,
|
||||||
SpeedRegion,
|
SpeedRegion,
|
||||||
|
WebcamLayoutPreset,
|
||||||
ZoomDepth,
|
ZoomDepth,
|
||||||
ZoomRegion,
|
ZoomRegion,
|
||||||
} from "@/components/video-editor/types";
|
} from "@/components/video-editor/types";
|
||||||
import { ZOOM_DEPTH_SCALES } from "@/components/video-editor/types";
|
import { ZOOM_DEPTH_SCALES } from "@/components/video-editor/types";
|
||||||
import {
|
import {
|
||||||
|
AUTO_FOLLOW_RAMP_DISTANCE,
|
||||||
|
AUTO_FOLLOW_SMOOTHING_FACTOR,
|
||||||
|
AUTO_FOLLOW_SMOOTHING_FACTOR_MAX,
|
||||||
DEFAULT_FOCUS,
|
DEFAULT_FOCUS,
|
||||||
ZOOM_SCALE_DEADZONE,
|
ZOOM_SCALE_DEADZONE,
|
||||||
ZOOM_TRANSLATION_DEADZONE_PX,
|
ZOOM_TRANSLATION_DEADZONE_PX,
|
||||||
} from "@/components/video-editor/videoPlayback/constants";
|
} 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 { clampFocusToStage as clampFocusToStageUtil } from "@/components/video-editor/videoPlayback/focusUtils";
|
||||||
import { findDominantRegion } from "@/components/video-editor/videoPlayback/zoomRegionUtils";
|
import { findDominantRegion } from "@/components/video-editor/videoPlayback/zoomRegionUtils";
|
||||||
import {
|
import {
|
||||||
@@ -30,8 +38,20 @@ import {
|
|||||||
createMotionBlurState,
|
createMotionBlurState,
|
||||||
type MotionBlurState,
|
type MotionBlurState,
|
||||||
} from "@/components/video-editor/videoPlayback/zoomTransform";
|
} from "@/components/video-editor/videoPlayback/zoomTransform";
|
||||||
import { computeWebcamOverlayLayout } from "@/lib/webcamOverlay";
|
import {
|
||||||
|
computeCompositeLayout,
|
||||||
|
getWebcamLayoutPresetDefinition,
|
||||||
|
type Size,
|
||||||
|
type StyledRenderRect,
|
||||||
|
} from "@/lib/compositeLayout";
|
||||||
|
import { drawCanvasClipPath } from "@/lib/webcamMaskShapes";
|
||||||
import { renderAnnotations } from "./annotationRenderer";
|
import { renderAnnotations } from "./annotationRenderer";
|
||||||
|
import {
|
||||||
|
getLinearGradientPoints,
|
||||||
|
getRadialGradientShape,
|
||||||
|
parseCssGradient,
|
||||||
|
resolveLinearGradientAngle,
|
||||||
|
} from "./gradientParser";
|
||||||
|
|
||||||
interface FrameRenderConfig {
|
interface FrameRenderConfig {
|
||||||
width: number;
|
width: number;
|
||||||
@@ -47,12 +67,15 @@ interface FrameRenderConfig {
|
|||||||
cropRegion: CropRegion;
|
cropRegion: CropRegion;
|
||||||
videoWidth: number;
|
videoWidth: number;
|
||||||
videoHeight: number;
|
videoHeight: number;
|
||||||
webcamWidth?: number;
|
webcamSize?: Size | null;
|
||||||
webcamHeight?: number;
|
webcamLayoutPreset?: WebcamLayoutPreset;
|
||||||
|
webcamMaskShape?: import("@/components/video-editor/types").WebcamMaskShape;
|
||||||
|
webcamPosition?: { cx: number; cy: number } | null;
|
||||||
annotationRegions?: AnnotationRegion[];
|
annotationRegions?: AnnotationRegion[];
|
||||||
speedRegions?: SpeedRegion[];
|
speedRegions?: SpeedRegion[];
|
||||||
previewWidth?: number;
|
previewWidth?: number;
|
||||||
previewHeight?: number;
|
previewHeight?: number;
|
||||||
|
cursorTelemetry?: import("@/components/video-editor/types").CursorTelemetryPoint[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AnimationState {
|
interface AnimationState {
|
||||||
@@ -71,6 +94,7 @@ interface LayoutCache {
|
|||||||
baseScale: number;
|
baseScale: number;
|
||||||
baseOffset: { x: number; y: number };
|
baseOffset: { x: number; y: number };
|
||||||
maskRect: { x: number; y: number; width: number; height: number };
|
maskRect: { x: number; y: number; width: number; height: number };
|
||||||
|
webcamRect: StyledRenderRect | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Renders video frames with all effects (background, zoom, crop, blur, shadow) to an offscreen canvas for export.
|
// Renders video frames with all effects (background, zoom, crop, blur, shadow) to an offscreen canvas for export.
|
||||||
@@ -93,6 +117,9 @@ export class FrameRenderer {
|
|||||||
private layoutCache: LayoutCache | null = null;
|
private layoutCache: LayoutCache | null = null;
|
||||||
private currentVideoTime = 0;
|
private currentVideoTime = 0;
|
||||||
private motionBlurState: MotionBlurState = createMotionBlurState();
|
private motionBlurState: MotionBlurState = createMotionBlurState();
|
||||||
|
private smoothedAutoFocus: { cx: number; cy: number } | null = null;
|
||||||
|
private prevAnimationTimeMs: number | null = null;
|
||||||
|
private prevTargetProgress = 0;
|
||||||
|
|
||||||
constructor(config: FrameRenderConfig) {
|
constructor(config: FrameRenderConfig) {
|
||||||
this.config = config;
|
this.config = config;
|
||||||
@@ -156,7 +183,9 @@ export class FrameRenderer {
|
|||||||
this.compositeCanvas = document.createElement("canvas");
|
this.compositeCanvas = document.createElement("canvas");
|
||||||
this.compositeCanvas.width = this.config.width;
|
this.compositeCanvas.width = this.config.width;
|
||||||
this.compositeCanvas.height = this.config.height;
|
this.compositeCanvas.height = this.config.height;
|
||||||
this.compositeCtx = this.compositeCanvas.getContext("2d", { willReadFrequently: false });
|
this.compositeCtx = this.compositeCanvas.getContext("2d", {
|
||||||
|
willReadFrequently: false,
|
||||||
|
});
|
||||||
|
|
||||||
if (!this.compositeCtx) {
|
if (!this.compositeCtx) {
|
||||||
throw new Error("Failed to get 2D context for composite canvas");
|
throw new Error("Failed to get 2D context for composite canvas");
|
||||||
@@ -167,7 +196,9 @@ export class FrameRenderer {
|
|||||||
this.shadowCanvas = document.createElement("canvas");
|
this.shadowCanvas = document.createElement("canvas");
|
||||||
this.shadowCanvas.width = this.config.width;
|
this.shadowCanvas.width = this.config.width;
|
||||||
this.shadowCanvas.height = this.config.height;
|
this.shadowCanvas.height = this.config.height;
|
||||||
this.shadowCtx = this.shadowCanvas.getContext("2d", { willReadFrequently: false });
|
this.shadowCtx = this.shadowCanvas.getContext("2d", {
|
||||||
|
willReadFrequently: false,
|
||||||
|
});
|
||||||
|
|
||||||
if (!this.shadowCtx) {
|
if (!this.shadowCtx) {
|
||||||
throw new Error("Failed to get 2D context for shadow canvas");
|
throw new Error("Failed to get 2D context for shadow canvas");
|
||||||
@@ -248,40 +279,39 @@ export class FrameRenderer {
|
|||||||
wallpaper.startsWith("linear-gradient") ||
|
wallpaper.startsWith("linear-gradient") ||
|
||||||
wallpaper.startsWith("radial-gradient")
|
wallpaper.startsWith("radial-gradient")
|
||||||
) {
|
) {
|
||||||
const gradientMatch = wallpaper.match(/(linear|radial)-gradient\((.+)\)/);
|
const parsedGradient = parseCssGradient(wallpaper);
|
||||||
if (gradientMatch) {
|
if (parsedGradient) {
|
||||||
const [, type, params] = gradientMatch;
|
const gradient =
|
||||||
const parts = params.split(",").map((s) => s.trim());
|
parsedGradient.type === "linear"
|
||||||
|
? (() => {
|
||||||
|
const points = getLinearGradientPoints(
|
||||||
|
resolveLinearGradientAngle(parsedGradient.descriptor),
|
||||||
|
this.config.width,
|
||||||
|
this.config.height,
|
||||||
|
);
|
||||||
|
|
||||||
let gradient: CanvasGradient;
|
return bgCtx.createLinearGradient(points.x0, points.y0, points.x1, points.y1);
|
||||||
|
})()
|
||||||
|
: (() => {
|
||||||
|
const shape = getRadialGradientShape(
|
||||||
|
parsedGradient.descriptor,
|
||||||
|
this.config.width,
|
||||||
|
this.config.height,
|
||||||
|
);
|
||||||
|
|
||||||
if (type === "linear") {
|
return bgCtx.createRadialGradient(
|
||||||
gradient = bgCtx.createLinearGradient(0, 0, 0, this.config.height);
|
shape.cx,
|
||||||
parts.forEach((part, index) => {
|
shape.cy,
|
||||||
if (part.startsWith("to ") || part.includes("deg")) return;
|
0,
|
||||||
|
shape.cx,
|
||||||
|
shape.cy,
|
||||||
|
shape.radius,
|
||||||
|
);
|
||||||
|
})();
|
||||||
|
|
||||||
const colorMatch = part.match(/^(#[0-9a-fA-F]{3,8}|rgba?\([^)]+\)|[a-z]+)/);
|
parsedGradient.stops.forEach((stop) => {
|
||||||
if (colorMatch) {
|
gradient.addColorStop(stop.offset, stop.color);
|
||||||
const color = colorMatch[1];
|
});
|
||||||
const position = index / (parts.length - 1);
|
|
||||||
gradient.addColorStop(position, color);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const cx = this.config.width / 2;
|
|
||||||
const cy = this.config.height / 2;
|
|
||||||
const radius = Math.max(this.config.width, this.config.height) / 2;
|
|
||||||
gradient = bgCtx.createRadialGradient(cx, cy, 0, cx, cy, radius);
|
|
||||||
|
|
||||||
parts.forEach((part, index) => {
|
|
||||||
const colorMatch = part.match(/^(#[0-9a-fA-F]{3,8}|rgba?\([^)]+\)|[a-z]+)/);
|
|
||||||
if (colorMatch) {
|
|
||||||
const color = colorMatch[1];
|
|
||||||
const position = index / (parts.length - 1);
|
|
||||||
gradient.addColorStop(position, color);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
bgCtx.fillStyle = gradient;
|
bgCtx.fillStyle = gradient;
|
||||||
bgCtx.fillRect(0, 0, this.config.width, this.config.height);
|
bgCtx.fillRect(0, 0, this.config.width, this.config.height);
|
||||||
@@ -329,7 +359,7 @@ export class FrameRenderer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Apply layout
|
// Apply layout
|
||||||
this.updateLayout();
|
this.updateLayout(webcamFrame);
|
||||||
|
|
||||||
const timeMs = this.currentVideoTime * 1000;
|
const timeMs = this.currentVideoTime * 1000;
|
||||||
const TICKS_PER_FRAME = 1;
|
const TICKS_PER_FRAME = 1;
|
||||||
@@ -393,7 +423,7 @@ export class FrameRenderer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateLayout(): void {
|
private updateLayout(webcamFrame?: VideoFrame | null): void {
|
||||||
if (!this.app || !this.videoSprite || !this.maskGraphics || !this.videoContainer) return;
|
if (!this.app || !this.videoSprite || !this.maskGraphics || !this.videoContainer) return;
|
||||||
|
|
||||||
const { width, height } = this.config;
|
const { width, height } = this.config;
|
||||||
@@ -412,42 +442,62 @@ export class FrameRenderer {
|
|||||||
|
|
||||||
// Calculate scale to fit in viewport
|
// Calculate scale to fit in viewport
|
||||||
// Padding is a percentage (0-100), where 50% ~ 0.8 scale
|
// Padding is a percentage (0-100), where 50% ~ 0.8 scale
|
||||||
const paddingScale = 1.0 - (padding / 100) * 0.4;
|
// Vertical stack ignores padding — it's full-bleed
|
||||||
|
const effectivePadding = this.config.webcamLayoutPreset === "vertical-stack" ? 0 : padding;
|
||||||
|
const paddingScale = 1.0 - (effectivePadding / 100) * 0.4;
|
||||||
const viewportWidth = width * paddingScale;
|
const viewportWidth = width * paddingScale;
|
||||||
const viewportHeight = height * paddingScale;
|
const viewportHeight = height * paddingScale;
|
||||||
const scale = Math.min(viewportWidth / croppedVideoWidth, viewportHeight / croppedVideoHeight);
|
const compositeLayout = computeCompositeLayout({
|
||||||
|
canvasSize: { width, height },
|
||||||
|
maxContentSize: { width: viewportWidth, height: viewportHeight },
|
||||||
|
screenSize: { width: croppedVideoWidth, height: croppedVideoHeight },
|
||||||
|
webcamSize: webcamFrame ? this.config.webcamSize : null,
|
||||||
|
layoutPreset: this.config.webcamLayoutPreset,
|
||||||
|
webcamPosition: this.config.webcamPosition,
|
||||||
|
webcamMaskShape: this.config.webcamMaskShape,
|
||||||
|
});
|
||||||
|
if (!compositeLayout) return;
|
||||||
|
|
||||||
|
const screenRect = compositeLayout.screenRect;
|
||||||
|
|
||||||
|
// Cover mode: scale to fill the rect (may crop), otherwise fit-to-width
|
||||||
|
let scale: number;
|
||||||
|
if (compositeLayout.screenCover) {
|
||||||
|
scale = Math.max(
|
||||||
|
screenRect.width / croppedVideoWidth,
|
||||||
|
screenRect.height / croppedVideoHeight,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
scale = screenRect.width / croppedVideoWidth;
|
||||||
|
}
|
||||||
|
|
||||||
// Position video sprite
|
// Position video sprite
|
||||||
this.videoSprite.width = videoWidth * scale;
|
this.videoSprite.width = videoWidth * scale;
|
||||||
this.videoSprite.height = videoHeight * scale;
|
this.videoSprite.height = videoHeight * scale;
|
||||||
|
|
||||||
const cropPixelX = cropStartX * videoWidth * scale;
|
// Center the cropped region within the screenRect
|
||||||
const cropPixelY = cropStartY * videoHeight * scale;
|
|
||||||
this.videoSprite.x = -cropPixelX;
|
|
||||||
this.videoSprite.y = -cropPixelY;
|
|
||||||
|
|
||||||
// Position video container
|
|
||||||
const croppedDisplayWidth = croppedVideoWidth * scale;
|
const croppedDisplayWidth = croppedVideoWidth * scale;
|
||||||
const croppedDisplayHeight = croppedVideoHeight * scale;
|
const croppedDisplayHeight = croppedVideoHeight * scale;
|
||||||
const centerOffsetX = (width - croppedDisplayWidth) / 2;
|
const coverOffsetX = (screenRect.width - croppedDisplayWidth) / 2;
|
||||||
const centerOffsetY = (height - croppedDisplayHeight) / 2;
|
const coverOffsetY = (screenRect.height - croppedDisplayHeight) / 2;
|
||||||
this.videoContainer.x = centerOffsetX;
|
|
||||||
this.videoContainer.y = centerOffsetY;
|
const cropPixelX = cropStartX * videoWidth * scale;
|
||||||
|
const cropPixelY = cropStartY * videoHeight * scale;
|
||||||
|
this.videoSprite.x = -cropPixelX + coverOffsetX;
|
||||||
|
this.videoSprite.y = -cropPixelY + coverOffsetY;
|
||||||
|
|
||||||
|
// Position video container
|
||||||
|
this.videoContainer.x = screenRect.x;
|
||||||
|
this.videoContainer.y = screenRect.y;
|
||||||
|
|
||||||
// scale border radius by export/preview canvas ratio
|
// scale border radius by export/preview canvas ratio
|
||||||
const previewWidth = this.config.previewWidth || 1920;
|
const previewWidth = this.config.previewWidth || 1920;
|
||||||
const previewHeight = this.config.previewHeight || 1080;
|
const previewHeight = this.config.previewHeight || 1080;
|
||||||
const canvasScaleFactor = Math.min(width / previewWidth, height / previewHeight);
|
const canvasScaleFactor = Math.min(width / previewWidth, height / previewHeight);
|
||||||
const scaledBorderRadius = borderRadius * canvasScaleFactor;
|
const scaledBorderRadius = compositeLayout.screenCover ? 0 : borderRadius * canvasScaleFactor;
|
||||||
|
|
||||||
this.maskGraphics.clear();
|
this.maskGraphics.clear();
|
||||||
this.maskGraphics.roundRect(
|
this.maskGraphics.roundRect(0, 0, screenRect.width, screenRect.height, scaledBorderRadius);
|
||||||
0,
|
|
||||||
0,
|
|
||||||
croppedDisplayWidth,
|
|
||||||
croppedDisplayHeight,
|
|
||||||
scaledBorderRadius,
|
|
||||||
);
|
|
||||||
this.maskGraphics.fill({ color: 0xffffff });
|
this.maskGraphics.fill({ color: 0xffffff });
|
||||||
|
|
||||||
// Cache layout info
|
// Cache layout info
|
||||||
@@ -455,8 +505,9 @@ export class FrameRenderer {
|
|||||||
stageSize: { width, height },
|
stageSize: { width, height },
|
||||||
videoSize: { width: croppedVideoWidth, height: croppedVideoHeight },
|
videoSize: { width: croppedVideoWidth, height: croppedVideoHeight },
|
||||||
baseScale: scale,
|
baseScale: scale,
|
||||||
baseOffset: { x: centerOffsetX, y: centerOffsetY },
|
baseOffset: { x: compositeLayout.screenRect.x, y: compositeLayout.screenRect.y },
|
||||||
maskRect: { x: 0, y: 0, width: croppedDisplayWidth, height: croppedDisplayHeight },
|
maskRect: compositeLayout.screenRect,
|
||||||
|
webcamRect: compositeLayout.webcamRect,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -471,10 +522,16 @@ export class FrameRenderer {
|
|||||||
private updateAnimationState(timeMs: number): number {
|
private updateAnimationState(timeMs: number): number {
|
||||||
if (!this.cameraContainer || !this.layoutCache) return 0;
|
if (!this.cameraContainer || !this.layoutCache) return 0;
|
||||||
|
|
||||||
|
const 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(
|
const { region, strength, blendedScale, transition } = findDominantRegion(
|
||||||
this.config.zoomRegions,
|
this.config.zoomRegions,
|
||||||
timeMs,
|
timeMs,
|
||||||
{ connectZooms: true },
|
{ connectZooms: true, cursorTelemetry: this.config.cursorTelemetry, viewportRatio },
|
||||||
);
|
);
|
||||||
|
|
||||||
const defaultFocus = DEFAULT_FOCUS;
|
const defaultFocus = DEFAULT_FOCUS;
|
||||||
@@ -490,6 +547,50 @@ export class FrameRenderer {
|
|||||||
targetFocus = regionFocus;
|
targetFocus = regionFocus;
|
||||||
targetProgress = strength;
|
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) {
|
if (transition) {
|
||||||
const startTransform = computeZoomTransform({
|
const startTransform = computeZoomTransform({
|
||||||
stageSize: this.layoutCache.stageSize,
|
stageSize: this.layoutCache.stageSize,
|
||||||
@@ -565,6 +666,8 @@ export class FrameRenderer {
|
|||||||
state.y = appliedY;
|
state.y = appliedY;
|
||||||
state.appliedScale = appliedScale;
|
state.appliedScale = appliedScale;
|
||||||
|
|
||||||
|
this.prevAnimationTimeMs = timeMs;
|
||||||
|
|
||||||
return Math.max(
|
return Math.max(
|
||||||
Math.abs(appliedScale - prevScale),
|
Math.abs(appliedScale - prevScale),
|
||||||
Math.abs(appliedX - prevX) / Math.max(1, this.layoutCache.stageSize.width),
|
Math.abs(appliedX - prevX) / Math.max(1, this.layoutCache.stageSize.width),
|
||||||
@@ -628,34 +731,37 @@ export class FrameRenderer {
|
|||||||
ctx.drawImage(videoCanvas, 0, 0, w, h);
|
ctx.drawImage(videoCanvas, 0, 0, w, h);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (webcamFrame && this.config.webcamWidth && this.config.webcamHeight) {
|
const webcamRect = this.layoutCache?.webcamRect ?? null;
|
||||||
const layout = computeWebcamOverlayLayout({
|
if (webcamFrame && webcamRect) {
|
||||||
stageWidth: w,
|
const preset = getWebcamLayoutPresetDefinition(this.config.webcamLayoutPreset);
|
||||||
stageHeight: h,
|
const shape = webcamRect.maskShape ?? this.config.webcamMaskShape ?? "rectangle";
|
||||||
videoWidth: this.config.webcamWidth,
|
ctx.save();
|
||||||
videoHeight: this.config.webcamHeight,
|
drawCanvasClipPath(
|
||||||
});
|
ctx,
|
||||||
|
webcamRect.x,
|
||||||
if (layout) {
|
webcamRect.y,
|
||||||
ctx.save();
|
webcamRect.width,
|
||||||
ctx.beginPath();
|
webcamRect.height,
|
||||||
ctx.roundRect(layout.x, layout.y, layout.width, layout.height, layout.borderRadius);
|
shape,
|
||||||
ctx.closePath();
|
webcamRect.borderRadius,
|
||||||
ctx.shadowColor = "rgba(0,0,0,0.35)";
|
);
|
||||||
ctx.shadowBlur = 24;
|
if (preset.shadow) {
|
||||||
ctx.shadowOffsetY = 10;
|
ctx.shadowColor = preset.shadow.color;
|
||||||
ctx.fillStyle = "#000000";
|
ctx.shadowBlur = preset.shadow.blur;
|
||||||
ctx.fill();
|
ctx.shadowOffsetX = preset.shadow.offsetX;
|
||||||
ctx.clip();
|
ctx.shadowOffsetY = preset.shadow.offsetY;
|
||||||
ctx.drawImage(
|
|
||||||
webcamFrame as unknown as CanvasImageSource,
|
|
||||||
layout.x,
|
|
||||||
layout.y,
|
|
||||||
layout.width,
|
|
||||||
layout.height,
|
|
||||||
);
|
|
||||||
ctx.restore();
|
|
||||||
}
|
}
|
||||||
|
ctx.fillStyle = "#000000";
|
||||||
|
ctx.fill();
|
||||||
|
ctx.clip();
|
||||||
|
ctx.drawImage(
|
||||||
|
webcamFrame as unknown as CanvasImageSource,
|
||||||
|
webcamRect.x,
|
||||||
|
webcamRect.y,
|
||||||
|
webcamRect.width,
|
||||||
|
webcamRect.height,
|
||||||
|
);
|
||||||
|
ctx.restore();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -673,7 +779,11 @@ export class FrameRenderer {
|
|||||||
}
|
}
|
||||||
this.backgroundSprite = null;
|
this.backgroundSprite = null;
|
||||||
if (this.app) {
|
if (this.app) {
|
||||||
this.app.destroy(true, { children: true, texture: true, textureSource: true });
|
this.app.destroy(true, {
|
||||||
|
children: true,
|
||||||
|
texture: true,
|
||||||
|
textureSource: true,
|
||||||
|
});
|
||||||
this.app = null;
|
this.app = null;
|
||||||
}
|
}
|
||||||
this.cameraContainer = null;
|
this.cameraContainer = null;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type {
|
|||||||
CropRegion,
|
CropRegion,
|
||||||
SpeedRegion,
|
SpeedRegion,
|
||||||
TrimRegion,
|
TrimRegion,
|
||||||
|
WebcamLayoutPreset,
|
||||||
ZoomRegion,
|
ZoomRegion,
|
||||||
} from "@/components/video-editor/types";
|
} from "@/components/video-editor/types";
|
||||||
import { AsyncVideoFrameQueue } from "./asyncVideoFrameQueue";
|
import { AsyncVideoFrameQueue } from "./asyncVideoFrameQueue";
|
||||||
@@ -39,9 +40,13 @@ interface GifExporterConfig {
|
|||||||
padding?: number;
|
padding?: number;
|
||||||
videoPadding?: number;
|
videoPadding?: number;
|
||||||
cropRegion: CropRegion;
|
cropRegion: CropRegion;
|
||||||
|
webcamLayoutPreset?: WebcamLayoutPreset;
|
||||||
|
webcamMaskShape?: import("@/components/video-editor/types").WebcamMaskShape;
|
||||||
|
webcamPosition?: { cx: number; cy: number } | null;
|
||||||
annotationRegions?: AnnotationRegion[];
|
annotationRegions?: AnnotationRegion[];
|
||||||
previewWidth?: number;
|
previewWidth?: number;
|
||||||
previewHeight?: number;
|
previewHeight?: number;
|
||||||
|
cursorTelemetry?: import("@/components/video-editor/types").CursorTelemetryPoint[];
|
||||||
onProgress?: (progress: ExportProgress) => void;
|
onProgress?: (progress: ExportProgress) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,12 +141,15 @@ export class GifExporter {
|
|||||||
cropRegion: this.config.cropRegion,
|
cropRegion: this.config.cropRegion,
|
||||||
videoWidth: videoInfo.width,
|
videoWidth: videoInfo.width,
|
||||||
videoHeight: videoInfo.height,
|
videoHeight: videoInfo.height,
|
||||||
webcamWidth: webcamInfo?.width,
|
webcamSize: webcamInfo ? { width: webcamInfo.width, height: webcamInfo.height } : null,
|
||||||
webcamHeight: webcamInfo?.height,
|
webcamLayoutPreset: this.config.webcamLayoutPreset,
|
||||||
|
webcamMaskShape: this.config.webcamMaskShape,
|
||||||
|
webcamPosition: this.config.webcamPosition,
|
||||||
annotationRegions: this.config.annotationRegions,
|
annotationRegions: this.config.annotationRegions,
|
||||||
speedRegions: this.config.speedRegions,
|
speedRegions: this.config.speedRegions,
|
||||||
previewWidth: this.config.previewWidth,
|
previewWidth: this.config.previewWidth,
|
||||||
previewHeight: this.config.previewHeight,
|
previewHeight: this.config.previewHeight,
|
||||||
|
cursorTelemetry: this.config.cursorTelemetry,
|
||||||
});
|
});
|
||||||
await this.renderer.initialize();
|
await this.renderer.initialize();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
getLinearGradientPoints,
|
||||||
|
getRadialGradientShape,
|
||||||
|
parseCssGradient,
|
||||||
|
resolveLinearGradientAngle,
|
||||||
|
} from "./gradientParser";
|
||||||
|
|
||||||
|
describe("parseCssGradient", () => {
|
||||||
|
it("parses rgba-based gradient presets without splitting inside color functions", () => {
|
||||||
|
const parsed = parseCssGradient(
|
||||||
|
"linear-gradient( 111.6deg, rgba(114,167,232,1) 9.4%, rgba(253,129,82,1) 43.9%, rgba(253,129,82,1) 54.8%, rgba(249,202,86,1) 86.3% )",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(parsed?.type).toBe("linear");
|
||||||
|
expect(parsed?.descriptor).toBe("111.6deg");
|
||||||
|
expect(parsed?.stops).toHaveLength(4);
|
||||||
|
expect(parsed?.stops.map((stop) => stop.color)).toEqual([
|
||||||
|
"rgba(114,167,232,1)",
|
||||||
|
"rgba(253,129,82,1)",
|
||||||
|
"rgba(253,129,82,1)",
|
||||||
|
"rgba(249,202,86,1)",
|
||||||
|
]);
|
||||||
|
expect(parsed?.stops[0]?.offset).toBeCloseTo(0.094);
|
||||||
|
expect(parsed?.stops[1]?.offset).toBeCloseTo(0.439);
|
||||||
|
expect(parsed?.stops[2]?.offset).toBeCloseTo(0.548);
|
||||||
|
expect(parsed?.stops[3]?.offset).toBeCloseTo(0.863);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fills missing stop positions for simple hex gradients", () => {
|
||||||
|
const parsed = parseCssGradient("linear-gradient(135deg, #FBC8B4, #2447B1)");
|
||||||
|
|
||||||
|
expect(parsed?.stops).toEqual([
|
||||||
|
{ color: "#FBC8B4", offset: 0 },
|
||||||
|
{ color: "#2447B1", offset: 1 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("gradient geometry", () => {
|
||||||
|
it("maps linear directions to canvas endpoints", () => {
|
||||||
|
const angle = resolveLinearGradientAngle("to right");
|
||||||
|
const points = getLinearGradientPoints(angle, 1920, 1080);
|
||||||
|
|
||||||
|
expect(points.x0).toBeCloseTo(0);
|
||||||
|
expect(points.y0).toBeCloseTo(540);
|
||||||
|
expect(points.x1).toBeCloseTo(1920);
|
||||||
|
expect(points.y1).toBeCloseTo(540);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses radial positions from the descriptor", () => {
|
||||||
|
const shape = getRadialGradientShape("circle farthest-corner at 10% 20%", 1000, 500);
|
||||||
|
|
||||||
|
expect(shape.cx).toBe(100);
|
||||||
|
expect(shape.cy).toBe(100);
|
||||||
|
expect(shape.radius).toBeCloseTo(Math.hypot(900, 400));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
export interface ParsedGradientStop {
|
||||||
|
color: string;
|
||||||
|
offset: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParsedGradient {
|
||||||
|
type: "linear" | "radial";
|
||||||
|
descriptor: string | null;
|
||||||
|
stops: ParsedGradientStop[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLOR_TOKEN_RE = /^(#[0-9a-fA-F]{3,8}|(?:rgba?|hsla?)\([^)]*\)|[a-zA-Z-]+)/;
|
||||||
|
|
||||||
|
export function parseCssGradient(input: string): ParsedGradient | null {
|
||||||
|
const gradientMatch = input.match(/^(linear|radial)-gradient\((.*)\)$/i);
|
||||||
|
if (!gradientMatch) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = gradientMatch[1].toLowerCase() as ParsedGradient["type"];
|
||||||
|
const rawArgs = splitGradientArgs(gradientMatch[2]);
|
||||||
|
if (rawArgs.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let descriptor: string | null = null;
|
||||||
|
let stopArgs = rawArgs;
|
||||||
|
|
||||||
|
if (isGradientDescriptor(type, rawArgs[0])) {
|
||||||
|
descriptor = rawArgs[0];
|
||||||
|
stopArgs = rawArgs.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedStops = stopArgs
|
||||||
|
.map((part) => parseColorStop(part))
|
||||||
|
.filter((stop): stop is { color: string; offset: number | null } => stop !== null);
|
||||||
|
|
||||||
|
if (parsedStops.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
descriptor,
|
||||||
|
stops: normalizeStopOffsets(parsedStops),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLinearGradientPoints(angleDeg: number, width: number, height: number) {
|
||||||
|
const radians = (angleDeg * Math.PI) / 180;
|
||||||
|
const vx = Math.sin(radians);
|
||||||
|
const vy = -Math.cos(radians);
|
||||||
|
const halfSpan = (Math.abs(vx) * width + Math.abs(vy) * height) / 2;
|
||||||
|
const cx = width / 2;
|
||||||
|
const cy = height / 2;
|
||||||
|
|
||||||
|
return {
|
||||||
|
x0: cx - vx * halfSpan,
|
||||||
|
y0: cy - vy * halfSpan,
|
||||||
|
x1: cx + vx * halfSpan,
|
||||||
|
y1: cy + vy * halfSpan,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveLinearGradientAngle(descriptor: string | null): number {
|
||||||
|
if (!descriptor) {
|
||||||
|
return 180;
|
||||||
|
}
|
||||||
|
|
||||||
|
const angleMatch = descriptor.match(/(-?\d*\.?\d+)deg/i);
|
||||||
|
if (angleMatch) {
|
||||||
|
return Number.parseFloat(angleMatch[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = descriptor.trim().toLowerCase().replace(/\s+/g, " ");
|
||||||
|
const directionMap: Record<string, number> = {
|
||||||
|
"to top": 0,
|
||||||
|
"to top right": 45,
|
||||||
|
"to right": 90,
|
||||||
|
"to bottom right": 135,
|
||||||
|
"to bottom": 180,
|
||||||
|
"to bottom left": 225,
|
||||||
|
"to left": 270,
|
||||||
|
"to top left": 315,
|
||||||
|
};
|
||||||
|
|
||||||
|
return directionMap[normalized] ?? 180;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRadialGradientShape(descriptor: string | null, width: number, height: number) {
|
||||||
|
const atMatch = descriptor?.match(/at\s+(-?\d*\.?\d+)%\s+(-?\d*\.?\d+)%/i);
|
||||||
|
const cx = atMatch ? (Number.parseFloat(atMatch[1]) / 100) * width : width / 2;
|
||||||
|
const cy = atMatch ? (Number.parseFloat(atMatch[2]) / 100) * height : height / 2;
|
||||||
|
|
||||||
|
const distances = [
|
||||||
|
Math.hypot(cx, cy),
|
||||||
|
Math.hypot(width - cx, cy),
|
||||||
|
Math.hypot(cx, height - cy),
|
||||||
|
Math.hypot(width - cx, height - cy),
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
cx,
|
||||||
|
cy,
|
||||||
|
radius: Math.max(...distances),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitGradientArgs(input: string): string[] {
|
||||||
|
const parts: string[] = [];
|
||||||
|
let current = "";
|
||||||
|
let depth = 0;
|
||||||
|
|
||||||
|
for (const char of input) {
|
||||||
|
if (char === "(") {
|
||||||
|
depth += 1;
|
||||||
|
current += char;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (char === ")") {
|
||||||
|
depth = Math.max(0, depth - 1);
|
||||||
|
current += char;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (char === "," && depth === 0) {
|
||||||
|
const trimmed = current.trim();
|
||||||
|
if (trimmed) {
|
||||||
|
parts.push(trimmed);
|
||||||
|
}
|
||||||
|
current = "";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
current += char;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = current.trim();
|
||||||
|
if (trimmed) {
|
||||||
|
parts.push(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isGradientDescriptor(type: ParsedGradient["type"], part: string) {
|
||||||
|
if (type === "linear") {
|
||||||
|
return /^\s*to\s+/i.test(part) || /-?\d*\.?\d+deg/i.test(part);
|
||||||
|
}
|
||||||
|
|
||||||
|
return /\b(circle|ellipse|closest|farthest)\b/i.test(part) || /\bat\b/i.test(part);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseColorStop(part: string): { color: string; offset: number | null } | null {
|
||||||
|
const match = part.trim().match(COLOR_TOKEN_RE);
|
||||||
|
if (!match) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const color = match[1];
|
||||||
|
const rest = part.slice(match[0].length);
|
||||||
|
const percentMatch = rest.match(/(-?\d*\.?\d+)%/);
|
||||||
|
const offset = percentMatch ? clamp(Number.parseFloat(percentMatch[1]) / 100, 0, 1) : null;
|
||||||
|
|
||||||
|
return { color, offset };
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeStopOffsets(
|
||||||
|
stops: Array<{ color: string; offset: number | null }>,
|
||||||
|
): ParsedGradientStop[] {
|
||||||
|
const explicitCount = stops.filter((stop) => stop.offset !== null).length;
|
||||||
|
if (explicitCount === 0) {
|
||||||
|
if (stops.length === 1) {
|
||||||
|
return [{ color: stops[0].color, offset: 0 }];
|
||||||
|
}
|
||||||
|
|
||||||
|
return stops.map((stop, index) => ({
|
||||||
|
color: stop.color,
|
||||||
|
offset: index / (stops.length - 1),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolved = stops.map((stop) => stop.offset);
|
||||||
|
const firstExplicit = resolved.findIndex((offset) => offset !== null);
|
||||||
|
const lastExplicit = findLastDefinedIndex(resolved);
|
||||||
|
|
||||||
|
for (let index = 0; index < firstExplicit; index += 1) {
|
||||||
|
const end = resolved[firstExplicit] ?? 0;
|
||||||
|
resolved[index] = firstExplicit === 0 ? end : (end * index) / firstExplicit;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let index = lastExplicit + 1; index < resolved.length; index += 1) {
|
||||||
|
const start = resolved[lastExplicit] ?? 1;
|
||||||
|
const denominator = resolved.length - 1 - lastExplicit;
|
||||||
|
resolved[index] =
|
||||||
|
denominator <= 0 ? start : start + ((1 - start) * (index - lastExplicit)) / denominator;
|
||||||
|
}
|
||||||
|
|
||||||
|
let runStart = firstExplicit;
|
||||||
|
while (runStart < lastExplicit) {
|
||||||
|
const nextExplicit = resolved.findIndex((offset, index) => index > runStart && offset !== null);
|
||||||
|
if (nextExplicit === -1) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = resolved[runStart] ?? 0;
|
||||||
|
const end = resolved[nextExplicit] ?? start;
|
||||||
|
const gap = nextExplicit - runStart;
|
||||||
|
|
||||||
|
for (let index = runStart + 1; index < nextExplicit; index += 1) {
|
||||||
|
resolved[index] = start + ((end - start) * (index - runStart)) / gap;
|
||||||
|
}
|
||||||
|
|
||||||
|
runStart = nextExplicit;
|
||||||
|
}
|
||||||
|
|
||||||
|
return stops.map((stop, index) => ({
|
||||||
|
color: stop.color,
|
||||||
|
offset: clamp(resolved[index] ?? 0, 0, 1),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(value: number, min: number, max: number) {
|
||||||
|
return Math.min(max, Math.max(min, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function findLastDefinedIndex(values: Array<number | null>) {
|
||||||
|
for (let index = values.length - 1; index >= 0; index -= 1) {
|
||||||
|
if (values[index] !== null) {
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { shouldFailDecodeEndedEarly } from "./streamingDecoder";
|
||||||
|
|
||||||
|
describe("shouldFailDecodeEndedEarly", () => {
|
||||||
|
it("does not fail once every segment has been satisfied", () => {
|
||||||
|
expect(
|
||||||
|
shouldFailDecodeEndedEarly({
|
||||||
|
cancelled: false,
|
||||||
|
lastDecodedFrameSec: 5.33,
|
||||||
|
requiredEndSec: 6.498,
|
||||||
|
streamDurationSec: 5.33,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails when decode stops far before the required end", () => {
|
||||||
|
expect(
|
||||||
|
shouldFailDecodeEndedEarly({
|
||||||
|
cancelled: false,
|
||||||
|
lastDecodedFrameSec: 5.33,
|
||||||
|
requiredEndSec: 10,
|
||||||
|
streamDurationSec: 5.33,
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails when no frame could be decoded for a non-empty timeline", () => {
|
||||||
|
expect(
|
||||||
|
shouldFailDecodeEndedEarly({
|
||||||
|
cancelled: false,
|
||||||
|
lastDecodedFrameSec: null,
|
||||||
|
requiredEndSec: 1,
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails when the decoder has not reached the reported stream end", () => {
|
||||||
|
expect(
|
||||||
|
shouldFailDecodeEndedEarly({
|
||||||
|
cancelled: false,
|
||||||
|
lastDecodedFrameSec: 4.9,
|
||||||
|
requiredEndSec: 6.498,
|
||||||
|
streamDurationSec: 5.33,
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { WebDemuxer } from "web-demuxer";
|
import { WebDemuxer } from "web-demuxer";
|
||||||
import type { SpeedRegion, TrimRegion } from "@/components/video-editor/types";
|
import type { SpeedRegion, TrimRegion } from "@/components/video-editor/types";
|
||||||
|
|
||||||
|
const SOURCE_LOAD_TIMEOUT_MS = 60_000;
|
||||||
|
|
||||||
export interface DecodedVideoInfo {
|
export interface DecodedVideoInfo {
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
@@ -12,6 +14,55 @@ export interface DecodedVideoInfo {
|
|||||||
audioCodec?: string;
|
audioCodec?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type EarlyDecodeEndCheck = {
|
||||||
|
cancelled: boolean;
|
||||||
|
lastDecodedFrameSec: number | null;
|
||||||
|
requiredEndSec: number;
|
||||||
|
streamDurationSec?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const EARLY_DECODE_END_THRESHOLD_SEC = 1;
|
||||||
|
const METADATA_TAIL_TOLERANCE_SEC = 1.5;
|
||||||
|
const STREAM_DURATION_MATCH_TOLERANCE_SEC = 0.25;
|
||||||
|
|
||||||
|
export function shouldFailDecodeEndedEarly({
|
||||||
|
cancelled,
|
||||||
|
lastDecodedFrameSec,
|
||||||
|
requiredEndSec,
|
||||||
|
streamDurationSec,
|
||||||
|
}: EarlyDecodeEndCheck): boolean {
|
||||||
|
if (cancelled || requiredEndSec <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastDecodedFrameSec === null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const decodeGapSec = requiredEndSec - lastDecodedFrameSec;
|
||||||
|
if (decodeGapSec <= EARLY_DECODE_END_THRESHOLD_SEC) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof streamDurationSec !== "number" || !Number.isFinite(streamDurationSec)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadataTailSec = requiredEndSec - streamDurationSec;
|
||||||
|
const decodedNearStreamEnd =
|
||||||
|
Math.abs(lastDecodedFrameSec - streamDurationSec) <= STREAM_DURATION_MATCH_TOLERANCE_SEC;
|
||||||
|
|
||||||
|
if (
|
||||||
|
decodedNearStreamEnd &&
|
||||||
|
metadataTailSec > 0 &&
|
||||||
|
metadataTailSec <= METADATA_TAIL_TOLERANCE_SEC
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/** Caller must close the VideoFrame after use. */
|
/** Caller must close the VideoFrame after use. */
|
||||||
type OnFrameCallback = (
|
type OnFrameCallback = (
|
||||||
frame: VideoFrame,
|
frame: VideoFrame,
|
||||||
@@ -36,7 +87,11 @@ export class StreamingVideoDecoder {
|
|||||||
const isRemoteUrl = /^(https?:|blob:|data:)/i.test(videoUrl);
|
const isRemoteUrl = /^(https?:|blob:|data:)/i.test(videoUrl);
|
||||||
|
|
||||||
if (!isRemoteUrl && window.electronAPI?.readBinaryFile) {
|
if (!isRemoteUrl && window.electronAPI?.readBinaryFile) {
|
||||||
const result = await window.electronAPI.readBinaryFile(videoUrl);
|
const result = await this.withTimeout(
|
||||||
|
window.electronAPI.readBinaryFile(videoUrl),
|
||||||
|
SOURCE_LOAD_TIMEOUT_MS,
|
||||||
|
"Timed out while loading the source video.",
|
||||||
|
);
|
||||||
if (!result.success || !result.data) {
|
if (!result.success || !result.data) {
|
||||||
throw new Error(result.message || result.error || "Failed to read source video");
|
throw new Error(result.message || result.error || "Failed to read source video");
|
||||||
}
|
}
|
||||||
@@ -49,11 +104,19 @@ export class StreamingVideoDecoder {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(videoUrl);
|
const response = await this.withTimeout(
|
||||||
|
fetch(videoUrl),
|
||||||
|
SOURCE_LOAD_TIMEOUT_MS,
|
||||||
|
"Timed out while loading the source video.",
|
||||||
|
);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Failed to fetch source video: ${response.status} ${response.statusText}`);
|
throw new Error(`Failed to fetch source video: ${response.status} ${response.statusText}`);
|
||||||
}
|
}
|
||||||
const blob = await response.blob();
|
const blob = await this.withTimeout(
|
||||||
|
response.blob(),
|
||||||
|
SOURCE_LOAD_TIMEOUT_MS,
|
||||||
|
"Timed out while reading the source video.",
|
||||||
|
);
|
||||||
const filename = videoUrl.split("/").pop() || "video";
|
const filename = videoUrl.split("/").pop() || "video";
|
||||||
return {
|
return {
|
||||||
blob,
|
blob,
|
||||||
@@ -67,9 +130,17 @@ export class StreamingVideoDecoder {
|
|||||||
// Relative URL so it resolves correctly in both dev (http) and packaged (file://) builds
|
// Relative URL so it resolves correctly in both dev (http) and packaged (file://) builds
|
||||||
const wasmUrl = new URL("./wasm/web-demuxer.wasm", window.location.href).href;
|
const wasmUrl = new URL("./wasm/web-demuxer.wasm", window.location.href).href;
|
||||||
this.demuxer = new WebDemuxer({ wasmFilePath: wasmUrl });
|
this.demuxer = new WebDemuxer({ wasmFilePath: wasmUrl });
|
||||||
await this.demuxer.load(file);
|
await this.withTimeout(
|
||||||
|
this.demuxer.load(file),
|
||||||
|
SOURCE_LOAD_TIMEOUT_MS,
|
||||||
|
"Timed out while parsing the source video.",
|
||||||
|
);
|
||||||
|
|
||||||
const mediaInfo = await this.demuxer.getMediaInfo();
|
const mediaInfo = await this.withTimeout(
|
||||||
|
this.demuxer.getMediaInfo(),
|
||||||
|
SOURCE_LOAD_TIMEOUT_MS,
|
||||||
|
"Timed out while reading video metadata.",
|
||||||
|
);
|
||||||
const videoStream = mediaInfo.streams.find((s) => s.codec_type_string === "video");
|
const videoStream = mediaInfo.streams.find((s) => s.codec_type_string === "video");
|
||||||
|
|
||||||
let frameRate = 60;
|
let frameRate = 60;
|
||||||
@@ -366,12 +437,17 @@ export class StreamingVideoDecoder {
|
|||||||
|
|
||||||
const requiredEndSec = segments.length > 0 ? segments[segments.length - 1].endSec : 0;
|
const requiredEndSec = segments.length > 0 ? segments[segments.length - 1].endSec : 0;
|
||||||
if (
|
if (
|
||||||
!this.cancelled &&
|
shouldFailDecodeEndedEarly({
|
||||||
lastDecodedFrameSec !== null &&
|
cancelled: this.cancelled,
|
||||||
requiredEndSec - lastDecodedFrameSec > 1
|
lastDecodedFrameSec,
|
||||||
|
requiredEndSec,
|
||||||
|
streamDurationSec: this.metadata.streamDuration,
|
||||||
|
})
|
||||||
) {
|
) {
|
||||||
|
const decodedAtLabel =
|
||||||
|
lastDecodedFrameSec === null ? "no decoded frame" : `${lastDecodedFrameSec.toFixed(3)}s`;
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Video decode ended early at ${lastDecodedFrameSec.toFixed(3)}s (needed ${requiredEndSec.toFixed(3)}s).`,
|
`Video decode ended early at ${decodedAtLabel} (needed ${requiredEndSec.toFixed(3)}s).`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -472,4 +548,20 @@ export class StreamingVideoDecoder {
|
|||||||
this.demuxer = null;
|
this.demuxer = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
|
||||||
|
return new Promise<T>((resolve, reject) => {
|
||||||
|
const timer = window.setTimeout(() => reject(new Error(message)), timeoutMs);
|
||||||
|
promise.then(
|
||||||
|
(value) => {
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
resolve(value);
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
reject(error);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+201
-107
@@ -3,6 +3,7 @@ import type {
|
|||||||
CropRegion,
|
CropRegion,
|
||||||
SpeedRegion,
|
SpeedRegion,
|
||||||
TrimRegion,
|
TrimRegion,
|
||||||
|
WebcamLayoutPreset,
|
||||||
ZoomRegion,
|
ZoomRegion,
|
||||||
} from "@/components/video-editor/types";
|
} from "@/components/video-editor/types";
|
||||||
import { AsyncVideoFrameQueue } from "./asyncVideoFrameQueue";
|
import { AsyncVideoFrameQueue } from "./asyncVideoFrameQueue";
|
||||||
@@ -12,6 +13,9 @@ import { VideoMuxer } from "./muxer";
|
|||||||
import { StreamingVideoDecoder } from "./streamingDecoder";
|
import { StreamingVideoDecoder } from "./streamingDecoder";
|
||||||
import type { ExportConfig, ExportProgress, ExportResult } from "./types";
|
import type { ExportConfig, ExportProgress, ExportResult } from "./types";
|
||||||
|
|
||||||
|
const ENCODER_STALL_TIMEOUT_MS = 15_000;
|
||||||
|
const ENCODER_FLUSH_TIMEOUT_MS = 20_000;
|
||||||
|
|
||||||
interface VideoExporterConfig extends ExportConfig {
|
interface VideoExporterConfig extends ExportConfig {
|
||||||
videoUrl: string;
|
videoUrl: string;
|
||||||
webcamVideoUrl?: string;
|
webcamVideoUrl?: string;
|
||||||
@@ -27,9 +31,13 @@ interface VideoExporterConfig extends ExportConfig {
|
|||||||
padding?: number;
|
padding?: number;
|
||||||
videoPadding?: number;
|
videoPadding?: number;
|
||||||
cropRegion: CropRegion;
|
cropRegion: CropRegion;
|
||||||
|
webcamLayoutPreset?: WebcamLayoutPreset;
|
||||||
|
webcamMaskShape?: import("@/components/video-editor/types").WebcamMaskShape;
|
||||||
|
webcamPosition?: { cx: number; cy: number } | null;
|
||||||
annotationRegions?: AnnotationRegion[];
|
annotationRegions?: AnnotationRegion[];
|
||||||
previewWidth?: number;
|
previewWidth?: number;
|
||||||
previewHeight?: number;
|
previewHeight?: number;
|
||||||
|
cursorTelemetry?: import("@/components/video-editor/types").CursorTelemetryPoint[];
|
||||||
onProgress?: (progress: ExportProgress) => void;
|
onProgress?: (progress: ExportProgress) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,35 +51,76 @@ export class VideoExporter {
|
|||||||
private webcamDecoder: StreamingVideoDecoder | null = null;
|
private webcamDecoder: StreamingVideoDecoder | null = null;
|
||||||
private cancelled = false;
|
private cancelled = false;
|
||||||
private encodeQueue = 0;
|
private encodeQueue = 0;
|
||||||
// Increased queue size for better throughput with hardware encoding
|
// Keep a smaller queue for software encoding so Windows does not balloon memory.
|
||||||
private readonly MAX_ENCODE_QUEUE = 120;
|
private readonly MAX_ENCODE_QUEUE = 120;
|
||||||
private videoDescription: Uint8Array | undefined;
|
private videoDescription: Uint8Array | undefined;
|
||||||
private videoColorSpace: VideoColorSpaceInit | undefined;
|
private videoColorSpace: VideoColorSpaceInit | undefined;
|
||||||
// Track muxing promises for parallel processing
|
|
||||||
private muxingPromises: Promise<void>[] = [];
|
private muxingPromises: Promise<void>[] = [];
|
||||||
private chunkCount = 0;
|
private chunkCount = 0;
|
||||||
|
private lastEncoderOutputAt = 0;
|
||||||
|
private fatalEncoderError: Error | null = null;
|
||||||
|
|
||||||
constructor(config: VideoExporterConfig) {
|
constructor(config: VideoExporterConfig) {
|
||||||
this.config = config;
|
this.config = config;
|
||||||
}
|
}
|
||||||
|
|
||||||
async export(): Promise<ExportResult> {
|
async export(): Promise<ExportResult> {
|
||||||
let webcamFrameQueue: AsyncVideoFrameQueue | null = null;
|
const encoderPreferences = this.getEncoderPreferences();
|
||||||
try {
|
let lastError: Error | null = null;
|
||||||
this.cleanup();
|
|
||||||
this.cancelled = false;
|
|
||||||
|
|
||||||
// Initialize streaming decoder and load video metadata
|
for (const encoderPreference of encoderPreferences) {
|
||||||
this.streamingDecoder = new StreamingVideoDecoder();
|
try {
|
||||||
const videoInfo = await this.streamingDecoder.loadMetadata(this.config.videoUrl);
|
return await this.exportWithEncoderPreference(encoderPreference);
|
||||||
|
} catch (error) {
|
||||||
|
const normalizedError = error instanceof Error ? error : new Error(String(error));
|
||||||
|
lastError = normalizedError;
|
||||||
|
|
||||||
|
if (this.cancelled) {
|
||||||
|
return { success: false, error: "Export cancelled" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (encoderPreferences.length > 1) {
|
||||||
|
console.warn(
|
||||||
|
`[VideoExporter] ${encoderPreference} export attempt failed:`,
|
||||||
|
normalizedError,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.cleanup();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: lastError?.message || "Export failed",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async exportWithEncoderPreference(
|
||||||
|
encoderPreference: HardwareAcceleration,
|
||||||
|
): Promise<ExportResult> {
|
||||||
|
let webcamFrameQueue: AsyncVideoFrameQueue | null = null;
|
||||||
|
let stopWebcamDecode = false;
|
||||||
|
let webcamDecodeError: Error | null = null;
|
||||||
|
let webcamDecodePromise: Promise<void> | null = null;
|
||||||
|
let webcamDecoder: StreamingVideoDecoder | null = null;
|
||||||
|
|
||||||
|
this.cleanup();
|
||||||
|
this.cancelled = false;
|
||||||
|
this.fatalEncoderError = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const streamingDecoder = new StreamingVideoDecoder();
|
||||||
|
this.streamingDecoder = streamingDecoder;
|
||||||
|
const videoInfo = await streamingDecoder.loadMetadata(this.config.videoUrl);
|
||||||
let webcamInfo: Awaited<ReturnType<StreamingVideoDecoder["loadMetadata"]>> | null = null;
|
let webcamInfo: Awaited<ReturnType<StreamingVideoDecoder["loadMetadata"]>> | null = null;
|
||||||
if (this.config.webcamVideoUrl) {
|
if (this.config.webcamVideoUrl) {
|
||||||
this.webcamDecoder = new StreamingVideoDecoder();
|
webcamDecoder = new StreamingVideoDecoder();
|
||||||
webcamInfo = await this.webcamDecoder.loadMetadata(this.config.webcamVideoUrl);
|
this.webcamDecoder = webcamDecoder;
|
||||||
|
webcamInfo = await webcamDecoder.loadMetadata(this.config.webcamVideoUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize frame renderer
|
const renderer = new FrameRenderer({
|
||||||
this.renderer = new FrameRenderer({
|
|
||||||
width: this.config.width,
|
width: this.config.width,
|
||||||
height: this.config.height,
|
height: this.config.height,
|
||||||
wallpaper: this.config.wallpaper,
|
wallpaper: this.config.wallpaper,
|
||||||
@@ -85,25 +134,27 @@ export class VideoExporter {
|
|||||||
cropRegion: this.config.cropRegion,
|
cropRegion: this.config.cropRegion,
|
||||||
videoWidth: videoInfo.width,
|
videoWidth: videoInfo.width,
|
||||||
videoHeight: videoInfo.height,
|
videoHeight: videoInfo.height,
|
||||||
webcamWidth: webcamInfo?.width,
|
webcamSize: webcamInfo ? { width: webcamInfo.width, height: webcamInfo.height } : null,
|
||||||
webcamHeight: webcamInfo?.height,
|
webcamLayoutPreset: this.config.webcamLayoutPreset,
|
||||||
|
webcamMaskShape: this.config.webcamMaskShape,
|
||||||
|
webcamPosition: this.config.webcamPosition,
|
||||||
annotationRegions: this.config.annotationRegions,
|
annotationRegions: this.config.annotationRegions,
|
||||||
speedRegions: this.config.speedRegions,
|
speedRegions: this.config.speedRegions,
|
||||||
previewWidth: this.config.previewWidth,
|
previewWidth: this.config.previewWidth,
|
||||||
previewHeight: this.config.previewHeight,
|
previewHeight: this.config.previewHeight,
|
||||||
|
cursorTelemetry: this.config.cursorTelemetry,
|
||||||
});
|
});
|
||||||
await this.renderer.initialize();
|
this.renderer = renderer;
|
||||||
|
await renderer.initialize();
|
||||||
|
|
||||||
// Initialize video encoder
|
await this.initializeEncoder(encoderPreference);
|
||||||
await this.initializeEncoder();
|
|
||||||
|
|
||||||
// Initialize muxer (with audio if source has an audio track)
|
|
||||||
const hasAudio = videoInfo.hasAudio;
|
const hasAudio = videoInfo.hasAudio;
|
||||||
this.muxer = new VideoMuxer(this.config, hasAudio);
|
const muxer = new VideoMuxer(this.config, hasAudio);
|
||||||
await this.muxer.initialize();
|
this.muxer = muxer;
|
||||||
|
await muxer.initialize();
|
||||||
|
|
||||||
// Calculate effective duration and frame count (excluding trim regions)
|
const effectiveDuration = streamingDecoder.getEffectiveDuration(
|
||||||
const effectiveDuration = this.streamingDecoder.getEffectiveDuration(
|
|
||||||
this.config.trimRegions,
|
this.config.trimRegions,
|
||||||
this.config.speedRegions,
|
this.config.speedRegions,
|
||||||
);
|
);
|
||||||
@@ -115,16 +166,19 @@ export class VideoExporter {
|
|||||||
console.log("[VideoExporter] Total frames to export:", totalFrames);
|
console.log("[VideoExporter] Total frames to export:", totalFrames);
|
||||||
console.log("[VideoExporter] Using streaming decode (web-demuxer + VideoDecoder)");
|
console.log("[VideoExporter] Using streaming decode (web-demuxer + VideoDecoder)");
|
||||||
|
|
||||||
const frameDuration = 1_000_000 / this.config.frameRate; // in microseconds
|
const frameDuration = 1_000_000 / this.config.frameRate;
|
||||||
let frameIndex = 0;
|
let frameIndex = 0;
|
||||||
|
const maxEncodeQueue =
|
||||||
|
encoderPreference === "prefer-software"
|
||||||
|
? Math.min(this.MAX_ENCODE_QUEUE, 32)
|
||||||
|
: this.MAX_ENCODE_QUEUE;
|
||||||
|
|
||||||
webcamFrameQueue = this.config.webcamVideoUrl ? new AsyncVideoFrameQueue() : null;
|
webcamFrameQueue = this.config.webcamVideoUrl ? new AsyncVideoFrameQueue() : null;
|
||||||
let stopWebcamDecode = false;
|
webcamDecodePromise =
|
||||||
let webcamDecodeError: Error | null = null;
|
webcamDecoder && webcamFrameQueue
|
||||||
const webcamDecodePromise =
|
|
||||||
this.webcamDecoder && webcamFrameQueue
|
|
||||||
? (() => {
|
? (() => {
|
||||||
const queue = webcamFrameQueue;
|
const queue = webcamFrameQueue;
|
||||||
return this.webcamDecoder
|
return webcamDecoder
|
||||||
.decodeAll(
|
.decodeAll(
|
||||||
this.config.frameRate,
|
this.config.frameRate,
|
||||||
this.config.trimRegions,
|
this.config.trimRegions,
|
||||||
@@ -142,7 +196,7 @@ export class VideoExporter {
|
|||||||
)
|
)
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
webcamDecodeError = error instanceof Error ? error : new Error(String(error));
|
webcamDecodeError = error instanceof Error ? error : new Error(String(error));
|
||||||
throw error;
|
throw webcamDecodeError;
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (webcamDecodeError) {
|
if (webcamDecodeError) {
|
||||||
@@ -154,8 +208,7 @@ export class VideoExporter {
|
|||||||
})()
|
})()
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
// Stream decode and process frames — no seeking!
|
await streamingDecoder.decodeAll(
|
||||||
await this.streamingDecoder.decodeAll(
|
|
||||||
this.config.frameRate,
|
this.config.frameRate,
|
||||||
this.config.trimRegions,
|
this.config.trimRegions,
|
||||||
this.config.speedRegions,
|
this.config.speedRegions,
|
||||||
@@ -166,22 +219,31 @@ export class VideoExporter {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.fatalEncoderError) {
|
||||||
|
throw this.fatalEncoderError;
|
||||||
|
}
|
||||||
|
|
||||||
const timestamp = frameIndex * frameDuration;
|
const timestamp = frameIndex * frameDuration;
|
||||||
webcamFrame = webcamFrameQueue ? await webcamFrameQueue.dequeue() : null;
|
webcamFrame = webcamFrameQueue ? await webcamFrameQueue.dequeue() : null;
|
||||||
const renderer = this.renderer;
|
if (this.cancelled) {
|
||||||
if (this.cancelled || !renderer) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render the frame with all effects using source timestamp
|
const sourceTimestampUs = sourceTimestampMs * 1000;
|
||||||
const sourceTimestampUs = sourceTimestampMs * 1000; // Convert to microseconds
|
|
||||||
await renderer.renderFrame(videoFrame, sourceTimestampUs, webcamFrame);
|
await renderer.renderFrame(videoFrame, sourceTimestampUs, webcamFrame);
|
||||||
|
|
||||||
const canvas = renderer.getCanvas();
|
const canvas = renderer.getCanvas();
|
||||||
|
|
||||||
// Create VideoFrame from canvas on GPU without reading pixels
|
// Read raw pixels from the canvas instead of passing
|
||||||
// @ts-expect-error - colorSpace not in TypeScript definitions but works at runtime
|
// the canvas directly to VideoFrame. On some Linux
|
||||||
const exportFrame = new VideoFrame(canvas, {
|
// 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,
|
timestamp,
|
||||||
duration: frameDuration,
|
duration: frameDuration,
|
||||||
colorSpace: {
|
colorSpace: {
|
||||||
@@ -192,12 +254,19 @@ export class VideoExporter {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check encoder queue before encoding to keep it full
|
|
||||||
while (
|
while (
|
||||||
this.encoder &&
|
this.encoder &&
|
||||||
this.encoder.encodeQueueSize >= this.MAX_ENCODE_QUEUE &&
|
this.encoder.encodeQueueSize >= maxEncodeQueue &&
|
||||||
!this.cancelled
|
!this.cancelled
|
||||||
) {
|
) {
|
||||||
|
if (Date.now() - this.lastEncoderOutputAt > ENCODER_STALL_TIMEOUT_MS) {
|
||||||
|
exportFrame.close();
|
||||||
|
throw new Error(
|
||||||
|
encoderPreference === "prefer-hardware"
|
||||||
|
? "The hardware video encoder stopped responding. Retrying with a safer encoder."
|
||||||
|
: "The video encoder stopped responding during export.",
|
||||||
|
);
|
||||||
|
}
|
||||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,18 +280,14 @@ export class VideoExporter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
exportFrame.close();
|
exportFrame.close();
|
||||||
|
|
||||||
frameIndex++;
|
frameIndex++;
|
||||||
|
|
||||||
// Update progress
|
this.reportProgress({
|
||||||
if (this.config.onProgress) {
|
currentFrame: frameIndex,
|
||||||
this.config.onProgress({
|
totalFrames,
|
||||||
currentFrame: frameIndex,
|
percentage: (frameIndex / totalFrames) * 100,
|
||||||
totalFrames,
|
estimatedTimeRemaining: 0,
|
||||||
percentage: (frameIndex / totalFrames) * 100,
|
});
|
||||||
estimatedTimeRemaining: 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
videoFrame.close();
|
videoFrame.close();
|
||||||
webcamFrame?.close();
|
webcamFrame?.close();
|
||||||
@@ -234,38 +299,47 @@ export class VideoExporter {
|
|||||||
return { success: false, error: "Export cancelled" };
|
return { success: false, error: "Export cancelled" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.fatalEncoderError) {
|
||||||
|
throw this.fatalEncoderError;
|
||||||
|
}
|
||||||
|
|
||||||
stopWebcamDecode = true;
|
stopWebcamDecode = true;
|
||||||
webcamFrameQueue?.destroy();
|
webcamFrameQueue?.destroy();
|
||||||
this.webcamDecoder?.cancel();
|
webcamDecoder?.cancel();
|
||||||
await webcamDecodePromise;
|
await webcamDecodePromise;
|
||||||
|
|
||||||
// Finalize encoding
|
|
||||||
if (this.encoder && this.encoder.state === "configured") {
|
if (this.encoder && this.encoder.state === "configured") {
|
||||||
await this.encoder.flush();
|
await this.withTimeout(
|
||||||
|
this.encoder.flush(),
|
||||||
|
ENCODER_FLUSH_TIMEOUT_MS,
|
||||||
|
encoderPreference === "prefer-hardware"
|
||||||
|
? "The hardware video encoder stopped responding while finalizing the export."
|
||||||
|
: "The video encoder stopped responding while finalizing the export.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.fatalEncoderError) {
|
||||||
|
throw this.fatalEncoderError;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for all video muxing operations to complete
|
|
||||||
await Promise.all(this.muxingPromises);
|
await Promise.all(this.muxingPromises);
|
||||||
|
|
||||||
if (this.config.onProgress) {
|
this.reportProgress({
|
||||||
this.config.onProgress({
|
currentFrame: totalFrames,
|
||||||
currentFrame: totalFrames,
|
totalFrames,
|
||||||
totalFrames,
|
percentage: 100,
|
||||||
percentage: 100,
|
estimatedTimeRemaining: 0,
|
||||||
estimatedTimeRemaining: 0,
|
phase: "finalizing",
|
||||||
phase: "finalizing",
|
});
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process audio track if present
|
|
||||||
if (hasAudio && !this.cancelled) {
|
if (hasAudio && !this.cancelled) {
|
||||||
const demuxer = this.streamingDecoder!.getDemuxer();
|
const demuxer = streamingDecoder.getDemuxer();
|
||||||
if (demuxer) {
|
if (demuxer) {
|
||||||
console.log("[VideoExporter] Processing audio track...");
|
console.log("[VideoExporter] Processing audio track...");
|
||||||
this.audioProcessor = new AudioProcessor();
|
this.audioProcessor = new AudioProcessor();
|
||||||
await this.audioProcessor.process(
|
await this.audioProcessor.process(
|
||||||
demuxer,
|
demuxer,
|
||||||
this.muxer!,
|
muxer,
|
||||||
this.config.videoUrl,
|
this.config.videoUrl,
|
||||||
this.config.trimRegions,
|
this.config.trimRegions,
|
||||||
this.config.speedRegions,
|
this.config.speedRegions,
|
||||||
@@ -274,31 +348,30 @@ export class VideoExporter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Finalize muxer and get output blob
|
const blob = await muxer.finalize();
|
||||||
const blob = await this.muxer!.finalize();
|
|
||||||
|
|
||||||
return { success: true, blob };
|
return { success: true, blob };
|
||||||
} catch (error) {
|
|
||||||
console.error("Export error:", error);
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : String(error),
|
|
||||||
};
|
|
||||||
} finally {
|
} finally {
|
||||||
|
stopWebcamDecode = true;
|
||||||
webcamFrameQueue?.destroy();
|
webcamFrameQueue?.destroy();
|
||||||
this.cleanup();
|
webcamDecoder?.cancel();
|
||||||
|
if (webcamDecodePromise) {
|
||||||
|
await webcamDecodePromise.catch(() => undefined);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async initializeEncoder(): Promise<void> {
|
private async initializeEncoder(hardwareAcceleration: HardwareAcceleration): Promise<void> {
|
||||||
this.encodeQueue = 0;
|
this.encodeQueue = 0;
|
||||||
this.muxingPromises = [];
|
this.muxingPromises = [];
|
||||||
this.chunkCount = 0;
|
this.chunkCount = 0;
|
||||||
|
this.lastEncoderOutputAt = Date.now();
|
||||||
|
this.fatalEncoderError = null;
|
||||||
let videoDescription: Uint8Array | undefined;
|
let videoDescription: Uint8Array | undefined;
|
||||||
|
|
||||||
this.encoder = new VideoEncoder({
|
this.encoder = new VideoEncoder({
|
||||||
output: (chunk, meta) => {
|
output: (chunk, meta) => {
|
||||||
// Capture decoder config metadata from encoder output
|
this.lastEncoderOutputAt = Date.now();
|
||||||
|
|
||||||
if (meta?.decoderConfig?.description && !videoDescription) {
|
if (meta?.decoderConfig?.description && !videoDescription) {
|
||||||
const desc = meta.decoderConfig.description;
|
const desc = meta.decoderConfig.description;
|
||||||
if (desc instanceof ArrayBuffer || desc instanceof SharedArrayBuffer) {
|
if (desc instanceof ArrayBuffer || desc instanceof SharedArrayBuffer) {
|
||||||
@@ -308,19 +381,17 @@ export class VideoExporter {
|
|||||||
}
|
}
|
||||||
this.videoDescription = videoDescription;
|
this.videoDescription = videoDescription;
|
||||||
}
|
}
|
||||||
// Capture colorSpace from encoder metadata if provided
|
|
||||||
if (meta?.decoderConfig?.colorSpace && !this.videoColorSpace) {
|
if (meta?.decoderConfig?.colorSpace && !this.videoColorSpace) {
|
||||||
this.videoColorSpace = meta.decoderConfig.colorSpace;
|
this.videoColorSpace = meta.decoderConfig.colorSpace;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stream chunk to muxer immediately (parallel processing)
|
|
||||||
const isFirstChunk = this.chunkCount === 0;
|
const isFirstChunk = this.chunkCount === 0;
|
||||||
this.chunkCount++;
|
this.chunkCount++;
|
||||||
|
|
||||||
const muxingPromise = (async () => {
|
const muxingPromise = (async () => {
|
||||||
try {
|
try {
|
||||||
if (isFirstChunk && this.videoDescription) {
|
if (isFirstChunk && this.videoDescription) {
|
||||||
// Add decoder config for the first chunk
|
|
||||||
const colorSpace = this.videoColorSpace || {
|
const colorSpace = this.videoColorSpace || {
|
||||||
primaries: "bt709",
|
primaries: "bt709",
|
||||||
transfer: "iec61966-2-1",
|
transfer: "iec61966-2-1",
|
||||||
@@ -352,43 +423,37 @@ export class VideoExporter {
|
|||||||
},
|
},
|
||||||
error: (error) => {
|
error: (error) => {
|
||||||
console.error("[VideoExporter] Encoder error:", error);
|
console.error("[VideoExporter] Encoder error:", error);
|
||||||
// Stop export encoding failed
|
this.fatalEncoderError =
|
||||||
this.cancelled = true;
|
error instanceof Error ? error : new Error(`Video encoder error: ${String(error)}`);
|
||||||
|
this.streamingDecoder?.cancel();
|
||||||
|
this.webcamDecoder?.cancel();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const codec = this.config.codec || "avc1.640033";
|
|
||||||
|
|
||||||
const encoderConfig: VideoEncoderConfig = {
|
const encoderConfig: VideoEncoderConfig = {
|
||||||
codec,
|
codec: this.config.codec || "avc1.640033",
|
||||||
width: this.config.width,
|
width: this.config.width,
|
||||||
height: this.config.height,
|
height: this.config.height,
|
||||||
bitrate: this.config.bitrate,
|
bitrate: this.config.bitrate,
|
||||||
framerate: this.config.frameRate,
|
framerate: this.config.frameRate,
|
||||||
latencyMode: "quality", // Changed from 'realtime' to 'quality' for better throughput
|
latencyMode: "quality",
|
||||||
bitrateMode: "variable",
|
bitrateMode: "variable",
|
||||||
hardwareAcceleration: "prefer-hardware",
|
hardwareAcceleration,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check hardware support first
|
const support = await VideoEncoder.isConfigSupported(encoderConfig);
|
||||||
const hardwareSupport = await VideoEncoder.isConfigSupported(encoderConfig);
|
if (!support.supported) {
|
||||||
|
throw new Error(
|
||||||
if (hardwareSupport.supported) {
|
hardwareAcceleration === "prefer-hardware"
|
||||||
// Use hardware encoding
|
? "Hardware video encoding is not supported on this system."
|
||||||
console.log("[VideoExporter] Using hardware acceleration");
|
: "Software video encoding is not supported on this system.",
|
||||||
this.encoder.configure(encoderConfig);
|
);
|
||||||
} else {
|
|
||||||
// Fall back to software encoding
|
|
||||||
console.log("[VideoExporter] Hardware not supported, using software encoding");
|
|
||||||
encoderConfig.hardwareAcceleration = "prefer-software";
|
|
||||||
|
|
||||||
const softwareSupport = await VideoEncoder.isConfigSupported(encoderConfig);
|
|
||||||
if (!softwareSupport.supported) {
|
|
||||||
throw new Error("Video encoding not supported on this system");
|
|
||||||
}
|
|
||||||
|
|
||||||
this.encoder.configure(encoderConfig);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[VideoExporter] Using ${hardwareAcceleration === "prefer-hardware" ? "hardware" : "software"} acceleration`,
|
||||||
|
);
|
||||||
|
this.encoder.configure(encoderConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
cancel(): void {
|
cancel(): void {
|
||||||
@@ -451,5 +516,34 @@ export class VideoExporter {
|
|||||||
this.chunkCount = 0;
|
this.chunkCount = 0;
|
||||||
this.videoDescription = undefined;
|
this.videoDescription = undefined;
|
||||||
this.videoColorSpace = undefined;
|
this.videoColorSpace = undefined;
|
||||||
|
this.lastEncoderOutputAt = 0;
|
||||||
|
this.fatalEncoderError = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getEncoderPreferences(): HardwareAcceleration[] {
|
||||||
|
if (typeof navigator !== "undefined" && /\bWindows\b/i.test(navigator.userAgent)) {
|
||||||
|
return ["prefer-software", "prefer-hardware"];
|
||||||
|
}
|
||||||
|
return ["prefer-hardware", "prefer-software"];
|
||||||
|
}
|
||||||
|
|
||||||
|
private reportProgress(progress: ExportProgress): void {
|
||||||
|
this.config.onProgress?.(progress);
|
||||||
|
}
|
||||||
|
|
||||||
|
private withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
|
||||||
|
return new Promise<T>((resolve, reject) => {
|
||||||
|
const timer = window.setTimeout(() => reject(new Error(message)), timeoutMs);
|
||||||
|
promise.then(
|
||||||
|
(value) => {
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
resolve(value);
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
reject(error);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
export type CameraAccessResult = {
|
||||||
|
success: boolean;
|
||||||
|
granted: boolean;
|
||||||
|
status: string;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getDeniedStatus(error: unknown) {
|
||||||
|
if (error instanceof DOMException) {
|
||||||
|
return error.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requestCameraAccess(): Promise<CameraAccessResult> {
|
||||||
|
if (window.electronAPI?.requestCameraAccess) {
|
||||||
|
try {
|
||||||
|
const electronResult = await window.electronAPI.requestCameraAccess();
|
||||||
|
if (!electronResult.success || !electronResult.granted) {
|
||||||
|
return electronResult;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
granted: false,
|
||||||
|
status: "error",
|
||||||
|
error: String(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!navigator.mediaDevices?.getUserMedia) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
granted: false,
|
||||||
|
status: "unsupported",
|
||||||
|
error: "Camera access is not supported in this runtime.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
audio: false,
|
||||||
|
video: true,
|
||||||
|
});
|
||||||
|
stream.getTracks().forEach((track) => track.stop());
|
||||||
|
return { success: true, granted: true, status: "granted" };
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
granted: false,
|
||||||
|
status: getDeniedStatus(error),
|
||||||
|
error: String(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
+25
-4
@@ -21,14 +21,16 @@ export interface ShortcutBinding {
|
|||||||
export type ShortcutsConfig = Record<ShortcutAction, ShortcutBinding>;
|
export type ShortcutsConfig = Record<ShortcutAction, ShortcutBinding>;
|
||||||
|
|
||||||
export interface FixedShortcut {
|
export interface FixedShortcut {
|
||||||
|
i18nKey: string;
|
||||||
label: string;
|
label: string;
|
||||||
display: string;
|
display: string;
|
||||||
bindings: ShortcutBinding[];
|
bindings: ShortcutBinding[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FIXED_SHORTCUTS: FixedShortcut[] = [
|
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",
|
label: "Redo",
|
||||||
display: "Ctrl + Shift + Z / Ctrl + Y",
|
display: "Ctrl + Shift + Z / Ctrl + Y",
|
||||||
bindings: [
|
bindings: [
|
||||||
@@ -36,19 +38,38 @@ export const FIXED_SHORTCUTS: FixedShortcut[] = [
|
|||||||
{ key: "y", ctrl: true },
|
{ 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",
|
label: "Cycle Annotations Backward",
|
||||||
display: "Shift + Tab",
|
display: "Shift + Tab",
|
||||||
bindings: [{ key: "tab", shift: true }],
|
bindings: [{ key: "tab", shift: true }],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
i18nKey: "deleteSelectedAlt",
|
||||||
label: "Delete Selected (alt)",
|
label: "Delete Selected (alt)",
|
||||||
display: "Del / ⌫",
|
display: "Del / ⌫",
|
||||||
bindings: [{ key: "delete" }, { key: "backspace" }],
|
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 =
|
export type ShortcutConflict =
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import type { ExportFormat, ExportQuality } from "@/lib/exporter";
|
||||||
|
import type { AspectRatio } from "@/utils/aspectRatioUtils";
|
||||||
|
|
||||||
|
const PREFS_KEY = "openscreen_user_preferences";
|
||||||
|
|
||||||
|
const VALID_ASPECT_RATIOS: readonly string[] = [
|
||||||
|
"16:9",
|
||||||
|
"9:16",
|
||||||
|
"1:1",
|
||||||
|
"4:3",
|
||||||
|
"4:5",
|
||||||
|
"16:10",
|
||||||
|
"10:16",
|
||||||
|
"native",
|
||||||
|
];
|
||||||
|
|
||||||
|
export interface UserPreferences {
|
||||||
|
/** Default padding % */
|
||||||
|
padding: number;
|
||||||
|
/** Default aspect ratio */
|
||||||
|
aspectRatio: AspectRatio;
|
||||||
|
/** Default export quality */
|
||||||
|
exportQuality: ExportQuality;
|
||||||
|
/** Default export format */
|
||||||
|
exportFormat: ExportFormat;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_PREFS: UserPreferences = {
|
||||||
|
padding: 50,
|
||||||
|
aspectRatio: "16:9",
|
||||||
|
exportQuality: "good",
|
||||||
|
exportFormat: "mp4",
|
||||||
|
};
|
||||||
|
|
||||||
|
function safeJsonParse(text: string | null): Record<string, unknown> | null {
|
||||||
|
if (!text) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load persisted user preferences from localStorage.
|
||||||
|
* Returns defaults for any missing or invalid fields.
|
||||||
|
*/
|
||||||
|
export function loadUserPreferences(): UserPreferences {
|
||||||
|
let raw: Record<string, unknown> | null = null;
|
||||||
|
try {
|
||||||
|
raw = safeJsonParse(localStorage.getItem(PREFS_KEY));
|
||||||
|
} catch {
|
||||||
|
return { ...DEFAULT_PREFS };
|
||||||
|
}
|
||||||
|
if (!raw || typeof raw !== "object") return { ...DEFAULT_PREFS };
|
||||||
|
|
||||||
|
return {
|
||||||
|
padding:
|
||||||
|
typeof raw.padding === "number" &&
|
||||||
|
Number.isFinite(raw.padding) &&
|
||||||
|
raw.padding >= 0 &&
|
||||||
|
raw.padding <= 100
|
||||||
|
? raw.padding
|
||||||
|
: DEFAULT_PREFS.padding,
|
||||||
|
aspectRatio:
|
||||||
|
typeof raw.aspectRatio === "string" && VALID_ASPECT_RATIOS.includes(raw.aspectRatio)
|
||||||
|
? (raw.aspectRatio as AspectRatio)
|
||||||
|
: DEFAULT_PREFS.aspectRatio,
|
||||||
|
exportQuality:
|
||||||
|
raw.exportQuality === "medium" ||
|
||||||
|
raw.exportQuality === "good" ||
|
||||||
|
raw.exportQuality === "source"
|
||||||
|
? (raw.exportQuality as ExportQuality)
|
||||||
|
: DEFAULT_PREFS.exportQuality,
|
||||||
|
exportFormat:
|
||||||
|
raw.exportFormat === "gif" || raw.exportFormat === "mp4"
|
||||||
|
? (raw.exportFormat as ExportFormat)
|
||||||
|
: DEFAULT_PREFS.exportFormat,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist user preferences to localStorage.
|
||||||
|
* Only the explicitly provided fields are updated.
|
||||||
|
*/
|
||||||
|
export function saveUserPreferences(partial: Partial<UserPreferences>): void {
|
||||||
|
const current = loadUserPreferences();
|
||||||
|
const merged = { ...current, ...partial };
|
||||||
|
try {
|
||||||
|
localStorage.setItem(PREFS_KEY, JSON.stringify(merged));
|
||||||
|
} catch {
|
||||||
|
// localStorage may be unavailable (e.g. private browsing quota exceeded)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { computeWebcamOverlayLayout } from "./webcamOverlay";
|
|
||||||
|
|
||||||
describe("computeWebcamOverlayLayout", () => {
|
|
||||||
it("anchors the overlay in the lower-right corner", () => {
|
|
||||||
const layout = computeWebcamOverlayLayout({
|
|
||||||
stageWidth: 1920,
|
|
||||||
stageHeight: 1080,
|
|
||||||
videoWidth: 1280,
|
|
||||||
videoHeight: 720,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(layout).not.toBeNull();
|
|
||||||
expect(layout!.x + layout!.width).toBeLessThanOrEqual(1920);
|
|
||||||
expect(layout!.y + layout!.height).toBeLessThanOrEqual(1080);
|
|
||||||
expect(layout!.x).toBeGreaterThan(1920 / 2);
|
|
||||||
expect(layout!.y).toBeGreaterThan(1080 / 2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps the overlay within the configured stage fraction while preserving aspect ratio", () => {
|
|
||||||
const layout = computeWebcamOverlayLayout({
|
|
||||||
stageWidth: 1280,
|
|
||||||
stageHeight: 720,
|
|
||||||
videoWidth: 1920,
|
|
||||||
videoHeight: 1080,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(layout).not.toBeNull();
|
|
||||||
expect(layout!.width).toBeLessThanOrEqual(Math.round(1280 * 0.18) + 1);
|
|
||||||
expect(layout!.height).toBeLessThanOrEqual(Math.round(720 * 0.18) + 1);
|
|
||||||
expect(Math.abs(layout!.width * 1080 - layout!.height * 1920)).toBeLessThanOrEqual(1920);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
export interface WebcamOverlayLayout {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
margin: number;
|
|
||||||
borderRadius: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const MAX_STAGE_FRACTION = 0.18;
|
|
||||||
const MARGIN_FRACTION = 0.02;
|
|
||||||
const MIN_SIZE = 96;
|
|
||||||
const MAX_BORDER_RADIUS = 24;
|
|
||||||
|
|
||||||
export function computeWebcamOverlayLayout(params: {
|
|
||||||
stageWidth: number;
|
|
||||||
stageHeight: number;
|
|
||||||
videoWidth: number;
|
|
||||||
videoHeight: number;
|
|
||||||
}): WebcamOverlayLayout | null {
|
|
||||||
const { stageWidth, stageHeight, videoWidth, videoHeight } = params;
|
|
||||||
|
|
||||||
if (stageWidth <= 0 || stageHeight <= 0 || videoWidth <= 0 || videoHeight <= 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const margin = Math.max(12, Math.round(Math.min(stageWidth, stageHeight) * MARGIN_FRACTION));
|
|
||||||
const maxWidth = Math.max(MIN_SIZE, stageWidth * MAX_STAGE_FRACTION);
|
|
||||||
const maxHeight = Math.max(MIN_SIZE, stageHeight * MAX_STAGE_FRACTION);
|
|
||||||
const scale = Math.min(maxWidth / videoWidth, maxHeight / videoHeight);
|
|
||||||
const width = Math.round(videoWidth * scale);
|
|
||||||
const height = Math.round(videoHeight * scale);
|
|
||||||
|
|
||||||
return {
|
|
||||||
x: Math.max(0, Math.round(stageWidth - margin - width)),
|
|
||||||
y: Math.max(0, Math.round(stageHeight - margin - height)),
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
margin,
|
|
||||||
borderRadius: Math.min(
|
|
||||||
MAX_BORDER_RADIUS,
|
|
||||||
Math.max(12, Math.round(Math.min(width, height) * 0.12)),
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
+4
-1
@@ -1,10 +1,13 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from "react-dom/client";
|
||||||
import App from "./App.tsx";
|
import App from "./App.tsx";
|
||||||
|
import { I18nProvider } from "./contexts/I18nContext";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<App />
|
<I18nProvider>
|
||||||
|
<App />
|
||||||
|
</I18nProvider>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -67,6 +67,10 @@ export function getAspectRatioLabel(aspectRatio: AspectRatio): string {
|
|||||||
return aspectRatio;
|
return aspectRatio;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isPortraitAspectRatio(aspectRatio: AspectRatio): boolean {
|
||||||
|
return getAspectRatioValue(aspectRatio) < 1;
|
||||||
|
}
|
||||||
|
|
||||||
export function formatAspectRatioForCSS(aspectRatio: AspectRatio, nativeRatio?: number): string {
|
export function formatAspectRatioForCSS(aspectRatio: AspectRatio, nativeRatio?: number): string {
|
||||||
if (aspectRatio === "native") return String(nativeRatio ?? 16 / 9);
|
if (aspectRatio === "native") return String(nativeRatio ?? 16 / 9);
|
||||||
return aspectRatio.replace(":", "/");
|
return aspectRatio.replace(":", "/");
|
||||||
|
|||||||
Vendored
+1
@@ -119,5 +119,6 @@ interface Window {
|
|||||||
setMicrophoneExpanded: (expanded: boolean) => void;
|
setMicrophoneExpanded: (expanded: boolean) => void;
|
||||||
setHasUnsavedChanges: (hasChanges: boolean) => void;
|
setHasUnsavedChanges: (hasChanges: boolean) => void;
|
||||||
onRequestSaveBeforeClose: (callback: () => Promise<boolean> | boolean) => () => void;
|
onRequestSaveBeforeClose: (callback: () => Promise<boolean> | boolean) => () => void;
|
||||||
|
setLocale: (locale: string) => Promise<void>;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,12 +11,15 @@ const TEST_VIDEO = path.join(__dirname, "../fixtures/sample.webm");
|
|||||||
|
|
||||||
test("exports a GIF from a loaded video", async () => {
|
test("exports a GIF from a loaded video", async () => {
|
||||||
const outputPath = path.join(os.tmpdir(), `test-gif-export-${Date.now()}.gif`);
|
const outputPath = path.join(os.tmpdir(), `test-gif-export-${Date.now()}.gif`);
|
||||||
|
let testVideoInRecordings = "";
|
||||||
|
|
||||||
const app = await electron.launch({
|
const app = await electron.launch({
|
||||||
args: [
|
args: [
|
||||||
MAIN_JS,
|
MAIN_JS,
|
||||||
// Required in CI sandbox environments (GitHub Actions, Docker, etc.)
|
// Required in CI sandbox environments (GitHub Actions, Docker, etc.)
|
||||||
"--no-sandbox",
|
"--no-sandbox",
|
||||||
|
// Force software WebGL in headless CI to avoid GPU framebuffer errors.
|
||||||
|
"--enable-unsafe-swiftshader",
|
||||||
],
|
],
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
@@ -58,14 +61,25 @@ test("exports a GIF from a loaded video", async () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
await hudWindow.evaluate((videoPath: string) => {
|
// Copy the test fixture into the app's recordings directory so it passes
|
||||||
window.electronAPI.setCurrentVideoPath(videoPath);
|
// the path security check in set-current-video-path.
|
||||||
try {
|
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();
|
window.electronAPI.switchToEditor();
|
||||||
} catch {
|
}, testVideoInRecordings);
|
||||||
// Expected: HUD window closes during this call, killing the context.
|
} catch {
|
||||||
}
|
// Expected: switchToEditor() closes the HUD window, terminating
|
||||||
}, TEST_VIDEO);
|
// the Playwright page context before evaluate() can resolve.
|
||||||
|
}
|
||||||
|
|
||||||
// ── 3. Switch to the editor window. This closes the HUD and opens
|
// ── 3. Switch to the editor window. This closes the HUD and opens
|
||||||
// a new BrowserWindow with ?windowType=editor.
|
// a new BrowserWindow with ?windowType=editor.
|
||||||
@@ -87,8 +101,8 @@ test("exports a GIF from a loaded video", async () => {
|
|||||||
await editorWindow.getByTestId("testId-gif-format-button").click();
|
await editorWindow.getByTestId("testId-gif-format-button").click();
|
||||||
await editorWindow.getByTestId("testId-export-button").click();
|
await editorWindow.getByTestId("testId-export-button").click();
|
||||||
|
|
||||||
// ── 6. Wait for the toast to say exported successfully
|
// ── 6. Wait for the success toast.
|
||||||
await expect(editorWindow.getByText(`GIF exported successfully to pending`)).toBeVisible({
|
await expect(editorWindow.getByText("GIF exported successfully")).toBeVisible({
|
||||||
timeout: 90_000,
|
timeout: 90_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -116,5 +130,8 @@ test("exports a GIF from a loaded video", async () => {
|
|||||||
if (fs.existsSync(outputPath)) {
|
if (fs.existsSync(outputPath)) {
|
||||||
fs.unlinkSync(outputPath);
|
fs.unlinkSync(outputPath);
|
||||||
}
|
}
|
||||||
|
if (testVideoInRecordings && fs.existsSync(testVideoInRecordings)) {
|
||||||
|
fs.unlinkSync(testVideoInRecordings);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ import { defineConfig } from "vitest/config";
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
test: {
|
test: {
|
||||||
globals: true,
|
globals: true,
|
||||||
environment: "node",
|
environment: "jsdom",
|
||||||
include: ["src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"],
|
include: ["src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"],
|
||||||
},
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
|
|||||||
Reference in New Issue
Block a user