Merge main into add-vietnamese-i18n-1022783609047552672
Resolve conflict in electron/i18n.ts by keeping both `ar` (from main) and `vi` (from this branch). Also add `vi` to SUPPORTED_LOCALES in src/i18n/config.ts so Vietnamese is selectable in the language picker.
This commit is contained in:
@@ -20,18 +20,15 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Install app dependencies
|
||||
run: npx electron-builder install-app-deps
|
||||
|
||||
|
||||
- name: Build Windows app
|
||||
run: npm run build:win
|
||||
env:
|
||||
@@ -234,8 +231,10 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Install app dependencies
|
||||
run: npx electron-builder install-app-deps
|
||||
# bsdtar (from libarchive-tools) is required by fpm to build pacman
|
||||
# packages. AppImage and deb don't need it; ubuntu-latest doesn't ship it.
|
||||
- name: Install pacman build dependencies
|
||||
run: sudo apt-get update && sudo apt-get install -y libarchive-tools
|
||||
|
||||
- name: Build Linux app
|
||||
run: npm run build:linux
|
||||
@@ -250,4 +249,5 @@ jobs:
|
||||
release/**/*.AppImage
|
||||
release/**/*.zsync
|
||||
release/**/*.deb
|
||||
release/**/*.pacman
|
||||
retention-days: 30
|
||||
|
||||
@@ -41,6 +41,7 @@ jobs:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run test
|
||||
- run: npm run test:browser:install
|
||||
- run: npm run test:browser
|
||||
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
# Writing Tests
|
||||
|
||||
This project uses [Vitest](https://vitest.dev/) for both unit/integration tests and browser tests. There are two separate configs — each targets a different set of files.
|
||||
|
||||
## Unit tests
|
||||
|
||||
**Config:** `vitest.config.ts`
|
||||
**Runs in:** jsdom (simulated DOM, no real browser)
|
||||
**File pattern:** `src/**/*.test.ts` — anything that does **not** end in `.browser.test.ts`
|
||||
**CI command:** `npm run test`
|
||||
|
||||
Use unit tests for pure logic, utility functions, data transformations, and anything that doesn't need real browser APIs (Canvas, WebCodecs, MediaRecorder, etc.).
|
||||
|
||||
### File placement
|
||||
|
||||
Co-locate the test file next to the source file, or put it in a `__tests__/` folder in the same directory.
|
||||
|
||||
```
|
||||
src/lib/compositeLayout.ts
|
||||
src/lib/compositeLayout.test.ts # co-located
|
||||
|
||||
src/i18n/__tests__/tutorialHelpTranslations.test.ts # grouped
|
||||
```
|
||||
|
||||
### Example
|
||||
|
||||
```ts
|
||||
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!.x).toBeGreaterThan(1920 / 2);
|
||||
expect(layout!.webcamRect!.y).toBeGreaterThan(1080 / 2);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Path aliases
|
||||
|
||||
The `@/` alias resolves to `src/`. Use it for imports that would otherwise need long relative paths.
|
||||
|
||||
```ts
|
||||
import { SUPPORTED_LOCALES } from "@/i18n/config";
|
||||
```
|
||||
|
||||
### Running locally
|
||||
|
||||
```bash
|
||||
npm run test # run once
|
||||
npm run test:watch # watch mode
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Browser tests
|
||||
|
||||
**Config:** `vitest.browser.config.ts`
|
||||
**Runs in:** real Chromium via Playwright (headless)
|
||||
**File pattern:** `src/**/*.browser.test.ts`
|
||||
**CI commands:** `npm run test:browser:install` then `npm run test:browser`
|
||||
|
||||
Use browser tests when the code under test depends on real browser APIs that jsdom doesn't implement: `VideoDecoder`, `VideoEncoder`, `MediaRecorder`, `OffscreenCanvas`, `WebGL`, etc.
|
||||
|
||||
### File placement
|
||||
|
||||
Name the file `<subject>.browser.test.ts` and place it next to the source file.
|
||||
|
||||
```
|
||||
src/lib/exporter/videoExporter.ts
|
||||
src/lib/exporter/videoExporter.browser.test.ts
|
||||
```
|
||||
|
||||
### Loading fixture assets
|
||||
|
||||
Static assets (video files, images) live in `tests/fixtures/`. Import them with Vite's `?url` suffix so Vite serves them through the dev server.
|
||||
|
||||
```ts
|
||||
import sampleVideoUrl from "../../../tests/fixtures/sample.webm?url";
|
||||
```
|
||||
|
||||
### Example
|
||||
|
||||
```ts
|
||||
import { describe, expect, it } from "vitest";
|
||||
import sampleVideoUrl from "../../../tests/fixtures/sample.webm?url";
|
||||
import { VideoExporter } from "./videoExporter";
|
||||
|
||||
describe("VideoExporter (real browser)", () => {
|
||||
it("exports a valid MP4 blob from a real video", async () => {
|
||||
const exporter = new VideoExporter({
|
||||
videoUrl: sampleVideoUrl,
|
||||
width: 320,
|
||||
height: 180,
|
||||
frameRate: 15,
|
||||
bitrate: 1_000_000,
|
||||
wallpaper: "#1a1a2e",
|
||||
zoomRegions: [],
|
||||
showShadow: false,
|
||||
shadowIntensity: 0,
|
||||
showBlur: false,
|
||||
cropRegion: { x: 0, y: 0, width: 1, height: 1 },
|
||||
});
|
||||
|
||||
const result = await exporter.export();
|
||||
|
||||
expect(result.success, result.error).toBe(true);
|
||||
expect(result.blob).toBeInstanceOf(Blob);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Timeouts
|
||||
|
||||
Browser tests have a default timeout of 120 seconds per test and 30 seconds per hook (set in `vitest.browser.config.ts`). Export operations are slow — prefer small fixture dimensions (320×180) and low bitrates to keep tests fast.
|
||||
|
||||
### Running locally
|
||||
|
||||
First install the browser (one-time):
|
||||
|
||||
```bash
|
||||
npm run test:browser:install
|
||||
```
|
||||
|
||||
Then run the tests:
|
||||
|
||||
```bash
|
||||
npm run test:browser
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Choosing the right type
|
||||
|
||||
| Situation | Use |
|
||||
|---|---|
|
||||
| Pure function / data transformation | Unit test |
|
||||
| i18n key coverage | Unit test |
|
||||
| React hook logic (no real browser APIs) | Unit test |
|
||||
| `VideoDecoder` / `VideoEncoder` / `MediaRecorder` | Browser test |
|
||||
| `OffscreenCanvas` / WebGL / Pixi.js rendering | Browser test |
|
||||
| File export producing a real `Blob` | Browser test |
|
||||
@@ -3,6 +3,11 @@
|
||||
"$schema": "https://raw.githubusercontent.com/electron-userland/electron-builder/master/packages/app-builder-lib/scheme.json",
|
||||
"appId": "com.siddharthvaddem.openscreen",
|
||||
"asar": true,
|
||||
// .node binaries can't be dlopen'd from inside an asar — must live unpacked.
|
||||
"asarUnpack": [
|
||||
"node_modules/uiohook-napi/**/*",
|
||||
"**/*.node"
|
||||
],
|
||||
"productName": "Openscreen",
|
||||
"npmRebuild": true,
|
||||
"buildDependenciesFromSource": true,
|
||||
@@ -46,13 +51,16 @@
|
||||
"NSAudioCaptureUsageDescription": "OpenScreen needs audio capture permission to record system audio.",
|
||||
"NSMicrophoneUsageDescription": "OpenScreen needs microphone access to record voice audio.",
|
||||
"NSCameraUsageDescription": "OpenScreen needs camera access to record webcam video.",
|
||||
"NSScreenCaptureUsageDescription": "OpenScreen needs screen recording permission to detect and capture windows.",
|
||||
"NSCameraUseContinuityCameraDeviceType": true,
|
||||
"com.apple.security.device.audio-input": true
|
||||
}
|
||||
},
|
||||
"linux": {
|
||||
"target": [
|
||||
"AppImage"
|
||||
"AppImage",
|
||||
"deb",
|
||||
"pacman"
|
||||
],
|
||||
"icon": "icons/icons/png",
|
||||
"artifactName": "${productName}-Linux-${version}.${ext}",
|
||||
|
||||
Vendored
+32
-3
@@ -37,6 +37,11 @@ interface Window {
|
||||
status: string;
|
||||
error?: string;
|
||||
}>;
|
||||
requestAccessibilityAccess: () => Promise<{
|
||||
success: boolean;
|
||||
granted: boolean;
|
||||
error?: string;
|
||||
}>;
|
||||
assetBaseUrl: string;
|
||||
storeRecordedVideo: (
|
||||
videoData: ArrayBuffer,
|
||||
@@ -68,15 +73,31 @@ interface Window {
|
||||
getCursorTelemetry: (videoPath?: string) => Promise<{
|
||||
success: boolean;
|
||||
samples: CursorTelemetryPoint[];
|
||||
clicks: number[];
|
||||
message?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
onStopRecordingFromTray: (callback: () => void) => () => void;
|
||||
openExternalUrl: (url: string) => Promise<{ success: boolean; error?: string }>;
|
||||
saveExportedVideo: (
|
||||
videoData: ArrayBuffer,
|
||||
pickExportSavePath: (
|
||||
fileName: string,
|
||||
) => Promise<{ success: boolean; path?: string; message?: string; canceled?: boolean }>;
|
||||
exportFolder?: string,
|
||||
) => Promise<{
|
||||
success: boolean;
|
||||
path?: string;
|
||||
message?: string;
|
||||
canceled?: boolean;
|
||||
error?: string;
|
||||
}>;
|
||||
writeExportToPath: (
|
||||
videoData: ArrayBuffer,
|
||||
filePath: string,
|
||||
) => Promise<{
|
||||
success: boolean;
|
||||
path?: string;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
openVideoFilePicker: () => Promise<{ success: boolean; path?: string; canceled?: boolean }>;
|
||||
setCurrentVideoPath: (path: string) => Promise<{ success: boolean }>;
|
||||
setCurrentRecordingSession: (
|
||||
@@ -143,7 +164,15 @@ interface Window {
|
||||
setMicrophoneExpanded: (expanded: boolean) => void;
|
||||
setHasUnsavedChanges: (hasChanges: boolean) => void;
|
||||
onRequestSaveBeforeClose: (callback: () => Promise<boolean> | boolean) => () => void;
|
||||
onRequestCloseConfirm: (callback: () => void) => () => void;
|
||||
sendCloseConfirmResponse: (choice: "save" | "discard" | "cancel") => void;
|
||||
setLocale: (locale: string) => Promise<void>;
|
||||
saveDiagnostic: (payload: {
|
||||
error: string;
|
||||
stack?: string;
|
||||
projectState: unknown;
|
||||
logs: string[];
|
||||
}) => Promise<{ success: boolean; path?: string; canceled?: boolean; error?: string }>;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+5
-1
@@ -1,6 +1,8 @@
|
||||
// Lightweight i18n for the Electron main process.
|
||||
// Imports the same JSON translation files used by the renderer.
|
||||
|
||||
import commonAr from "../src/i18n/locales/ar/common.json";
|
||||
import dialogsAr from "../src/i18n/locales/ar/dialogs.json";
|
||||
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";
|
||||
@@ -20,7 +22,7 @@ import dialogsZh from "../src/i18n/locales/zh-CN/dialogs.json";
|
||||
import commonZhTw from "../src/i18n/locales/zh-TW/common.json";
|
||||
import dialogsZhTw from "../src/i18n/locales/zh-TW/dialogs.json";
|
||||
|
||||
type Locale = "en" | "zh-CN" | "zh-TW" | "es" | "fr" | "ja-JP" | "ko-KR" | "tr" | "vi";
|
||||
type Locale = "en" | "zh-CN" | "zh-TW" | "es" | "fr" | "ja-JP" | "ko-KR" | "tr" | "ar" | "vi";
|
||||
type Namespace = "common" | "dialogs";
|
||||
type MessageMap = Record<string, unknown>;
|
||||
|
||||
@@ -33,6 +35,7 @@ const messages: Record<Locale, Record<Namespace, MessageMap>> = {
|
||||
"ja-JP": { common: commonJa, dialogs: dialogsJa },
|
||||
"ko-KR": { common: commonKo, dialogs: dialogsKo },
|
||||
tr: { common: commonTr, dialogs: dialogsTr },
|
||||
ar: { common: commonAr, dialogs: dialogsAr },
|
||||
vi: { common: commonVi, dialogs: dialogsVi },
|
||||
};
|
||||
|
||||
@@ -48,6 +51,7 @@ export function setMainLocale(locale: string) {
|
||||
locale === "ja-JP" ||
|
||||
locale === "ko-KR" ||
|
||||
locale === "tr" ||
|
||||
locale === "ar" ||
|
||||
locale === "vi"
|
||||
) {
|
||||
currentLocale = locale;
|
||||
|
||||
+321
-70
@@ -1,6 +1,11 @@
|
||||
import fs from "node:fs/promises";
|
||||
import { createRequire } from "node:module";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const nodeRequire = createRequire(import.meta.url);
|
||||
|
||||
import {
|
||||
app,
|
||||
BrowserWindow,
|
||||
@@ -56,6 +61,21 @@ function isPathAllowed(filePath: string): boolean {
|
||||
return getAllowedReadDirs().some((dir) => isPathWithinDir(resolved, dir));
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to build dialog options with a parent window only when it's valid.
|
||||
* This prevents passing stale or destroyed BrowserWindow references to dialog calls.
|
||||
*/
|
||||
function buildDialogOptions<T extends Electron.OpenDialogOptions | Electron.SaveDialogOptions>(
|
||||
baseOptions: T,
|
||||
parentWindow: BrowserWindow | null,
|
||||
): T & { parent?: BrowserWindow } {
|
||||
const mainWindow = parentWindow;
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
return { ...baseOptions, parent: mainWindow };
|
||||
}
|
||||
return baseOptions;
|
||||
}
|
||||
|
||||
function hasAllowedImportVideoExtension(filePath: string): boolean {
|
||||
return ALLOWED_IMPORT_VIDEO_EXTENSIONS.has(path.extname(filePath).toLowerCase());
|
||||
}
|
||||
@@ -280,19 +300,24 @@ async function storeRecordedSessionFiles(payload: StoreRecordedSessionInput) {
|
||||
|
||||
const telemetryPath = `${screenVideoPath}.cursor.json`;
|
||||
const pendingBatch = cursorTelemetryBuffer.takeNextBatch();
|
||||
if (pendingBatch && pendingBatch.samples.length > 0) {
|
||||
const pendingClicks = takeCursorClickTimestamps();
|
||||
if ((pendingBatch && pendingBatch.samples.length > 0) || pendingClicks.length > 0) {
|
||||
try {
|
||||
await fs.writeFile(
|
||||
telemetryPath,
|
||||
JSON.stringify(
|
||||
{ version: CURSOR_TELEMETRY_VERSION, samples: pendingBatch.samples },
|
||||
{
|
||||
version: CURSOR_TELEMETRY_VERSION,
|
||||
samples: pendingBatch?.samples ?? [],
|
||||
clicks: pendingClicks,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
} catch (err) {
|
||||
cursorTelemetryBuffer.prependBatch(pendingBatch);
|
||||
if (pendingBatch) cursorTelemetryBuffer.prependBatch(pendingBatch);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -321,15 +346,114 @@ const cursorTelemetryBuffer = createCursorTelemetryBuffer({
|
||||
maxActiveSamples: MAX_CURSOR_SAMPLES,
|
||||
});
|
||||
|
||||
// Mouse click timestamps (macOS only — uiohook-napi behind Accessibility).
|
||||
const MAX_CURSOR_CLICKS = 60 * 60 * 60; // ~1 click/sec for an hour
|
||||
let cursorClickTimestampsMs: number[] = [];
|
||||
let uioHookInstance: {
|
||||
start: () => void;
|
||||
stop: () => void;
|
||||
on: (...a: unknown[]) => void;
|
||||
off?: (...a: unknown[]) => void;
|
||||
removeListener?: (...a: unknown[]) => void;
|
||||
} | null = null;
|
||||
let uioHookMouseDownHandler: ((event: { time?: number }) => void) | null = null;
|
||||
let uioHookFailureLogged = false;
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function loadUioHookForClicks(): typeof uioHookInstance {
|
||||
try {
|
||||
// Dynamic require + try/catch so a broken native binary doesn't crash startup.
|
||||
const mod = nodeRequire("uiohook-napi");
|
||||
const candidate = mod.uIOhook ?? mod.default?.uIOhook ?? mod.uiohook ?? mod.default;
|
||||
if (candidate && typeof candidate.start === "function" && typeof candidate.on === "function") {
|
||||
return candidate;
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
if (!uioHookFailureLogged) {
|
||||
uioHookFailureLogged = true;
|
||||
console.warn("[clickCapture] uiohook-napi unavailable:", error);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function startClickCapture() {
|
||||
if (process.platform !== "darwin") return;
|
||||
if (uioHookInstance) return;
|
||||
|
||||
// Passive check — the prompt fires from the renderer when the user toggles
|
||||
// "Only on clicks" so it doesn't stack with the screen-recording prompt.
|
||||
try {
|
||||
if (!systemPreferences.isTrustedAccessibilityClient(false)) {
|
||||
if (!uioHookFailureLogged) {
|
||||
uioHookFailureLogged = true;
|
||||
console.warn(
|
||||
"[clickCapture] Accessibility permission not granted — click capture disabled.",
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// fall through; uiohook will fail defensively below
|
||||
}
|
||||
|
||||
const hook = loadUioHookForClicks();
|
||||
if (!hook) return;
|
||||
|
||||
uioHookMouseDownHandler = (event) => {
|
||||
const elapsed = Math.max(0, Date.now() - cursorCaptureStartTimeMs);
|
||||
void event;
|
||||
if (cursorClickTimestampsMs.length >= MAX_CURSOR_CLICKS) return;
|
||||
cursorClickTimestampsMs.push(elapsed);
|
||||
};
|
||||
|
||||
try {
|
||||
hook.on("mousedown", uioHookMouseDownHandler);
|
||||
hook.start();
|
||||
uioHookInstance = hook;
|
||||
} catch (error) {
|
||||
if (!uioHookFailureLogged) {
|
||||
uioHookFailureLogged = true;
|
||||
console.warn("[clickCapture] failed to start uiohook:", error);
|
||||
}
|
||||
uioHookMouseDownHandler = null;
|
||||
}
|
||||
}
|
||||
|
||||
function stopClickCapture() {
|
||||
if (!uioHookInstance) return;
|
||||
try {
|
||||
if (uioHookMouseDownHandler) {
|
||||
if (typeof uioHookInstance.off === "function") {
|
||||
uioHookInstance.off("mousedown", uioHookMouseDownHandler);
|
||||
} else if (typeof uioHookInstance.removeListener === "function") {
|
||||
uioHookInstance.removeListener("mousedown", uioHookMouseDownHandler);
|
||||
}
|
||||
}
|
||||
uioHookInstance.stop();
|
||||
} catch (error) {
|
||||
console.warn("[clickCapture] failed to stop uiohook:", error);
|
||||
}
|
||||
uioHookInstance = null;
|
||||
uioHookMouseDownHandler = null;
|
||||
}
|
||||
|
||||
function takeCursorClickTimestamps(): number[] {
|
||||
const out = cursorClickTimestampsMs;
|
||||
cursorClickTimestampsMs = [];
|
||||
return out;
|
||||
}
|
||||
|
||||
function stopCursorCapture() {
|
||||
if (cursorCaptureInterval) {
|
||||
clearInterval(cursorCaptureInterval);
|
||||
cursorCaptureInterval = null;
|
||||
}
|
||||
stopClickCapture();
|
||||
}
|
||||
|
||||
function sampleCursorPoint() {
|
||||
@@ -526,14 +650,27 @@ export function registerIpcHandlers(
|
||||
});
|
||||
|
||||
ipcMain.handle("get-sources", async (_, opts) => {
|
||||
const ownWindowSourceIds = new Set(
|
||||
BrowserWindow.getAllWindows()
|
||||
.map((win) => {
|
||||
try {
|
||||
return win.getMediaSourceId();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter((id): id is string => Boolean(id)),
|
||||
);
|
||||
const sources = await desktopCapturer.getSources(opts);
|
||||
return sources.map((source) => ({
|
||||
id: source.id,
|
||||
name: source.name,
|
||||
display_id: source.display_id,
|
||||
thumbnail: source.thumbnail ? source.thumbnail.toDataURL() : null,
|
||||
appIcon: source.appIcon ? source.appIcon.toDataURL() : null,
|
||||
}));
|
||||
return sources
|
||||
.filter((source) => !ownWindowSourceIds.has(source.id))
|
||||
.map((source) => ({
|
||||
id: source.id,
|
||||
name: source.name,
|
||||
display_id: source.display_id,
|
||||
thumbnail: source.thumbnail ? source.thumbnail.toDataURL() : null,
|
||||
appIcon: source.appIcon ? source.appIcon.toDataURL() : null,
|
||||
}));
|
||||
});
|
||||
|
||||
ipcMain.handle("select-source", (_, source: SelectedSource) => {
|
||||
@@ -581,6 +718,22 @@ export function registerIpcHandlers(
|
||||
}
|
||||
});
|
||||
|
||||
// macOS Accessibility prompt for global click capture. First call shows the
|
||||
// system dialog; the user has to toggle the app in System Settings (no
|
||||
// programmatic grant exists for Accessibility).
|
||||
ipcMain.handle("request-accessibility-access", () => {
|
||||
if (process.platform !== "darwin") {
|
||||
return { success: true, granted: true };
|
||||
}
|
||||
try {
|
||||
const granted = systemPreferences.isTrustedAccessibilityClient(true);
|
||||
return { success: true, granted };
|
||||
} catch (error) {
|
||||
console.error("Failed to request accessibility access:", error);
|
||||
return { success: false, granted: false, error: String(error) };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("open-source-selector", () => {
|
||||
const sourceSelectorWin = getSourceSelectorWindow();
|
||||
if (sourceSelectorWin) {
|
||||
@@ -710,6 +863,8 @@ export function registerIpcHandlers(
|
||||
const id = typeof recordingId === "number" ? recordingId : Date.now();
|
||||
cursorTelemetryBuffer.startSession(id);
|
||||
cursorCaptureStartTimeMs = Date.now();
|
||||
cursorClickTimestampsMs = [];
|
||||
startClickCapture();
|
||||
sampleCursorPoint();
|
||||
cursorCaptureInterval = setInterval(sampleCursorPoint, CURSOR_SAMPLE_INTERVAL_MS);
|
||||
} else {
|
||||
@@ -774,11 +929,19 @@ export function registerIpcHandlers(
|
||||
})
|
||||
.sort((a: CursorTelemetryPoint, b: CursorTelemetryPoint) => a.timeMs - b.timeMs);
|
||||
|
||||
return { success: true, samples };
|
||||
const rawClicks = Array.isArray(parsed?.clicks) ? parsed.clicks : [];
|
||||
const clicks: number[] = rawClicks
|
||||
.map((value: unknown) =>
|
||||
typeof value === "number" && Number.isFinite(value) ? Math.max(0, value) : null,
|
||||
)
|
||||
.filter((v: number | null): v is number => v !== null)
|
||||
.sort((a: number, b: number) => a - b);
|
||||
|
||||
return { success: true, samples, clicks };
|
||||
} catch (error) {
|
||||
const nodeError = error as NodeJS.ErrnoException;
|
||||
if (nodeError.code === "ENOENT") {
|
||||
return { success: true, samples: [] };
|
||||
return { success: true, samples: [], clicks: [] };
|
||||
}
|
||||
console.error("Failed to load cursor telemetry:", error);
|
||||
return {
|
||||
@@ -786,6 +949,7 @@ export function registerIpcHandlers(
|
||||
message: "Failed to load cursor telemetry",
|
||||
error: String(error),
|
||||
samples: [],
|
||||
clicks: [],
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -822,38 +986,72 @@ export function registerIpcHandlers(
|
||||
* @returns Object with success status, optional file path, and error details.
|
||||
*/
|
||||
|
||||
ipcMain.handle("save-exported-video", async (_, videoData: ArrayBuffer, fileName: string) => {
|
||||
ipcMain.handle("pick-export-save-path", async (_, fileName: string, exportFolder?: string) => {
|
||||
try {
|
||||
// Determine file type from extension
|
||||
const isGif = fileName.toLowerCase().endsWith(".gif");
|
||||
const filters = isGif
|
||||
? [{ name: mainT("dialogs", "fileDialogs.gifImage"), extensions: ["gif"] }]
|
||||
: [{ name: mainT("dialogs", "fileDialogs.mp4Video"), extensions: ["mp4"] }];
|
||||
|
||||
const result = await dialog.showSaveDialog({
|
||||
title: isGif
|
||||
? mainT("dialogs", "fileDialogs.saveGif")
|
||||
: mainT("dialogs", "fileDialogs.saveVideo"),
|
||||
defaultPath: path.join(app.getPath("downloads"), fileName),
|
||||
filters,
|
||||
properties: ["createDirectory", "showOverwriteConfirmation"],
|
||||
});
|
||||
// Prefer the user's last export folder if it still exists, otherwise fall
|
||||
// back to ~/Downloads. Validation must happen here because the renderer
|
||||
// can't stat the filesystem.
|
||||
let defaultDir = app.getPath("downloads");
|
||||
if (exportFolder) {
|
||||
try {
|
||||
const stats = await fs.stat(exportFolder);
|
||||
if (stats.isDirectory()) {
|
||||
defaultDir = exportFolder;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`Could not access remembered export folder "${exportFolder}", falling back to Downloads:`,
|
||||
err,
|
||||
);
|
||||
}
|
||||
}
|
||||
const dialogOptions = buildDialogOptions(
|
||||
{
|
||||
title: isGif
|
||||
? mainT("dialogs", "fileDialogs.saveGif")
|
||||
: mainT("dialogs", "fileDialogs.saveVideo"),
|
||||
defaultPath: path.join(defaultDir, fileName),
|
||||
filters,
|
||||
properties: ["createDirectory", "showOverwriteConfirmation"],
|
||||
},
|
||||
getMainWindow(),
|
||||
);
|
||||
const result = await dialog.showSaveDialog(dialogOptions);
|
||||
|
||||
if (result.canceled || !result.filePath) {
|
||||
return {
|
||||
success: false,
|
||||
canceled: true,
|
||||
message: "Export canceled",
|
||||
};
|
||||
return { success: false, canceled: true, message: "Export canceled" };
|
||||
}
|
||||
|
||||
// --- FIX: Normalize the path for Windows compatibility ---
|
||||
const normalizedPath = path.normalize(result.filePath);
|
||||
return { success: true, path: path.normalize(result.filePath) };
|
||||
} catch (error) {
|
||||
console.error("Failed to show save dialog:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed to show save dialog",
|
||||
error: String(error),
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Ensure the parent directory exists (Windows may fail if the folder is missing)
|
||||
ipcMain.handle("write-export-to-path", async (_, videoData: ArrayBuffer, filePath: string) => {
|
||||
try {
|
||||
// Sanity-check the path. The renderer is trusted (contextIsolation is on),
|
||||
// but a stale state bug shouldn't be able to clobber arbitrary files.
|
||||
if (typeof filePath !== "string" || !path.isAbsolute(filePath)) {
|
||||
return { success: false, message: "Invalid path" };
|
||||
}
|
||||
const lower = filePath.toLowerCase();
|
||||
if (!lower.endsWith(".mp4") && !lower.endsWith(".gif")) {
|
||||
return { success: false, message: "Invalid file type" };
|
||||
}
|
||||
|
||||
const normalizedPath = path.normalize(filePath);
|
||||
await fs.mkdir(path.dirname(normalizedPath), { recursive: true });
|
||||
// --- END FIX ---
|
||||
|
||||
await fs.writeFile(normalizedPath, Buffer.from(videoData));
|
||||
|
||||
return {
|
||||
@@ -862,7 +1060,7 @@ export function registerIpcHandlers(
|
||||
message: "Video exported successfully",
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Failed to save exported video:", error);
|
||||
console.error("Failed to write exported video:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed to save exported video",
|
||||
@@ -872,18 +1070,22 @@ export function registerIpcHandlers(
|
||||
});
|
||||
ipcMain.handle("open-video-file-picker", async () => {
|
||||
try {
|
||||
const result = await dialog.showOpenDialog({
|
||||
title: mainT("dialogs", "fileDialogs.selectVideo"),
|
||||
defaultPath: RECORDINGS_DIR,
|
||||
filters: [
|
||||
{
|
||||
name: mainT("dialogs", "fileDialogs.videoFiles"),
|
||||
extensions: ["webm", "mp4", "mov", "avi", "mkv"],
|
||||
},
|
||||
{ name: mainT("dialogs", "fileDialogs.allFiles"), extensions: ["*"] },
|
||||
],
|
||||
properties: ["openFile"],
|
||||
});
|
||||
const dialogOptions = buildDialogOptions(
|
||||
{
|
||||
title: mainT("dialogs", "fileDialogs.selectVideo"),
|
||||
defaultPath: RECORDINGS_DIR,
|
||||
filters: [
|
||||
{
|
||||
name: mainT("dialogs", "fileDialogs.videoFiles"),
|
||||
extensions: ["webm", "mp4", "mov", "avi", "mkv"],
|
||||
},
|
||||
{ name: mainT("dialogs", "fileDialogs.allFiles"), extensions: ["*"] },
|
||||
],
|
||||
properties: ["openFile"],
|
||||
},
|
||||
getMainWindow(),
|
||||
);
|
||||
const result = await dialog.showOpenDialog(dialogOptions);
|
||||
|
||||
if (result.canceled || result.filePaths.length === 0) {
|
||||
return { success: false, canceled: true };
|
||||
@@ -962,18 +1164,22 @@ export function registerIpcHandlers(
|
||||
? safeName
|
||||
: `${safeName}.${PROJECT_FILE_EXTENSION}`;
|
||||
|
||||
const result = await dialog.showSaveDialog({
|
||||
title: mainT("dialogs", "fileDialogs.saveProject"),
|
||||
defaultPath: path.join(RECORDINGS_DIR, defaultName),
|
||||
filters: [
|
||||
{
|
||||
name: mainT("dialogs", "fileDialogs.openscreenProject"),
|
||||
extensions: [PROJECT_FILE_EXTENSION],
|
||||
},
|
||||
{ name: "JSON", extensions: ["json"] },
|
||||
],
|
||||
properties: ["createDirectory", "showOverwriteConfirmation"],
|
||||
});
|
||||
const dialogOptions = buildDialogOptions(
|
||||
{
|
||||
title: mainT("dialogs", "fileDialogs.saveProject"),
|
||||
defaultPath: path.join(RECORDINGS_DIR, defaultName),
|
||||
filters: [
|
||||
{
|
||||
name: mainT("dialogs", "fileDialogs.openscreenProject"),
|
||||
extensions: [PROJECT_FILE_EXTENSION],
|
||||
},
|
||||
{ name: "JSON", extensions: ["json"] },
|
||||
],
|
||||
properties: ["createDirectory", "showOverwriteConfirmation"],
|
||||
},
|
||||
getMainWindow(),
|
||||
);
|
||||
const result = await dialog.showSaveDialog(dialogOptions);
|
||||
|
||||
if (result.canceled || !result.filePath) {
|
||||
return {
|
||||
@@ -1004,19 +1210,23 @@ export function registerIpcHandlers(
|
||||
|
||||
ipcMain.handle("load-project-file", async () => {
|
||||
try {
|
||||
const result = await dialog.showOpenDialog({
|
||||
title: mainT("dialogs", "fileDialogs.openProject"),
|
||||
defaultPath: RECORDINGS_DIR,
|
||||
filters: [
|
||||
{
|
||||
name: mainT("dialogs", "fileDialogs.openscreenProject"),
|
||||
extensions: [PROJECT_FILE_EXTENSION],
|
||||
},
|
||||
{ name: "JSON", extensions: ["json"] },
|
||||
{ name: mainT("dialogs", "fileDialogs.allFiles"), extensions: ["*"] },
|
||||
],
|
||||
properties: ["openFile"],
|
||||
});
|
||||
const dialogOptions = buildDialogOptions(
|
||||
{
|
||||
title: mainT("dialogs", "fileDialogs.openProject"),
|
||||
defaultPath: RECORDINGS_DIR,
|
||||
filters: [
|
||||
{
|
||||
name: mainT("dialogs", "fileDialogs.openscreenProject"),
|
||||
extensions: [PROJECT_FILE_EXTENSION],
|
||||
},
|
||||
{ name: "JSON", extensions: ["json"] },
|
||||
{ name: mainT("dialogs", "fileDialogs.allFiles"), extensions: ["*"] },
|
||||
],
|
||||
properties: ["openFile"],
|
||||
},
|
||||
getMainWindow(),
|
||||
);
|
||||
const result = await dialog.showOpenDialog(dialogOptions);
|
||||
|
||||
if (result.canceled || result.filePaths.length === 0) {
|
||||
return { success: false, canceled: true, message: "Open project canceled" };
|
||||
@@ -1138,4 +1348,45 @@ export function registerIpcHandlers(
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle(
|
||||
"save-diagnostic",
|
||||
async (
|
||||
_,
|
||||
payload: { error: string; stack?: string; projectState: unknown; logs: string[] },
|
||||
) => {
|
||||
const { filePath, canceled } = await dialog.showSaveDialog({
|
||||
title: "Save Diagnostic File",
|
||||
defaultPath: `openscreen-diagnostic-${Date.now()}.json`,
|
||||
filters: [{ name: "JSON", extensions: ["json"] }],
|
||||
});
|
||||
|
||||
if (canceled || !filePath) return { success: false, canceled: true };
|
||||
|
||||
const diagnostic = {
|
||||
timestamp: new Date().toISOString(),
|
||||
appVersion: app.getVersion(),
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
osRelease: os.release(),
|
||||
osVersion: os.version(),
|
||||
totalMemoryMB: Math.round(os.totalmem() / 1024 / 1024),
|
||||
nodeVersion: process.versions.node,
|
||||
electronVersion: process.versions.electron,
|
||||
chromeVersion: process.versions.chrome,
|
||||
error: payload.error,
|
||||
stack: payload.stack,
|
||||
projectState: payload.projectState,
|
||||
recentLogs: payload.logs,
|
||||
};
|
||||
|
||||
try {
|
||||
await fs.writeFile(filePath, JSON.stringify(diagnostic, null, 2), "utf-8");
|
||||
return { success: true, path: filePath };
|
||||
} catch (error) {
|
||||
console.error("Failed to write diagnostic file:", error);
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
+137
-53
@@ -4,7 +4,6 @@ import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
app,
|
||||
BrowserWindow,
|
||||
dialog,
|
||||
ipcMain,
|
||||
Menu,
|
||||
nativeImage,
|
||||
@@ -30,6 +29,18 @@ if (process.platform === "darwin") {
|
||||
app.commandLine.appendSwitch("disable-features", "MacCatapLoopbackAudioForScreenShare");
|
||||
}
|
||||
|
||||
// Enable Wayland support for proper screen capture and window management
|
||||
// on Wayland compositors (Hyprland, GNOME, KDE, etc.)
|
||||
if (process.platform === "linux") {
|
||||
const isWayland =
|
||||
process.env.XDG_SESSION_TYPE === "wayland" || process.env.WAYLAND_DISPLAY !== undefined;
|
||||
if (isWayland) {
|
||||
app.commandLine.appendSwitch("ozone-platform", "wayland");
|
||||
// Enable WebRTCPipeWireCapturer for screen capture on Wayland
|
||||
app.commandLine.appendSwitch("enable-features", "WaylandWindowDrag,WebRTCPipeWireCapturer");
|
||||
}
|
||||
}
|
||||
|
||||
export const RECORDINGS_DIR = path.join(app.getPath("userData"), "recordings");
|
||||
|
||||
async function ensureRecordingsDir() {
|
||||
@@ -124,15 +135,30 @@ function setupApplicationMenu() {
|
||||
template.push({
|
||||
label: app.name,
|
||||
submenu: [
|
||||
{ role: "about" },
|
||||
{
|
||||
role: "about",
|
||||
label: mainT("common", "actions.about") || "About OpenScreen",
|
||||
},
|
||||
{ type: "separator" },
|
||||
{ role: "services" },
|
||||
{
|
||||
role: "services",
|
||||
label: mainT("common", "actions.services") || "Services",
|
||||
},
|
||||
{ type: "separator" },
|
||||
{ role: "hide" },
|
||||
{ role: "hideOthers" },
|
||||
{ role: "unhide" },
|
||||
{
|
||||
role: "hide",
|
||||
label: mainT("common", "actions.hide") || "Hide OpenScreen",
|
||||
},
|
||||
{
|
||||
role: "hideOthers",
|
||||
label: mainT("common", "actions.hideOthers") || "Hide Others",
|
||||
},
|
||||
{
|
||||
role: "unhide",
|
||||
label: mainT("common", "actions.unhide") || "Show All",
|
||||
},
|
||||
{ type: "separator" },
|
||||
{ role: "quit" },
|
||||
{ role: "quit", label: mainT("common", "actions.quit") || "Quit" },
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -156,40 +182,89 @@ function setupApplicationMenu() {
|
||||
accelerator: "CmdOrCtrl+Shift+S",
|
||||
click: () => sendEditorMenuAction("menu-save-project-as"),
|
||||
},
|
||||
...(isMac ? [] : [{ type: "separator" as const }, { role: "quit" as const }]),
|
||||
...(isMac
|
||||
? []
|
||||
: [
|
||||
{ type: "separator" as const },
|
||||
{
|
||||
role: "quit" as const,
|
||||
label: mainT("common", "actions.quit") || "Quit",
|
||||
},
|
||||
]),
|
||||
],
|
||||
},
|
||||
{
|
||||
label: mainT("common", "actions.edit") || "Edit",
|
||||
submenu: [
|
||||
{ role: "undo" },
|
||||
{ role: "redo" },
|
||||
{ role: "undo", label: mainT("common", "actions.undo") || "Undo" },
|
||||
{ role: "redo", label: mainT("common", "actions.redo") || "Redo" },
|
||||
{ type: "separator" },
|
||||
{ role: "cut" },
|
||||
{ role: "copy" },
|
||||
{ role: "paste" },
|
||||
{ role: "selectAll" },
|
||||
{ role: "cut", label: mainT("common", "actions.cut") || "Cut" },
|
||||
{ role: "copy", label: mainT("common", "actions.copy") || "Copy" },
|
||||
{ role: "paste", label: mainT("common", "actions.paste") || "Paste" },
|
||||
{
|
||||
role: "selectAll",
|
||||
label: mainT("common", "actions.selectAll") || "Select All",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: mainT("common", "actions.view") || "View",
|
||||
submenu: [
|
||||
{ role: "reload" },
|
||||
{ role: "forceReload" },
|
||||
{ role: "toggleDevTools" },
|
||||
{
|
||||
role: "reload",
|
||||
label: mainT("common", "actions.reload") || "Reload",
|
||||
},
|
||||
{
|
||||
role: "forceReload",
|
||||
label: mainT("common", "actions.forceReload") || "Force Reload",
|
||||
},
|
||||
{
|
||||
role: "toggleDevTools",
|
||||
label: mainT("common", "actions.toggleDevTools") || "Toggle Developer Tools",
|
||||
},
|
||||
{ type: "separator" },
|
||||
{ role: "resetZoom" },
|
||||
{ role: "zoomIn" },
|
||||
{ role: "zoomOut" },
|
||||
{
|
||||
role: "resetZoom",
|
||||
label: mainT("common", "actions.actualSize") || "Actual Size",
|
||||
},
|
||||
{
|
||||
role: "zoomIn",
|
||||
label: mainT("common", "actions.zoomIn") || "Zoom In",
|
||||
},
|
||||
{
|
||||
role: "zoomOut",
|
||||
label: mainT("common", "actions.zoomOut") || "Zoom Out",
|
||||
},
|
||||
{ type: "separator" },
|
||||
{ role: "togglefullscreen" },
|
||||
{
|
||||
role: "togglefullscreen",
|
||||
label: mainT("common", "actions.toggleFullScreen") || "Toggle Full Screen",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: mainT("common", "actions.window") || "Window",
|
||||
submenu: isMac
|
||||
? [{ role: "minimize" }, { role: "zoom" }, { type: "separator" }, { role: "front" }]
|
||||
: [{ role: "minimize" }, { role: "close" }],
|
||||
? [
|
||||
{
|
||||
role: "minimize",
|
||||
label: mainT("common", "actions.minimize") || "Minimize",
|
||||
},
|
||||
{ role: "zoom" },
|
||||
{ type: "separator" },
|
||||
{ role: "front" },
|
||||
]
|
||||
: [
|
||||
{
|
||||
role: "minimize",
|
||||
label: mainT("common", "actions.minimize") || "Minimize",
|
||||
},
|
||||
{
|
||||
role: "close",
|
||||
label: mainT("common", "actions.close") || "Close",
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
@@ -220,7 +295,11 @@ function getTrayIcon(filename: string, size: number) {
|
||||
function updateTrayMenu(recording: boolean = false) {
|
||||
if (!tray) return;
|
||||
const trayIcon = recording ? recordingTrayIcon : defaultTrayIcon;
|
||||
const trayToolTip = recording ? `Recording: ${selectedSourceName}` : "OpenScreen";
|
||||
const trayToolTip = recording
|
||||
? mainT("common", "actions.recordingStatus", {
|
||||
source: selectedSourceName,
|
||||
}) || `Recording: ${selectedSourceName}`
|
||||
: "OpenScreen";
|
||||
const menuTemplate = recording
|
||||
? [
|
||||
{
|
||||
@@ -253,6 +332,7 @@ function updateTrayMenu(recording: boolean = false) {
|
||||
|
||||
let editorHasUnsavedChanges = false;
|
||||
let isForceClosing = false;
|
||||
let isCloseConfirmInFlight = false;
|
||||
|
||||
ipcMain.on("set-has-unsaved-changes", (_, hasChanges: boolean) => {
|
||||
editorHasUnsavedChanges = hasChanges;
|
||||
@@ -284,39 +364,35 @@ function createEditorWindowWrapper() {
|
||||
editorHasUnsavedChanges = false;
|
||||
|
||||
mainWindow.on("close", (event) => {
|
||||
if (isForceClosing || !editorHasUnsavedChanges) return;
|
||||
if (isForceClosing || !editorHasUnsavedChanges || isCloseConfirmInFlight) return;
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
const choice = dialog.showMessageBoxSync(mainWindow!, {
|
||||
type: "warning",
|
||||
buttons: [
|
||||
mainT("dialogs", "unsavedChanges.saveAndClose"),
|
||||
mainT("dialogs", "unsavedChanges.discardAndClose"),
|
||||
mainT("common", "actions.cancel"),
|
||||
],
|
||||
defaultId: 0,
|
||||
cancelId: 2,
|
||||
title: mainT("dialogs", "unsavedChanges.title"),
|
||||
message: mainT("dialogs", "unsavedChanges.message"),
|
||||
detail: mainT("dialogs", "unsavedChanges.detail"),
|
||||
});
|
||||
isCloseConfirmInFlight = true;
|
||||
|
||||
const windowToClose = mainWindow;
|
||||
if (!windowToClose || windowToClose.isDestroyed()) return;
|
||||
|
||||
if (choice === 0) {
|
||||
// Save & Close — tell renderer to save, then close
|
||||
windowToClose.webContents.send("request-save-before-close");
|
||||
ipcMain.once("save-before-close-done", (_, shouldClose: boolean) => {
|
||||
if (!shouldClose) return;
|
||||
// Ask renderer to show the custom in-app dialog
|
||||
windowToClose.webContents.send("request-close-confirm");
|
||||
|
||||
ipcMain.once("close-confirm-response", (event, choice: "save" | "discard" | "cancel") => {
|
||||
if (event.sender.id !== windowToClose?.webContents.id) return;
|
||||
isCloseConfirmInFlight = false;
|
||||
if (!windowToClose || windowToClose.isDestroyed()) return;
|
||||
|
||||
if (choice === "save") {
|
||||
// Tell renderer to save the project, then close when done
|
||||
windowToClose.webContents.send("request-save-before-close");
|
||||
ipcMain.once("save-before-close-done", (event, shouldClose: boolean) => {
|
||||
if (event.sender.id !== windowToClose?.webContents.id) return;
|
||||
if (!shouldClose) return;
|
||||
forceCloseEditorWindow(windowToClose);
|
||||
});
|
||||
} else if (choice === "discard") {
|
||||
forceCloseEditorWindow(windowToClose);
|
||||
});
|
||||
} else if (choice === 1) {
|
||||
// Discard & Close
|
||||
forceCloseEditorWindow(windowToClose);
|
||||
}
|
||||
// choice === 2: Cancel — do nothing, window stays open
|
||||
}
|
||||
// "cancel": flag reset, window stays open
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -340,10 +416,11 @@ function createCountdownOverlayWindowWrapper() {
|
||||
return countdownOverlayWindow;
|
||||
}
|
||||
|
||||
// On macOS, applications and their menu bar stay active until the user quits
|
||||
// explicitly with Cmd + Q.
|
||||
// Closing every window quits the app entirely (tray icon goes too).
|
||||
// The in-app "Return to Recorder" button covers the editor → HUD round-trip,
|
||||
// so closing the last window is an explicit "I'm done" signal.
|
||||
app.on("window-all-closed", () => {
|
||||
// Keep app running (macOS behavior)
|
||||
app.quit();
|
||||
});
|
||||
|
||||
app.on("activate", () => {
|
||||
@@ -365,6 +442,13 @@ app.on("activate", () => {
|
||||
|
||||
// Register all IPC handlers when app is ready
|
||||
app.whenReady().then(async () => {
|
||||
// Force the app into "regular" activation policy so the Dock icon appears.
|
||||
// The HUD overlay (transparent + frameless + skipTaskbar) is the first
|
||||
// window we open, and AppKit otherwise classifies us as an accessory app.
|
||||
if (process.platform === "darwin") {
|
||||
app.dock?.show();
|
||||
}
|
||||
|
||||
// Allow microphone/media permission checks
|
||||
session.defaultSession.setPermissionCheckHandler((_webContents, permission) => {
|
||||
const allowed = ["media", "audioCapture", "microphone", "videoCapture", "camera"];
|
||||
|
||||
+24
-2
@@ -40,6 +40,9 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
||||
requestCameraAccess: () => {
|
||||
return ipcRenderer.invoke("request-camera-access");
|
||||
},
|
||||
requestAccessibilityAccess: () => {
|
||||
return ipcRenderer.invoke("request-accessibility-access");
|
||||
},
|
||||
|
||||
storeRecordedVideo: (videoData: ArrayBuffer, fileName: string) => {
|
||||
return ipcRenderer.invoke("store-recorded-video", videoData, fileName);
|
||||
@@ -68,8 +71,11 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
||||
openExternalUrl: (url: string) => {
|
||||
return ipcRenderer.invoke("open-external-url", url);
|
||||
},
|
||||
saveExportedVideo: (videoData: ArrayBuffer, fileName: string) => {
|
||||
return ipcRenderer.invoke("save-exported-video", videoData, fileName);
|
||||
pickExportSavePath: (fileName: string, exportFolder?: string) => {
|
||||
return ipcRenderer.invoke("pick-export-save-path", fileName, exportFolder);
|
||||
},
|
||||
writeExportToPath: (videoData: ArrayBuffer, filePath: string) => {
|
||||
return ipcRenderer.invoke("write-export-to-path", videoData, filePath);
|
||||
},
|
||||
openVideoFilePicker: () => {
|
||||
return ipcRenderer.invoke("open-video-file-picker");
|
||||
@@ -131,6 +137,14 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
||||
setLocale: (locale: string) => {
|
||||
return ipcRenderer.invoke("set-locale", locale);
|
||||
},
|
||||
saveDiagnostic: (payload: {
|
||||
error: string;
|
||||
stack?: string;
|
||||
projectState: unknown;
|
||||
logs: string[];
|
||||
}) => {
|
||||
return ipcRenderer.invoke("save-diagnostic", payload);
|
||||
},
|
||||
setMicrophoneExpanded: (expanded: boolean) => {
|
||||
ipcRenderer.send("hud:setMicrophoneExpanded", expanded);
|
||||
},
|
||||
@@ -163,4 +177,12 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
||||
ipcRenderer.on("request-save-before-close", listener);
|
||||
return () => ipcRenderer.removeListener("request-save-before-close", listener);
|
||||
},
|
||||
onRequestCloseConfirm: (callback: () => void) => {
|
||||
const listener = () => callback();
|
||||
ipcRenderer.on("request-close-confirm", listener);
|
||||
return () => ipcRenderer.removeListener("request-close-confirm", listener);
|
||||
},
|
||||
sendCloseConfirmResponse: (choice: "save" | "discard" | "cancel") => {
|
||||
ipcRenderer.send("close-confirm-response", choice);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -21,5 +21,9 @@
|
||||
<!-- Camera (webcam capture) -->
|
||||
<key>com.apple.security.device.camera</key>
|
||||
<true/>
|
||||
|
||||
<!-- Screen recording (required for desktopCapturer.getSources() on macOS 10.15+) -->
|
||||
<key>com.apple.security.device.screen-capture</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
+2
-2
@@ -11,7 +11,7 @@
|
||||
buildNpmPackage {
|
||||
nodejs = nodejs_22;
|
||||
pname = "openscreen";
|
||||
version = "1.3.0";
|
||||
version = "1.4.0";
|
||||
|
||||
src =
|
||||
let
|
||||
@@ -33,7 +33,7 @@ buildNpmPackage {
|
||||
);
|
||||
};
|
||||
|
||||
npmDepsHash = "sha256-Pd6J9TuggA9vM4s/LjdoK4MoBEivSzAWc/G2+pFOM2U=";
|
||||
npmDepsHash = "sha256-i8QMhvd/ydFPww7qTG3Bz2LOAIFyp65n1NXakr3MTk8=";
|
||||
|
||||
env.ELECTRON_SKIP_BINARY_DOWNLOAD = "1";
|
||||
|
||||
|
||||
Generated
+266
-1016
File diff suppressed because it is too large
Load Diff
+9
-4
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "openscreen",
|
||||
"private": true,
|
||||
"version": "1.3.0",
|
||||
"version": "1.4.0",
|
||||
"type": "module",
|
||||
"packageManager": "npm@10.9.4",
|
||||
"engines": {
|
||||
@@ -21,15 +21,17 @@
|
||||
"i18n:check": "node scripts/i18n-check.mjs",
|
||||
"preview": "vite preview",
|
||||
"build:mac": "tsc && vite build && electron-builder --mac",
|
||||
"build:win": "tsc && vite build && electron-builder --win",
|
||||
"build:linux": "tsc && vite build && electron-builder --linux AppImage deb",
|
||||
"build:win": "tsc && vite build && electron-builder --win --config.npmRebuild=false",
|
||||
"build:linux": "tsc && vite build && electron-builder --linux AppImage deb pacman --config.npmRebuild=false",
|
||||
"test": "vitest --run",
|
||||
"test:watch": "vitest",
|
||||
"build-vite": "tsc && vite build",
|
||||
"test:browser": "vitest --config vitest.browser.config.ts --run",
|
||||
"test:browser:install": "playwright install --with-deps chromium-headless-shell",
|
||||
"test:e2e": "playwright test",
|
||||
"prepare": "husky"
|
||||
"prepare": "husky",
|
||||
"rebuild:native": "node ./scripts/rebuild-native.mjs",
|
||||
"postinstall": "npm run rebuild:native"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fix-webm-duration/fix": "^1.0.1",
|
||||
@@ -49,6 +51,7 @@
|
||||
"@types/gif.js": "^0.2.5",
|
||||
"@uiw/color-convert": "^2.10.1",
|
||||
"@uiw/react-color-block": "^2.10.1",
|
||||
"@uiw/react-color-colorful": "^2.9.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dnd-timeline": "^2.4.0",
|
||||
@@ -70,11 +73,13 @@
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"uiohook-napi": "^1.5.5",
|
||||
"uuid": "^13.0.0",
|
||||
"web-demuxer": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.12",
|
||||
"@electron/rebuild": "^4.0.4",
|
||||
"@playwright/test": "^1.59.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import process from "node:process";
|
||||
|
||||
// uiohook-napi click capture is macOS-only at runtime (gated in
|
||||
// electron/ipc/handlers.ts). Skip the rebuild on other platforms so CI runners
|
||||
// without X11 dev headers don't fail npm install. The library's prebuilt
|
||||
// .node binaries are still bundled and loadable; we just don't need a fresh
|
||||
// build against Electron's ABI on platforms where we don't load it.
|
||||
if (process.platform !== "darwin") {
|
||||
console.log(
|
||||
`[rebuild:native] Skipping uiohook-napi rebuild on ${process.platform} (macOS-only).`,
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
["./node_modules/@electron/rebuild/lib/cli.js", "--force", "--only", "uiohook-napi"],
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
process.exit(result.status ?? 0);
|
||||
+14
@@ -25,6 +25,20 @@ export default function App() {
|
||||
document.documentElement.style.background = "transparent";
|
||||
document.getElementById("root")?.style.setProperty("background", "transparent");
|
||||
}
|
||||
|
||||
// HUD is a fixed-size BrowserWindow; pin the document shell and hide overflow
|
||||
// so the renderer can't introduce scrollbars (see issue #305).
|
||||
if (type === "hud-overlay") {
|
||||
document.documentElement.style.height = "100%";
|
||||
document.documentElement.style.overflow = "hidden";
|
||||
document.body.style.height = "100%";
|
||||
document.body.style.margin = "0";
|
||||
document.body.style.overflow = "hidden";
|
||||
const root = document.getElementById("root");
|
||||
root?.style.setProperty("height", "100%");
|
||||
root?.style.setProperty("min-height", "0");
|
||||
root?.style.setProperty("overflow", "hidden");
|
||||
}
|
||||
}, [windowType]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -314,7 +314,13 @@ export function LaunchWindow() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`w-screen h-screen overflow-x-hidden bg-transparent ${styles.electronDrag}`}>
|
||||
// Root fills the HUD window only. Avoid w-screen/h-screen (100vw/100vh):
|
||||
// 100vw can exceed the inner layout width when scrollbars affect the
|
||||
// viewport (notably on Windows), causing a horizontal scrollbar once the
|
||||
// recording toolbar widened (issue #305).
|
||||
<div
|
||||
className={`h-full w-full min-w-0 max-w-full overflow-x-hidden overflow-y-hidden bg-transparent ${styles.electronDrag}`}
|
||||
>
|
||||
{systemLocaleSuggestion && (
|
||||
<div
|
||||
className={`fixed top-8 left-1/2 z-30 w-[calc(100vw-1rem)] max-w-[520px] -translate-x-1/2 rounded-xl border border-white/15 bg-[rgba(20,20,28,0.95)] p-3 shadow-2xl backdrop-blur-xl text-white animate-in fade-in-0 zoom-in-95 duration-200 ${styles.electronNoDrag}`}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { HsvaColor, hexToHsva } from "@uiw/color-convert";
|
||||
import Block from "@uiw/react-color-block";
|
||||
import Colorful from "@uiw/react-color-colorful";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "./button";
|
||||
import { Input } from "./input";
|
||||
|
||||
type BaseProps = {
|
||||
selectedColor: string;
|
||||
colorPalette: string[];
|
||||
onUpdateColor: (color: string) => void;
|
||||
};
|
||||
|
||||
type ColorPickerProps =
|
||||
| (BaseProps & {
|
||||
clearBackgroundOption?: false;
|
||||
translations: Record<"colorWheel" | "colorPalette", string>;
|
||||
})
|
||||
| (BaseProps & {
|
||||
clearBackgroundOption: true;
|
||||
translations: Record<"colorWheel" | "colorPalette" | "clearBackground", string>;
|
||||
});
|
||||
|
||||
export default function ColorPicker(props: ColorPickerProps) {
|
||||
const { selectedColor, colorPalette, translations, onUpdateColor } = props;
|
||||
const [colorMode, setColorMode] = useState<"wheel" | "palette">("wheel");
|
||||
const [hexInput, setHexInput] = useState(selectedColor);
|
||||
const [transparentColorHSVA, setTransparentColorHSVA] = useState<HsvaColor>({
|
||||
h: 0,
|
||||
s: 0,
|
||||
v: 0,
|
||||
a: 0,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setHexInput(selectedColor);
|
||||
}, [selectedColor]);
|
||||
|
||||
const getTextColor = (color: string) => {
|
||||
if (color === "transparent") return "#ffffff";
|
||||
const r = parseInt(color.slice(1, 3), 16);
|
||||
const g = parseInt(color.slice(3, 5), 16);
|
||||
const b = parseInt(color.slice(5, 7), 16);
|
||||
const luminance = 0.299 * r + 0.587 * g + 0.114 * b;
|
||||
if (luminance > 186) return "#000000";
|
||||
return "#ffffff";
|
||||
};
|
||||
|
||||
// Normalize the hex input.
|
||||
// Adds a # at the beginning of the input if it's not there.
|
||||
const normalizeHexDraft = (raw: string) => {
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed === "") return "";
|
||||
if (/^[0-9A-Fa-f]/.test(trimmed[0])) return `#${trimmed}`;
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
const handleColorInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const normalized = normalizeHexDraft(e.target.value);
|
||||
setHexInput(normalized);
|
||||
// Check if the normalized hex is a valid hex color.
|
||||
// It should follow the format #RRGGBB or #RGB.
|
||||
const isValidHexColor =
|
||||
/^#[0-9A-Fa-f]{3}$/.test(normalized) || /^#[0-9A-Fa-f]{6}$/.test(normalized);
|
||||
if (isValidHexColor) {
|
||||
onUpdateColor(normalized);
|
||||
}
|
||||
};
|
||||
|
||||
const toTransparent = (color: string) => {
|
||||
if (color === "transparent") return;
|
||||
const hsva = hexToHsva(color);
|
||||
hsva.a = 0;
|
||||
return hsva;
|
||||
};
|
||||
return (
|
||||
<div className="p-1 flex flex-col gap-4 items-center">
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full h-9 justify-start gap-2 bg-white/5 border-white/10 hover:bg-white/10 px-2"
|
||||
onClick={() => setColorMode("wheel")}
|
||||
style={{
|
||||
backgroundColor: colorMode === "wheel" ? "#34B27B" : "transparent",
|
||||
}}
|
||||
>
|
||||
<span className="text-xs text-slate-300 truncate flex-1 text-left">
|
||||
{translations.colorWheel}
|
||||
</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full h-9 justify-start gap-2 bg-white/5 border-white/10 hover:bg-white/10 px-2"
|
||||
onClick={() => setColorMode("palette")}
|
||||
style={{
|
||||
backgroundColor: colorMode === "palette" ? "#34B27B" : "transparent",
|
||||
}}
|
||||
>
|
||||
<span className="text-xs text-slate-300 truncate flex-1 text-left">
|
||||
{translations.colorPalette}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
{colorMode === "wheel" && (
|
||||
<>
|
||||
<div
|
||||
className={`w-full h-20 flex items-center justify-center border border-white/10 rounded-lg`}
|
||||
style={{ backgroundColor: selectedColor }}
|
||||
>
|
||||
<span style={{ color: getTextColor(selectedColor) }}>{selectedColor}</span>
|
||||
</div>
|
||||
<Colorful
|
||||
color={selectedColor !== "transparent" ? selectedColor : transparentColorHSVA}
|
||||
onChange={(color) => {
|
||||
onUpdateColor(color.hex);
|
||||
}}
|
||||
style={{
|
||||
borderRadius: "8px",
|
||||
}}
|
||||
disableAlpha={true}
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
value={hexInput}
|
||||
className="w-full h-9 rounded-md border border-white/10 bg-white/5 px-2 text-xs text-slate-200 outline-none focus:border-[#34B27B]/50 focus:ring-1 focus:ring-[#34B27B]/30 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
onChange={handleColorInputChange}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{colorMode === "palette" && (
|
||||
<Block
|
||||
color={selectedColor !== "transparent" ? selectedColor : transparentColorHSVA}
|
||||
colors={colorPalette}
|
||||
onChange={(color) => {
|
||||
onUpdateColor(color.hex);
|
||||
}}
|
||||
style={{
|
||||
width: "100%",
|
||||
borderRadius: "8px",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{props.clearBackgroundOption === true && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full mt-2 text-xs h-7 hover:bg-white/5 text-slate-400"
|
||||
onClick={() => {
|
||||
const hsva = toTransparent(selectedColor);
|
||||
if (hsva) setTransparentColorHSVA(hsva);
|
||||
onUpdateColor("transparent");
|
||||
}}
|
||||
>
|
||||
{props.translations.clearBackground}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -31,6 +31,7 @@ import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
import { useScopedT } from "@/contexts/I18nContext";
|
||||
import { type CustomFont, getCustomFonts } from "@/lib/customFonts";
|
||||
import { cn } from "@/lib/utils";
|
||||
import ColorPicker from "../ui/color-picker";
|
||||
import { AddCustomFontDialog } from "./AddCustomFontDialog";
|
||||
import { getArrowComponent } from "./ArrowSvgs";
|
||||
import {
|
||||
@@ -75,7 +76,6 @@ export function AnnotationSettingsPanel({
|
||||
const t = useScopedT("settings");
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [customFonts, setCustomFonts] = useState<CustomFont[]>([]);
|
||||
|
||||
const fontStyleLabels: Record<string, string> = {
|
||||
classic: t("fontStyles.classic"),
|
||||
editor: t("fontStyles.editor"),
|
||||
@@ -388,15 +388,19 @@ export function AnnotationSettingsPanel({
|
||||
<ChevronDown className="h-3 w-3 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[260px] p-3 bg-[#1a1a1c] border border-white/10 rounded-xl shadow-xl">
|
||||
<Block
|
||||
color={annotation.style.color}
|
||||
colors={colorPalette}
|
||||
onChange={(color) => {
|
||||
onStyleChange({ color: color.hex });
|
||||
<PopoverContent
|
||||
side="top"
|
||||
className="w-[260px] p-3 bg-[#1a1a1c] border border-white/10 rounded-xl shadow-xl"
|
||||
>
|
||||
<ColorPicker
|
||||
selectedColor={annotation.style.color}
|
||||
colorPalette={colorPalette}
|
||||
translations={{
|
||||
colorWheel: t("annotation.colorWheel"),
|
||||
colorPalette: t("annotation.colorPalette"),
|
||||
}}
|
||||
style={{
|
||||
borderRadius: "8px",
|
||||
onUpdateColor={(color) => {
|
||||
onStyleChange({ color: color });
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
@@ -427,31 +431,23 @@ export function AnnotationSettingsPanel({
|
||||
<ChevronDown className="h-3 w-3 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[260px] p-3 bg-[#1a1a1c] border border-white/10 rounded-xl shadow-xl">
|
||||
<Block
|
||||
color={
|
||||
annotation.style.backgroundColor === "transparent"
|
||||
? "#000000"
|
||||
: annotation.style.backgroundColor
|
||||
}
|
||||
colors={colorPalette}
|
||||
onChange={(color) => {
|
||||
onStyleChange({ backgroundColor: color.hex });
|
||||
<PopoverContent
|
||||
side="top"
|
||||
className="w-[260px] p-3 bg-[#1a1a1c] border border-white/10 rounded-xl shadow-xl"
|
||||
>
|
||||
<ColorPicker
|
||||
selectedColor={annotation.style.backgroundColor}
|
||||
colorPalette={colorPalette}
|
||||
translations={{
|
||||
colorWheel: t("annotation.colorWheel"),
|
||||
colorPalette: t("annotation.colorPalette"),
|
||||
clearBackground: t("annotation.clearBackground"),
|
||||
}}
|
||||
style={{
|
||||
borderRadius: "8px",
|
||||
clearBackgroundOption={true}
|
||||
onUpdateColor={(color) => {
|
||||
onStyleChange({ backgroundColor: color });
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full mt-2 text-xs h-7 hover:bg-white/5 text-slate-400"
|
||||
onClick={() => {
|
||||
onStyleChange({ backgroundColor: "transparent" });
|
||||
}}
|
||||
>
|
||||
{t("annotation.clearBackground")}
|
||||
</Button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import Block from "@uiw/react-color-block";
|
||||
import * as SliderPrimitive from "@radix-ui/react-slider";
|
||||
import {
|
||||
Bug,
|
||||
ChevronDown,
|
||||
Crop,
|
||||
Download,
|
||||
FileDown,
|
||||
Film,
|
||||
Image,
|
||||
Lock,
|
||||
@@ -23,6 +25,7 @@ import {
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -41,6 +44,7 @@ import { cn } from "@/lib/utils";
|
||||
import { resolveImageWallpaperUrl, WALLPAPER_PATHS } from "@/lib/wallpaper";
|
||||
import { type AspectRatio, isPortraitAspectRatio } from "@/utils/aspectRatioUtils";
|
||||
import { getTestId } from "@/utils/getTestId";
|
||||
import ColorPicker from "../ui/color-picker";
|
||||
import { AnnotationSettingsPanel } from "./AnnotationSettingsPanel";
|
||||
import { BlurSettingsPanel } from "./BlurSettingsPanel";
|
||||
import { CropControl } from "./CropControl";
|
||||
@@ -52,13 +56,24 @@ import type {
|
||||
CropRegion,
|
||||
FigureData,
|
||||
PlaybackSpeed,
|
||||
Rotation3DPreset,
|
||||
WebcamLayoutPreset,
|
||||
WebcamMaskShape,
|
||||
WebcamSizePreset,
|
||||
ZoomDepth,
|
||||
ZoomFocus,
|
||||
ZoomFocusMode,
|
||||
} from "./types";
|
||||
import { DEFAULT_WEBCAM_SIZE_PRESET, MAX_PLAYBACK_SPEED, SPEED_OPTIONS } from "./types";
|
||||
import {
|
||||
DEFAULT_WEBCAM_SIZE_PRESET,
|
||||
MAX_PLAYBACK_SPEED,
|
||||
MAX_ZOOM_SCALE,
|
||||
MIN_ZOOM_SCALE,
|
||||
ROTATION_3D_PRESET_ORDER,
|
||||
SPEED_OPTIONS,
|
||||
ZOOM_DEPTH_SCALES,
|
||||
} from "./types";
|
||||
import { getFocusBoundsForScale } from "./videoPlayback/focusUtils";
|
||||
|
||||
function CustomSpeedInput({
|
||||
value,
|
||||
@@ -123,6 +138,58 @@ function CustomSpeedInput({
|
||||
);
|
||||
}
|
||||
|
||||
function ZoomFocusCoordInput({
|
||||
percent,
|
||||
onChange,
|
||||
onCommit,
|
||||
disabled,
|
||||
ariaLabel,
|
||||
}: {
|
||||
percent: number;
|
||||
onChange: (nextPercent: number) => void;
|
||||
onCommit?: () => void;
|
||||
disabled?: boolean;
|
||||
ariaLabel: string;
|
||||
}) {
|
||||
// While the input is focused (user is editing), show their draft text
|
||||
// so partial entries like "5" or "" don't get overwritten by re-renders.
|
||||
// When not focused, mirror the live prop value so external changes
|
||||
// (dragging the overlay on the preview) update the displayed number in real time.
|
||||
const [draft, setDraft] = useState<string | null>(null);
|
||||
const display = percent.toFixed(1);
|
||||
|
||||
return (
|
||||
<input
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
min={0}
|
||||
max={100}
|
||||
step={0.1}
|
||||
value={draft ?? display}
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel}
|
||||
onFocus={() => setDraft(display)}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value;
|
||||
setDraft(next);
|
||||
const parsed = Number(next);
|
||||
if (next !== "" && Number.isFinite(parsed)) {
|
||||
const clamped = Math.min(100, Math.max(0, parsed));
|
||||
onChange(clamped);
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
setDraft(null);
|
||||
onCommit?.();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") (e.target as HTMLInputElement).blur();
|
||||
}}
|
||||
className="w-[90px] h-8 rounded-md border border-white/10 bg-white/5 px-2 text-xs text-slate-200 outline-none focus:border-[#34B27B]/50 focus:ring-1 focus:ring-[#34B27B]/30 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const GRADIENTS = [
|
||||
"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% )",
|
||||
"linear-gradient(120deg, #d4fc79 0%, #96e6a1 100%)",
|
||||
@@ -151,15 +218,29 @@ const GRADIENTS = [
|
||||
];
|
||||
|
||||
interface SettingsPanelProps {
|
||||
cursorHighlight?: import("./videoPlayback/cursorHighlight").CursorHighlightConfig;
|
||||
onCursorHighlightChange?: (
|
||||
next: import("./videoPlayback/cursorHighlight").CursorHighlightConfig,
|
||||
) => void;
|
||||
// macOS only — gates the "Only on clicks" toggle (needs uiohook).
|
||||
cursorHighlightSupportsClicks?: boolean;
|
||||
selected: string;
|
||||
onWallpaperChange: (path: string) => void;
|
||||
selectedZoomDepth?: ZoomDepth | null;
|
||||
onZoomDepthChange?: (depth: ZoomDepth) => void;
|
||||
selectedZoomCustomScale?: number | null;
|
||||
onZoomCustomScaleChange?: (scale: number) => void;
|
||||
onZoomCustomScaleCommit?: () => void;
|
||||
selectedZoomFocusMode?: ZoomFocusMode | null;
|
||||
onZoomFocusModeChange?: (mode: ZoomFocusMode) => void;
|
||||
selectedZoomFocus?: ZoomFocus | null;
|
||||
onZoomFocusCoordinateChange?: (focus: ZoomFocus) => void;
|
||||
onZoomFocusCoordinateCommit?: () => void;
|
||||
hasCursorTelemetry?: boolean;
|
||||
selectedZoomId?: string | null;
|
||||
onZoomDelete?: (id: string) => void;
|
||||
selectedZoomRotationPreset?: Rotation3DPreset | null;
|
||||
onZoomRotationPresetChange?: (preset: Rotation3DPreset | null) => void;
|
||||
selectedTrimId?: string | null;
|
||||
onTrimDelete?: (id: string) => void;
|
||||
shadowIntensity?: number;
|
||||
@@ -224,6 +305,7 @@ interface SettingsPanelProps {
|
||||
webcamSizePreset?: WebcamSizePreset;
|
||||
onWebcamSizePresetChange?: (size: WebcamSizePreset) => void;
|
||||
onWebcamSizePresetCommit?: () => void;
|
||||
onSaveDiagnostic?: () => Promise<void>;
|
||||
}
|
||||
|
||||
export default SettingsPanel;
|
||||
@@ -238,15 +320,26 @@ const ZOOM_DEPTH_OPTIONS: Array<{ depth: ZoomDepth; label: string }> = [
|
||||
];
|
||||
|
||||
export function SettingsPanel({
|
||||
cursorHighlight,
|
||||
onCursorHighlightChange,
|
||||
cursorHighlightSupportsClicks = false,
|
||||
selected,
|
||||
onWallpaperChange,
|
||||
selectedZoomDepth,
|
||||
onZoomDepthChange,
|
||||
selectedZoomCustomScale,
|
||||
onZoomCustomScaleChange,
|
||||
onZoomCustomScaleCommit,
|
||||
selectedZoomFocusMode,
|
||||
onZoomFocusModeChange,
|
||||
selectedZoomFocus,
|
||||
onZoomFocusCoordinateChange,
|
||||
onZoomFocusCoordinateCommit,
|
||||
hasCursorTelemetry = false,
|
||||
selectedZoomId,
|
||||
onZoomDelete,
|
||||
selectedZoomRotationPreset,
|
||||
onZoomRotationPresetChange,
|
||||
selectedTrimId,
|
||||
onTrimDelete,
|
||||
shadowIntensity = 0,
|
||||
@@ -306,6 +399,7 @@ export function SettingsPanel({
|
||||
webcamSizePreset = DEFAULT_WEBCAM_SIZE_PRESET,
|
||||
onWebcamSizePresetChange,
|
||||
onWebcamSizePresetCommit,
|
||||
onSaveDiagnostic,
|
||||
}: SettingsPanelProps) {
|
||||
const t = useScopedT("settings");
|
||||
// Resolved URLs are for DOM rendering only (backgroundImage). The canonical
|
||||
@@ -569,7 +663,9 @@ export function SettingsPanel({
|
||||
<div className="flex items-center gap-2">
|
||||
{zoomEnabled && selectedZoomDepth && (
|
||||
<span className="text-[10px] uppercase tracking-wider font-medium text-[#34B27B] bg-[#34B27B]/10 px-2 py-0.5 rounded-full">
|
||||
{ZOOM_DEPTH_OPTIONS.find((o) => o.depth === selectedZoomDepth)?.label}
|
||||
{selectedZoomCustomScale != null
|
||||
? `${selectedZoomCustomScale.toFixed(2)}×`
|
||||
: ZOOM_DEPTH_OPTIONS.find((o) => o.depth === selectedZoomDepth)?.label}
|
||||
</span>
|
||||
)}
|
||||
<KeyboardShortcutsHelp />
|
||||
@@ -577,7 +673,10 @@ export function SettingsPanel({
|
||||
</div>
|
||||
<div className="grid grid-cols-6 gap-1.5">
|
||||
{ZOOM_DEPTH_OPTIONS.map((option) => {
|
||||
const isActive = selectedZoomDepth === option.depth;
|
||||
const effectiveScale =
|
||||
selectedZoomCustomScale ??
|
||||
(selectedZoomDepth != null ? ZOOM_DEPTH_SCALES[selectedZoomDepth] : null);
|
||||
const isActive = effectiveScale === ZOOM_DEPTH_SCALES[option.depth];
|
||||
return (
|
||||
<Button
|
||||
key={option.depth}
|
||||
@@ -598,6 +697,65 @@ export function SettingsPanel({
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{zoomEnabled && (
|
||||
<div className="mt-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs text-slate-400">{t("zoom.customScale")}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs font-mono font-semibold tabular-nums",
|
||||
selectedZoomCustomScale != null ? "text-[#34B27B]" : "text-slate-400",
|
||||
)}
|
||||
>
|
||||
{(
|
||||
selectedZoomCustomScale ??
|
||||
(selectedZoomDepth != null
|
||||
? ZOOM_DEPTH_SCALES[selectedZoomDepth]
|
||||
: MIN_ZOOM_SCALE)
|
||||
).toFixed(2)}
|
||||
×
|
||||
</span>
|
||||
</div>
|
||||
<SliderPrimitive.Root
|
||||
min={MIN_ZOOM_SCALE}
|
||||
max={MAX_ZOOM_SCALE}
|
||||
step={0.01}
|
||||
value={[
|
||||
selectedZoomCustomScale ??
|
||||
(selectedZoomDepth != null
|
||||
? ZOOM_DEPTH_SCALES[selectedZoomDepth]
|
||||
: MIN_ZOOM_SCALE),
|
||||
]}
|
||||
onValueChange={(values) => onZoomCustomScaleChange?.(values[0])}
|
||||
onValueCommit={() => onZoomCustomScaleCommit?.()}
|
||||
disabled={!zoomEnabled}
|
||||
className="relative flex w-full touch-none select-none items-center py-1"
|
||||
>
|
||||
<SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full border border-white/10 bg-white/5">
|
||||
<SliderPrimitive.Range
|
||||
className={cn(
|
||||
"absolute h-full transition-colors duration-150",
|
||||
selectedZoomCustomScale != null ? "bg-[#34B27B]" : "bg-white/20",
|
||||
)}
|
||||
/>
|
||||
</SliderPrimitive.Track>
|
||||
<SliderPrimitive.Thumb
|
||||
className={cn(
|
||||
"block h-3.5 w-3.5 rounded-full border-2 shadow transition-all duration-150",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#34B27B]/50",
|
||||
"disabled:pointer-events-none disabled:opacity-50 cursor-grab active:cursor-grabbing",
|
||||
selectedZoomCustomScale != null
|
||||
? "border-[#34B27B] bg-[#34B27B] shadow-[0_0_6px_rgba(52,178,123,0.4)]"
|
||||
: "border-white/20 bg-[#2a2a30] hover:border-white/40",
|
||||
)}
|
||||
/>
|
||||
</SliderPrimitive.Root>
|
||||
<div className="flex justify-between text-[10px] text-slate-600 mt-0.5">
|
||||
<span>{MIN_ZOOM_SCALE.toFixed(1)}×</span>
|
||||
<span>{MAX_ZOOM_SCALE.toFixed(1)}×</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!zoomEnabled && (
|
||||
<p className="text-[10px] text-slate-500 mt-2 text-center">{t("zoom.selectRegion")}</p>
|
||||
)}
|
||||
@@ -636,6 +794,100 @@ export function SettingsPanel({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{zoomEnabled &&
|
||||
selectedZoomFocusMode !== "auto" &&
|
||||
selectedZoomFocus &&
|
||||
onZoomFocusCoordinateChange &&
|
||||
(() => {
|
||||
const effectiveZoomScale =
|
||||
selectedZoomCustomScale ??
|
||||
(selectedZoomDepth != null ? ZOOM_DEPTH_SCALES[selectedZoomDepth] : MIN_ZOOM_SCALE);
|
||||
const bounds = getFocusBoundsForScale(effectiveZoomScale);
|
||||
const xRange = bounds.maxX - bounds.minX;
|
||||
const yRange = bounds.maxY - bounds.minY;
|
||||
const focusToPercentX = (cx: number) =>
|
||||
xRange <= 0 ? 50 : Math.max(0, Math.min(100, ((cx - bounds.minX) / xRange) * 100));
|
||||
const focusToPercentY = (cy: number) =>
|
||||
yRange <= 0 ? 50 : Math.max(0, Math.min(100, ((cy - bounds.minY) / yRange) * 100));
|
||||
const percentToFocusX = (p: number) =>
|
||||
xRange <= 0 ? bounds.minX : bounds.minX + (p / 100) * xRange;
|
||||
const percentToFocusY = (p: number) =>
|
||||
yRange <= 0 ? bounds.minY : bounds.minY + (p / 100) * yRange;
|
||||
return (
|
||||
<div className="mt-4">
|
||||
<span className="text-sm font-medium text-slate-200 mb-2 block">
|
||||
{t("zoom.position.title")}
|
||||
</span>
|
||||
<div className="flex items-end gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-medium text-slate-400 uppercase tracking-wider">
|
||||
{t("zoom.position.x")}
|
||||
</label>
|
||||
<ZoomFocusCoordInput
|
||||
ariaLabel={t("zoom.position.x")}
|
||||
percent={focusToPercentX(selectedZoomFocus.cx)}
|
||||
onChange={(p) =>
|
||||
onZoomFocusCoordinateChange({
|
||||
cx: percentToFocusX(p),
|
||||
cy: selectedZoomFocus.cy,
|
||||
})
|
||||
}
|
||||
onCommit={onZoomFocusCoordinateCommit}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-medium text-slate-400 uppercase tracking-wider">
|
||||
{t("zoom.position.y")}
|
||||
</label>
|
||||
<ZoomFocusCoordInput
|
||||
ariaLabel={t("zoom.position.y")}
|
||||
percent={focusToPercentY(selectedZoomFocus.cy)}
|
||||
onChange={(p) =>
|
||||
onZoomFocusCoordinateChange({
|
||||
cx: selectedZoomFocus.cx,
|
||||
cy: percentToFocusY(p),
|
||||
})
|
||||
}
|
||||
onCommit={onZoomFocusCoordinateCommit}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-500 pb-2">
|
||||
{t("zoom.position.hint")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{zoomEnabled && (
|
||||
<div className="mt-4">
|
||||
<span className="text-sm font-medium text-slate-200 mb-2 block">
|
||||
{t("zoom.threeD.title")}
|
||||
</span>
|
||||
<div className="grid grid-cols-3 gap-1.5">
|
||||
{ROTATION_3D_PRESET_ORDER.map((preset) => {
|
||||
const isActive = selectedZoomRotationPreset === preset;
|
||||
return (
|
||||
<Button
|
||||
key={preset}
|
||||
type="button"
|
||||
onClick={() => onZoomRotationPresetChange?.(isActive ? null : preset)}
|
||||
className={cn(
|
||||
"h-auto w-full rounded-lg border px-1 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.threeD.preset.${preset}`)}
|
||||
</span>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{zoomEnabled && (
|
||||
<Button
|
||||
onClick={handleDeleteClick}
|
||||
@@ -770,6 +1022,7 @@ export function SettingsPanel({
|
||||
<SelectContent>
|
||||
{WEBCAM_LAYOUT_PRESETS.filter((preset) => {
|
||||
if (preset.value === "picture-in-picture") return true;
|
||||
if (preset.value === "no-webcam") return true;
|
||||
if (preset.value === "vertical-stack") return isPortraitCanvas;
|
||||
return !isPortraitCanvas;
|
||||
}).map((preset) => (
|
||||
@@ -778,7 +1031,9 @@ export function SettingsPanel({
|
||||
? t("layout.pictureInPicture")
|
||||
: preset.value === "vertical-stack"
|
||||
? t("layout.verticalStack")
|
||||
: t("layout.dualFrame")}
|
||||
: preset.value === "no-webcam"
|
||||
? t("layout.noWebcam")
|
||||
: t("layout.dualFrame")}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -991,6 +1246,205 @@ export function SettingsPanel({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{cursorHighlight && onCursorHighlightChange && (
|
||||
<div className="p-2 rounded-lg bg-white/5 border border-white/5 mt-2 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-[10px] font-medium text-slate-300">
|
||||
{t("effects.cursorHighlight.title")}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onCursorHighlightChange({
|
||||
...cursorHighlight,
|
||||
enabled: !cursorHighlight.enabled,
|
||||
})
|
||||
}
|
||||
className={`text-[10px] px-2 py-0.5 rounded border transition-colors ${
|
||||
cursorHighlight.enabled
|
||||
? "bg-[#34B27B]/20 border-[#34B27B]/50 text-[#34B27B]"
|
||||
: "bg-white/5 border-white/10 text-slate-400"
|
||||
}`}
|
||||
>
|
||||
{cursorHighlight.enabled ? t("effects.on") : t("effects.off")}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
className={`grid grid-cols-2 gap-1 ${cursorHighlight.enabled ? "" : "opacity-40 pointer-events-none"}`}
|
||||
>
|
||||
{(["dot", "ring"] as const).map((style) => (
|
||||
<button
|
||||
key={style}
|
||||
type="button"
|
||||
onClick={() => onCursorHighlightChange({ ...cursorHighlight, style })}
|
||||
className={`text-[10px] px-2 py-1 rounded border capitalize transition-colors ${
|
||||
cursorHighlight.style === style
|
||||
? "bg-[#34B27B]/20 border-[#34B27B]/50 text-[#34B27B]"
|
||||
: "bg-white/5 border-white/10 text-slate-300 hover:border-white/20"
|
||||
}`}
|
||||
>
|
||||
{t(`effects.cursorHighlight.${style}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className={cursorHighlight.enabled ? "" : "opacity-40 pointer-events-none"}>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="text-[10px] text-slate-400">
|
||||
{t("effects.cursorHighlight.size")}
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-500 font-mono">
|
||||
{cursorHighlight.sizePx}px
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[cursorHighlight.sizePx]}
|
||||
onValueChange={(values) =>
|
||||
onCursorHighlightChange({
|
||||
...cursorHighlight,
|
||||
sizePx: values[0],
|
||||
})
|
||||
}
|
||||
min={10}
|
||||
max={36}
|
||||
step={1}
|
||||
className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3"
|
||||
/>
|
||||
</div>
|
||||
{cursorHighlightSupportsClicks && (
|
||||
<div
|
||||
className={`flex items-center justify-between ${cursorHighlight.enabled ? "" : "opacity-40 pointer-events-none"}`}
|
||||
>
|
||||
<div className="text-[10px] text-slate-400">
|
||||
{t("effects.cursorHighlight.onlyOnClicks")}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
const turningOn = !cursorHighlight.onlyOnClicks;
|
||||
if (turningOn) {
|
||||
try {
|
||||
const result =
|
||||
await window.electronAPI?.requestAccessibilityAccess?.();
|
||||
if (!result?.granted) {
|
||||
toast.message(
|
||||
t("effects.cursorHighlight.accessibilityPermissionTitle"),
|
||||
{
|
||||
description: t(
|
||||
"effects.cursorHighlight.accessibilityPermissionDescription",
|
||||
),
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("Accessibility request failed:", err);
|
||||
}
|
||||
}
|
||||
onCursorHighlightChange({
|
||||
...cursorHighlight,
|
||||
onlyOnClicks: turningOn,
|
||||
});
|
||||
}}
|
||||
className={`text-[10px] px-2 py-0.5 rounded border transition-colors ${
|
||||
cursorHighlight.onlyOnClicks
|
||||
? "bg-[#34B27B]/20 border-[#34B27B]/50 text-[#34B27B]"
|
||||
: "bg-white/5 border-white/10 text-slate-400"
|
||||
}`}
|
||||
>
|
||||
{cursorHighlight.onlyOnClicks ? t("effects.on") : t("effects.off")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className={cursorHighlight.enabled ? "" : "opacity-40 pointer-events-none"}>
|
||||
<div className="text-[10px] text-slate-400 mb-1">
|
||||
{t("effects.cursorHighlight.color")}
|
||||
</div>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full h-8 justify-start gap-2 bg-white/5 border-white/10 hover:bg-white/10 px-2"
|
||||
>
|
||||
<div
|
||||
className="w-4 h-4 rounded-full border border-white/20"
|
||||
style={{ backgroundColor: cursorHighlight.color }}
|
||||
/>
|
||||
<span className="text-[10px] text-slate-300 truncate flex-1 text-left font-mono">
|
||||
{cursorHighlight.color}
|
||||
</span>
|
||||
<ChevronDown className="h-3 w-3 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="top"
|
||||
className="w-[260px] p-3 bg-[#1a1a1c] border border-white/10 rounded-xl shadow-xl"
|
||||
>
|
||||
<ColorPicker
|
||||
selectedColor={cursorHighlight.color}
|
||||
colorPalette={colorPalette}
|
||||
translations={{
|
||||
colorWheel: t("background.colorWheel"),
|
||||
colorPalette: t("background.colorPalette"),
|
||||
}}
|
||||
onUpdateColor={(color) =>
|
||||
onCursorHighlightChange({
|
||||
...cursorHighlight,
|
||||
color,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<div className={cursorHighlight.enabled ? "" : "opacity-40 pointer-events-none"}>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="text-[10px] text-slate-400">
|
||||
{t("effects.cursorHighlight.offsetX")}
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-500 font-mono">
|
||||
{(cursorHighlight.offsetXNorm * 100).toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[cursorHighlight.offsetXNorm]}
|
||||
onValueChange={(values) =>
|
||||
onCursorHighlightChange({
|
||||
...cursorHighlight,
|
||||
offsetXNorm: values[0],
|
||||
})
|
||||
}
|
||||
min={-0.25}
|
||||
max={0.25}
|
||||
step={0.005}
|
||||
className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3"
|
||||
/>
|
||||
</div>
|
||||
<div className={cursorHighlight.enabled ? "" : "opacity-40 pointer-events-none"}>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="text-[10px] text-slate-400">
|
||||
{t("effects.cursorHighlight.offsetY")}
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-500 font-mono">
|
||||
{(cursorHighlight.offsetYNorm * 100).toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[cursorHighlight.offsetYNorm]}
|
||||
onValueChange={(values) =>
|
||||
onCursorHighlightChange({
|
||||
...cursorHighlight,
|
||||
offsetYNorm: values[0],
|
||||
})
|
||||
}
|
||||
min={-0.25}
|
||||
max={0.25}
|
||||
step={0.005}
|
||||
className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={handleCropToggle}
|
||||
variant="outline"
|
||||
@@ -1035,7 +1489,7 @@ export function SettingsPanel({
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<div className="max-h-[min(200px,25vh)] overflow-y-auto custom-scrollbar">
|
||||
<div className="overflow-y-auto custom-scrollbar">
|
||||
<TabsContent value="image" className="mt-0 space-y-2">
|
||||
<input
|
||||
type="file"
|
||||
@@ -1109,20 +1563,18 @@ export function SettingsPanel({
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="color" className="mt-0">
|
||||
<div className="p-1">
|
||||
<Block
|
||||
color={selectedColor}
|
||||
colors={colorPalette}
|
||||
onChange={(color) => {
|
||||
setSelectedColor(color.hex);
|
||||
onWallpaperChange(color.hex);
|
||||
}}
|
||||
style={{
|
||||
width: "100%",
|
||||
borderRadius: "8px",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<ColorPicker
|
||||
selectedColor={selectedColor}
|
||||
colorPalette={colorPalette}
|
||||
translations={{
|
||||
colorWheel: t("background.colorWheel"),
|
||||
colorPalette: t("background.colorPalette"),
|
||||
}}
|
||||
onUpdateColor={(color) => {
|
||||
setSelectedColor(color);
|
||||
onWallpaperChange(color);
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="gradient" className="mt-0">
|
||||
@@ -1434,6 +1886,16 @@ export function SettingsPanel({
|
||||
<Bug className="w-3 h-3 text-[#34B27B]" />
|
||||
{t("links.reportBug")}
|
||||
</button>
|
||||
{onSaveDiagnostic && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSaveDiagnostic}
|
||||
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"
|
||||
>
|
||||
<FileDown className="w-3 h-3 text-slate-400" />
|
||||
Save Diagnostics
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
|
||||
@@ -126,95 +126,99 @@ export function ShortcutsConfigDialog() {
|
||||
if (!open) handleClose();
|
||||
}}
|
||||
>
|
||||
<DialogContent className="bg-[#09090b] border-white/10 text-white max-w-[420px]">
|
||||
<DialogHeader>
|
||||
<DialogContent className="bg-[#09090b] border-white/10 text-white max-w-[420px] max-h-[85vh] flex flex-col">
|
||||
<DialogHeader className="shrink-0">
|
||||
<DialogTitle className="flex items-center gap-2 text-sm">
|
||||
<Keyboard className="w-4 h-4 text-[#34B27B]" />
|
||||
{t("title")}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-[10px] text-slate-500 mb-2 uppercase tracking-wide font-semibold">
|
||||
{t("configurable")}
|
||||
</p>
|
||||
{SHORTCUT_ACTIONS.map((action) => {
|
||||
const isCapturing = captureFor === action;
|
||||
const hasConflict = conflict?.forAction === action;
|
||||
return (
|
||||
<div key={action}>
|
||||
<div className="flex items-center justify-between py-1.5 px-1 border-b border-white/5">
|
||||
<span className="text-sm text-slate-300">{t(`actions.${action}`)}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setConflict(null);
|
||||
setCaptureFor(isCapturing ? null : action);
|
||||
}}
|
||||
title={isCapturing ? t("pressEscToCancel") : t("clickToChange")}
|
||||
className={[
|
||||
"px-2 py-1 rounded text-xs font-mono border transition-all min-w-[90px] text-center select-none",
|
||||
isCapturing
|
||||
? "bg-[#34B27B]/20 border-[#34B27B] text-[#34B27B] animate-pulse"
|
||||
: hasConflict
|
||||
? "bg-amber-500/10 border-amber-500/50 text-amber-400"
|
||||
: "bg-white/5 border-white/10 text-slate-200 hover:border-[#34B27B]/50 hover:text-[#34B27B] cursor-pointer",
|
||||
].join(" ")}
|
||||
>
|
||||
{isCapturing ? t("pressKey") : formatBinding(draft[action], isMac)}
|
||||
</button>
|
||||
</div>
|
||||
{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">
|
||||
<span className="text-amber-400">
|
||||
⚠{" "}
|
||||
{t("alreadyUsedBy", { action: t(`actions.${conflict.conflictWith.action}`) })}
|
||||
</span>
|
||||
<div className="flex gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
{t("swap")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
{tc("actions.cancel")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto pr-1 -mr-1">
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-[10px] text-slate-500 mb-2 uppercase tracking-wide font-semibold">
|
||||
{t("configurable")}
|
||||
</p>
|
||||
{SHORTCUT_ACTIONS.map((action) => {
|
||||
const isCapturing = captureFor === action;
|
||||
const hasConflict = conflict?.forAction === action;
|
||||
return (
|
||||
<div key={action}>
|
||||
<div className="flex items-center justify-between py-1.5 px-1 border-b border-white/5">
|
||||
<span className="text-sm text-slate-300">{t(`actions.${action}`)}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setConflict(null);
|
||||
setCaptureFor(isCapturing ? null : action);
|
||||
}}
|
||||
title={isCapturing ? t("pressEscToCancel") : t("clickToChange")}
|
||||
className={[
|
||||
"px-2 py-1 rounded text-xs font-mono border transition-all min-w-[90px] text-center select-none",
|
||||
isCapturing
|
||||
? "bg-[#34B27B]/20 border-[#34B27B] text-[#34B27B] animate-pulse"
|
||||
: hasConflict
|
||||
? "bg-amber-500/10 border-amber-500/50 text-amber-400"
|
||||
: "bg-white/5 border-white/10 text-slate-200 hover:border-[#34B27B]/50 hover:text-[#34B27B] cursor-pointer",
|
||||
].join(" ")}
|
||||
>
|
||||
{isCapturing ? t("pressKey") : formatBinding(draft[action], isMac)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{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">
|
||||
<span className="text-amber-400">
|
||||
⚠{" "}
|
||||
{t("alreadyUsedBy", {
|
||||
action: t(`actions.${conflict.conflictWith.action}`),
|
||||
})}
|
||||
</span>
|
||||
<div className="flex gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
{t("swap")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
{tc("actions.cancel")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="space-y-0.5 mt-2">
|
||||
<p className="text-[10px] text-slate-500 mb-2 uppercase tracking-wide font-semibold">
|
||||
{t("fixed")}
|
||||
</p>
|
||||
{FIXED_SHORTCUTS.map(({ i18nKey, label, display }) => (
|
||||
<div
|
||||
key={i18nKey}
|
||||
className="flex items-center justify-between py-1.5 px-1 border-b border-white/5 last:border-0"
|
||||
>
|
||||
<span className="text-sm text-slate-400">
|
||||
{t(`fixedActions.${i18nKey}`, { defaultValue: label })}
|
||||
</span>
|
||||
<kbd className="px-2 py-1 bg-white/5 border border-white/10 rounded text-xs font-mono text-slate-400 min-w-[90px] text-center">
|
||||
{display}
|
||||
</kbd>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="text-[10px] text-slate-500 mt-1">{t("helpText")}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-0.5 mt-2">
|
||||
<p className="text-[10px] text-slate-500 mb-2 uppercase tracking-wide font-semibold">
|
||||
{t("fixed")}
|
||||
</p>
|
||||
{FIXED_SHORTCUTS.map(({ i18nKey, label, display }) => (
|
||||
<div
|
||||
key={i18nKey}
|
||||
className="flex items-center justify-between py-1.5 px-1 border-b border-white/5 last:border-0"
|
||||
>
|
||||
<span className="text-sm text-slate-400">
|
||||
{t(`fixedActions.${i18nKey}`, { defaultValue: label })}
|
||||
</span>
|
||||
<kbd className="px-2 py-1 bg-white/5 border border-white/10 rounded text-xs font-mono text-slate-400 min-w-[90px] text-center">
|
||||
{display}
|
||||
</kbd>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="text-[10px] text-slate-500 mt-1">{t("helpText")}</p>
|
||||
|
||||
<DialogFooter className="flex gap-2 sm:justify-between mt-2">
|
||||
<DialogFooter className="shrink-0 flex gap-2 sm:justify-between mt-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Save, Trash2 } from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useScopedT } from "@/contexts/I18nContext";
|
||||
|
||||
interface UnsavedChangesDialogProps {
|
||||
isOpen: boolean;
|
||||
onSaveAndClose: () => void;
|
||||
onDiscardAndClose: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function UnsavedChangesDialog({
|
||||
isOpen,
|
||||
onSaveAndClose,
|
||||
onDiscardAndClose,
|
||||
onCancel,
|
||||
}: UnsavedChangesDialogProps) {
|
||||
const td = useScopedT("dialogs");
|
||||
const tc = useScopedT("common");
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onCancel()}>
|
||||
<DialogContent className="bg-[#09090b] border-white/10 rounded-2xl max-w-sm p-6 gap-0">
|
||||
<DialogHeader className="mb-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<img
|
||||
src="./openscreen.png"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="w-9 h-9 rounded-xl flex-shrink-0"
|
||||
/>
|
||||
<DialogTitle className="text-base font-semibold text-slate-200 leading-tight">
|
||||
{td("unsavedChanges.title")}
|
||||
</DialogTitle>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<p className="text-sm text-slate-300 mb-1">{td("unsavedChanges.message")}</p>
|
||||
<DialogDescription className="text-sm text-slate-500 mb-6">
|
||||
{td("unsavedChanges.detail")}
|
||||
</DialogDescription>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSaveAndClose}
|
||||
className="flex items-center justify-center gap-2 w-full px-4 py-2.5 rounded-lg bg-[#34B27B] hover:bg-[#2d9e6c] active:bg-[#27885c] text-white font-medium text-sm transition-colors outline-none focus-visible:ring-2 focus-visible:ring-[#34B27B] focus-visible:ring-offset-2 focus-visible:ring-offset-[#09090b]"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
{td("unsavedChanges.saveAndClose")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDiscardAndClose}
|
||||
className="flex items-center justify-center gap-2 w-full px-4 py-2.5 rounded-lg bg-white/5 hover:bg-red-500/15 border border-white/10 hover:border-red-500/30 text-slate-300 hover:text-red-400 font-medium text-sm transition-colors outline-none focus-visible:ring-2 focus-visible:ring-white/30 focus-visible:ring-offset-2 focus-visible:ring-offset-[#09090b]"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
{td("unsavedChanges.discardAndClose")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="flex items-center justify-center gap-2 w-full px-4 py-2.5 rounded-lg hover:bg-white/5 text-slate-500 hover:text-slate-300 font-medium text-sm transition-colors outline-none focus-visible:ring-2 focus-visible:ring-white/20 focus-visible:ring-offset-2 focus-visible:ring-offset-[#09090b]"
|
||||
>
|
||||
{tc("actions.cancel")}
|
||||
</button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -31,7 +31,12 @@ import {
|
||||
import { computeFrameStepTime } from "@/lib/frameStep";
|
||||
import type { ProjectMedia } from "@/lib/recordingSession";
|
||||
import { matchesShortcut } from "@/lib/shortcuts";
|
||||
import { loadUserPreferences, saveUserPreferences } from "@/lib/userPreferences";
|
||||
import {
|
||||
getExportFolder,
|
||||
loadUserPreferences,
|
||||
parentDirectoryOf,
|
||||
saveUserPreferences,
|
||||
} from "@/lib/userPreferences";
|
||||
import { BackgroundLoadError } from "@/lib/wallpaper";
|
||||
import {
|
||||
getAspectRatioValue,
|
||||
@@ -67,13 +72,16 @@ import {
|
||||
DEFAULT_ZOOM_DEPTH,
|
||||
type FigureData,
|
||||
type PlaybackSpeed,
|
||||
type Rotation3DPreset,
|
||||
type SpeedRegion,
|
||||
type TrimRegion,
|
||||
ZOOM_DEPTH_SCALES,
|
||||
type ZoomDepth,
|
||||
type ZoomFocus,
|
||||
type ZoomFocusMode,
|
||||
type ZoomRegion,
|
||||
} from "./types";
|
||||
import { UnsavedChangesDialog } from "./UnsavedChangesDialog";
|
||||
import VideoPlayback, { VideoPlaybackRef } from "./VideoPlayback";
|
||||
|
||||
export default function VideoEditor() {
|
||||
@@ -103,6 +111,7 @@ export default function VideoEditor() {
|
||||
webcamMaskShape,
|
||||
webcamSizePreset,
|
||||
webcamPosition,
|
||||
cursorHighlight,
|
||||
} = editorState;
|
||||
|
||||
// ── Non-undoable state
|
||||
@@ -121,6 +130,7 @@ export default function VideoEditor() {
|
||||
const durationRef = useRef(duration);
|
||||
durationRef.current = duration;
|
||||
const [cursorTelemetry, setCursorTelemetry] = useState<CursorTelemetryPoint[]>([]);
|
||||
const [cursorClickTimestamps, setCursorClickTimestamps] = useState<number[]>([]);
|
||||
const [selectedZoomId, setSelectedZoomId] = useState<string | null>(null);
|
||||
const [selectedTrimId, setSelectedTrimId] = useState<string | null>(null);
|
||||
const [selectedSpeedId, setSelectedSpeedId] = useState<string | null>(null);
|
||||
@@ -144,6 +154,7 @@ export default function VideoEditor() {
|
||||
format: string;
|
||||
} | null>(null);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [showCloseConfirmDialog, setShowCloseConfirmDialog] = useState(false);
|
||||
|
||||
const playerContainerRef = useRef<HTMLDivElement>(null);
|
||||
const videoPlaybackRef = useRef<VideoPlaybackRef>(null);
|
||||
@@ -153,6 +164,12 @@ export default function VideoEditor() {
|
||||
const nextSpeedIdRef = useRef(1);
|
||||
|
||||
const { shortcuts, isMac } = useShortcuts();
|
||||
// Off-Mac doesn't have click telemetry, so force `onlyOnClicks` off for
|
||||
// renderers while keeping the persisted value intact for round-tripping.
|
||||
const effectiveCursorHighlight = useMemo(
|
||||
() => (isMac ? cursorHighlight : { ...cursorHighlight, onlyOnClicks: false }),
|
||||
[cursorHighlight, isMac],
|
||||
);
|
||||
const { locale, setLocale, t: rawT } = useI18n();
|
||||
const t = useScopedT("editor");
|
||||
const ts = useScopedT("settings");
|
||||
@@ -430,7 +447,7 @@ export default function VideoEditor() {
|
||||
return false;
|
||||
}
|
||||
|
||||
const projectData = createProjectData(currentProjectMedia, {
|
||||
const editorState = {
|
||||
wallpaper,
|
||||
shadowIntensity,
|
||||
showBlur,
|
||||
@@ -452,14 +469,18 @@ export default function VideoEditor() {
|
||||
gifFrameRate,
|
||||
gifLoop,
|
||||
gifSizePreset,
|
||||
});
|
||||
cursorHighlight,
|
||||
};
|
||||
const projectData = createProjectData(currentProjectMedia, editorState);
|
||||
|
||||
const fileNameBase =
|
||||
currentProjectMedia.screenVideoPath
|
||||
.split(/[\\/]/)
|
||||
.pop()
|
||||
?.replace(/\.[^.]+$/, "") || `project-${Date.now()}`;
|
||||
const projectSnapshot = JSON.stringify(projectData);
|
||||
// Match the normalization path used by `currentProjectSnapshot` so the
|
||||
// post-save baseline compares equal and `hasUnsavedChanges` clears.
|
||||
const projectSnapshot = createProjectSnapshot(currentProjectMedia, editorState);
|
||||
const result = await window.electronAPI.saveProjectFile(
|
||||
projectData,
|
||||
fileNameBase,
|
||||
@@ -510,6 +531,7 @@ export default function VideoEditor() {
|
||||
videoPath,
|
||||
t,
|
||||
webcamSizePreset,
|
||||
cursorHighlight,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -524,6 +546,28 @@ export default function VideoEditor() {
|
||||
return () => cleanup();
|
||||
}, [saveProject]);
|
||||
|
||||
useEffect(() => {
|
||||
const cleanup = window.electronAPI.onRequestCloseConfirm(() => {
|
||||
setShowCloseConfirmDialog(true);
|
||||
});
|
||||
return () => cleanup();
|
||||
}, []);
|
||||
|
||||
const handleCloseConfirmSave = useCallback(() => {
|
||||
setShowCloseConfirmDialog(false);
|
||||
window.electronAPI.sendCloseConfirmResponse("save");
|
||||
}, []);
|
||||
|
||||
const handleCloseConfirmDiscard = useCallback(() => {
|
||||
setShowCloseConfirmDialog(false);
|
||||
window.electronAPI.sendCloseConfirmResponse("discard");
|
||||
}, []);
|
||||
|
||||
const handleCloseConfirmCancel = useCallback(() => {
|
||||
setShowCloseConfirmDialog(false);
|
||||
window.electronAPI.sendCloseConfirmResponse("cancel");
|
||||
}, []);
|
||||
|
||||
const handleSaveProject = useCallback(async () => {
|
||||
await saveProject(false);
|
||||
}, [saveProject]);
|
||||
@@ -584,6 +628,7 @@ export default function VideoEditor() {
|
||||
if (!sourcePath) {
|
||||
if (mounted) {
|
||||
setCursorTelemetry([]);
|
||||
setCursorClickTimestamps([]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -592,11 +637,13 @@ export default function VideoEditor() {
|
||||
const result = await window.electronAPI.getCursorTelemetry(sourcePath);
|
||||
if (mounted) {
|
||||
setCursorTelemetry(result.success ? result.samples : []);
|
||||
setCursorClickTimestamps(result.success ? (result.clicks ?? []) : []);
|
||||
}
|
||||
} catch (telemetryError) {
|
||||
console.warn("Unable to load cursor telemetry:", telemetryError);
|
||||
if (mounted) {
|
||||
setCursorTelemetry([]);
|
||||
setCursorClickTimestamps([]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -686,6 +733,7 @@ export default function VideoEditor() {
|
||||
startMs: Math.round(span.start),
|
||||
endMs: Math.round(span.end),
|
||||
depth: DEFAULT_ZOOM_DEPTH,
|
||||
customScale: ZOOM_DEPTH_SCALES[DEFAULT_ZOOM_DEPTH],
|
||||
focus: { cx: 0.5, cy: 0.5 },
|
||||
};
|
||||
pushState((prev) => ({ zoomRegions: [...prev.zoomRegions, newRegion] }));
|
||||
@@ -705,6 +753,7 @@ export default function VideoEditor() {
|
||||
startMs: Math.round(span.start),
|
||||
endMs: Math.round(span.end),
|
||||
depth: DEFAULT_ZOOM_DEPTH,
|
||||
customScale: ZOOM_DEPTH_SCALES[DEFAULT_ZOOM_DEPTH],
|
||||
focus: clampFocusToDepth(focus, DEFAULT_ZOOM_DEPTH),
|
||||
};
|
||||
pushState((prev) => ({ zoomRegions: [...prev.zoomRegions, newRegion] }));
|
||||
@@ -788,6 +837,7 @@ export default function VideoEditor() {
|
||||
? {
|
||||
...region,
|
||||
depth,
|
||||
customScale: ZOOM_DEPTH_SCALES[depth],
|
||||
focus: clampFocusToDepth(region.focus, depth),
|
||||
}
|
||||
: region,
|
||||
@@ -797,6 +847,24 @@ export default function VideoEditor() {
|
||||
[selectedZoomId, pushState],
|
||||
);
|
||||
|
||||
const handleZoomCustomScaleChange = useCallback(
|
||||
(scale: number) => {
|
||||
if (!selectedZoomId) return;
|
||||
const rounded = Math.round(scale * 100) / 100;
|
||||
if (!Number.isFinite(rounded)) return;
|
||||
updateState((prev) => ({
|
||||
zoomRegions: prev.zoomRegions.map((region) =>
|
||||
region.id === selectedZoomId ? { ...region, customScale: rounded } : region,
|
||||
),
|
||||
}));
|
||||
},
|
||||
[selectedZoomId, updateState],
|
||||
);
|
||||
|
||||
const handleZoomCustomScaleCommit = useCallback(() => {
|
||||
commitState();
|
||||
}, [commitState]);
|
||||
|
||||
const handleZoomFocusModeChange = useCallback(
|
||||
(focusMode: ZoomFocusMode) => {
|
||||
if (!selectedZoomId) return;
|
||||
@@ -821,6 +889,23 @@ export default function VideoEditor() {
|
||||
[selectedZoomId, pushState],
|
||||
);
|
||||
|
||||
const handleZoomRotationPresetChange = useCallback(
|
||||
(preset: Rotation3DPreset | null) => {
|
||||
if (!selectedZoomId) return;
|
||||
pushState((prev) => ({
|
||||
zoomRegions: prev.zoomRegions.map((region) => {
|
||||
if (region.id !== selectedZoomId) return region;
|
||||
if (preset === null) {
|
||||
const { rotationPreset: _p, ...rest } = region;
|
||||
return rest;
|
||||
}
|
||||
return { ...region, rotationPreset: preset };
|
||||
}),
|
||||
}));
|
||||
},
|
||||
[selectedZoomId, pushState],
|
||||
);
|
||||
|
||||
const handleTrimDelete = useCallback(
|
||||
(id: string) => {
|
||||
pushState((prev) => ({
|
||||
@@ -1285,6 +1370,10 @@ export default function VideoEditor() {
|
||||
const handleExportSaved = useCallback(
|
||||
(formatLabel: "GIF" | "Video", filePath: string) => {
|
||||
setExportedFilePath(filePath);
|
||||
const folder = parentDirectoryOf(filePath);
|
||||
if (folder) {
|
||||
saveUserPreferences({ exportFolder: folder });
|
||||
}
|
||||
toast.success(
|
||||
t("export.exportedSuccessfully", {
|
||||
format: formatLabel,
|
||||
@@ -1306,13 +1395,19 @@ export default function VideoEditor() {
|
||||
const handleSaveUnsavedExport = useCallback(async () => {
|
||||
if (!unsavedExport) return;
|
||||
try {
|
||||
const saveResult = await window.electronAPI.saveExportedVideo(
|
||||
unsavedExport.arrayBuffer,
|
||||
const pickResult = await window.electronAPI.pickExportSavePath(
|
||||
unsavedExport.fileName,
|
||||
getExportFolder(),
|
||||
);
|
||||
if (saveResult.canceled) {
|
||||
if (pickResult.canceled || !pickResult.success || !pickResult.path) {
|
||||
toast.info("Export canceled");
|
||||
} else if (saveResult.success && saveResult.path) {
|
||||
return;
|
||||
}
|
||||
const saveResult = await window.electronAPI.writeExportToPath(
|
||||
unsavedExport.arrayBuffer,
|
||||
pickResult.path,
|
||||
);
|
||||
if (saveResult.success && saveResult.path) {
|
||||
setUnsavedExport(null);
|
||||
handleExportSaved(unsavedExport.format === "gif" ? "GIF" : "Video", saveResult.path);
|
||||
} else {
|
||||
@@ -1337,6 +1432,21 @@ export default function VideoEditor() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Ask the user where to save BEFORE starting the export. This avoids the
|
||||
// post-export save dialog getting hidden behind other windows after a
|
||||
// long-running export.
|
||||
const isGifFormat = settings.format === "gif";
|
||||
const targetFileName = `export-${Date.now()}.${isGifFormat ? "gif" : "mp4"}`;
|
||||
const pickResult = await window.electronAPI.pickExportSavePath(
|
||||
targetFileName,
|
||||
getExportFolder(),
|
||||
);
|
||||
if (pickResult.canceled || !pickResult.success || !pickResult.path) {
|
||||
setShowExportDialog(false);
|
||||
return;
|
||||
}
|
||||
const targetPath = pickResult.path;
|
||||
|
||||
setIsExporting(true);
|
||||
setExportProgress(null);
|
||||
setExportError(null);
|
||||
@@ -1391,6 +1501,8 @@ export default function VideoEditor() {
|
||||
previewWidth,
|
||||
previewHeight,
|
||||
cursorTelemetry,
|
||||
cursorClickTimestamps,
|
||||
cursorHighlight: effectiveCursorHighlight,
|
||||
onProgress: (progress: ExportProgress) => {
|
||||
setExportProgress(progress);
|
||||
},
|
||||
@@ -1401,8 +1513,6 @@ export default function VideoEditor() {
|
||||
|
||||
if (result.success && result.blob) {
|
||||
const arrayBuffer = await result.blob.arrayBuffer();
|
||||
const timestamp = Date.now();
|
||||
const fileName = `export-${timestamp}.gif`;
|
||||
|
||||
if (result.warnings) {
|
||||
for (const warning of result.warnings) {
|
||||
@@ -1410,15 +1520,13 @@ export default function VideoEditor() {
|
||||
}
|
||||
}
|
||||
|
||||
const saveResult = await window.electronAPI.saveExportedVideo(arrayBuffer, fileName);
|
||||
const saveResult = await window.electronAPI.writeExportToPath(arrayBuffer, targetPath);
|
||||
|
||||
if (saveResult.canceled) {
|
||||
setUnsavedExport({ arrayBuffer, fileName, format: "gif" });
|
||||
toast.info("Export canceled");
|
||||
} else if (saveResult.success && saveResult.path) {
|
||||
if (saveResult.success && saveResult.path) {
|
||||
setUnsavedExport(null);
|
||||
handleExportSaved("GIF", saveResult.path);
|
||||
} else {
|
||||
setUnsavedExport({ arrayBuffer, fileName: targetFileName, format: "gif" });
|
||||
setExportError(saveResult.message || "Failed to save GIF");
|
||||
toast.error(saveResult.message || "Failed to save GIF");
|
||||
}
|
||||
@@ -1434,18 +1542,19 @@ export default function VideoEditor() {
|
||||
let bitrate: number;
|
||||
|
||||
if (quality === "source") {
|
||||
// Use source resolution
|
||||
exportWidth = sourceWidth;
|
||||
exportHeight = sourceHeight;
|
||||
|
||||
// Use the source's longer dimension as the long axis of the export so
|
||||
// a landscape recording can still fill a portrait target (and vice versa).
|
||||
const sourceLongDim = Math.max(sourceWidth, sourceHeight);
|
||||
|
||||
if (aspectRatioValue === 1) {
|
||||
// Square (1:1): use smaller dimension to avoid codec limits
|
||||
const baseDimension = Math.floor(Math.min(sourceWidth, sourceHeight) / 2) * 2;
|
||||
exportWidth = baseDimension;
|
||||
exportHeight = baseDimension;
|
||||
} else if (aspectRatioValue > 1) {
|
||||
// Landscape: find largest even dimensions that exactly match aspect ratio
|
||||
const baseWidth = Math.floor(sourceWidth / 2) * 2;
|
||||
const baseWidth = Math.floor(sourceLongDim / 2) * 2;
|
||||
let found = false;
|
||||
for (let w = baseWidth; w >= 100 && !found; w -= 2) {
|
||||
const h = Math.round(w / aspectRatioValue);
|
||||
@@ -1460,8 +1569,7 @@ export default function VideoEditor() {
|
||||
exportHeight = Math.floor(baseWidth / aspectRatioValue / 2) * 2;
|
||||
}
|
||||
} else {
|
||||
// Portrait: find largest even dimensions that exactly match aspect ratio
|
||||
const baseHeight = Math.floor(sourceHeight / 2) * 2;
|
||||
const baseHeight = Math.floor(sourceLongDim / 2) * 2;
|
||||
let found = false;
|
||||
for (let h = baseHeight; h >= 100 && !found; h -= 2) {
|
||||
const w = Math.round(h * aspectRatioValue);
|
||||
@@ -1477,7 +1585,6 @@ export default function VideoEditor() {
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate visually lossless bitrate matching screen recording optimization
|
||||
const totalPixels = exportWidth * exportHeight;
|
||||
bitrate = 30_000_000;
|
||||
if (totalPixels > 1920 * 1080 && totalPixels <= 2560 * 1440) {
|
||||
@@ -1486,14 +1593,18 @@ export default function VideoEditor() {
|
||||
bitrate = 80_000_000;
|
||||
}
|
||||
} else {
|
||||
// Use quality-based target resolution
|
||||
const targetHeight = quality === "medium" ? 720 : 1080;
|
||||
// Quality presets target the SHORT side; the long side derives from the
|
||||
// aspect ratio. This keeps 1080p portrait at 1080×1920 instead of 607×1080.
|
||||
const targetShortDim = quality === "medium" ? 720 : 1080;
|
||||
|
||||
// Calculate dimensions maintaining aspect ratio
|
||||
exportHeight = Math.floor(targetHeight / 2) * 2;
|
||||
exportWidth = Math.floor((exportHeight * aspectRatioValue) / 2) * 2;
|
||||
if (aspectRatioValue >= 1) {
|
||||
exportHeight = Math.floor(targetShortDim / 2) * 2;
|
||||
exportWidth = Math.floor((exportHeight * aspectRatioValue) / 2) * 2;
|
||||
} else {
|
||||
exportWidth = Math.floor(targetShortDim / 2) * 2;
|
||||
exportHeight = Math.floor(exportWidth / aspectRatioValue / 2) * 2;
|
||||
}
|
||||
|
||||
// Adjust bitrate for lower resolutions
|
||||
const totalPixels = exportWidth * exportHeight;
|
||||
if (totalPixels <= 1280 * 720) {
|
||||
bitrate = 10_000_000;
|
||||
@@ -1531,6 +1642,8 @@ export default function VideoEditor() {
|
||||
previewWidth,
|
||||
previewHeight,
|
||||
cursorTelemetry,
|
||||
cursorClickTimestamps,
|
||||
cursorHighlight: effectiveCursorHighlight,
|
||||
onProgress: (progress: ExportProgress) => {
|
||||
setExportProgress(progress);
|
||||
},
|
||||
@@ -1541,8 +1654,6 @@ export default function VideoEditor() {
|
||||
|
||||
if (result.success && result.blob) {
|
||||
const arrayBuffer = await result.blob.arrayBuffer();
|
||||
const timestamp = Date.now();
|
||||
const fileName = `export-${timestamp}.mp4`;
|
||||
|
||||
if (result.warnings) {
|
||||
for (const warning of result.warnings) {
|
||||
@@ -1550,15 +1661,13 @@ export default function VideoEditor() {
|
||||
}
|
||||
}
|
||||
|
||||
const saveResult = await window.electronAPI.saveExportedVideo(arrayBuffer, fileName);
|
||||
const saveResult = await window.electronAPI.writeExportToPath(arrayBuffer, targetPath);
|
||||
|
||||
if (saveResult.canceled) {
|
||||
setUnsavedExport({ arrayBuffer, fileName, format: "mp4" });
|
||||
toast.info("Export canceled");
|
||||
} else if (saveResult.success && saveResult.path) {
|
||||
if (saveResult.success && saveResult.path) {
|
||||
setUnsavedExport(null);
|
||||
handleExportSaved("Video", saveResult.path);
|
||||
} else {
|
||||
setUnsavedExport({ arrayBuffer, fileName: targetFileName, format: "mp4" });
|
||||
setExportError(saveResult.message || "Failed to save video");
|
||||
toast.error(saveResult.message || "Failed to save video");
|
||||
}
|
||||
@@ -1614,6 +1723,8 @@ export default function VideoEditor() {
|
||||
exportQuality,
|
||||
handleExportSaved,
|
||||
cursorTelemetry,
|
||||
cursorClickTimestamps,
|
||||
effectiveCursorHighlight,
|
||||
t,
|
||||
],
|
||||
);
|
||||
@@ -1690,6 +1801,19 @@ export default function VideoEditor() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSaveDiagnostic = useCallback(async () => {
|
||||
const result = await window.electronAPI.saveDiagnostic({
|
||||
error: exportError ?? "Manual diagnostic export",
|
||||
projectState: editorState,
|
||||
logs: [],
|
||||
});
|
||||
if (result.success) {
|
||||
toast.success("Diagnostic file saved");
|
||||
} else if (!result.canceled) {
|
||||
toast.error("Failed to save diagnostic file");
|
||||
}
|
||||
}, [exportError, editorState]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen bg-background">
|
||||
@@ -1871,6 +1995,8 @@ export default function VideoEditor() {
|
||||
onBlurDataChange={handleBlurDataPreviewChange}
|
||||
onBlurDataCommit={commitState}
|
||||
cursorTelemetry={cursorTelemetry}
|
||||
cursorHighlight={effectiveCursorHighlight}
|
||||
cursorClickTimestamps={cursorClickTimestamps}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1954,21 +2080,46 @@ export default function VideoEditor() {
|
||||
{/* Right section: settings panel */}
|
||||
<div className="flex-[3] min-w-[280px] max-w-[420px] h-full">
|
||||
<SettingsPanel
|
||||
cursorHighlight={cursorHighlight}
|
||||
onCursorHighlightChange={(next) => pushState({ cursorHighlight: next })}
|
||||
cursorHighlightSupportsClicks={isMac}
|
||||
selected={wallpaper}
|
||||
onWallpaperChange={(w) => pushState({ wallpaper: w })}
|
||||
selectedZoomDepth={
|
||||
selectedZoomId ? zoomRegions.find((z) => z.id === selectedZoomId)?.depth : null
|
||||
}
|
||||
onZoomDepthChange={(depth) => selectedZoomId && handleZoomDepthChange(depth)}
|
||||
selectedZoomCustomScale={
|
||||
selectedZoomId
|
||||
? (zoomRegions.find((z) => z.id === selectedZoomId)?.customScale ?? null)
|
||||
: null
|
||||
}
|
||||
onZoomCustomScaleChange={handleZoomCustomScaleChange}
|
||||
onZoomCustomScaleCommit={handleZoomCustomScaleCommit}
|
||||
selectedZoomFocusMode={
|
||||
selectedZoomId
|
||||
? (zoomRegions.find((z) => z.id === selectedZoomId)?.focusMode ?? "manual")
|
||||
: null
|
||||
}
|
||||
onZoomFocusModeChange={(mode) => selectedZoomId && handleZoomFocusModeChange(mode)}
|
||||
selectedZoomFocus={
|
||||
selectedZoomId
|
||||
? (zoomRegions.find((z) => z.id === selectedZoomId)?.focus ?? null)
|
||||
: null
|
||||
}
|
||||
onZoomFocusCoordinateChange={(focus) =>
|
||||
selectedZoomId && handleZoomFocusChange(selectedZoomId, focus)
|
||||
}
|
||||
onZoomFocusCoordinateCommit={commitState}
|
||||
hasCursorTelemetry={cursorTelemetry.length > 0}
|
||||
selectedZoomId={selectedZoomId}
|
||||
onZoomDelete={handleZoomDelete}
|
||||
selectedZoomRotationPreset={
|
||||
selectedZoomId
|
||||
? (zoomRegions.find((z) => z.id === selectedZoomId)?.rotationPreset ?? null)
|
||||
: null
|
||||
}
|
||||
onZoomRotationPresetChange={handleZoomRotationPresetChange}
|
||||
selectedTrimId={selectedTrimId}
|
||||
onTrimDelete={handleTrimDelete}
|
||||
shadowIntensity={shadowIntensity}
|
||||
@@ -2049,6 +2200,7 @@ export default function VideoEditor() {
|
||||
onSpeedDelete={handleSpeedDelete}
|
||||
unsavedExport={unsavedExport}
|
||||
onSaveUnsavedExport={handleSaveUnsavedExport}
|
||||
onSaveDiagnostic={handleSaveDiagnostic}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2066,6 +2218,13 @@ export default function VideoEditor() {
|
||||
exportedFilePath ? () => void handleShowExportedFile(exportedFilePath) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<UnsavedChangesDialog
|
||||
isOpen={showCloseConfirmDialog}
|
||||
onSaveAndClose={handleCloseConfirmSave}
|
||||
onDiscardAndClose={handleCloseConfirmDiscard}
|
||||
onCancel={handleCloseConfirmCancel}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -36,10 +36,14 @@ import { AnnotationOverlay } from "./AnnotationOverlay";
|
||||
import {
|
||||
type AnnotationRegion,
|
||||
type BlurData,
|
||||
computeRotation3DContainScale,
|
||||
DEFAULT_ROTATION_3D,
|
||||
getZoomScale,
|
||||
isRotation3DIdentity,
|
||||
lerpRotation3D,
|
||||
rotation3DPerspective,
|
||||
type SpeedRegion,
|
||||
type TrimRegion,
|
||||
ZOOM_DEPTH_SCALES,
|
||||
type ZoomDepth,
|
||||
type ZoomFocus,
|
||||
type ZoomRegion,
|
||||
} from "./types";
|
||||
@@ -51,8 +55,18 @@ import {
|
||||
ZOOM_SCALE_DEADZONE,
|
||||
ZOOM_TRANSLATION_DEADZONE_PX,
|
||||
} from "./videoPlayback/constants";
|
||||
import { adaptiveSmoothFactor, smoothCursorFocus } from "./videoPlayback/cursorFollowUtils";
|
||||
import { clampFocusToStage as clampFocusToStageUtil } from "./videoPlayback/focusUtils";
|
||||
import {
|
||||
adaptiveSmoothFactor,
|
||||
interpolateCursorAt,
|
||||
smoothCursorFocus,
|
||||
} from "./videoPlayback/cursorFollowUtils";
|
||||
import {
|
||||
type CursorHighlightConfig,
|
||||
clickEmphasisAlpha,
|
||||
DEFAULT_CURSOR_HIGHLIGHT,
|
||||
drawCursorHighlightGraphics,
|
||||
} from "./videoPlayback/cursorHighlight";
|
||||
import { clampFocusToScale } from "./videoPlayback/focusUtils";
|
||||
import { layoutVideoContent as layoutVideoContentUtil } from "./videoPlayback/layoutUtils";
|
||||
import { clamp01 } from "./videoPlayback/mathUtils";
|
||||
import { updateOverlayIndicator } from "./videoPlayback/overlayUtils";
|
||||
@@ -110,6 +124,8 @@ interface VideoPlaybackProps {
|
||||
onBlurDataChange?: (id: string, blurData: BlurData) => void;
|
||||
onBlurDataCommit?: () => void;
|
||||
cursorTelemetry?: import("./types").CursorTelemetryPoint[];
|
||||
cursorHighlight?: CursorHighlightConfig;
|
||||
cursorClickTimestamps?: number[];
|
||||
}
|
||||
|
||||
export interface VideoPlaybackRef {
|
||||
@@ -168,6 +184,8 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
onBlurDataChange,
|
||||
onBlurDataCommit,
|
||||
cursorTelemetry = [],
|
||||
cursorHighlight = DEFAULT_CURSOR_HIGHLIGHT,
|
||||
cursorClickTimestamps = [],
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
@@ -186,11 +204,16 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
const overlayRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const focusIndicatorRef = useRef<HTMLDivElement | null>(null);
|
||||
const composite3DRef = useRef<HTMLDivElement | null>(null);
|
||||
const outerWrapperRef = useRef<HTMLDivElement | null>(null);
|
||||
const [webcamLayout, setWebcamLayout] = useState<StyledRenderRect | null>(null);
|
||||
const [webcamDimensions, setWebcamDimensions] = useState<Size | null>(null);
|
||||
const currentTimeRef = useRef(0);
|
||||
const zoomRegionsRef = useRef<ZoomRegion[]>([]);
|
||||
const cursorTelemetryRef = useRef<import("./types").CursorTelemetryPoint[]>([]);
|
||||
const cursorHighlightRef = useRef<CursorHighlightConfig>(DEFAULT_CURSOR_HIGHLIGHT);
|
||||
const cursorClickTimestampsRef = useRef<number[]>([]);
|
||||
const cursorHighlightGraphicsRef = useRef<Graphics | null>(null);
|
||||
const selectedZoomIdRef = useRef<string | null>(null);
|
||||
const animationStateRef = useRef({
|
||||
scale: 1,
|
||||
@@ -215,6 +238,9 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
const maskGraphicsRef = useRef<Graphics | null>(null);
|
||||
const isPlayingRef = useRef(isPlaying);
|
||||
const isSeekingRef = useRef(false);
|
||||
const isScrubbingRef = useRef(false);
|
||||
const scrubEndTimerRef = useRef<number | null>(null);
|
||||
const [isScrubbing, setIsScrubbing] = useState(false);
|
||||
const allowPlaybackRef = useRef(false);
|
||||
const lockedVideoDimensionsRef = useRef<{
|
||||
width: number;
|
||||
@@ -231,10 +257,6 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
const smoothedAutoFocusRef = useRef<ZoomFocus | null>(null);
|
||||
const prevTargetProgressRef = useRef(0);
|
||||
|
||||
const clampFocusToStage = useCallback((focus: ZoomFocus, depth: ZoomDepth) => {
|
||||
return clampFocusToStageUtil(focus, depth, stageSizeRef.current);
|
||||
}, []);
|
||||
|
||||
const updateOverlayForRegion = useCallback(
|
||||
(region: ZoomRegion | null, focusOverride?: ZoomFocus) => {
|
||||
const overlayEl = overlayRef.current;
|
||||
@@ -415,7 +437,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
cx: clamp01(localX / stageWidth),
|
||||
cy: clamp01(localY / stageHeight),
|
||||
};
|
||||
const clampedFocus = clampFocusToStage(unclampedFocus, region.depth);
|
||||
const clampedFocus = clampFocusToScale(unclampedFocus, getZoomScale(region));
|
||||
|
||||
onZoomFocusChange(region.id, clampedFocus);
|
||||
updateOverlayForRegion({ ...region, focus: clampedFocus }, clampedFocus);
|
||||
@@ -515,6 +537,17 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
cursorTelemetryRef.current = cursorTelemetry;
|
||||
}, [cursorTelemetry]);
|
||||
|
||||
useEffect(() => {
|
||||
cursorHighlightRef.current = cursorHighlight;
|
||||
if (cursorHighlightGraphicsRef.current) {
|
||||
drawCursorHighlightGraphics(cursorHighlightGraphicsRef.current, cursorHighlight);
|
||||
}
|
||||
}, [cursorHighlight]);
|
||||
|
||||
useEffect(() => {
|
||||
cursorClickTimestampsRef.current = cursorClickTimestamps;
|
||||
}, [cursorClickTimestamps]);
|
||||
|
||||
useEffect(() => {
|
||||
selectedZoomIdRef.current = selectedZoomId;
|
||||
}, [selectedZoomId]);
|
||||
@@ -583,6 +616,24 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
};
|
||||
}, [pixiReady, videoReady, layoutVideoContent]);
|
||||
|
||||
// Drop the PIXI canvas resolution to 1.0 while scrubbing (the user is
|
||||
// navigating, not previewing) and restore native DPR on play/idle so the
|
||||
// preview stays faithful. Mutating renderer.resolution per-frame would
|
||||
// thrash texture uploads; we only do it on scrub-state transitions.
|
||||
useEffect(() => {
|
||||
if (!pixiReady) return;
|
||||
const app = appRef.current;
|
||||
const container = containerRef.current;
|
||||
if (!app || !container) return;
|
||||
|
||||
const targetResolution = isScrubbing ? 1 : window.devicePixelRatio || 1;
|
||||
if (app.renderer.resolution === targetResolution) return;
|
||||
|
||||
app.renderer.resolution = targetResolution;
|
||||
app.renderer.resize(container.clientWidth, container.clientHeight);
|
||||
layoutVideoContentRef.current?.();
|
||||
}, [isScrubbing, pixiReady]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pixiReady || !videoReady) return;
|
||||
updateOverlayForRegion(selectedZoom);
|
||||
@@ -738,6 +789,12 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
videoContainer.mask = maskGraphics;
|
||||
maskGraphicsRef.current = maskGraphics;
|
||||
|
||||
const cursorHighlightGraphics = new Graphics();
|
||||
cursorHighlightGraphics.visible = false;
|
||||
videoContainer.addChild(cursorHighlightGraphics);
|
||||
cursorHighlightGraphicsRef.current = cursorHighlightGraphics;
|
||||
drawCursorHighlightGraphics(cursorHighlightGraphics, cursorHighlightRef.current);
|
||||
|
||||
animationStateRef.current = {
|
||||
scale: 1,
|
||||
focusX: DEFAULT_FOCUS.cx,
|
||||
@@ -770,6 +827,9 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
onTimeUpdate: (time) => onTimeUpdateRef.current(time),
|
||||
trimRegionsRef,
|
||||
speedRegionsRef,
|
||||
isScrubbingRef,
|
||||
scrubEndTimerRef,
|
||||
onScrubChange: (scrubbing) => setIsScrubbing(scrubbing),
|
||||
});
|
||||
|
||||
video.addEventListener("play", handlePlay);
|
||||
@@ -797,6 +857,11 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
videoContainer.removeChild(maskGraphics);
|
||||
maskGraphics.destroy();
|
||||
}
|
||||
if (cursorHighlightGraphicsRef.current) {
|
||||
videoContainer.removeChild(cursorHighlightGraphicsRef.current);
|
||||
cursorHighlightGraphicsRef.current.destroy();
|
||||
cursorHighlightGraphicsRef.current = null;
|
||||
}
|
||||
videoContainer.mask = null;
|
||||
maskGraphicsRef.current = null;
|
||||
if (blurFilterRef.current) {
|
||||
@@ -858,8 +923,10 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
};
|
||||
|
||||
let lastMotionBlurActive: boolean | null = null;
|
||||
let lastTransformIsIdentity = true;
|
||||
let lastPerspectiveValue = 0;
|
||||
const ticker = () => {
|
||||
const { region, strength, blendedScale, transition } = findDominantRegion(
|
||||
const { region, strength, blendedScale, rotation3D, transition } = findDominantRegion(
|
||||
zoomRegionsRef.current,
|
||||
currentTimeRef.current,
|
||||
{
|
||||
@@ -879,7 +946,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
const shouldShowUnzoomedView = hasSelectedZoom && !isPlayingRef.current;
|
||||
|
||||
if (region && strength > 0 && !shouldShowUnzoomedView) {
|
||||
const zoomScale = blendedScale ?? ZOOM_DEPTH_SCALES[region.depth];
|
||||
const zoomScale = blendedScale ?? getZoomScale(region);
|
||||
const regionFocus = region.focus;
|
||||
|
||||
targetScaleFactor = zoomScale;
|
||||
@@ -1016,7 +1083,41 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
motionVector,
|
||||
);
|
||||
|
||||
const isMotionBlurActive = (motionBlurAmountRef.current || 0) > 0 && isPlayingRef.current;
|
||||
const cursorGraphics = cursorHighlightGraphicsRef.current;
|
||||
const cursorConfig = cursorHighlightRef.current;
|
||||
const lockedDims = lockedVideoDimensionsRef.current;
|
||||
if (cursorGraphics) {
|
||||
if (cursorConfig.enabled && lockedDims && cursorTelemetryRef.current.length > 0) {
|
||||
const emphasisAlpha = clickEmphasisAlpha(
|
||||
currentTimeRef.current,
|
||||
cursorClickTimestampsRef.current,
|
||||
cursorConfig,
|
||||
);
|
||||
const cursorPoint =
|
||||
emphasisAlpha > 0
|
||||
? interpolateCursorAt(cursorTelemetryRef.current, currentTimeRef.current)
|
||||
: null;
|
||||
if (cursorPoint) {
|
||||
const baseScale = baseScaleRef.current;
|
||||
const baseOffset = baseOffsetRef.current;
|
||||
const cx = cursorPoint.cx + cursorConfig.offsetXNorm;
|
||||
const cy = cursorPoint.cy + cursorConfig.offsetYNorm;
|
||||
cursorGraphics.position.set(
|
||||
baseOffset.x + cx * lockedDims.width * baseScale,
|
||||
baseOffset.y + cy * lockedDims.height * baseScale,
|
||||
);
|
||||
cursorGraphics.alpha = emphasisAlpha;
|
||||
cursorGraphics.visible = true;
|
||||
} else {
|
||||
cursorGraphics.visible = false;
|
||||
}
|
||||
} else {
|
||||
cursorGraphics.visible = false;
|
||||
}
|
||||
}
|
||||
|
||||
const isMotionBlurActive =
|
||||
(motionBlurAmountRef.current || 0) > 0 && isPlayingRef.current && !isScrubbingRef.current;
|
||||
|
||||
if (isMotionBlurActive !== lastMotionBlurActive && videoContainerRef.current) {
|
||||
if (isMotionBlurActive) {
|
||||
@@ -1032,6 +1133,44 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
lastMotionBlurActive = false;
|
||||
}
|
||||
}
|
||||
|
||||
const composite3D = composite3DRef.current;
|
||||
const outerWrapper = outerWrapperRef.current;
|
||||
if (composite3D && outerWrapper) {
|
||||
const effectiveRotation =
|
||||
region && targetProgress > 0 && !shouldShowUnzoomedView
|
||||
? lerpRotation3D(DEFAULT_ROTATION_3D, rotation3D, targetProgress)
|
||||
: DEFAULT_ROTATION_3D;
|
||||
const isIdentity = isRotation3DIdentity(effectiveRotation);
|
||||
if (isIdentity) {
|
||||
if (!lastTransformIsIdentity) {
|
||||
composite3D.style.transform = "";
|
||||
composite3D.style.willChange = "auto";
|
||||
lastTransformIsIdentity = true;
|
||||
}
|
||||
if (lastPerspectiveValue !== 0) {
|
||||
outerWrapper.style.perspective = "";
|
||||
lastPerspectiveValue = 0;
|
||||
}
|
||||
} else {
|
||||
const wrapperW = outerWrapper.clientWidth || 1;
|
||||
const wrapperH = outerWrapper.clientHeight || 1;
|
||||
const persp = rotation3DPerspective(wrapperW, wrapperH);
|
||||
const containScale = computeRotation3DContainScale(
|
||||
effectiveRotation,
|
||||
wrapperW,
|
||||
wrapperH,
|
||||
persp,
|
||||
);
|
||||
composite3D.style.transform = `scale(${containScale}) rotateX(${effectiveRotation.rotationX}deg) rotateY(${effectiveRotation.rotationY}deg) rotateZ(${effectiveRotation.rotationZ}deg)`;
|
||||
composite3D.style.willChange = "transform";
|
||||
lastTransformIsIdentity = false;
|
||||
if (persp !== lastPerspectiveValue) {
|
||||
outerWrapper.style.perspective = `${persp}px`;
|
||||
lastPerspectiveValue = persp;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
app.ticker.add(ticker);
|
||||
@@ -1153,6 +1292,10 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
cancelAnimationFrame(videoReadyRafRef.current);
|
||||
videoReadyRafRef.current = null;
|
||||
}
|
||||
if (scrubEndTimerRef.current !== null) {
|
||||
window.clearTimeout(scrubEndTimerRef.current);
|
||||
scrubEndTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -1169,6 +1312,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={outerWrapperRef}
|
||||
className="relative rounded-sm overflow-hidden"
|
||||
style={{
|
||||
width: "100%",
|
||||
@@ -1193,189 +1337,204 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
ref={containerRef}
|
||||
ref={composite3DRef}
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
filter:
|
||||
showShadow && shadowIntensity > 0
|
||||
? `drop-shadow(0 ${shadowIntensity * 12}px ${shadowIntensity * 48}px rgba(0,0,0,${shadowIntensity * 0.7})) drop-shadow(0 ${shadowIntensity * 4}px ${shadowIntensity * 16}px rgba(0,0,0,${shadowIntensity * 0.5})) drop-shadow(0 ${shadowIntensity * 2}px ${shadowIntensity * 8}px rgba(0,0,0,${shadowIntensity * 0.3}))`
|
||||
: "none",
|
||||
transformStyle: "preserve-3d",
|
||||
transformOrigin: "center center",
|
||||
}}
|
||||
/>
|
||||
{webcamVideoPath &&
|
||||
(() => {
|
||||
const clipPath = getCssClipPath(webcamLayout?.maskShape ?? "rectangle");
|
||||
const useClipPath = !!clipPath;
|
||||
return (
|
||||
<div
|
||||
className="absolute"
|
||||
style={{
|
||||
left: webcamLayout?.x ?? 0,
|
||||
top: webcamLayout?.y ?? 0,
|
||||
width: webcamLayout?.width ?? 0,
|
||||
height: webcamLayout?.height ?? 0,
|
||||
zIndex: 20,
|
||||
opacity: webcamLayout ? 1 : 0,
|
||||
filter:
|
||||
useClipPath && webcamCssBoxShadow !== "none"
|
||||
? `drop-shadow(${webcamCssBoxShadow})`
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
<video
|
||||
ref={webcamVideoRef}
|
||||
src={webcamVideoPath}
|
||||
className={`w-full h-full object-cover ${webcamLayoutPreset === "picture-in-picture" ? "cursor-grab active:cursor-grabbing" : "pointer-events-none"}`}
|
||||
style={{
|
||||
borderRadius: useClipPath ? 0 : (webcamLayout?.borderRadius ?? 0),
|
||||
clipPath: clipPath ?? undefined,
|
||||
boxShadow: useClipPath ? "none" : webcamCssBoxShadow,
|
||||
backgroundColor: "#000",
|
||||
}}
|
||||
onPointerDown={handleWebcamPointerDown}
|
||||
onPointerMove={handleWebcamPointerMove}
|
||||
onPointerUp={handleWebcamPointerUp}
|
||||
onPointerLeave={handleWebcamPointerUp}
|
||||
muted
|
||||
preload="metadata"
|
||||
playsInline
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{/* Only render overlay after PIXI and video are fully initialized */}
|
||||
{pixiReady && videoReady && (
|
||||
>
|
||||
<div
|
||||
ref={setOverlayRefs}
|
||||
className="absolute inset-0 select-none"
|
||||
style={{ pointerEvents: "auto", zIndex: 30 }}
|
||||
onPointerDown={handleOverlayPointerDown}
|
||||
onPointerMove={handleOverlayPointerMove}
|
||||
onPointerUp={handleOverlayPointerUp}
|
||||
onPointerLeave={handleOverlayPointerLeave}
|
||||
>
|
||||
<div
|
||||
ref={focusIndicatorRef}
|
||||
className="absolute rounded-md border border-[#34B27B]/80 bg-[#34B27B]/20 shadow-[0_0_0_1px_rgba(52,178,123,0.35)]"
|
||||
style={{ display: "none", pointerEvents: "none" }}
|
||||
/>
|
||||
{(() => {
|
||||
const filteredAnnotations = (annotationRegions || []).filter((annotation) => {
|
||||
if (typeof annotation.startMs !== "number" || typeof annotation.endMs !== "number")
|
||||
return false;
|
||||
|
||||
if (annotation.id === selectedAnnotationId) return true;
|
||||
|
||||
const timeMs = Math.round(currentTime * 1000);
|
||||
return timeMs >= annotation.startMs && timeMs < annotation.endMs;
|
||||
});
|
||||
|
||||
const filteredBlurRegions = (blurRegions || []).filter((blurRegion) => {
|
||||
if (typeof blurRegion.startMs !== "number" || typeof blurRegion.endMs !== "number")
|
||||
return false;
|
||||
|
||||
if (blurRegion.id === selectedBlurId) return true;
|
||||
|
||||
const timeMs = Math.round(currentTime * 1000);
|
||||
return timeMs >= blurRegion.startMs && timeMs < blurRegion.endMs;
|
||||
});
|
||||
|
||||
const sorted = [
|
||||
...filteredAnnotations.map((annotation) => ({
|
||||
kind: "annotation" as const,
|
||||
region: annotation,
|
||||
})),
|
||||
...filteredBlurRegions.map((blurRegion) => ({
|
||||
kind: "blur" as const,
|
||||
region: blurRegion,
|
||||
})),
|
||||
].sort((a, b) => a.region.zIndex - b.region.zIndex);
|
||||
const previewSnapshotCanvas =
|
||||
filteredBlurRegions.length > 0
|
||||
? (() => {
|
||||
const app = appRef.current;
|
||||
if (!app?.renderer?.extract) return null;
|
||||
try {
|
||||
return app.renderer.extract.canvas(app.stage);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})()
|
||||
: null;
|
||||
|
||||
// Handle click-through cycling: when clicking same annotation, cycle to next
|
||||
const handleAnnotationClick = (clickedId: string) => {
|
||||
if (!onSelectAnnotation) return;
|
||||
|
||||
// If clicking on already selected annotation and there are multiple overlapping
|
||||
if (clickedId === selectedAnnotationId && filteredAnnotations.length > 1) {
|
||||
// Find current index and cycle to next
|
||||
const currentIndex = filteredAnnotations.findIndex((a) => a.id === clickedId);
|
||||
const nextIndex = (currentIndex + 1) % filteredAnnotations.length;
|
||||
onSelectAnnotation(filteredAnnotations[nextIndex].id);
|
||||
} else {
|
||||
// First click or clicking different annotation
|
||||
onSelectAnnotation(clickedId);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlurClick = (clickedId: string) => {
|
||||
if (!onSelectBlur) return;
|
||||
|
||||
if (clickedId === selectedBlurId && filteredBlurRegions.length > 1) {
|
||||
const currentIndex = filteredBlurRegions.findIndex((a) => a.id === clickedId);
|
||||
const nextIndex = (currentIndex + 1) % filteredBlurRegions.length;
|
||||
onSelectBlur(filteredBlurRegions[nextIndex].id);
|
||||
} else {
|
||||
onSelectBlur(clickedId);
|
||||
}
|
||||
};
|
||||
|
||||
return sorted.map((item) => (
|
||||
<AnnotationOverlay
|
||||
key={
|
||||
item.kind === "blur"
|
||||
? `${item.region.id}-${overlaySize.width}-${overlaySize.height}-${item.region.blurData?.type ?? "blur"}-${item.region.blurData?.shape ?? "rectangle"}-${item.region.blurData?.color ?? "white"}-${Math.round(item.region.blurData?.blockSize ?? 0)}-${Math.round(item.region.blurData?.intensity ?? 0)}-${(item.region.blurData?.freehandPoints ?? []).map((p) => `${Math.round(p.x)}_${Math.round(p.y)}`).join("-")}`
|
||||
: `${item.region.id}-${overlaySize.width}-${overlaySize.height}`
|
||||
}
|
||||
annotation={item.region}
|
||||
isSelected={
|
||||
item.kind === "blur"
|
||||
? item.region.id === selectedBlurId
|
||||
: item.region.id === selectedAnnotationId
|
||||
}
|
||||
containerWidth={overlaySize.width}
|
||||
containerHeight={overlaySize.height}
|
||||
onPositionChange={(id, position) =>
|
||||
item.kind === "blur"
|
||||
? onBlurPositionChange?.(id, position)
|
||||
: onAnnotationPositionChange?.(id, position)
|
||||
}
|
||||
onSizeChange={(id, size) =>
|
||||
item.kind === "blur"
|
||||
? onBlurSizeChange?.(id, size)
|
||||
: onAnnotationSizeChange?.(id, size)
|
||||
}
|
||||
onBlurDataChange={
|
||||
item.kind === "blur"
|
||||
? (id, blurData) => onBlurDataChange?.(id, blurData)
|
||||
: undefined
|
||||
}
|
||||
onBlurDataCommit={item.kind === "blur" ? onBlurDataCommit : undefined}
|
||||
onClick={item.kind === "blur" ? handleBlurClick : handleAnnotationClick}
|
||||
zIndex={item.region.zIndex}
|
||||
isSelectedBoost={
|
||||
item.kind === "blur"
|
||||
? item.region.id === selectedBlurId
|
||||
: item.region.id === selectedAnnotationId
|
||||
}
|
||||
previewSourceCanvas={previewSnapshotCanvas}
|
||||
previewFrameVersion={Math.round(currentTime * 1000)}
|
||||
/>
|
||||
));
|
||||
ref={containerRef}
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
filter:
|
||||
showShadow && shadowIntensity > 0
|
||||
? `drop-shadow(0 ${shadowIntensity * 12}px ${shadowIntensity * 48}px rgba(0,0,0,${shadowIntensity * 0.7})) drop-shadow(0 ${shadowIntensity * 4}px ${shadowIntensity * 16}px rgba(0,0,0,${shadowIntensity * 0.5})) drop-shadow(0 ${shadowIntensity * 2}px ${shadowIntensity * 8}px rgba(0,0,0,${shadowIntensity * 0.3}))`
|
||||
: "none",
|
||||
}}
|
||||
/>
|
||||
{webcamVideoPath &&
|
||||
(() => {
|
||||
const clipPath = getCssClipPath(webcamLayout?.maskShape ?? "rectangle");
|
||||
const useClipPath = !!clipPath;
|
||||
return (
|
||||
<div
|
||||
className="absolute"
|
||||
style={{
|
||||
left: webcamLayout?.x ?? 0,
|
||||
top: webcamLayout?.y ?? 0,
|
||||
width: webcamLayout?.width ?? 0,
|
||||
height: webcamLayout?.height ?? 0,
|
||||
zIndex: 20,
|
||||
opacity: webcamLayout ? 1 : 0,
|
||||
filter:
|
||||
useClipPath && webcamCssBoxShadow !== "none"
|
||||
? `drop-shadow(${webcamCssBoxShadow})`
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
<video
|
||||
ref={webcamVideoRef}
|
||||
src={webcamVideoPath}
|
||||
className={`w-full h-full object-cover ${webcamLayoutPreset === "picture-in-picture" ? "cursor-grab active:cursor-grabbing" : "pointer-events-none"}`}
|
||||
style={{
|
||||
borderRadius: useClipPath ? 0 : (webcamLayout?.borderRadius ?? 0),
|
||||
clipPath: clipPath ?? undefined,
|
||||
boxShadow: useClipPath ? "none" : webcamCssBoxShadow,
|
||||
backgroundColor: "#000",
|
||||
}}
|
||||
onPointerDown={handleWebcamPointerDown}
|
||||
onPointerMove={handleWebcamPointerMove}
|
||||
onPointerUp={handleWebcamPointerUp}
|
||||
onPointerLeave={handleWebcamPointerUp}
|
||||
muted
|
||||
preload="metadata"
|
||||
playsInline
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
{/* Only render overlay after PIXI and video are fully initialized */}
|
||||
{pixiReady && videoReady && (
|
||||
<div
|
||||
ref={setOverlayRefs}
|
||||
className="absolute inset-0 select-none"
|
||||
style={{ pointerEvents: "auto", zIndex: 30 }}
|
||||
onPointerDown={handleOverlayPointerDown}
|
||||
onPointerMove={handleOverlayPointerMove}
|
||||
onPointerUp={handleOverlayPointerUp}
|
||||
onPointerLeave={handleOverlayPointerLeave}
|
||||
>
|
||||
<div
|
||||
ref={focusIndicatorRef}
|
||||
className="absolute rounded-md border border-[#34B27B]/80 bg-[#34B27B]/20 shadow-[0_0_0_1px_rgba(52,178,123,0.35)]"
|
||||
style={{ display: "none", pointerEvents: "none" }}
|
||||
/>
|
||||
{(() => {
|
||||
const filteredAnnotations = (annotationRegions || []).filter((annotation) => {
|
||||
if (
|
||||
typeof annotation.startMs !== "number" ||
|
||||
typeof annotation.endMs !== "number"
|
||||
)
|
||||
return false;
|
||||
|
||||
if (annotation.id === selectedAnnotationId) return true;
|
||||
|
||||
const timeMs = Math.round(currentTime * 1000);
|
||||
return timeMs >= annotation.startMs && timeMs < annotation.endMs;
|
||||
});
|
||||
|
||||
const filteredBlurRegions = (blurRegions || []).filter((blurRegion) => {
|
||||
if (
|
||||
typeof blurRegion.startMs !== "number" ||
|
||||
typeof blurRegion.endMs !== "number"
|
||||
)
|
||||
return false;
|
||||
|
||||
if (blurRegion.id === selectedBlurId) return true;
|
||||
|
||||
const timeMs = Math.round(currentTime * 1000);
|
||||
return timeMs >= blurRegion.startMs && timeMs < blurRegion.endMs;
|
||||
});
|
||||
|
||||
const sorted = [
|
||||
...filteredAnnotations.map((annotation) => ({
|
||||
kind: "annotation" as const,
|
||||
region: annotation,
|
||||
})),
|
||||
...filteredBlurRegions.map((blurRegion) => ({
|
||||
kind: "blur" as const,
|
||||
region: blurRegion,
|
||||
})),
|
||||
].sort((a, b) => a.region.zIndex - b.region.zIndex);
|
||||
const previewSnapshotCanvas =
|
||||
filteredBlurRegions.length > 0
|
||||
? (() => {
|
||||
const app = appRef.current;
|
||||
if (!app?.renderer?.extract) return null;
|
||||
try {
|
||||
return app.renderer.extract.canvas(app.stage);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})()
|
||||
: null;
|
||||
|
||||
// Handle click-through cycling: when clicking same annotation, cycle to next
|
||||
const handleAnnotationClick = (clickedId: string) => {
|
||||
if (!onSelectAnnotation) return;
|
||||
|
||||
// If clicking on already selected annotation and there are multiple overlapping
|
||||
if (clickedId === selectedAnnotationId && filteredAnnotations.length > 1) {
|
||||
// Find current index and cycle to next
|
||||
const currentIndex = filteredAnnotations.findIndex((a) => a.id === clickedId);
|
||||
const nextIndex = (currentIndex + 1) % filteredAnnotations.length;
|
||||
onSelectAnnotation(filteredAnnotations[nextIndex].id);
|
||||
} else {
|
||||
// First click or clicking different annotation
|
||||
onSelectAnnotation(clickedId);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlurClick = (clickedId: string) => {
|
||||
if (!onSelectBlur) return;
|
||||
|
||||
if (clickedId === selectedBlurId && filteredBlurRegions.length > 1) {
|
||||
const currentIndex = filteredBlurRegions.findIndex((a) => a.id === clickedId);
|
||||
const nextIndex = (currentIndex + 1) % filteredBlurRegions.length;
|
||||
onSelectBlur(filteredBlurRegions[nextIndex].id);
|
||||
} else {
|
||||
onSelectBlur(clickedId);
|
||||
}
|
||||
};
|
||||
|
||||
return sorted.map((item) => (
|
||||
<AnnotationOverlay
|
||||
key={
|
||||
item.kind === "blur"
|
||||
? `${item.region.id}-${overlaySize.width}-${overlaySize.height}-${item.region.blurData?.type ?? "blur"}-${item.region.blurData?.shape ?? "rectangle"}-${item.region.blurData?.color ?? "white"}-${Math.round(item.region.blurData?.blockSize ?? 0)}-${Math.round(item.region.blurData?.intensity ?? 0)}-${(item.region.blurData?.freehandPoints ?? []).map((p) => `${Math.round(p.x)}_${Math.round(p.y)}`).join("-")}`
|
||||
: `${item.region.id}-${overlaySize.width}-${overlaySize.height}`
|
||||
}
|
||||
annotation={item.region}
|
||||
isSelected={
|
||||
item.kind === "blur"
|
||||
? item.region.id === selectedBlurId
|
||||
: item.region.id === selectedAnnotationId
|
||||
}
|
||||
containerWidth={overlaySize.width}
|
||||
containerHeight={overlaySize.height}
|
||||
onPositionChange={(id, position) =>
|
||||
item.kind === "blur"
|
||||
? onBlurPositionChange?.(id, position)
|
||||
: onAnnotationPositionChange?.(id, position)
|
||||
}
|
||||
onSizeChange={(id, size) =>
|
||||
item.kind === "blur"
|
||||
? onBlurSizeChange?.(id, size)
|
||||
: onAnnotationSizeChange?.(id, size)
|
||||
}
|
||||
onBlurDataChange={
|
||||
item.kind === "blur"
|
||||
? (id, blurData) => onBlurDataChange?.(id, blurData)
|
||||
: undefined
|
||||
}
|
||||
onBlurDataCommit={item.kind === "blur" ? onBlurDataCommit : undefined}
|
||||
onClick={item.kind === "blur" ? handleBlurClick : handleAnnotationClick}
|
||||
zIndex={item.region.zIndex}
|
||||
isSelectedBoost={
|
||||
item.kind === "blur"
|
||||
? item.region.id === selectedBlurId
|
||||
: item.region.id === selectedAnnotationId
|
||||
}
|
||||
previewSourceCanvas={previewSnapshotCanvas}
|
||||
previewFrameVersion={Math.round(currentTime * 1000)}
|
||||
/>
|
||||
));
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={videoPath}
|
||||
|
||||
@@ -80,6 +80,7 @@ export interface ProjectEditorState {
|
||||
gifFrameRate: GifFrameRate;
|
||||
gifLoop: boolean;
|
||||
gifSizePreset: GifSizePreset;
|
||||
cursorHighlight: import("./videoPlayback/cursorHighlight").CursorHighlightConfig;
|
||||
}
|
||||
|
||||
export interface EditorProjectData {
|
||||
@@ -99,6 +100,7 @@ function computeNormalizedWebcamLayoutPreset(
|
||||
): WebcamLayoutPreset {
|
||||
switch (webcamLayoutPreset) {
|
||||
case "picture-in-picture":
|
||||
case "no-webcam":
|
||||
return webcamLayoutPreset;
|
||||
case "vertical-stack":
|
||||
return isPortraitAspectRatio(normalizedAspectRatio)
|
||||
@@ -250,6 +252,12 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
|
||||
const startMs = Math.max(0, Math.min(rawStart, rawEnd));
|
||||
const endMs = Math.max(startMs + 1, rawEnd);
|
||||
|
||||
const validPreset =
|
||||
region.rotationPreset === "iso" ||
|
||||
region.rotationPreset === "left" ||
|
||||
region.rotationPreset === "right"
|
||||
? region.rotationPreset
|
||||
: undefined;
|
||||
return {
|
||||
id: region.id,
|
||||
startMs,
|
||||
@@ -260,6 +268,7 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
|
||||
cy: clamp(isFiniteNumber(region.focus?.cy) ? region.focus.cy : 0.5, 0, 1),
|
||||
},
|
||||
focusMode: region.focusMode === "auto" ? "auto" : "manual",
|
||||
...(validPreset ? { rotationPreset: validPreset } : {}),
|
||||
};
|
||||
})
|
||||
: [];
|
||||
@@ -494,6 +503,52 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
|
||||
editor.gifSizePreset === "original"
|
||||
? editor.gifSizePreset
|
||||
: "medium",
|
||||
cursorHighlight: normalizeCursorHighlight(editor.cursorHighlight),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeCursorHighlight(
|
||||
value: unknown,
|
||||
): import("./videoPlayback/cursorHighlight").CursorHighlightConfig {
|
||||
const fallback: import("./videoPlayback/cursorHighlight").CursorHighlightConfig = {
|
||||
enabled: false,
|
||||
style: "ring",
|
||||
sizePx: 24,
|
||||
color: "#FFD700",
|
||||
opacity: 0.9,
|
||||
onlyOnClicks: false,
|
||||
clickEmphasisDurationMs: 350,
|
||||
offsetXNorm: 0,
|
||||
offsetYNorm: 0,
|
||||
};
|
||||
if (!value || typeof value !== "object") return fallback;
|
||||
const v = value as Partial<import("./videoPlayback/cursorHighlight").CursorHighlightConfig>;
|
||||
return {
|
||||
enabled: typeof v.enabled === "boolean" ? v.enabled : fallback.enabled,
|
||||
style: v.style === "dot" || v.style === "ring" ? v.style : fallback.style,
|
||||
sizePx:
|
||||
typeof v.sizePx === "number" && v.sizePx >= 10 && v.sizePx <= 36 ? v.sizePx : fallback.sizePx,
|
||||
color:
|
||||
typeof v.color === "string" && /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(v.color)
|
||||
? v.color
|
||||
: fallback.color,
|
||||
opacity:
|
||||
typeof v.opacity === "number" && v.opacity >= 0 && v.opacity <= 1
|
||||
? v.opacity
|
||||
: fallback.opacity,
|
||||
onlyOnClicks: typeof v.onlyOnClicks === "boolean" ? v.onlyOnClicks : fallback.onlyOnClicks,
|
||||
clickEmphasisDurationMs:
|
||||
typeof v.clickEmphasisDurationMs === "number" && v.clickEmphasisDurationMs > 0
|
||||
? v.clickEmphasisDurationMs
|
||||
: fallback.clickEmphasisDurationMs,
|
||||
offsetXNorm:
|
||||
typeof v.offsetXNorm === "number" && Number.isFinite(v.offsetXNorm)
|
||||
? Math.max(-1, Math.min(1, v.offsetXNorm))
|
||||
: fallback.offsetXNorm,
|
||||
offsetYNorm:
|
||||
typeof v.offsetYNorm === "number" && Number.isFinite(v.offsetYNorm)
|
||||
? Math.max(-1, Math.min(1, v.offsetYNorm))
|
||||
: fallback.offsetYNorm,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ interface ItemProps {
|
||||
isSelected?: boolean;
|
||||
onSelect?: () => void;
|
||||
zoomDepth?: number;
|
||||
zoomCustomScale?: number;
|
||||
speedValue?: number;
|
||||
isAutoFocus?: boolean;
|
||||
variant?: "zoom" | "trim" | "annotation" | "speed" | "blur";
|
||||
@@ -46,6 +47,7 @@ export default function Item({
|
||||
isSelected = false,
|
||||
onSelect,
|
||||
zoomDepth = 1,
|
||||
zoomCustomScale,
|
||||
speedValue,
|
||||
isAutoFocus = false,
|
||||
variant = "zoom",
|
||||
@@ -134,7 +136,9 @@ export default function Item({
|
||||
<>
|
||||
<ZoomIn className="w-3.5 h-3.5 shrink-0" />
|
||||
<span className="text-[11px] font-semibold tracking-tight whitespace-nowrap">
|
||||
{ZOOM_LABELS[zoomDepth] || `${zoomDepth}×`}
|
||||
{zoomCustomScale != null
|
||||
? `${zoomCustomScale.toFixed(2)}×`
|
||||
: ZOOM_LABELS[zoomDepth] || `${zoomDepth}×`}
|
||||
</span>
|
||||
{isAutoFocus && (
|
||||
<MousePointer2
|
||||
|
||||
@@ -102,6 +102,7 @@ interface TimelineRenderItem {
|
||||
span: Span;
|
||||
label: string;
|
||||
zoomDepth?: number;
|
||||
zoomCustomScale?: number;
|
||||
speedValue?: number;
|
||||
isAutoFocus?: boolean;
|
||||
variant: "zoom" | "trim" | "annotation" | "speed" | "blur";
|
||||
@@ -683,6 +684,7 @@ function Timeline({
|
||||
isSelected={item.id === selectedZoomId}
|
||||
onSelect={() => onSelectZoom?.(item.id)}
|
||||
zoomDepth={item.zoomDepth}
|
||||
zoomCustomScale={item.zoomCustomScale}
|
||||
isAutoFocus={item.isAutoFocus}
|
||||
variant="zoom"
|
||||
>
|
||||
@@ -1339,6 +1341,7 @@ export default function TimelineEditor({
|
||||
span: { start: region.startMs, end: region.endMs },
|
||||
label: t("labels.zoomItem", { index: String(index + 1) }),
|
||||
zoomDepth: region.depth,
|
||||
zoomCustomScale: region.customScale,
|
||||
isAutoFocus: region.focusMode === "auto",
|
||||
variant: "zoom",
|
||||
}));
|
||||
|
||||
@@ -57,7 +57,7 @@ export default function TimelineWrapper({
|
||||
const duration = Math.min(Math.max(rawDuration, minDuration), totalMs);
|
||||
|
||||
const start = Math.max(0, Math.min(normalizedStart, totalMs - duration));
|
||||
const end = start + duration;
|
||||
const end = Math.min(start + duration, totalMs);
|
||||
|
||||
return { start, end };
|
||||
},
|
||||
|
||||
@@ -26,6 +26,37 @@ export interface ZoomFocus {
|
||||
cy: number; // normalized vertical center (0-1)
|
||||
}
|
||||
|
||||
export interface Rotation3D {
|
||||
rotationX: number;
|
||||
rotationY: number;
|
||||
rotationZ: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_ROTATION_3D: Rotation3D = {
|
||||
rotationX: 0,
|
||||
rotationY: 0,
|
||||
rotationZ: 0,
|
||||
};
|
||||
|
||||
export type Rotation3DPreset = "iso" | "left" | "right";
|
||||
|
||||
export const ROTATION_3D_PRESETS: Record<Rotation3DPreset, Rotation3D> = {
|
||||
iso: { rotationX: -10, rotationY: -16, rotationZ: 0 },
|
||||
left: { rotationX: 0, rotationY: -22, rotationZ: 0 },
|
||||
right: { rotationX: 0, rotationY: 22, rotationZ: 0 },
|
||||
};
|
||||
|
||||
export const ROTATION_3D_PRESET_ORDER: Rotation3DPreset[] = ["iso", "left", "right"];
|
||||
|
||||
/** Perspective distance in CSS px is computed at render-time as this factor times
|
||||
* min(viewport width, viewport height). Same factor used in preview and export so
|
||||
* the visual look is identical regardless of canvas resolution. */
|
||||
export const ROTATION_3D_PERSPECTIVE_FACTOR = 2.6;
|
||||
|
||||
export function rotation3DPerspective(width: number, height: number): number {
|
||||
return Math.min(width, height) * ROTATION_3D_PERSPECTIVE_FACTOR;
|
||||
}
|
||||
|
||||
export interface ZoomRegion {
|
||||
id: string;
|
||||
startMs: number;
|
||||
@@ -33,6 +64,106 @@ export interface ZoomRegion {
|
||||
depth: ZoomDepth;
|
||||
focus: ZoomFocus;
|
||||
focusMode?: ZoomFocusMode;
|
||||
rotationPreset?: Rotation3DPreset;
|
||||
/** Custom scale overriding the preset depth (1.0–5.0, two decimal precision). */
|
||||
customScale?: number;
|
||||
}
|
||||
|
||||
export function getRotation3D(region: Pick<ZoomRegion, "rotationPreset">): Rotation3D {
|
||||
if (!region.rotationPreset) return DEFAULT_ROTATION_3D;
|
||||
return ROTATION_3D_PRESETS[region.rotationPreset];
|
||||
}
|
||||
|
||||
export function isRotation3DIdentity(r: Rotation3D, eps = 0.01): boolean {
|
||||
return Math.abs(r.rotationX) < eps && Math.abs(r.rotationY) < eps && Math.abs(r.rotationZ) < eps;
|
||||
}
|
||||
|
||||
export function lerpRotation3D(a: Rotation3D, b: Rotation3D, t: number): Rotation3D {
|
||||
return {
|
||||
rotationX: a.rotationX + (b.rotationX - a.rotationX) * t,
|
||||
rotationY: a.rotationY + (b.rotationY - a.rotationY) * t,
|
||||
rotationZ: a.rotationZ + (b.rotationZ - a.rotationZ) * t,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the maximum uniform scale that, when applied alongside `rot` and a perspective
|
||||
* of `perspective` CSS px, keeps the projected bounding box of a `width × height` element
|
||||
* inside its original `width × height` rectangle. Returns 1 when no scaling is needed.
|
||||
*
|
||||
* Math: project each rotated corner onto the screen via x' = x·P/(P−z); take the worst-case
|
||||
* |x'|/|y'| against the half-extents and return the limiting ratio. This makes the rotated
|
||||
* recording sit *inside* the zoom window instead of bleeding past it.
|
||||
*/
|
||||
export function computeRotation3DContainScale(
|
||||
rot: Rotation3D,
|
||||
width: number,
|
||||
height: number,
|
||||
perspective: number,
|
||||
): number {
|
||||
const a = (rot.rotationX * Math.PI) / 180;
|
||||
const b = (rot.rotationY * Math.PI) / 180;
|
||||
const g = (rot.rotationZ * Math.PI) / 180;
|
||||
const ca = Math.cos(a);
|
||||
const sa = Math.sin(a);
|
||||
const cb = Math.cos(b);
|
||||
const sb = Math.sin(b);
|
||||
const cg = Math.cos(g);
|
||||
const sg = Math.sin(g);
|
||||
const halfW = width / 2;
|
||||
const halfH = height / 2;
|
||||
const corners: Array<[number, number]> = [
|
||||
[-halfW, -halfH],
|
||||
[halfW, -halfH],
|
||||
[halfW, halfH],
|
||||
[-halfW, halfH],
|
||||
];
|
||||
|
||||
let maxAbsX = 0;
|
||||
let maxAbsY = 0;
|
||||
|
||||
for (const [x0, y0] of corners) {
|
||||
// CSS "rotateX(α) rotateY(β) rotateZ(γ)" reads right-to-left: Z first, then Y, then X.
|
||||
let px = x0;
|
||||
let py = y0;
|
||||
let pz = 0;
|
||||
|
||||
// rotateZ
|
||||
const zx = px * cg - py * sg;
|
||||
const zy = px * sg + py * cg;
|
||||
px = zx;
|
||||
py = zy;
|
||||
|
||||
// rotateY
|
||||
const yx = px * cb + pz * sb;
|
||||
const yz = -px * sb + pz * cb;
|
||||
px = yx;
|
||||
pz = yz;
|
||||
|
||||
// rotateX
|
||||
const xy = py * ca - pz * sa;
|
||||
const xz = py * sa + pz * ca;
|
||||
py = xy;
|
||||
pz = xz;
|
||||
|
||||
// Perspective projection: viewer at (0, 0, P), looking toward −z. A point at z=pz
|
||||
// is scaled by P / (P − pz). When perspective ≤ 0 we treat as orthographic.
|
||||
if (perspective > 0) {
|
||||
const denom = perspective - pz;
|
||||
if (denom <= 0) return 1; // pathological — skip scaling rather than crash
|
||||
const f = perspective / denom;
|
||||
px *= f;
|
||||
py *= f;
|
||||
}
|
||||
|
||||
if (Math.abs(px) > maxAbsX) maxAbsX = Math.abs(px);
|
||||
if (Math.abs(py) > maxAbsY) maxAbsY = Math.abs(py);
|
||||
}
|
||||
|
||||
if (maxAbsX === 0 || maxAbsY === 0) return 1;
|
||||
const sx = halfW / maxAbsX;
|
||||
const sy = halfH / maxAbsY;
|
||||
return Math.min(sx, sy, 1);
|
||||
}
|
||||
|
||||
export interface CursorTelemetryPoint {
|
||||
@@ -227,8 +358,20 @@ export const ZOOM_DEPTH_SCALES: Record<ZoomDepth, number> = {
|
||||
6: 5.0,
|
||||
};
|
||||
|
||||
export const MIN_ZOOM_SCALE = 1.0;
|
||||
export const MAX_ZOOM_SCALE = 5.0;
|
||||
|
||||
export const DEFAULT_ZOOM_DEPTH: ZoomDepth = 3;
|
||||
|
||||
/** Returns the effective zoom scale for a region, preferring customScale over the preset. */
|
||||
export function getZoomScale(region: ZoomRegion): number {
|
||||
if (region.customScale != null) {
|
||||
const clamped = Math.max(MIN_ZOOM_SCALE, Math.min(MAX_ZOOM_SCALE, region.customScale));
|
||||
if (Number.isFinite(clamped)) return clamped;
|
||||
}
|
||||
return ZOOM_DEPTH_SCALES[region.depth];
|
||||
}
|
||||
|
||||
export function clampFocusToDepth(focus: ZoomFocus, _depth: ZoomDepth): ZoomFocus {
|
||||
return {
|
||||
cx: clamp(focus.cx, 0, 1),
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import type { Graphics } from "pixi.js";
|
||||
|
||||
export type CursorHighlightStyle = "dot" | "ring";
|
||||
|
||||
export interface CursorHighlightConfig {
|
||||
enabled: boolean;
|
||||
style: CursorHighlightStyle;
|
||||
sizePx: number;
|
||||
color: string;
|
||||
opacity: number;
|
||||
// Show only on clicks (macOS — depends on click telemetry from uiohook).
|
||||
onlyOnClicks: boolean;
|
||||
clickEmphasisDurationMs: number;
|
||||
// Per-recording manual nudge. Cursor telemetry is normalized to the display,
|
||||
// but window recordings frame a subset of the display so the highlight
|
||||
// lands offset. Users dial these in once to align with the actual cursor.
|
||||
offsetXNorm: number;
|
||||
offsetYNorm: number;
|
||||
}
|
||||
|
||||
export const CURSOR_HIGHLIGHT_MIN_SIZE_PX = 10;
|
||||
export const CURSOR_HIGHLIGHT_MAX_SIZE_PX = 36;
|
||||
|
||||
export const DEFAULT_CURSOR_HIGHLIGHT: CursorHighlightConfig = {
|
||||
enabled: false,
|
||||
style: "ring",
|
||||
sizePx: 24,
|
||||
color: "#FFD700",
|
||||
opacity: 0.9,
|
||||
onlyOnClicks: false,
|
||||
clickEmphasisDurationMs: 350,
|
||||
offsetXNorm: 0,
|
||||
offsetYNorm: 0,
|
||||
};
|
||||
|
||||
export const CURSOR_HIGHLIGHT_OFFSET_RANGE = 0.25; // ±25% of recorded surface
|
||||
|
||||
// Alpha multiplier for the highlight at `timeMs`. Returns 1 when not in
|
||||
// click-only mode; in click-only mode fades 1→0 across each click's window.
|
||||
export function clickEmphasisAlpha(
|
||||
timeMs: number,
|
||||
clickTimestampsMs: number[] | undefined,
|
||||
config: CursorHighlightConfig,
|
||||
): number {
|
||||
if (!config.onlyOnClicks) return 1;
|
||||
if (!clickTimestampsMs || clickTimestampsMs.length === 0) return 0;
|
||||
const window = Math.max(1, config.clickEmphasisDurationMs);
|
||||
for (let i = 0; i < clickTimestampsMs.length; i++) {
|
||||
const dt = timeMs - clickTimestampsMs[i];
|
||||
if (dt >= 0 && dt <= window) {
|
||||
return 1 - dt / window;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function parseHexColor(hex: string): number {
|
||||
const cleaned = hex.replace("#", "");
|
||||
if (cleaned.length === 3) {
|
||||
const r = cleaned[0];
|
||||
const g = cleaned[1];
|
||||
const b = cleaned[2];
|
||||
return Number.parseInt(`${r}${r}${g}${g}${b}${b}`, 16);
|
||||
}
|
||||
return Number.parseInt(cleaned.slice(0, 6), 16);
|
||||
}
|
||||
|
||||
export function drawCursorHighlightGraphics(g: Graphics, config: CursorHighlightConfig): void {
|
||||
g.clear();
|
||||
if (!config.enabled) return;
|
||||
|
||||
const color = parseHexColor(config.color);
|
||||
const radius = Math.max(1, config.sizePx / 2);
|
||||
const alpha = Math.max(0, Math.min(1, config.opacity));
|
||||
|
||||
switch (config.style) {
|
||||
case "dot": {
|
||||
g.circle(0, 0, radius);
|
||||
g.fill({ color, alpha });
|
||||
break;
|
||||
}
|
||||
case "ring": {
|
||||
g.circle(0, 0, radius);
|
||||
g.stroke({ color, alpha, width: Math.max(2, radius * 0.18) });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function drawCursorHighlightCanvas(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
cx: number,
|
||||
cy: number,
|
||||
config: CursorHighlightConfig,
|
||||
pixelScale = 1,
|
||||
): void {
|
||||
if (!config.enabled) return;
|
||||
|
||||
const radius = Math.max(1, (config.sizePx / 2) * pixelScale);
|
||||
const alpha = Math.max(0, Math.min(1, config.opacity));
|
||||
const color = config.color;
|
||||
|
||||
ctx.save();
|
||||
ctx.globalAlpha = alpha;
|
||||
|
||||
switch (config.style) {
|
||||
case "dot": {
|
||||
ctx.fillStyle = color;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
break;
|
||||
}
|
||||
case "ring": {
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = Math.max(2, radius * 0.18);
|
||||
ctx.stroke();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
@@ -44,7 +44,7 @@ interface ViewportRatio {
|
||||
heightRatio: number;
|
||||
}
|
||||
|
||||
function getFocusBoundsForScale(zoomScale: number, viewportRatio?: ViewportRatio) {
|
||||
export 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));
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ZOOM_DEPTH_SCALES, type ZoomFocus, type ZoomRegion } from "../types";
|
||||
import { clampFocusToStage } from "./focusUtils";
|
||||
import { getZoomScale, type ZoomFocus, type ZoomRegion } from "../types";
|
||||
import { clampFocusToScale } from "./focusUtils";
|
||||
|
||||
interface OverlayUpdateParams {
|
||||
overlayEl: HTMLDivElement;
|
||||
@@ -35,11 +35,8 @@ export function updateOverlayIndicator(params: OverlayUpdateParams) {
|
||||
return;
|
||||
}
|
||||
|
||||
const zoomScale = ZOOM_DEPTH_SCALES[region.depth];
|
||||
const focus = clampFocusToStage(focusOverride ?? region.focus, region.depth, {
|
||||
width: stageWidth,
|
||||
height: stageHeight,
|
||||
});
|
||||
const zoomScale = getZoomScale(region);
|
||||
const focus = clampFocusToScale(focusOverride ?? region.focus, zoomScale);
|
||||
|
||||
// Zoom window shows the stage area that will be visible after zooming (1/zoomScale of stage dimensions)
|
||||
const indicatorWidth = stageWidth / zoomScale;
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import type React from "react";
|
||||
import type { SpeedRegion, TrimRegion } from "../types";
|
||||
|
||||
// Keep "scrub mode" on for a brief tail after `seeked` — rapid drag-scrubbing
|
||||
// fires `seeking`/`seeked` dozens of times per second, and toggling effects
|
||||
// each time would flicker.
|
||||
const SCRUB_END_DEBOUNCE_MS = 150;
|
||||
|
||||
interface VideoEventHandlersParams {
|
||||
video: HTMLVideoElement;
|
||||
isSeekingRef: React.MutableRefObject<boolean>;
|
||||
@@ -12,6 +17,9 @@ interface VideoEventHandlersParams {
|
||||
onTimeUpdate: (time: number) => void;
|
||||
trimRegionsRef: React.MutableRefObject<TrimRegion[]>;
|
||||
speedRegionsRef: React.MutableRefObject<SpeedRegion[]>;
|
||||
isScrubbingRef?: React.MutableRefObject<boolean>;
|
||||
scrubEndTimerRef?: React.MutableRefObject<number | null>;
|
||||
onScrubChange?: (scrubbing: boolean) => void;
|
||||
}
|
||||
|
||||
export function createVideoEventHandlers(params: VideoEventHandlersParams) {
|
||||
@@ -26,8 +34,18 @@ export function createVideoEventHandlers(params: VideoEventHandlersParams) {
|
||||
onTimeUpdate,
|
||||
trimRegionsRef,
|
||||
speedRegionsRef,
|
||||
isScrubbingRef,
|
||||
scrubEndTimerRef,
|
||||
onScrubChange,
|
||||
} = params;
|
||||
|
||||
const clearScrubEndTimer = () => {
|
||||
if (scrubEndTimerRef && scrubEndTimerRef.current !== null) {
|
||||
window.clearTimeout(scrubEndTimerRef.current);
|
||||
scrubEndTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const emitTime = (timeValue: number) => {
|
||||
currentTimeRef.current = timeValue * 1000;
|
||||
onTimeUpdate(timeValue);
|
||||
@@ -113,6 +131,15 @@ export function createVideoEventHandlers(params: VideoEventHandlersParams) {
|
||||
const handleSeeked = () => {
|
||||
isSeekingRef.current = false;
|
||||
|
||||
if (isScrubbingRef && scrubEndTimerRef) {
|
||||
clearScrubEndTimer();
|
||||
scrubEndTimerRef.current = window.setTimeout(() => {
|
||||
isScrubbingRef.current = false;
|
||||
scrubEndTimerRef.current = null;
|
||||
onScrubChange?.(false);
|
||||
}, SCRUB_END_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
const currentTimeMs = video.currentTime * 1000;
|
||||
const activeTrimRegion = findActiveTrimRegion(currentTimeMs);
|
||||
|
||||
@@ -137,6 +164,14 @@ export function createVideoEventHandlers(params: VideoEventHandlersParams) {
|
||||
const handleSeeking = () => {
|
||||
isSeekingRef.current = true;
|
||||
|
||||
if (isScrubbingRef) {
|
||||
clearScrubEndTimer();
|
||||
if (!isScrubbingRef.current) {
|
||||
isScrubbingRef.current = true;
|
||||
onScrubChange?.(true);
|
||||
}
|
||||
}
|
||||
|
||||
if (!isPlayingRef.current && !video.paused) {
|
||||
video.pause();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { CursorTelemetryPoint, ZoomFocus, ZoomRegion } from "../types";
|
||||
import { ZOOM_DEPTH_SCALES } from "../types";
|
||||
import type { CursorTelemetryPoint, Rotation3D, ZoomFocus, ZoomRegion } from "../types";
|
||||
import { DEFAULT_ROTATION_3D, getRotation3D, getZoomScale, lerpRotation3D } from "../types";
|
||||
import { TRANSITION_WINDOW_MS, ZOOM_IN_TRANSITION_WINDOW_MS } from "./constants";
|
||||
import { interpolateCursorAt } from "./cursorFollowUtils";
|
||||
import { clampFocusToScale } from "./focusUtils";
|
||||
@@ -155,7 +155,7 @@ function getActiveRegion(
|
||||
}
|
||||
|
||||
const activeRegion = activeRegions[0].region;
|
||||
const activeScale = ZOOM_DEPTH_SCALES[activeRegion.depth];
|
||||
const activeScale = getZoomScale(activeRegion);
|
||||
|
||||
return {
|
||||
region: {
|
||||
@@ -164,6 +164,7 @@ function getActiveRegion(
|
||||
},
|
||||
strength: activeRegions[0].strength,
|
||||
blendedScale: null,
|
||||
rotation3D: getRotation3D(activeRegion),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -175,7 +176,7 @@ function getConnectedRegionHold(
|
||||
) {
|
||||
for (const pair of connectedPairs) {
|
||||
if (timeMs > pair.transitionEnd && timeMs < pair.nextRegion.startMs) {
|
||||
const nextScale = ZOOM_DEPTH_SCALES[pair.nextRegion.depth];
|
||||
const nextScale = getZoomScale(pair.nextRegion);
|
||||
return {
|
||||
region: {
|
||||
...pair.nextRegion,
|
||||
@@ -189,6 +190,7 @@ function getConnectedRegionHold(
|
||||
},
|
||||
strength: 1,
|
||||
blendedScale: null,
|
||||
rotation3D: getRotation3D(pair.nextRegion),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -212,8 +214,8 @@ function getConnectedRegionTransition(
|
||||
const transitionProgress = easeConnectedPan(
|
||||
clamp01((timeMs - transitionStart) / Math.max(1, transitionEnd - transitionStart)),
|
||||
);
|
||||
const currentScale = ZOOM_DEPTH_SCALES[currentRegion.depth];
|
||||
const nextScale = ZOOM_DEPTH_SCALES[nextRegion.depth];
|
||||
const currentScale = getZoomScale(currentRegion);
|
||||
const nextScale = getZoomScale(nextRegion);
|
||||
const transitionScale = lerp(currentScale, nextScale, transitionProgress);
|
||||
// Both regions share the same timeMs, so interpolate cursor once and reuse.
|
||||
const sharedCursorFocus =
|
||||
@@ -233,6 +235,11 @@ function getConnectedRegionTransition(
|
||||
viewportRatio,
|
||||
);
|
||||
const transitionFocus = getLinearFocus(currentFocus, nextFocus, transitionProgress);
|
||||
const transitionRotation = lerpRotation3D(
|
||||
getRotation3D(currentRegion),
|
||||
getRotation3D(nextRegion),
|
||||
transitionProgress,
|
||||
);
|
||||
|
||||
return {
|
||||
region: {
|
||||
@@ -241,6 +248,7 @@ function getConnectedRegionTransition(
|
||||
},
|
||||
strength: 1,
|
||||
blendedScale: transitionScale,
|
||||
rotation3D: transitionRotation,
|
||||
transition: {
|
||||
progress: transitionProgress,
|
||||
startFocus: currentFocus,
|
||||
@@ -254,34 +262,92 @@ function getConnectedRegionTransition(
|
||||
return null;
|
||||
}
|
||||
|
||||
type DominantRegionResult = {
|
||||
region: ZoomRegion | null;
|
||||
strength: number;
|
||||
blendedScale: number | null;
|
||||
rotation3D: Rotation3D;
|
||||
transition: ConnectedPanTransition | null;
|
||||
};
|
||||
|
||||
// Single-slot cache: the ticker calls findDominantRegion at 60fps with mostly
|
||||
// unchanged inputs (especially while paused). Reusing the previous result when
|
||||
// inputs match avoids the per-frame O(N) region scan + allocations.
|
||||
let dominantRegionCache: {
|
||||
regions: ZoomRegion[];
|
||||
timeMsKey: number;
|
||||
telemetry: CursorTelemetryPoint[] | undefined;
|
||||
connectZooms: boolean;
|
||||
viewportRatio: ViewportRatio | undefined;
|
||||
result: DominantRegionResult;
|
||||
} | null = null;
|
||||
|
||||
export function findDominantRegion(
|
||||
regions: ZoomRegion[],
|
||||
timeMs: number,
|
||||
options: DominantRegionOptions = {},
|
||||
): {
|
||||
region: ZoomRegion | null;
|
||||
strength: number;
|
||||
blendedScale: number | null;
|
||||
transition: ConnectedPanTransition | null;
|
||||
} {
|
||||
const connectedPairs = options.connectZooms ? getConnectedRegionPairs(regions) : [];
|
||||
): DominantRegionResult {
|
||||
const connectZooms = !!options.connectZooms;
|
||||
const telemetry = options.cursorTelemetry;
|
||||
const vr = options.viewportRatio;
|
||||
const timeMsKey = Math.round(timeMs);
|
||||
|
||||
if (options.connectZooms) {
|
||||
const connectedTransition = getConnectedRegionTransition(connectedPairs, timeMs, telemetry, vr);
|
||||
if (connectedTransition) {
|
||||
return connectedTransition;
|
||||
}
|
||||
|
||||
const connectedHold = getConnectedRegionHold(timeMs, connectedPairs, telemetry, vr);
|
||||
if (connectedHold) {
|
||||
return { ...connectedHold, transition: null };
|
||||
}
|
||||
if (
|
||||
dominantRegionCache &&
|
||||
dominantRegionCache.regions === regions &&
|
||||
dominantRegionCache.timeMsKey === timeMsKey &&
|
||||
dominantRegionCache.telemetry === telemetry &&
|
||||
dominantRegionCache.connectZooms === connectZooms &&
|
||||
dominantRegionCache.viewportRatio === vr
|
||||
) {
|
||||
return dominantRegionCache.result;
|
||||
}
|
||||
|
||||
const activeRegion = getActiveRegion(regions, timeMs, connectedPairs, telemetry, vr);
|
||||
return activeRegion
|
||||
? { ...activeRegion, transition: null }
|
||||
: { region: null, strength: 0, blendedScale: null, transition: null };
|
||||
const connectedPairs = connectZooms ? getConnectedRegionPairs(regions) : [];
|
||||
|
||||
let result: DominantRegionResult;
|
||||
if (connectZooms) {
|
||||
const connectedTransition = getConnectedRegionTransition(connectedPairs, timeMs, telemetry, vr);
|
||||
if (connectedTransition) {
|
||||
result = connectedTransition;
|
||||
} else {
|
||||
const connectedHold = getConnectedRegionHold(timeMs, connectedPairs, telemetry, vr);
|
||||
if (connectedHold) {
|
||||
result = { ...connectedHold, transition: null };
|
||||
} else {
|
||||
const activeRegion = getActiveRegion(regions, timeMs, connectedPairs, telemetry, vr);
|
||||
result = activeRegion
|
||||
? { ...activeRegion, transition: null }
|
||||
: {
|
||||
region: null,
|
||||
strength: 0,
|
||||
blendedScale: null,
|
||||
rotation3D: DEFAULT_ROTATION_3D,
|
||||
transition: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const activeRegion = getActiveRegion(regions, timeMs, connectedPairs, telemetry, vr);
|
||||
result = activeRegion
|
||||
? { ...activeRegion, transition: null }
|
||||
: {
|
||||
region: null,
|
||||
strength: 0,
|
||||
blendedScale: null,
|
||||
rotation3D: DEFAULT_ROTATION_3D,
|
||||
transition: null,
|
||||
};
|
||||
}
|
||||
|
||||
dominantRegionCache = {
|
||||
regions,
|
||||
timeMsKey,
|
||||
telemetry,
|
||||
connectZooms,
|
||||
viewportRatio: vr,
|
||||
result,
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,10 @@ import {
|
||||
DEFAULT_WEBCAM_POSITION,
|
||||
DEFAULT_WEBCAM_SIZE_PRESET,
|
||||
} from "@/components/video-editor/types";
|
||||
import {
|
||||
type CursorHighlightConfig,
|
||||
DEFAULT_CURSOR_HIGHLIGHT,
|
||||
} from "@/components/video-editor/videoPlayback/cursorHighlight";
|
||||
import { DEFAULT_WALLPAPER } from "@/lib/wallpaper";
|
||||
import type { AspectRatio } from "@/utils/aspectRatioUtils";
|
||||
|
||||
@@ -39,6 +43,7 @@ export interface EditorState {
|
||||
webcamMaskShape: WebcamMaskShape;
|
||||
webcamSizePreset: WebcamSizePreset;
|
||||
webcamPosition: WebcamPosition | null;
|
||||
cursorHighlight: CursorHighlightConfig;
|
||||
}
|
||||
|
||||
export const INITIAL_EDITOR_STATE: EditorState = {
|
||||
@@ -58,6 +63,7 @@ export const INITIAL_EDITOR_STATE: EditorState = {
|
||||
webcamMaskShape: DEFAULT_WEBCAM_MASK_SHAPE,
|
||||
webcamSizePreset: DEFAULT_WEBCAM_SIZE_PRESET,
|
||||
webcamPosition: DEFAULT_WEBCAM_POSITION,
|
||||
cursorHighlight: DEFAULT_CURSOR_HIGHLIGHT,
|
||||
};
|
||||
|
||||
type StateUpdate = Partial<EditorState> | ((prev: EditorState) => Partial<EditorState>);
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { type Locale, SUPPORTED_LOCALES } from "@/i18n/config";
|
||||
import arDialogs from "@/i18n/locales/ar/dialogs.json";
|
||||
import enDialogs from "@/i18n/locales/en/dialogs.json";
|
||||
import esDialogs from "@/i18n/locales/es/dialogs.json";
|
||||
import frDialogs from "@/i18n/locales/fr/dialogs.json";
|
||||
import jaJPDialogs from "@/i18n/locales/ja-JP/dialogs.json";
|
||||
import koKRDialogs from "@/i18n/locales/ko-KR/dialogs.json";
|
||||
import ruDialogs from "@/i18n/locales/ru/dialogs.json";
|
||||
import trDialogs from "@/i18n/locales/tr/dialogs.json";
|
||||
import zhCNDialogs from "@/i18n/locales/zh-CN/dialogs.json";
|
||||
import zhTWDialogs from "@/i18n/locales/zh-TW/dialogs.json";
|
||||
|
||||
const tutorialHelpKeys = [
|
||||
"triggerLabel",
|
||||
@@ -35,10 +39,14 @@ const keysThatMayBeEmpty = new Set<(typeof tutorialHelpKeys)[number]>(["step1Des
|
||||
const dialogsByLocale = {
|
||||
en: enDialogs,
|
||||
"zh-CN": zhCNDialogs,
|
||||
"zh-TW": zhTWDialogs,
|
||||
es: esDialogs,
|
||||
fr: frDialogs,
|
||||
tr: trDialogs,
|
||||
"ko-KR": koKRDialogs,
|
||||
ru: ruDialogs,
|
||||
"ja-JP": jaJPDialogs,
|
||||
ar: arDialogs,
|
||||
} satisfies Record<Locale, { tutorial: Record<string, unknown> }>;
|
||||
|
||||
describe("TutorialHelp translations", () => {
|
||||
|
||||
@@ -8,6 +8,9 @@ export const SUPPORTED_LOCALES = [
|
||||
"tr",
|
||||
"ko-KR",
|
||||
"ja-JP",
|
||||
"ar",
|
||||
"ru",
|
||||
"vi",
|
||||
] as const;
|
||||
export const I18N_NAMESPACES = [
|
||||
"common",
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"actions": {
|
||||
"cancel": "الغاء",
|
||||
"save": "حفظ",
|
||||
"delete": "حذف",
|
||||
"close": "اغلاق",
|
||||
"share": "مشاركة",
|
||||
"done": "تم",
|
||||
"open": "فتح",
|
||||
"upload": "رفع",
|
||||
"export": "تصدير",
|
||||
"showInFolder": "عرض في المجلد",
|
||||
"file": "ملف",
|
||||
"edit": "تعديل",
|
||||
"view": "عرض",
|
||||
"window": "نافذة",
|
||||
"quit": "خروج",
|
||||
"stopRecording": "إيقاف التسجيل",
|
||||
"undo": "تراجع",
|
||||
"redo": "إعادة",
|
||||
"cut": "قص",
|
||||
"copy": "نسخ",
|
||||
"paste": "لصق",
|
||||
"selectAll": "تحديد الكل",
|
||||
"minimize": "تصغير",
|
||||
"reload": "إعادة تحميل",
|
||||
"forceReload": "إعادة تحميل إجبارية",
|
||||
"toggleDevTools": "أدوات المطور",
|
||||
"actualSize": "الحجم الفعلي",
|
||||
"zoomIn": "تكبير",
|
||||
"zoomOut": "تصغير",
|
||||
"toggleFullScreen": "ملء الشاشة",
|
||||
"recordingStatus": "جاري التسجيل: {{source}}",
|
||||
"about": "حول OpenScreen",
|
||||
"services": "خدمات",
|
||||
"hide": "إخفاء OpenScreen",
|
||||
"hideOthers": "إخفاء الآخرين",
|
||||
"unhide": "إظهار الكل"
|
||||
},
|
||||
"playback": {
|
||||
"play": "تشغيل",
|
||||
"pause": "ايقاف مؤقت",
|
||||
"fullscreen": "ملء الشاشة",
|
||||
"exitFullscreen": "خروج من ملء الشاشة"
|
||||
},
|
||||
"locale": {
|
||||
"name": "عربي",
|
||||
"short": "AR"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"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": "فهم كيفية قص الأجزاء غير المرغوب فيها من الفيديو الخاص بك.",
|
||||
"explanationBefore": "تعمل أداة القص من خلال تحديد المقاطع التي تريد",
|
||||
"remove": "إزالتها",
|
||||
"explanationMiddle": " — أي شيء",
|
||||
"covered": "مغطى",
|
||||
"explanationAfter": "بمقطع قص أحمر سيتم قصه عند التصدير.",
|
||||
"visualExample": "مثال مرئي",
|
||||
"removed": "مُزال",
|
||||
"kept": "مُحتفظ به",
|
||||
"part1": "الجزء 1",
|
||||
"part2": "الجزء 2",
|
||||
"part3": "الجزء 3",
|
||||
"finalVideo": "الفيديو النهائي",
|
||||
"step1Title": "1. إضافة قص",
|
||||
"step1DescriptionBefore": "اضغط على ",
|
||||
"step1DescriptionAfter": " أو انقر على أيقونة المقص لتحديد قسم لإزالته.",
|
||||
"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,45 @@
|
||||
{
|
||||
"newRecording": {
|
||||
"title": "العودة إلى المسجل",
|
||||
"description": "تم حفظ جلستك الحالية.",
|
||||
"cancel": "إلغاء",
|
||||
"confirm": "تأكيد"
|
||||
},
|
||||
"loadingVideo": "جاري تحميل الفيديو...",
|
||||
"errors": {
|
||||
"noVideoLoaded": "لم يتم تحميل أي فيديو",
|
||||
"videoNotReady": "الفيديو غير جاهز",
|
||||
"unableToDetermineSourcePath": "تعذر تحديد مسار الفيديو المصدر",
|
||||
"failedToSaveGif": "فشل حفظ GIF",
|
||||
"gifExportFailed": "فشل تصدير GIF",
|
||||
"failedToSaveVideo": "فشل حفظ الفيديو",
|
||||
"exportFailed": "فشل التصدير",
|
||||
"exportFailedWithError": "فشل التصدير: {{error}}",
|
||||
"exportBackgroundLoadFailed": "فشل التصدير: تعذر تحميل صورة الخلفية ({{url}})",
|
||||
"failedToSaveExport": "فشل حفظ التصدير",
|
||||
"failedToSaveExportedVideo": "فشل حفظ الفيديو المُصدَّر",
|
||||
"failedToRevealInFolder": "خطأ في الكشف في المجلد: {{error}}"
|
||||
},
|
||||
"export": {
|
||||
"canceled": "تم إلغاء التصدير",
|
||||
"exportedSuccessfully": "تم تصدير {{format}} بنجاح"
|
||||
},
|
||||
"project": {
|
||||
"saveCanceled": "تم إلغاء حفظ المشروع",
|
||||
"failedToSave": "فشل حفظ المشروع",
|
||||
"savedTo": "تم حفظ المشروع في {{path}}",
|
||||
"failedToLoad": "فشل تحميل المشروع",
|
||||
"invalidFormat": "تنسيق ملف المشروع غير صالح",
|
||||
"loadedFrom": "تم تحميل المشروع من {{path}}"
|
||||
},
|
||||
"recording": {
|
||||
"failedCameraAccess": "فشل طلب الوصول إلى الكاميرا.",
|
||||
"cameraBlocked": "الوصول إلى الكاميرا محظور. قم بتمكينه في إعدادات النظام لاستخدام كاميرا الويب.",
|
||||
"systemAudioUnavailable": "صوت النظام غير متوفر. يتم التسجيل بدون صوت النظام.",
|
||||
"microphoneDenied": "تم رفض الوصول إلى الميكروفون. سيستمر التسجيل بدون صوت.",
|
||||
"cameraDenied": "تم رفض الوصول إلى الكاميرا. سيستمر التسجيل بدون كاميرا الويب.",
|
||||
"cameraDisconnected": "تم فصل كاميرا الويب.",
|
||||
"cameraNotFound": "لم يتم العثور على كاميرا.",
|
||||
"permissionDenied": "تم رفض إذن التسجيل. يرجى السماح بتسجيل الشاشة."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"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": "اللغة",
|
||||
"systemLanguagePrompt": {
|
||||
"title": "هل تريد استخدام لغة نظامك؟",
|
||||
"description": "اكتشفنا أن {{language}} هي لغة نظامك. هل تريد تبديل OpenScreen إلى {{language}}؟",
|
||||
"switch": "التبديل إلى {{language}}",
|
||||
"keepDefault": "الاحتفاظ باللغة الحالية"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
{
|
||||
"zoom": {
|
||||
"level": "مستوى التكبير",
|
||||
"selectRegion": "حدد منطقة التكبير للتعديل",
|
||||
"deleteZoom": "حذف التكبير",
|
||||
"focusMode": {
|
||||
"title": "وضع التركيز",
|
||||
"manual": "يدوي",
|
||||
"auto": "تلقائي",
|
||||
"autoDescription": "الكاميرا تتبع موضع المؤشر المسجل"
|
||||
}
|
||||
},
|
||||
"speed": {
|
||||
"playbackSpeed": "سرعة التشغيل",
|
||||
"selectRegion": "حدد منطقة السرعة للتعديل",
|
||||
"deleteRegion": "حذف منطقة السرعة",
|
||||
"customPlaybackSpeed": "سرعة تشغيل مخصصة",
|
||||
"maxSpeedError": "لا يمكن للسرعة أن تتجاوز 16×"
|
||||
},
|
||||
"trim": {
|
||||
"deleteRegion": "حذف منطقة القص"
|
||||
},
|
||||
"layout": {
|
||||
"title": "التخطيط",
|
||||
"preset": "الإعداد المسبق",
|
||||
"selectPreset": "حدد إعدادًا مسبقًا",
|
||||
"pictureInPicture": "صورة داخل صورة",
|
||||
"verticalStack": "تكدس عمودي",
|
||||
"dualFrame": "إطار مزدوج",
|
||||
"webcamShape": "شكل الكاميرا",
|
||||
"webcamSize": "حجم كاميرا الويب"
|
||||
},
|
||||
"effects": {
|
||||
"title": "تأثيرات الفيديو",
|
||||
"blurBg": "تمويه الخلفية",
|
||||
"motionBlur": "ضبابية الحركة",
|
||||
"off": "إيقاف",
|
||||
"on": "تشغيل",
|
||||
"shadow": "ظل",
|
||||
"roundness": "الاستدارة",
|
||||
"padding": "المسافة البادئة",
|
||||
"cursorHighlight": {
|
||||
"title": "تمييز المؤشر",
|
||||
"style": "النمط",
|
||||
"dot": "نقطة",
|
||||
"ring": "حلقة",
|
||||
"size": "الحجم",
|
||||
"onlyOnClicks": "عند النقر فقط",
|
||||
"color": "اللون",
|
||||
"offsetX": "إزاحة X (لتسجيلات النوافذ)",
|
||||
"offsetY": "إزاحة Y",
|
||||
"accessibilityPermissionTitle": "مطلوب إذن الوصول",
|
||||
"accessibilityPermissionDescription": "افتح إعدادات النظام ← الخصوصية والأمان ← إمكانية الوصول، وقم بتفعيل Openscreen، ثم أعد تشغيل التطبيق."
|
||||
}
|
||||
},
|
||||
"background": {
|
||||
"title": "الخلفية",
|
||||
"image": "صورة",
|
||||
"color": "لون",
|
||||
"gradient": "تدرج لوني",
|
||||
"uploadCustom": "رفع صورة مخصصة",
|
||||
"gradientLabel": "تدرج لوني {{index}}",
|
||||
"colorWheel": "عجلة الألوان",
|
||||
"colorPalette": "لوحة الألوان"
|
||||
},
|
||||
"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": "سهم",
|
||||
"typeBlur": "تمويه",
|
||||
"textContent": "محتوى النص",
|
||||
"textPlaceholder": "أدخل النص هنا...",
|
||||
"fontStyle": "نمط الخط",
|
||||
"selectStyle": "حدد النمط",
|
||||
"size": "الحجم",
|
||||
"customFonts": "خطوط مخصصة",
|
||||
"textColor": "لون النص",
|
||||
"background": "الخلفية",
|
||||
"none": "بدون",
|
||||
"color": "لون",
|
||||
"colorWheel": "عجلة الألوان",
|
||||
"colorPalette": "لوحة الألوان",
|
||||
"clearBackground": "مسح الخلفية",
|
||||
"uploadImage": "رفع صورة",
|
||||
"supportedFormats": "الصيغ المدعومة: JPG, PNG, GIF, WebP",
|
||||
"arrowDirection": "اتجاه السهم",
|
||||
"strokeWidth": "عرض الخط: {{width}}px",
|
||||
"arrowColor": "لون السهم",
|
||||
"blurType": "نوع التمويه",
|
||||
"blurTypeBlur": "تمويه",
|
||||
"blurTypeMosaic": "فسيفساء",
|
||||
"blurColor": "لون التمويه",
|
||||
"blurColorWhite": "أبيض",
|
||||
"blurColorBlack": "أسود",
|
||||
"blurShape": "شكل التمويه",
|
||||
"blurIntensity": "كثافة التمويه",
|
||||
"mosaicBlockSize": "حجم كتلة الفسيفساء",
|
||||
"blurShapeRectangle": "مستطيل",
|
||||
"blurShapeOval": "بيضاوي",
|
||||
"blurShapeFreehand": "رسم حر",
|
||||
"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",
|
||||
"urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
|
||||
"urlHelp": "احصل على هذا من خطوط Google: حدد خطًا → انقر على \"احصل على الخط\" → انسخ رابط `@import`",
|
||||
"nameLabel": "اسم العرض",
|
||||
"namePlaceholder": "خطي المخصص",
|
||||
"nameHelp": "هكذا سيظهر الخط في محدد الخطوط",
|
||||
"addButton": "إضافة خط",
|
||||
"addingButton": "جاري الإضافة...",
|
||||
"errorEmptyUrl": "يرجى إدخال رابط استيراد لخطوط Google",
|
||||
"errorInvalidUrl": "يرجى إدخال رابط صحيح لخطوط Google",
|
||||
"errorEmptyName": "يرجى إدخال اسم الخط",
|
||||
"errorExtractFailed": "تعذر استخراج عائلة الخط من الرابط",
|
||||
"successMessage": "تم إضافة الخط \"{{fontName}}\" بنجاح",
|
||||
"failedToAdd": "فشل في إضافة الخط",
|
||||
"errorTimeout": "استغرق تحميل الخط وقتًا طويلاً. يرجى التحقق من الرابط والمحاولة مرة أخرى.",
|
||||
"errorLoadFailed": "تعذر تحميل الخط. يرجى التحقق من صحة رابط خطوط Google."
|
||||
},
|
||||
"language": {
|
||||
"title": "اللغة"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"title": "اختصارات لوحة المفاتيح",
|
||||
"customize": "تخصيص",
|
||||
"configurable": "قابل للتكوين",
|
||||
"fixed": "ثابت",
|
||||
"pressKey": "اضغط على مفتاح...",
|
||||
"clickToChange": "انقر للتغيير",
|
||||
"pressEscToCancel": "اضغط على Esc للإلغاء",
|
||||
"helpText": "انقر على اختصار ثم اضغط على مجموعة المفاتيح الجديدة. اضغط على Esc للإلغاء.",
|
||||
"resetToDefaults": "إعادة تعيين إلى الافتراضيات",
|
||||
"alreadyUsedBy": "مستخدم بالفعل بواسطة {{action}}",
|
||||
"swap": "تبديل",
|
||||
"reservedShortcut": "هذا الاختصار محجوز لـ \"{{label}}\" ولا يمكن إعادة تعيينه.",
|
||||
"savedToast": "تم حفظ اختصارات لوحة المفاتيح",
|
||||
"resetToast": "إعادة تعيين إلى الاختصارات الافتراضية — انقر فوق حفظ للتطبيق",
|
||||
"actions": {
|
||||
"addZoom": "إضافة تكبير",
|
||||
"addTrim": "إضافة قص",
|
||||
"addSpeed": "إضافة سرعة",
|
||||
"addAnnotation": "إضافة شرح",
|
||||
"addBlur": "إضافة تمويه",
|
||||
"addKeyframe": "إضافة إطار رئيسي",
|
||||
"deleteSelected": "حذف المحدد",
|
||||
"playPause": "تشغيل / إيقاف مؤقت"
|
||||
},
|
||||
"fixedActions": {
|
||||
"undo": "تراجع",
|
||||
"redo": "إعادة",
|
||||
"cycleAnnotationsForward": "التنقل بين الشروح للأمام",
|
||||
"cycleAnnotationsBackward": "التنقل بين الشروح للخلف",
|
||||
"deleteSelectedAlt": "حذف المحدد (alt)",
|
||||
"panTimeline": "تحريك المخطط الزمني",
|
||||
"zoomTimeline": "تكبير المخطط الزمني",
|
||||
"frameBack": "إطار للخلف",
|
||||
"frameForward": "إطار للأمام"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"buttons": {
|
||||
"addZoom": "إضافة تكبير (Z)",
|
||||
"suggestZooms": "اقتراح تكبير من المؤشر",
|
||||
"addTrim": "إضافة قص (T)",
|
||||
"addAnnotation": "إضافة شرح (A)",
|
||||
"addBlur": "إضافة تمويه (B)",
|
||||
"addSpeed": "إضافة سرعة (S)"
|
||||
},
|
||||
"hints": {
|
||||
"pressZoom": "اضغط Z لإضافة تكبير",
|
||||
"pressTrim": "اضغط T لإضافة قص",
|
||||
"pressAnnotation": "اضغط A لإضافة شرح",
|
||||
"pressBlur": "اضغط B لإضافة منطقة تمويه",
|
||||
"pressSpeed": "اضغط S لإضافة سرعة"
|
||||
},
|
||||
"labels": {
|
||||
"pan": "تحريك",
|
||||
"zoom": "تكبير",
|
||||
"trim": "قص",
|
||||
"speed": "سرعة",
|
||||
"zoomItem": "تكبير {{index}}",
|
||||
"trimItem": "قص {{index}}",
|
||||
"speedItem": "سرعة {{index}}",
|
||||
"annotationItem": "شرح",
|
||||
"blurItem": "تمويه {{index}}",
|
||||
"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}} اقتراحات تكبير بناءً على المؤشر"
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,27 @@
|
||||
"view": "View",
|
||||
"window": "Window",
|
||||
"quit": "Quit",
|
||||
"stopRecording": "Stop Recording"
|
||||
"stopRecording": "Stop Recording",
|
||||
"undo": "Undo",
|
||||
"redo": "Redo",
|
||||
"cut": "Cut",
|
||||
"copy": "Copy",
|
||||
"paste": "Paste",
|
||||
"selectAll": "Select All",
|
||||
"minimize": "Minimize",
|
||||
"reload": "Reload",
|
||||
"forceReload": "Force Reload",
|
||||
"toggleDevTools": "Toggle Developer Tools",
|
||||
"actualSize": "Actual Size",
|
||||
"zoomIn": "Zoom In",
|
||||
"zoomOut": "Zoom Out",
|
||||
"toggleFullScreen": "Toggle Full Screen",
|
||||
"recordingStatus": "Recording: {{source}}",
|
||||
"about": "About OpenScreen",
|
||||
"services": "Services",
|
||||
"hide": "Hide OpenScreen",
|
||||
"hideOthers": "Hide Others",
|
||||
"unhide": "Show All"
|
||||
},
|
||||
"playback": {
|
||||
"play": "Play",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"zoom": {
|
||||
"level": "Zoom Level",
|
||||
"customScale": "Custom Zoom",
|
||||
"selectRegion": "Select a zoom region to adjust",
|
||||
"deleteZoom": "Delete Zoom",
|
||||
"focusMode": {
|
||||
@@ -8,6 +9,20 @@
|
||||
"manual": "Manual",
|
||||
"auto": "Auto",
|
||||
"autoDescription": "Camera follows the recorded cursor position"
|
||||
},
|
||||
"threeD": {
|
||||
"title": "3D Rotation",
|
||||
"preset": {
|
||||
"iso": "Iso",
|
||||
"left": "Left",
|
||||
"right": "Right"
|
||||
}
|
||||
},
|
||||
"position": {
|
||||
"title": "Focus Position",
|
||||
"x": "X (%)",
|
||||
"y": "Y (%)",
|
||||
"hint": "0 = leftmost / topmost, 100 = rightmost / bottommost"
|
||||
}
|
||||
},
|
||||
"speed": {
|
||||
@@ -27,6 +42,7 @@
|
||||
"pictureInPicture": "Picture in Picture",
|
||||
"verticalStack": "Vertical Stack",
|
||||
"dualFrame": "Dual Frame",
|
||||
"noWebcam": "No Webcam",
|
||||
"webcamShape": "Camera Shape",
|
||||
"webcamSize": "Webcam Size"
|
||||
},
|
||||
@@ -35,9 +51,23 @@
|
||||
"blurBg": "Blur BG",
|
||||
"motionBlur": "Motion Blur",
|
||||
"off": "off",
|
||||
"on": "on",
|
||||
"shadow": "Shadow",
|
||||
"roundness": "Roundness",
|
||||
"padding": "Padding"
|
||||
"padding": "Padding",
|
||||
"cursorHighlight": {
|
||||
"title": "Cursor highlight",
|
||||
"style": "Style",
|
||||
"dot": "Dot",
|
||||
"ring": "Ring",
|
||||
"size": "Size",
|
||||
"onlyOnClicks": "Only on clicks",
|
||||
"color": "Color",
|
||||
"offsetX": "Offset X (window recordings)",
|
||||
"offsetY": "Offset Y",
|
||||
"accessibilityPermissionTitle": "Accessibility permission needed",
|
||||
"accessibilityPermissionDescription": "Open System Settings → Privacy & Security → Accessibility, enable Openscreen, then restart the app."
|
||||
}
|
||||
},
|
||||
"background": {
|
||||
"title": "Background",
|
||||
@@ -45,7 +75,9 @@
|
||||
"color": "Color",
|
||||
"gradient": "Gradient",
|
||||
"uploadCustom": "Upload Custom",
|
||||
"gradientLabel": "Gradient {{index}}"
|
||||
"gradientLabel": "Gradient {{index}}",
|
||||
"colorWheel": "Color Wheel",
|
||||
"colorPalette": "Color Palette"
|
||||
},
|
||||
"crop": {
|
||||
"title": "Crop",
|
||||
@@ -113,6 +145,8 @@
|
||||
"background": "Background",
|
||||
"none": "None",
|
||||
"color": "Color",
|
||||
"colorWheel": "Color Wheel",
|
||||
"colorPalette": "Color Palette",
|
||||
"clearBackground": "Clear Background",
|
||||
"uploadImage": "Upload Image",
|
||||
"supportedFormats": "Supported formats: JPG, PNG, GIF, WebP",
|
||||
|
||||
@@ -34,5 +34,12 @@
|
||||
"cameraDisconnected": "Cámara web desconectada.",
|
||||
"cameraNotFound": "Cámara no encontrada.",
|
||||
"permissionDenied": "Permiso de grabación denegado. Por favor permite la grabación de pantalla."
|
||||
},
|
||||
"loadingVideo": "Cargando video...",
|
||||
"newRecording": {
|
||||
"title": "Volver a la grabadora",
|
||||
"description": "Tu sesión actual ha sido guardada.",
|
||||
"cancel": "Cancelar",
|
||||
"confirm": "Confirmar"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,14 @@
|
||||
"manual": "Manual",
|
||||
"auto": "Auto",
|
||||
"autoDescription": "La cámara sigue la posición del cursor grabado"
|
||||
},
|
||||
"threeD": {
|
||||
"title": "Rotación 3D",
|
||||
"preset": {
|
||||
"iso": "Iso",
|
||||
"left": "Izquierda",
|
||||
"right": "Derecha"
|
||||
}
|
||||
}
|
||||
},
|
||||
"speed": {
|
||||
@@ -45,7 +53,9 @@
|
||||
"color": "Color",
|
||||
"gradient": "Degradado",
|
||||
"uploadCustom": "Subir personalizado",
|
||||
"gradientLabel": "Degradado {{index}}"
|
||||
"gradientLabel": "Degradado {{index}}",
|
||||
"colorWheel": "Rueda de colores",
|
||||
"colorPalette": "Paleta de colores"
|
||||
},
|
||||
"crop": {
|
||||
"title": "Recortar",
|
||||
@@ -113,6 +123,8 @@
|
||||
"background": "Fondo",
|
||||
"none": "Ninguno",
|
||||
"color": "Color",
|
||||
"colorWheel": "Rueda de colores",
|
||||
"colorPalette": "Paleta de colores",
|
||||
"clearBackground": "Quitar fondo",
|
||||
"uploadImage": "Subir imagen",
|
||||
"supportedFormats": "Formatos compatibles: JPG, PNG, GIF, WebP",
|
||||
|
||||
@@ -40,5 +40,6 @@
|
||||
"cameraDisconnected": "Webcam déconnectée.",
|
||||
"cameraNotFound": "Caméra introuvable.",
|
||||
"permissionDenied": "Permission d'enregistrement refusée. Veuillez autoriser l'enregistrement d'écran."
|
||||
}
|
||||
},
|
||||
"loadingVideo": "Chargement de la vidéo..."
|
||||
}
|
||||
|
||||
@@ -15,6 +15,14 @@
|
||||
"fast": "Rapide",
|
||||
"smooth": "Fluide",
|
||||
"lazy": "Lent"
|
||||
},
|
||||
"threeD": {
|
||||
"title": "Rotation 3D",
|
||||
"preset": {
|
||||
"iso": "Iso",
|
||||
"left": "Gauche",
|
||||
"right": "Droite"
|
||||
}
|
||||
}
|
||||
},
|
||||
"speed": {
|
||||
@@ -52,7 +60,9 @@
|
||||
"color": "Couleur",
|
||||
"gradient": "Dégradé",
|
||||
"uploadCustom": "Téléverser une image",
|
||||
"gradientLabel": "Dégradé {{index}}"
|
||||
"gradientLabel": "Dégradé {{index}}",
|
||||
"colorWheel": "Roue chromatique",
|
||||
"colorPalette": "Palette de couleurs"
|
||||
},
|
||||
"crop": {
|
||||
"title": "Recadrage",
|
||||
@@ -120,6 +130,8 @@
|
||||
"background": "Arrière-plan",
|
||||
"none": "Aucun",
|
||||
"color": "Couleur",
|
||||
"colorWheel": "Roue chromatique",
|
||||
"colorPalette": "Palette de couleurs",
|
||||
"clearBackground": "Supprimer l'arrière-plan",
|
||||
"uploadImage": "Téléverser une image",
|
||||
"supportedFormats": "Formats supportés : JPG, PNG, GIF, WebP",
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"share": "共有",
|
||||
"done": "完了",
|
||||
"open": "開く",
|
||||
"upload": "アップロード",
|
||||
"upload": "読み込む",
|
||||
"export": "エクスポート",
|
||||
"showInFolder": "フォルダに表示",
|
||||
"file": "ファイル",
|
||||
@@ -15,7 +15,7 @@
|
||||
"view": "表示",
|
||||
"window": "ウィンドウ",
|
||||
"quit": "終了",
|
||||
"stopRecording": "録画停止"
|
||||
"stopRecording": "録画を停止"
|
||||
},
|
||||
"playback": {
|
||||
"play": "再生",
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
{
|
||||
"export": {
|
||||
"complete": "エクスポート完了",
|
||||
"yourFormatReady": "あなたの{{format}}が準備できました",
|
||||
"yourFormatReady": "{{format}}の準備ができました",
|
||||
"showInFolder": "フォルダで表示",
|
||||
"finalizingVideo": "ビデオのエクスポートを最終処理中...",
|
||||
"compilingGifProgress": "GIFをコンパイル中... {{progress}}%",
|
||||
"compilingGifWait": "GIFをコンパイル中... しばらくお待ちください",
|
||||
"finalizingVideo": "動画のエクスポートを仕上げています...",
|
||||
"compilingGifProgress": "GIFを生成中... {{progress}}%",
|
||||
"compilingGifWait": "GIFを生成中... しばらくお待ちください",
|
||||
"takeMoment": "少々お待ちください...",
|
||||
"failed": "エクスポートに失敗しました",
|
||||
"tryAgain": "もう一度お試しください",
|
||||
"finalizingVideoTitle": "ビデオの最終処理",
|
||||
"compilingGif": "GIFをコンパイル中",
|
||||
"finalizingVideoTitle": "動画の仕上げ",
|
||||
"compilingGif": "GIFを生成中",
|
||||
"exportingFormat": "{{format}}をエクスポート中",
|
||||
"compiling": "コンパイル中",
|
||||
"compiling": "生成中",
|
||||
"renderingFrames": "フレームをレンダリング中",
|
||||
"processing": "処理中...",
|
||||
"finalizing": "最終処理中...",
|
||||
"compilingStatus": "コンパイル中...",
|
||||
"compilingStatus": "生成中...",
|
||||
"status": "ステータス",
|
||||
"format": "フォーマット",
|
||||
"frames": "フレーム",
|
||||
@@ -58,13 +58,13 @@
|
||||
},
|
||||
"fileDialogs": {
|
||||
"saveGif": "エクスポートしたGIFを保存",
|
||||
"saveVideo": "エクスポートしたビデオを保存",
|
||||
"selectVideo": "ビデオファイルを選択",
|
||||
"saveVideo": "エクスポートした動画を保存",
|
||||
"selectVideo": "動画ファイルを選択",
|
||||
"saveProject": "OpenScreen プロジェクトを保存",
|
||||
"openProject": "OpenScreen プロジェクトを開く",
|
||||
"gifImage": "GIF 画像",
|
||||
"mp4Video": "MP4 ビデオ",
|
||||
"videoFiles": "ビデオファイル",
|
||||
"mp4Video": "MP4 動画",
|
||||
"videoFiles": "動画ファイル",
|
||||
"openscreenProject": "OpenScreen プロジェクト",
|
||||
"allFiles": "すべてのファイル"
|
||||
}
|
||||
|
||||
@@ -5,19 +5,20 @@
|
||||
"cancel": "キャンセル",
|
||||
"confirm": "確認"
|
||||
},
|
||||
"loadingVideo": "ビデオを読み込み中...",
|
||||
"loadingVideo": "動画を読み込み中...",
|
||||
"errors": {
|
||||
"noVideoLoaded": "ビデオが読み込まれていません",
|
||||
"videoNotReady": "ビデオが準備できていません",
|
||||
"unableToDetermineSourcePath": "ソースビデオのパスを特定できません",
|
||||
"noVideoLoaded": "動画が読み込まれていません",
|
||||
"videoNotReady": "動画の準備ができていません",
|
||||
"unableToDetermineSourcePath": "元動画のパスを特定できません",
|
||||
"failedToSaveGif": "GIFの保存に失敗しました",
|
||||
"gifExportFailed": "GIFのエクスポートに失敗しました",
|
||||
"failedToSaveVideo": "ビデオの保存に失敗しました",
|
||||
"failedToSaveVideo": "動画の保存に失敗しました",
|
||||
"exportFailed": "エクスポートに失敗しました",
|
||||
"exportFailedWithError": "エクスポートに失敗しました: {{error}}",
|
||||
"failedToSaveExport": "エクスポートの保存に失敗しました",
|
||||
"failedToSaveExportedVideo": "エクスポートしたビデオの保存に失敗しました",
|
||||
"failedToRevealInFolder": "フォルダの表示に失敗しました: {{error}}"
|
||||
"failedToSaveExportedVideo": "エクスポートした動画の保存に失敗しました",
|
||||
"failedToRevealInFolder": "フォルダの表示に失敗しました: {{error}}",
|
||||
"exportBackgroundLoadFailed": "エクスポートに失敗しました: 背景画像を読み込めませんでした ({{url}})"
|
||||
},
|
||||
"export": {
|
||||
"canceled": "エクスポートがキャンセルされました",
|
||||
@@ -34,9 +35,11 @@
|
||||
"recording": {
|
||||
"failedCameraAccess": "カメラのアクセス要求に失敗しました。",
|
||||
"cameraBlocked": "カメラのアクセスがブロックされています。システム設定で有効にして、ウェブカメラを使用してください。",
|
||||
"systemAudioUnavailable": "システムオーディオが利用できません。システムオーディオなしで録画します。",
|
||||
"microphoneDenied": "マイクのアクセスが拒否されました。オーディオなしで録画を続行します。",
|
||||
"systemAudioUnavailable": "システム音声を利用できません。システム音声なしで録画します。",
|
||||
"microphoneDenied": "マイクへのアクセスが拒否されました。音声なしで録画を続行します。",
|
||||
"cameraDenied": "カメラのアクセスが拒否されました。ウェブカメラなしで録画を続行します。",
|
||||
"permissionDenied": "録画の権限が拒否されました。画面録画を許可してください。"
|
||||
"permissionDenied": "録画の権限が拒否されました。画面録画を許可してください。",
|
||||
"cameraDisconnected": "ウェブカメラが切断されました。",
|
||||
"cameraNotFound": "カメラが見つかりません。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,12 +6,12 @@
|
||||
"cancelRecording": "録画をキャンセル",
|
||||
"pauseRecording": "録画を一時停止",
|
||||
"resumeRecording": "録画を再開",
|
||||
"openVideoFile": "ビデオファイルを開く",
|
||||
"openVideoFile": "動画ファイルを開く",
|
||||
"openProject": "プロジェクトを開く"
|
||||
},
|
||||
"audio": {
|
||||
"enableSystemAudio": "システムオーディオを有効にする",
|
||||
"disableSystemAudio": "システムオーディオを無効にする",
|
||||
"enableSystemAudio": "システム音声を有効にする",
|
||||
"disableSystemAudio": "システム音声を無効にする",
|
||||
"enableMicrophone": "マイクを有効にする",
|
||||
"disableMicrophone": "マイクを無効にする",
|
||||
"defaultMicrophone": "デフォルトのマイク"
|
||||
|
||||
@@ -7,20 +7,21 @@
|
||||
"title": "フォーカスモード",
|
||||
"manual": "手動",
|
||||
"auto": "自動",
|
||||
"autoDescription": "カメラが録画中のカーソル位置に追従します"
|
||||
"autoDescription": "表示範囲が録画中のカーソル位置に追従します"
|
||||
},
|
||||
"speed": {
|
||||
"title": "ズーム速度",
|
||||
"instant": "即時",
|
||||
"fast": "高速",
|
||||
"smooth": "滑らか",
|
||||
"lazy": "遅延"
|
||||
"threeD": {
|
||||
"title": "3D回転",
|
||||
"preset": {
|
||||
"iso": "Iso",
|
||||
"left": "左",
|
||||
"right": "右"
|
||||
}
|
||||
}
|
||||
},
|
||||
"speed": {
|
||||
"playbackSpeed": "再生速度",
|
||||
"selectRegion": "速度範囲を選択して調整",
|
||||
"deleteRegion": "速度範囲を削除",
|
||||
"selectRegion": "再生速度の範囲を選択して調整",
|
||||
"deleteRegion": "再生速度の範囲を削除",
|
||||
"customPlaybackSpeed": "カスタム再生速度",
|
||||
"maxSpeedError": "速度は16×を超えることはできません"
|
||||
},
|
||||
@@ -31,14 +32,14 @@
|
||||
"title": "レイアウト",
|
||||
"preset": "プリセット",
|
||||
"selectPreset": "プリセットを選択",
|
||||
"pictureInPicture": "ピクチャーインピクチャー",
|
||||
"verticalStack": "縦積み",
|
||||
"pictureInPicture": "ピクチャーインピクチャ",
|
||||
"verticalStack": "縦並び",
|
||||
"dualFrame": "デュアルフレーム",
|
||||
"webcamShape": "カメラの形状",
|
||||
"webcamSize": "カメラのサイズ"
|
||||
},
|
||||
"effects": {
|
||||
"title": "ビデオ効果",
|
||||
"title": "動画効果",
|
||||
"blurBg": "背景をぼかす",
|
||||
"motionBlur": "モーションブラー",
|
||||
"off": "オフ",
|
||||
@@ -51,12 +52,14 @@
|
||||
"image": "画像",
|
||||
"color": "色",
|
||||
"gradient": "グラデーション",
|
||||
"uploadCustom": "カスタムをアップロード",
|
||||
"gradientLabel": "グラデーション {{index}}"
|
||||
"uploadCustom": "カスタム画像を読み込む",
|
||||
"gradientLabel": "グラデーション {{index}}",
|
||||
"colorWheel": "カラーホイール",
|
||||
"colorPalette": "カラーパレット"
|
||||
},
|
||||
"crop": {
|
||||
"title": "クロップ",
|
||||
"cropVideo": "ビデオをクロップ",
|
||||
"cropVideo": "動画をクロップ",
|
||||
"dragInstruction": "各辺をドラッグしてクロップ範囲を調整",
|
||||
"ratio": "比率",
|
||||
"free": "自由",
|
||||
@@ -67,8 +70,8 @@
|
||||
"exportFormat": {
|
||||
"mp4": "MP4",
|
||||
"gif": "GIF",
|
||||
"mp4Video": "MP4 ビデオ",
|
||||
"mp4Description": "高品質のビデオファイル",
|
||||
"mp4Video": "MP4 動画",
|
||||
"mp4Description": "高品質の動画ファイル",
|
||||
"gifAnimation": "GIF アニメーション",
|
||||
"gifDescription": "共有用のアニメーション画像"
|
||||
},
|
||||
@@ -88,7 +91,7 @@
|
||||
"load": "プロジェクトを読み込む"
|
||||
},
|
||||
"export": {
|
||||
"videoButton": "ビデオをエクスポート",
|
||||
"videoButton": "動画をエクスポート",
|
||||
"gifButton": "GIF をエクスポート",
|
||||
"chooseSaveLocation": "保存場所を選択"
|
||||
},
|
||||
@@ -98,9 +101,9 @@
|
||||
},
|
||||
"imageUpload": {
|
||||
"invalidFileType": "無効なファイル形式",
|
||||
"jpgOnly": "JPG または JPEG 画像ファイルをアップロードしてください。",
|
||||
"uploadSuccess": "カスタム画像が正常にアップロードされました!",
|
||||
"failedToUpload": "画像のアップロードに失敗しました",
|
||||
"jpgOnly": "JPG または JPEG 画像ファイルを選択してください。",
|
||||
"uploadSuccess": "カスタム画像を読み込みました。",
|
||||
"failedToUpload": "画像の読み込みに失敗しました",
|
||||
"errorReading": "ファイルの読み取り中にエラーが発生しました。"
|
||||
},
|
||||
"annotation": {
|
||||
@@ -120,8 +123,10 @@
|
||||
"background": "背景",
|
||||
"none": "なし",
|
||||
"color": "色",
|
||||
"colorWheel": "カラーホイール",
|
||||
"colorPalette": "カラーパレット",
|
||||
"clearBackground": "背景をクリア",
|
||||
"uploadImage": "画像をアップロード",
|
||||
"uploadImage": "画像を読み込む",
|
||||
"supportedFormats": "サポートされている形式: JPG, PNG, GIF, WebP",
|
||||
"arrowDirection": "矢印の方向",
|
||||
"strokeWidth": "線の太さ: {{width}}px",
|
||||
@@ -144,9 +149,9 @@
|
||||
"tipTabCycle": "Tabキーを使用して重なっている項目を順に切り替えます。",
|
||||
"tipShiftTabCycle": "Shift+Tabキーを使用して逆順に切り替えます。",
|
||||
"invalidImageType": "無効なファイル形式",
|
||||
"imageFormatsOnly": "JPG、PNG、GIF、またはWebP画像ファイルをアップロードしてください。",
|
||||
"imageUploadSuccess": "画像が正常にアップロードされました!",
|
||||
"failedImageUpload": "画像のアップロードに失敗しました"
|
||||
"imageFormatsOnly": "JPG、PNG、GIF、またはWebP画像ファイルを選択してください。",
|
||||
"imageUploadSuccess": "画像を読み込みました。",
|
||||
"failedImageUpload": "画像の読み込みに失敗しました"
|
||||
},
|
||||
"fontStyles": {
|
||||
"classic": "クラシック",
|
||||
|
||||
@@ -5,23 +5,23 @@
|
||||
"addTrim": "トリムを追加 (T)",
|
||||
"addAnnotation": "注釈を追加 (A)",
|
||||
"addBlur": "ぼかしを追加 (B)",
|
||||
"addSpeed": "速度を追加 (S)"
|
||||
"addSpeed": "再生速度を追加 (S)"
|
||||
},
|
||||
"hints": {
|
||||
"pressZoom": "Zキーを押してズームを追加",
|
||||
"pressTrim": "Tキーを押してトリムを追加",
|
||||
"pressAnnotation": "Aキーを押して注釈を追加",
|
||||
"pressBlur": "Bキーを押してぼかしを追加",
|
||||
"pressSpeed": "Sキーを押して速度を追加"
|
||||
"pressSpeed": "Sキーを押して再生速度を追加"
|
||||
},
|
||||
"labels": {
|
||||
"pan": "移動",
|
||||
"zoom": "ズーム",
|
||||
"trim": "トリム",
|
||||
"speed": "速度",
|
||||
"speed": "再生速度",
|
||||
"zoomItem": "ズーム {{index}}",
|
||||
"trimItem": "トリム {{index}}",
|
||||
"speedItem": "速度 {{index}}",
|
||||
"speedItem": "再生速度 {{index}}",
|
||||
"annotationItem": "注釈",
|
||||
"blurItem": "ぼかし {{index}}",
|
||||
"imageItem": "画像",
|
||||
@@ -36,17 +36,17 @@
|
||||
"zoomExistsAtLocation": "この場所にはすでにズームが存在するか、十分なスペースがありません。",
|
||||
"zoomSuggestionUnavailable": "ズームの自動提案機能が利用できません",
|
||||
"noCursorTelemetry": "カーソルの動きが記録されていません",
|
||||
"noCursorTelemetryDescription": "まず画面収録を行い、カーソルに基づく提案を生成してください。",
|
||||
"noCursorTelemetryDescription": "まず画面録画を行い、カーソルに基づく提案を生成してください。",
|
||||
"noUsableTelemetry": "使用可能なカーソルの動きデータがありません",
|
||||
"noUsableTelemetryDescription": "録画には十分なカーソルの動きデータが含まれていません。",
|
||||
"noDwellMoments": "カーソルが静止したポイントが見つかりません",
|
||||
"noDwellMomentsDescription": "強調したい操作の際に、カーソルを一時停止させて録画してみてください。",
|
||||
"noAutoZoomSlots": "自動ズームを適用できる箇所がありません",
|
||||
"noAutoZoomSlotsDescription": "検出された滞留ポイントが既存のズーム領域と重なっています。",
|
||||
"cannotPlaceTrim": "ここに切り取りを配置できません",
|
||||
"trimExistsAtLocation": "この場所にはすでに切り取りが存在するか、十分なスペースがありません。",
|
||||
"cannotPlaceSpeed": "ここに速度を配置できません",
|
||||
"speedExistsAtLocation": "この場所にはすでに速度が存在するか、十分なスペースがありません。"
|
||||
"cannotPlaceTrim": "ここにトリムを配置できません",
|
||||
"trimExistsAtLocation": "この場所にはすでにトリムが存在するか、十分なスペースがありません。",
|
||||
"cannotPlaceSpeed": "ここに再生速度を配置できません",
|
||||
"speedExistsAtLocation": "この場所にはすでに再生速度の範囲が存在するか、十分なスペースがありません。"
|
||||
},
|
||||
"success": {
|
||||
"addedZoomSuggestions": "カーソルに基づくズーム提案を {{count}} 件追加しました",
|
||||
|
||||
@@ -38,6 +38,8 @@
|
||||
"systemAudioUnavailable": "시스템 오디오를 사용할 수 없습니다. 시스템 오디오 없이 녹화합니다.",
|
||||
"microphoneDenied": "마이크 접근이 거부되었습니다. 오디오 없이 녹화를 계속합니다.",
|
||||
"cameraDenied": "카메라 접근이 거부되었습니다. 웹캠 없이 녹화를 계속합니다.",
|
||||
"permissionDenied": "녹화 권한이 거부되었습니다. 화면 녹화를 허용해 주세요."
|
||||
"permissionDenied": "녹화 권한이 거부되었습니다. 화면 녹화를 허용해 주세요.",
|
||||
"cameraDisconnected": "웹캠 연결이 끊어졌습니다.",
|
||||
"cameraNotFound": "카메라를 찾을 수 없습니다."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,5 +33,11 @@
|
||||
"recording": {
|
||||
"selectSource": "녹화할 소스를 선택해 주세요"
|
||||
},
|
||||
"language": "언어"
|
||||
"language": "언어",
|
||||
"systemLanguagePrompt": {
|
||||
"title": "시스템 언어를 사용하시겠습니까?",
|
||||
"description": "시스템 언어가 {{language}}(으)로 감지되었습니다. OpenScreen을 {{language}}(으)로 전환하시겠습니까?",
|
||||
"switch": "{{language}}(으)로 전환",
|
||||
"keepDefault": "현재 언어 유지"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,14 @@
|
||||
"manual": "수동",
|
||||
"auto": "자동",
|
||||
"autoDescription": "녹화된 커서 위치를 따라 카메라가 이동합니다"
|
||||
},
|
||||
"threeD": {
|
||||
"title": "3D 회전",
|
||||
"preset": {
|
||||
"iso": "Iso",
|
||||
"left": "왼쪽",
|
||||
"right": "오른쪽"
|
||||
}
|
||||
}
|
||||
},
|
||||
"speed": {
|
||||
@@ -27,7 +35,8 @@
|
||||
"pictureInPicture": "화면 속 화면",
|
||||
"verticalStack": "세로 배치",
|
||||
"webcamShape": "카메라 모양",
|
||||
"webcamSize": "웹캠 크기"
|
||||
"webcamSize": "웹캠 크기",
|
||||
"dualFrame": "듀얼 프레임"
|
||||
},
|
||||
"effects": {
|
||||
"title": "비디오 효과",
|
||||
@@ -44,7 +53,9 @@
|
||||
"color": "색상",
|
||||
"gradient": "그라디언트",
|
||||
"uploadCustom": "직접 업로드",
|
||||
"gradientLabel": "그라디언트 {{index}}"
|
||||
"gradientLabel": "그라디언트 {{index}}",
|
||||
"colorWheel": "색상 휠",
|
||||
"colorPalette": "색상 팔레트"
|
||||
},
|
||||
"crop": {
|
||||
"title": "자르기",
|
||||
@@ -111,6 +122,8 @@
|
||||
"background": "배경",
|
||||
"none": "없음",
|
||||
"color": "색상",
|
||||
"colorWheel": "색상 휠",
|
||||
"colorPalette": "색상 팔레트",
|
||||
"clearBackground": "배경 지우기",
|
||||
"uploadImage": "이미지 업로드",
|
||||
"supportedFormats": "지원 형식: JPG, PNG, GIF, WebP",
|
||||
@@ -125,7 +138,20 @@
|
||||
"invalidImageType": "지원하지 않는 파일 형식입니다",
|
||||
"imageFormatsOnly": "JPG, PNG, GIF 또는 WebP 이미지 파일을 업로드해 주세요.",
|
||||
"imageUploadSuccess": "이미지가 성공적으로 업로드되었습니다!",
|
||||
"failedImageUpload": "이미지 업로드에 실패했습니다"
|
||||
"failedImageUpload": "이미지 업로드에 실패했습니다",
|
||||
"blurColor": "블러 색상",
|
||||
"blurColorBlack": "검정",
|
||||
"blurColorWhite": "흰색",
|
||||
"blurIntensity": "블러 강도",
|
||||
"blurShape": "블러 모양",
|
||||
"blurShapeFreehand": "자유 곡선",
|
||||
"blurShapeOval": "타원",
|
||||
"blurShapeRectangle": "사각형",
|
||||
"blurType": "블러 종류",
|
||||
"blurTypeBlur": "블러",
|
||||
"blurTypeMosaic": "모자이크 블러",
|
||||
"mosaicBlockSize": "모자이크 블록 크기",
|
||||
"typeBlur": "블러"
|
||||
},
|
||||
"fontStyles": {
|
||||
"classic": "클래식",
|
||||
|
||||
@@ -20,7 +20,8 @@
|
||||
"addAnnotation": "주석 추가",
|
||||
"addKeyframe": "키프레임 추가",
|
||||
"deleteSelected": "선택 항목 삭제",
|
||||
"playPause": "재생 / 일시정지"
|
||||
"playPause": "재생 / 일시정지",
|
||||
"addBlur": "블러 추가"
|
||||
},
|
||||
"fixedActions": {
|
||||
"undo": "실행 취소",
|
||||
|
||||
@@ -4,13 +4,15 @@
|
||||
"suggestZooms": "커서 기반 줌 제안",
|
||||
"addTrim": "트림 추가 (T)",
|
||||
"addAnnotation": "주석 추가 (A)",
|
||||
"addSpeed": "속도 추가 (S)"
|
||||
"addSpeed": "속도 추가 (S)",
|
||||
"addBlur": "블러 추가 (B)"
|
||||
},
|
||||
"hints": {
|
||||
"pressZoom": "Z를 눌러 줌 추가",
|
||||
"pressTrim": "T를 눌러 트림 추가",
|
||||
"pressAnnotation": "A를 눌러 주석 추가",
|
||||
"pressSpeed": "S를 눌러 속도 추가"
|
||||
"pressSpeed": "S를 눌러 속도 추가",
|
||||
"pressBlur": "B 키를 눌러 블러 영역을 추가하세요"
|
||||
},
|
||||
"labels": {
|
||||
"pan": "이동",
|
||||
@@ -22,7 +24,8 @@
|
||||
"speedItem": "속도 {{index}}",
|
||||
"annotationItem": "주석",
|
||||
"imageItem": "이미지",
|
||||
"emptyText": "빈 텍스트"
|
||||
"emptyText": "빈 텍스트",
|
||||
"blurItem": "블러 {{index}}"
|
||||
},
|
||||
"emptyState": {
|
||||
"noVideo": "불러온 비디오 없음",
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"actions": {
|
||||
"cancel": "Отмена",
|
||||
"save": "Сохранить",
|
||||
"delete": "Удалить",
|
||||
"close": "Закрыть",
|
||||
"share": "Поделиться",
|
||||
"done": "Готово",
|
||||
"open": "Открыть",
|
||||
"upload": "Загрузить",
|
||||
"export": "Экспорт",
|
||||
"showInFolder": "Показать в папке",
|
||||
"file": "Файл",
|
||||
"edit": "Редактировать",
|
||||
"view": "Вид",
|
||||
"window": "Окно",
|
||||
"quit": "Выход",
|
||||
"stopRecording": "Остановить запись",
|
||||
"undo": "Отменить",
|
||||
"redo": "Повторить",
|
||||
"cut": "Вырезать",
|
||||
"copy": "Копировать",
|
||||
"paste": "Вставить",
|
||||
"selectAll": "Выделить всё",
|
||||
"minimize": "Свернуть",
|
||||
"reload": "Перезагрузить",
|
||||
"forceReload": "Принудительная перезагрузка",
|
||||
"toggleDevTools": "Переключить инструменты разработчика",
|
||||
"actualSize": "Реальный размер",
|
||||
"zoomIn": "Увеличить",
|
||||
"zoomOut": "Уменьшить",
|
||||
"toggleFullScreen": "Полноэкранный режим",
|
||||
"recordingStatus": "Запись: {{source}}",
|
||||
"about": "О OpenScreen",
|
||||
"services": "Сервисы",
|
||||
"hide": "Скрыть OpenScreen",
|
||||
"hideOthers": "Скрыть остальные",
|
||||
"unhide": "Показать все"
|
||||
},
|
||||
"playback": {
|
||||
"play": "Воспроизвести",
|
||||
"pause": "Пауза",
|
||||
"fullscreen": "Полный экран",
|
||||
"exitFullscreen": "Выход из полного экрана"
|
||||
},
|
||||
"locale": {
|
||||
"name": "Русский",
|
||||
"short": "RU"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"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": "Как вырезать ненужные части видео.",
|
||||
"explanationBefore": "Инструмент обрезки работает путём определения сегментов, которые вы хотите",
|
||||
"remove": "удалить",
|
||||
"explanationMiddle": " — всё, что",
|
||||
"covered": "покрыто",
|
||||
"explanationAfter": "красным сегментом обрезки, будет вырезано при экспорте.",
|
||||
"visualExample": "Визуальный пример",
|
||||
"removed": "УДАЛЕНО",
|
||||
"kept": "Сохранено",
|
||||
"part1": "Часть 1",
|
||||
"part2": "Часть 2",
|
||||
"part3": "Часть 3",
|
||||
"finalVideo": "Итоговое видео",
|
||||
"step1Title": "1. Добавить обрезку",
|
||||
"step1DescriptionBefore": "Нажмите ",
|
||||
"step1DescriptionAfter": " или нажмите на значок ножниц, чтобы отметить секцию для удаления.",
|
||||
"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,45 @@
|
||||
{
|
||||
"newRecording": {
|
||||
"title": "Вернуться к записи",
|
||||
"description": "Ваша текущая сессия была сохранена.",
|
||||
"cancel": "Отмена",
|
||||
"confirm": "Подтвердить"
|
||||
},
|
||||
"loadingVideo": "Загрузка видео...",
|
||||
"errors": {
|
||||
"noVideoLoaded": "Видео не загружено",
|
||||
"videoNotReady": "Видео не готово",
|
||||
"unableToDetermineSourcePath": "Не удалось определить путь к исходному видео",
|
||||
"failedToSaveGif": "Не удалось сохранить GIF",
|
||||
"gifExportFailed": "Экспорт GIF не удался",
|
||||
"failedToSaveVideo": "Не удалось сохранить видео",
|
||||
"exportFailed": "Экспорт не удался",
|
||||
"exportFailedWithError": "Экспорт не удался: {{error}}",
|
||||
"exportBackgroundLoadFailed": "Экспорт не удался: не удалось загрузить фоновое изображение ({{url}})",
|
||||
"failedToSaveExport": "Не удалось сохранить экспорт",
|
||||
"failedToSaveExportedVideo": "Не удалось сохранить экспортированное видео",
|
||||
"failedToRevealInFolder": "Ошибка при показе в папке: {{error}}"
|
||||
},
|
||||
"export": {
|
||||
"canceled": "Экспорт отменён",
|
||||
"exportedSuccessfully": "{{format}} успешно экспортирован"
|
||||
},
|
||||
"project": {
|
||||
"saveCanceled": "Сохранение проекта отменено",
|
||||
"failedToSave": "Не удалось сохранить проект",
|
||||
"savedTo": "Проект сохранён в {{path}}",
|
||||
"failedToLoad": "Не удалось загрузить проект",
|
||||
"invalidFormat": "Неверный формат файла проекта",
|
||||
"loadedFrom": "Проект загружен из {{path}}"
|
||||
},
|
||||
"recording": {
|
||||
"failedCameraAccess": "Не удалось запросить доступ к камере.",
|
||||
"cameraBlocked": "Доступ к камере заблокирован. Включите его в системных настройках для использования веб-камеры.",
|
||||
"systemAudioUnavailable": "Системное аудио недоступно. Запись без системного аудио.",
|
||||
"microphoneDenied": "Доступ к микрофону запрещён. Запись продолжится без аудио.",
|
||||
"cameraDenied": "Доступ к камере запрещён. Запись продолжится без веб-камеры.",
|
||||
"cameraDisconnected": "Веб-камера отключена.",
|
||||
"cameraNotFound": "Камера не найдена.",
|
||||
"permissionDenied": "Разрешение на запись запрещено. Пожалуйста, разрешите запись экрана."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"tooltips": {
|
||||
"hideHUD": "Скрыть HUD",
|
||||
"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": "Язык",
|
||||
"systemLanguagePrompt": {
|
||||
"title": "Использовать системный язык?",
|
||||
"description": "Мы обнаружили {{language}} как системный язык. Хотите переключить OpenScreen на {{language}}?",
|
||||
"switch": "Переключить на {{language}}",
|
||||
"keepDefault": "Оставить текущий язык"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
{
|
||||
"zoom": {
|
||||
"level": "Уровень масштабирования",
|
||||
"selectRegion": "Выберите область масштабирования для настройки",
|
||||
"deleteZoom": "Удалить масштабирование",
|
||||
"focusMode": {
|
||||
"title": "Режим фокуса",
|
||||
"manual": "Ручной",
|
||||
"auto": "Авто",
|
||||
"autoDescription": "Камера следует за записанной позицией курсора"
|
||||
},
|
||||
"threeD": {
|
||||
"title": "3D вращение",
|
||||
"preset": {
|
||||
"iso": "Изометрия",
|
||||
"left": "Слева",
|
||||
"right": "Справа"
|
||||
}
|
||||
}
|
||||
},
|
||||
"speed": {
|
||||
"playbackSpeed": "Скорость воспроизведения",
|
||||
"selectRegion": "Выберите область скорости для настройки",
|
||||
"deleteRegion": "Удалить область скорости",
|
||||
"customPlaybackSpeed": "Пользовательская скорость воспроизведения",
|
||||
"maxSpeedError": "Скорость не может быть выше 16×"
|
||||
},
|
||||
"trim": {
|
||||
"deleteRegion": "Удалить область обрезки"
|
||||
},
|
||||
"layout": {
|
||||
"title": "Макет",
|
||||
"preset": "Пресет",
|
||||
"selectPreset": "Выбрать пресет",
|
||||
"pictureInPicture": "Картинка в картинке",
|
||||
"verticalStack": "Вертикальный стек",
|
||||
"dualFrame": "Двойной кадр",
|
||||
"webcamShape": "Форма камеры",
|
||||
"webcamSize": "Размер веб-камеры"
|
||||
},
|
||||
"effects": {
|
||||
"title": "Видеоэффекты",
|
||||
"blurBg": "Размытие фона",
|
||||
"motionBlur": "Размытие движения",
|
||||
"off": "выкл",
|
||||
"on": "вкл",
|
||||
"shadow": "Тень",
|
||||
"roundness": "Скругление",
|
||||
"padding": "Отступ",
|
||||
"cursorHighlight": {
|
||||
"title": "Подсветка курсора",
|
||||
"style": "Стиль",
|
||||
"dot": "Точка",
|
||||
"ring": "Кольцо",
|
||||
"size": "Размер",
|
||||
"onlyOnClicks": "Только при кликах",
|
||||
"color": "Цвет",
|
||||
"offsetX": "Смещение X (записи окон)",
|
||||
"offsetY": "Смещение Y",
|
||||
"accessibilityPermissionTitle": "Требуется разрешение на доступность",
|
||||
"accessibilityPermissionDescription": "Откройте Системные настройки → Конфиденциальность и безопасность → Универсальный доступ, включите Openscreen, затем перезапустите приложение."
|
||||
}
|
||||
},
|
||||
"background": {
|
||||
"title": "Фон",
|
||||
"image": "Изображение",
|
||||
"color": "Цвет",
|
||||
"gradient": "Градиент",
|
||||
"uploadCustom": "Загрузить свой",
|
||||
"gradientLabel": "Градиент {{index}}",
|
||||
"colorWheel": "Цветовой круг",
|
||||
"colorPalette": "Палитра цветов"
|
||||
},
|
||||
"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": "Стрелка",
|
||||
"typeBlur": "Размытие",
|
||||
"textContent": "Содержание текста",
|
||||
"textPlaceholder": "Введите ваш текст...",
|
||||
"fontStyle": "Стиль шрифта",
|
||||
"selectStyle": "Выбрать стиль",
|
||||
"size": "Размер",
|
||||
"customFonts": "Пользовательские шрифты",
|
||||
"textColor": "Цвет текста",
|
||||
"background": "Фон",
|
||||
"none": "Нет",
|
||||
"color": "Цвет",
|
||||
"colorWheel": "Цветовой круг",
|
||||
"colorPalette": "Палитра цветов",
|
||||
"clearBackground": "Очистить фон",
|
||||
"uploadImage": "Загрузить изображение",
|
||||
"supportedFormats": "Поддерживаемые форматы: JPG, PNG, GIF, WebP",
|
||||
"arrowDirection": "Направление стрелки",
|
||||
"strokeWidth": "Толщина линии: {{width}}px",
|
||||
"arrowColor": "Цвет стрелки",
|
||||
"blurType": "Тип размытия",
|
||||
"blurTypeBlur": "Размытие",
|
||||
"blurTypeMosaic": "Мозаичное размытие",
|
||||
"blurColor": "Цвет размытия",
|
||||
"blurColorWhite": "Белый",
|
||||
"blurColorBlack": "Чёрный",
|
||||
"blurShape": "Форма размытия",
|
||||
"blurIntensity": "Интенсивность размытия",
|
||||
"mosaicBlockSize": "Размер блока мозаики",
|
||||
"blurShapeRectangle": "Прямоугольник",
|
||||
"blurShapeOval": "Овал",
|
||||
"blurShapeFreehand": "От руки",
|
||||
"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": "URL импорта Google Fonts",
|
||||
"urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
|
||||
"urlHelp": "Возьмите его из Google Fonts: Выберите шрифт → Нажмите \"Get font\" → Скопируйте URL @import",
|
||||
"nameLabel": "Отображаемое имя",
|
||||
"namePlaceholder": "Мой пользовательский шрифт",
|
||||
"nameHelp": "Так шрифт будет отображаться в селекторе шрифтов",
|
||||
"addButton": "Добавить шрифт",
|
||||
"addingButton": "Добавление...",
|
||||
"errorEmptyUrl": "Пожалуйста, введите URL импорта Google Fonts",
|
||||
"errorInvalidUrl": "Пожалуйста, введите корректный URL Google Fonts",
|
||||
"errorEmptyName": "Пожалуйста, введите имя шрифта",
|
||||
"errorExtractFailed": "Не удалось извлечь семейство шрифтов из URL",
|
||||
"successMessage": "Шрифт \"{{fontName}}\" успешно добавлен",
|
||||
"failedToAdd": "Не удалось добавить шрифт",
|
||||
"errorTimeout": "Загрузка шрифта заняла слишком много времени. Пожалуйста, проверьте URL и попробуйте снова.",
|
||||
"errorLoadFailed": "Не удалось загрузить шрифт. Пожалуйста, проверьте правильность URL Google Fonts."
|
||||
},
|
||||
"language": {
|
||||
"title": "Язык"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"title": "Горячие клавиши",
|
||||
"customize": "Настроить",
|
||||
"configurable": "Настраиваемые",
|
||||
"fixed": "Фиксированные",
|
||||
"pressKey": "Нажмите клавишу…",
|
||||
"clickToChange": "Нажмите для изменения",
|
||||
"pressEscToCancel": "Нажмите Esc для отмены",
|
||||
"helpText": "Нажмите на горячую клавишу, затем нажмите новую комбинацию клавиш. Нажмите Esc для отмены.",
|
||||
"resetToDefaults": "Сбросить по умолчанию",
|
||||
"alreadyUsedBy": "Уже используется для {{action}}",
|
||||
"swap": "Поменять",
|
||||
"reservedShortcut": "Эта горячая клавиша зарезервирована для \"{{label}}\" и не может быть переназначена.",
|
||||
"savedToast": "Горячие клавиши сохранены",
|
||||
"resetToast": "Сброс к горячим клавишам по умолчанию — нажмите Сохранить для применения",
|
||||
"actions": {
|
||||
"addZoom": "Добавить масштабирование",
|
||||
"addTrim": "Добавить обрезку",
|
||||
"addSpeed": "Изменить скорость",
|
||||
"addAnnotation": "Добавить аннотацию",
|
||||
"addBlur": "Добавить размытие",
|
||||
"addKeyframe": "Добавить ключевой кадр",
|
||||
"deleteSelected": "Удалить выбранное",
|
||||
"playPause": "Воспроизведение / Пауза"
|
||||
},
|
||||
"fixedActions": {
|
||||
"undo": "Отменить",
|
||||
"redo": "Повторить",
|
||||
"cycleAnnotationsForward": "Циклически переключить аннотации вперёд",
|
||||
"cycleAnnotationsBackward": "Циклически переключить аннотации назад",
|
||||
"deleteSelectedAlt": "Удалить выбранное (альт)",
|
||||
"panTimeline": "Панорамирование таймлайна",
|
||||
"zoomTimeline": "Масштабирование таймлайна",
|
||||
"frameBack": "Кадр назад",
|
||||
"frameForward": "Кадр вперёд"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"buttons": {
|
||||
"addZoom": "Добавить масштабирование (Z)",
|
||||
"suggestZooms": "Предложить масштабирование на основе курсора",
|
||||
"addTrim": "Добавить обрезку (T)",
|
||||
"addAnnotation": "Добавить аннотацию (A)",
|
||||
"addBlur": "Добавить размытие (B)",
|
||||
"addSpeed": "Изменить скорость (S)"
|
||||
},
|
||||
"hints": {
|
||||
"pressZoom": "Нажмите Z для добавления масштабирования",
|
||||
"pressTrim": "Нажмите T для добавления обрезки",
|
||||
"pressAnnotation": "Нажмите A для добавления аннотации",
|
||||
"pressBlur": "Нажмите B для добавления области размытия",
|
||||
"pressSpeed": "Нажмите S для изменения скорости"
|
||||
},
|
||||
"labels": {
|
||||
"pan": "Панорамирование",
|
||||
"zoom": "Масштабирование",
|
||||
"trim": "Обрезка",
|
||||
"speed": "Скорость воспроизведения",
|
||||
"zoomItem": "Масштабирование {{index}}",
|
||||
"trimItem": "Обрезка {{index}}",
|
||||
"speedItem": "Скорость воспроизведения {{index}}",
|
||||
"annotationItem": "Аннотация",
|
||||
"blurItem": "Размытие {{index}}",
|
||||
"imageItem": "Изображение",
|
||||
"emptyText": "Пустой текст"
|
||||
},
|
||||
"emptyState": {
|
||||
"noVideo": "Видео не загружено",
|
||||
"dragAndDrop": "Перетащите видео для начала редактирования"
|
||||
},
|
||||
"errors": {
|
||||
"cannotPlaceZoom": "Невозможно разместить масштабирование здесь",
|
||||
"zoomExistsAtLocation": "Масштабирование уже существует в этом месте или недостаточно свободного места.",
|
||||
"zoomSuggestionUnavailable": "Обработчик предложений масштабирования недоступен",
|
||||
"noCursorTelemetry": "Нет данных телеметрии курсора",
|
||||
"noCursorTelemetryDescription": "Сначала запишите screencast для генерации предложений на основе курсора.",
|
||||
"noUsableTelemetry": "Нет пригодной телеметрии курсора",
|
||||
"noUsableTelemetryDescription": "Запись не содержит достаточно данных о движении курсора.",
|
||||
"noDwellMoments": "Не найдено чётких моментов задержки курсора",
|
||||
"noDwellMomentsDescription": "Попробуйте запись с более медленными паузами курсора на важных действиях.",
|
||||
"noAutoZoomSlots": "Нет доступных слотов авто-масштабирования",
|
||||
"noAutoZoomSlotsDescription": "Обнаруженные точки задержки перекрывают существующие области масштабирования.",
|
||||
"cannotPlaceTrim": "Невозможно разместить обрезку здесь",
|
||||
"trimExistsAtLocation": "Обрезка уже существует в этом месте или недостаточно свободного места.",
|
||||
"cannotPlaceSpeed": "Невозможно разместить изменение скорости здесь",
|
||||
"speedExistsAtLocation": "Область изменения скорости уже существует в этом месте или недостаточно свободного места."
|
||||
},
|
||||
"success": {
|
||||
"addedZoomSuggestions": "Добавлено {{count}} предложение масштабирования на основе курсора",
|
||||
"addedZoomSuggestionsPlural": "Добавлено {{count}} предложений масштабирования на основе курсора"
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,15 @@
|
||||
"systemAudioUnavailable": "Sistem sesi kullanılamıyor. Sistem sesi olmadan kaydediliyor.",
|
||||
"microphoneDenied": "Mikrofon erişimi reddedildi. Kayıt ses olmadan devam edecek.",
|
||||
"cameraDenied": "Kamera erişimi reddedildi. Kayıt kamera olmadan devam edecek.",
|
||||
"permissionDenied": "Kayıt izni reddedildi. Lütfen ekran kaydına izin verin."
|
||||
"permissionDenied": "Kayıt izni reddedildi. Lütfen ekran kaydına izin verin.",
|
||||
"cameraDisconnected": "Webcam bağlantısı kesildi.",
|
||||
"cameraNotFound": "Kamera bulunamadı."
|
||||
},
|
||||
"loadingVideo": "Video yükleniyor...",
|
||||
"newRecording": {
|
||||
"title": "Kaydediciye Dön",
|
||||
"description": "Mevcut oturumunuz kaydedildi.",
|
||||
"cancel": "İptal",
|
||||
"confirm": "Onayla"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,5 +44,11 @@
|
||||
"recording": {
|
||||
"selectSource": "Lütfen kayıt için bir kaynak seçin"
|
||||
},
|
||||
"language": "Dil"
|
||||
"language": "Dil",
|
||||
"systemLanguagePrompt": {
|
||||
"title": "Sistem dilinizi kullanmak ister misiniz?",
|
||||
"description": "Sistem diliniz {{language}} olarak algılandı. OpenScreen i {{language}} diline geçirmek ister misiniz?",
|
||||
"switch": "{{language}} diline geç",
|
||||
"keepDefault": "Mevcut dili koru"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,12 +8,22 @@
|
||||
"manual": "Manuel",
|
||||
"auto": "Otomatik",
|
||||
"autoDescription": "Kamera kaydedilen imleç konumunu takip eder"
|
||||
},
|
||||
"threeD": {
|
||||
"title": "3D Döndürme",
|
||||
"preset": {
|
||||
"iso": "Iso",
|
||||
"left": "Sol",
|
||||
"right": "Sağ"
|
||||
}
|
||||
}
|
||||
},
|
||||
"speed": {
|
||||
"playbackSpeed": "Oynatma Hızı",
|
||||
"selectRegion": "Ayarlamak için bir hız bölgesi seçin",
|
||||
"deleteRegion": "Hız Bölgesini Sil"
|
||||
"deleteRegion": "Hız Bölgesini Sil",
|
||||
"customPlaybackSpeed": "Özel Oynatma Hızı",
|
||||
"maxSpeedError": "Hız 16× değerinden yüksek olamaz"
|
||||
},
|
||||
"trim": {
|
||||
"deleteRegion": "Kırpma Bölgesini Sil"
|
||||
@@ -24,7 +34,9 @@
|
||||
"selectPreset": "Ön ayar seçin",
|
||||
"pictureInPicture": "Resim İçinde Resim",
|
||||
"verticalStack": "Dikey Yığın",
|
||||
"webcamShape": "Kamera Şekli"
|
||||
"webcamShape": "Kamera Şekli",
|
||||
"dualFrame": "Çift Kare",
|
||||
"webcamSize": "Webcam Boyutu"
|
||||
},
|
||||
"effects": {
|
||||
"title": "Video Efektleri",
|
||||
@@ -41,7 +53,9 @@
|
||||
"color": "Renk",
|
||||
"gradient": "Gradyan",
|
||||
"uploadCustom": "Özel Yükle",
|
||||
"gradientLabel": "Gradyan {{index}}"
|
||||
"gradientLabel": "Gradyan {{index}}",
|
||||
"colorWheel": "Renk çarkı",
|
||||
"colorPalette": "Renk paleti"
|
||||
},
|
||||
"crop": {
|
||||
"title": "Kırpma",
|
||||
@@ -109,6 +123,8 @@
|
||||
"background": "Arka Plan",
|
||||
"none": "Yok",
|
||||
"color": "Renk",
|
||||
"colorWheel": "Renk çarkı",
|
||||
"colorPalette": "Renk paleti",
|
||||
"clearBackground": "Arka Planı Temizle",
|
||||
"uploadImage": "Görüntü Yükle",
|
||||
"supportedFormats": "Desteklenen biçimler: JPG, PNG, GIF, WebP",
|
||||
@@ -128,7 +144,14 @@
|
||||
"invalidImageType": "Geçersiz dosya türü",
|
||||
"imageFormatsOnly": "Lütfen bir JPG, PNG, GIF veya WebP görüntü dosyası yükleyin.",
|
||||
"imageUploadSuccess": "Görüntü başarıyla yüklendi!",
|
||||
"failedImageUpload": "Görüntü yüklenemedi"
|
||||
"failedImageUpload": "Görüntü yüklenemedi",
|
||||
"blurColor": "Bulanıklık Rengi",
|
||||
"blurColorBlack": "Siyah",
|
||||
"blurColorWhite": "Beyaz",
|
||||
"blurType": "Bulanıklık Türü",
|
||||
"blurTypeBlur": "Bulanık",
|
||||
"blurTypeMosaic": "Mozaik Bulanıklık",
|
||||
"mosaicBlockSize": "Mozaik Blok Boyutu"
|
||||
},
|
||||
"fontStyles": {
|
||||
"classic": "Klasik",
|
||||
|
||||
@@ -8,6 +8,14 @@
|
||||
"manual": "手动",
|
||||
"auto": "自动",
|
||||
"autoDescription": "摄像头跟随录制时的光标位置"
|
||||
},
|
||||
"threeD": {
|
||||
"title": "3D 旋转",
|
||||
"preset": {
|
||||
"iso": "Iso",
|
||||
"left": "左",
|
||||
"right": "右"
|
||||
}
|
||||
}
|
||||
},
|
||||
"speed": {
|
||||
@@ -45,7 +53,9 @@
|
||||
"color": "颜色",
|
||||
"gradient": "渐变",
|
||||
"uploadCustom": "上传自定义",
|
||||
"gradientLabel": "渐变 {{index}}"
|
||||
"gradientLabel": "渐变 {{index}}",
|
||||
"colorWheel": "颜色轮",
|
||||
"colorPalette": "颜色调色板"
|
||||
},
|
||||
"crop": {
|
||||
"title": "裁剪",
|
||||
@@ -113,6 +123,8 @@
|
||||
"background": "背景",
|
||||
"none": "无",
|
||||
"color": "颜色",
|
||||
"colorWheel": "颜色轮",
|
||||
"colorPalette": "颜色调色板",
|
||||
"clearBackground": "清除背景",
|
||||
"uploadImage": "上传图片",
|
||||
"supportedFormats": "支持的格式:JPG、PNG、GIF、WebP",
|
||||
@@ -132,7 +144,14 @@
|
||||
"invalidImageType": "无效的文件类型",
|
||||
"imageFormatsOnly": "请上传 JPG、PNG、GIF 或 WebP 格式的图片文件。",
|
||||
"imageUploadSuccess": "图片上传成功!",
|
||||
"failedImageUpload": "上传图片失败"
|
||||
"failedImageUpload": "上传图片失败",
|
||||
"blurColor": "模糊颜色",
|
||||
"blurColorBlack": "黑色",
|
||||
"blurColorWhite": "白色",
|
||||
"blurType": "模糊类型",
|
||||
"blurTypeBlur": "模糊",
|
||||
"blurTypeMosaic": "马赛克模糊",
|
||||
"mosaicBlockSize": "马赛克块大小"
|
||||
},
|
||||
"fontStyles": {
|
||||
"classic": "经典",
|
||||
|
||||
@@ -38,6 +38,8 @@
|
||||
"systemAudioUnavailable": "系統音訊不可用。將在無系統音訊的情況下錄製。",
|
||||
"microphoneDenied": "麥克風權限被拒絕。錄製將繼續,但不包含音訊。",
|
||||
"cameraDenied": "攝影機權限被拒絕。錄製將繼續,但不包含攝影機畫面。",
|
||||
"permissionDenied": "錄影權限被拒絕。請允許螢幕錄製。"
|
||||
"permissionDenied": "錄影權限被拒絕。請允許螢幕錄製。",
|
||||
"cameraDisconnected": "網路攝影機已中斷連線。",
|
||||
"cameraNotFound": "找不到攝影機。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,5 +33,11 @@
|
||||
"recording": {
|
||||
"selectSource": "請選擇要錄製的來源"
|
||||
},
|
||||
"language": "語言"
|
||||
"language": "語言",
|
||||
"systemLanguagePrompt": {
|
||||
"title": "要使用系統語言嗎?",
|
||||
"description": "偵測到系統語言為 {{language}}。要將 OpenScreen 切換為 {{language}} 嗎?",
|
||||
"switch": "切換為 {{language}}",
|
||||
"keepDefault": "保持目前語言"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,14 @@
|
||||
"fast": "快速",
|
||||
"smooth": "平滑",
|
||||
"lazy": "緩慢"
|
||||
},
|
||||
"threeD": {
|
||||
"title": "3D 旋轉",
|
||||
"preset": {
|
||||
"iso": "Iso",
|
||||
"left": "左",
|
||||
"right": "右"
|
||||
}
|
||||
}
|
||||
},
|
||||
"speed": {
|
||||
@@ -52,7 +60,9 @@
|
||||
"color": "顏色",
|
||||
"gradient": "漸層",
|
||||
"uploadCustom": "上傳自訂",
|
||||
"gradientLabel": "漸層 {{index}}"
|
||||
"gradientLabel": "漸層 {{index}}",
|
||||
"colorWheel": "色輪",
|
||||
"colorPalette": "調色盤"
|
||||
},
|
||||
"crop": {
|
||||
"title": "裁剪",
|
||||
@@ -120,6 +130,8 @@
|
||||
"background": "背景",
|
||||
"none": "無",
|
||||
"color": "顏色",
|
||||
"colorWheel": "色輪",
|
||||
"colorPalette": "調色盤",
|
||||
"clearBackground": "清除背景",
|
||||
"uploadImage": "上傳圖片",
|
||||
"supportedFormats": "支援的格式:JPG、PNG、GIF、WebP",
|
||||
@@ -139,7 +151,14 @@
|
||||
"invalidImageType": "無效的檔案類型",
|
||||
"imageFormatsOnly": "請上傳 JPG、PNG、GIF 或 WebP 格式的圖片檔案。",
|
||||
"imageUploadSuccess": "圖片上傳成功!",
|
||||
"failedImageUpload": "上傳圖片失敗"
|
||||
"failedImageUpload": "上傳圖片失敗",
|
||||
"blurColor": "模糊顏色",
|
||||
"blurColorBlack": "黑色",
|
||||
"blurColorWhite": "白色",
|
||||
"blurType": "模糊類型",
|
||||
"blurTypeBlur": "模糊",
|
||||
"blurTypeMosaic": "馬賽克模糊",
|
||||
"mosaicBlockSize": "馬賽克區塊大小"
|
||||
},
|
||||
"fontStyles": {
|
||||
"classic": "經典",
|
||||
|
||||
@@ -75,6 +75,6 @@ describe("blur color helpers", () => {
|
||||
intensity: 12,
|
||||
blockSize: 12,
|
||||
}),
|
||||
).toBe("rgba(0, 0, 0, 0.18)");
|
||||
).toBe("rgba(0, 0, 0, 0.56)");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,7 +15,11 @@ export interface Size {
|
||||
height: number;
|
||||
}
|
||||
|
||||
export type WebcamLayoutPreset = "picture-in-picture" | "vertical-stack" | "dual-frame";
|
||||
export type WebcamLayoutPreset =
|
||||
| "picture-in-picture"
|
||||
| "vertical-stack"
|
||||
| "dual-frame"
|
||||
| "no-webcam";
|
||||
/** Webcam size as a percentage of the canvas reference dimension (10–50). */
|
||||
export type WebcamSizePreset = number;
|
||||
|
||||
@@ -126,6 +130,21 @@ const WEBCAM_LAYOUT_PRESET_MAP: Record<WebcamLayoutPreset, WebcamLayoutPresetDef
|
||||
},
|
||||
shadow: null,
|
||||
},
|
||||
"no-webcam": {
|
||||
label: "No Webcam",
|
||||
transform: {
|
||||
type: "overlay",
|
||||
marginFraction: 0,
|
||||
minMargin: 0,
|
||||
minSize: 0,
|
||||
},
|
||||
borderRadius: {
|
||||
max: 0,
|
||||
min: 0,
|
||||
fraction: 0,
|
||||
},
|
||||
shadow: null,
|
||||
},
|
||||
};
|
||||
|
||||
export const WEBCAM_LAYOUT_PRESETS = Object.entries(WEBCAM_LAYOUT_PRESET_MAP).map(
|
||||
@@ -172,6 +191,17 @@ export function computeCompositeLayout(params: {
|
||||
} = params;
|
||||
const { width: canvasWidth, height: canvasHeight } = canvasSize;
|
||||
const { width: screenWidth, height: screenHeight } = screenSize;
|
||||
|
||||
// "no-webcam" preset: hide the webcam entirely, screen fills the canvas normally
|
||||
if (layoutPreset === "no-webcam") {
|
||||
const screenRect = centerRect({
|
||||
canvasSize,
|
||||
size: screenSize,
|
||||
maxSize: maxContentSize,
|
||||
});
|
||||
return { screenRect, webcamRect: null };
|
||||
}
|
||||
|
||||
const webcamWidth = webcamSize?.width;
|
||||
const webcamHeight = webcamSize?.height;
|
||||
const preset = getWebcamLayoutPresetDefinition(layoutPreset);
|
||||
|
||||
@@ -11,13 +11,18 @@ import { MotionBlurFilter } from "pixi-filters/motion-blur";
|
||||
import type {
|
||||
AnnotationRegion,
|
||||
CropRegion,
|
||||
Rotation3D,
|
||||
SpeedRegion,
|
||||
WebcamLayoutPreset,
|
||||
WebcamSizePreset,
|
||||
ZoomDepth,
|
||||
ZoomRegion,
|
||||
} from "@/components/video-editor/types";
|
||||
import { ZOOM_DEPTH_SCALES } from "@/components/video-editor/types";
|
||||
import {
|
||||
DEFAULT_ROTATION_3D,
|
||||
getZoomScale,
|
||||
isRotation3DIdentity,
|
||||
lerpRotation3D,
|
||||
} from "@/components/video-editor/types";
|
||||
import {
|
||||
AUTO_FOLLOW_RAMP_DISTANCE,
|
||||
AUTO_FOLLOW_SMOOTHING_FACTOR,
|
||||
@@ -28,9 +33,15 @@ import {
|
||||
} from "@/components/video-editor/videoPlayback/constants";
|
||||
import {
|
||||
adaptiveSmoothFactor,
|
||||
interpolateCursorAt,
|
||||
smoothCursorFocus,
|
||||
} from "@/components/video-editor/videoPlayback/cursorFollowUtils";
|
||||
import { clampFocusToStage as clampFocusToStageUtil } from "@/components/video-editor/videoPlayback/focusUtils";
|
||||
import {
|
||||
type CursorHighlightConfig,
|
||||
clickEmphasisAlpha,
|
||||
drawCursorHighlightCanvas,
|
||||
} from "@/components/video-editor/videoPlayback/cursorHighlight";
|
||||
import { clampFocusToScale } from "@/components/video-editor/videoPlayback/focusUtils";
|
||||
import { findDominantRegion } from "@/components/video-editor/videoPlayback/zoomRegionUtils";
|
||||
import {
|
||||
applyZoomTransform,
|
||||
@@ -54,6 +65,7 @@ import {
|
||||
parseCssGradient,
|
||||
resolveLinearGradientAngle,
|
||||
} from "./gradientParser";
|
||||
import { createThreeDPass, type ThreeDPass } from "./threeDPass";
|
||||
|
||||
interface FrameRenderConfig {
|
||||
width: number;
|
||||
@@ -79,6 +91,8 @@ interface FrameRenderConfig {
|
||||
previewWidth?: number;
|
||||
previewHeight?: number;
|
||||
cursorTelemetry?: import("@/components/video-editor/types").CursorTelemetryPoint[];
|
||||
cursorHighlight?: CursorHighlightConfig;
|
||||
cursorClickTimestamps?: number[];
|
||||
platform: string;
|
||||
}
|
||||
|
||||
@@ -116,8 +130,12 @@ export class FrameRenderer {
|
||||
private shadowCtx: CanvasRenderingContext2D | null = null;
|
||||
private compositeCanvas: HTMLCanvasElement | null = null;
|
||||
private compositeCtx: CanvasRenderingContext2D | null = null;
|
||||
private foregroundCanvas: HTMLCanvasElement | null = null;
|
||||
private foregroundCtx: CanvasRenderingContext2D | null = null;
|
||||
private rasterCanvas: HTMLCanvasElement | null = null;
|
||||
private rasterCtx: CanvasRenderingContext2D | null = null;
|
||||
private threeDPass: ThreeDPass | null = null;
|
||||
private currentRotation3D: Rotation3D = { ...DEFAULT_ROTATION_3D };
|
||||
private config: FrameRenderConfig;
|
||||
private animationState: AnimationState;
|
||||
private layoutCache: LayoutCache | null = null;
|
||||
@@ -209,6 +227,19 @@ export class FrameRenderer {
|
||||
throw new Error("Failed to get 2D context for raster canvas");
|
||||
}
|
||||
|
||||
// Foreground canvas: holds recording + shadow + webcam + cursor + annotations,
|
||||
// transparent background. The 3D rotation pass operates only on this layer so
|
||||
// the wallpaper stays flat behind the rotated content (matching preview).
|
||||
this.foregroundCanvas = document.createElement("canvas");
|
||||
this.foregroundCanvas.width = this.config.width;
|
||||
this.foregroundCanvas.height = this.config.height;
|
||||
this.foregroundCtx = this.foregroundCanvas.getContext("2d", {
|
||||
willReadFrequently: this.isLinux,
|
||||
});
|
||||
if (!this.foregroundCtx) {
|
||||
throw new Error("Failed to get 2D context for foreground canvas");
|
||||
}
|
||||
|
||||
// Setup shadow canvas if needed
|
||||
if (this.config.showShadow) {
|
||||
this.shadowCanvas = document.createElement("canvas");
|
||||
@@ -227,6 +258,13 @@ export class FrameRenderer {
|
||||
this.maskGraphics = new Graphics();
|
||||
this.videoContainer.addChild(this.maskGraphics);
|
||||
this.videoContainer.mask = this.maskGraphics;
|
||||
|
||||
try {
|
||||
this.threeDPass = createThreeDPass(this.config.width, this.config.height);
|
||||
} catch (error) {
|
||||
console.warn("[FrameRenderer] 3D pass unavailable, rotation fields will be ignored:", error);
|
||||
this.threeDPass = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async setupBackground(): Promise<void> {
|
||||
@@ -384,16 +422,58 @@ export class FrameRenderer {
|
||||
// Render the PixiJS stage to its canvas (video only, transparent background)
|
||||
this.app.renderer.render(this.app.stage);
|
||||
|
||||
// Composite with shadows to final output canvas
|
||||
this.compositeWithShadows(webcamFrame);
|
||||
// Skip baking the shadow when the WebGL rotation pass will run — it'd alias to
|
||||
// a hard edge through bilinear sampling. We re-apply shadow fresh after rotation.
|
||||
const willRotate = !isRotation3DIdentity(this.currentRotation3D);
|
||||
this.compositeWithShadows(webcamFrame, !willRotate);
|
||||
|
||||
// Render annotations on top if present
|
||||
// Cursor highlight overlay (rendered above video, below annotations)
|
||||
// Drawn onto foreground so it rotates with the recording.
|
||||
if (
|
||||
this.config.cursorHighlight?.enabled &&
|
||||
this.config.cursorTelemetry &&
|
||||
this.config.cursorTelemetry.length > 0 &&
|
||||
this.foregroundCtx
|
||||
) {
|
||||
const emphasisAlpha = clickEmphasisAlpha(
|
||||
timeMs,
|
||||
this.config.cursorClickTimestamps,
|
||||
this.config.cursorHighlight,
|
||||
);
|
||||
const cursorPoint =
|
||||
emphasisAlpha > 0 ? interpolateCursorAt(this.config.cursorTelemetry, timeMs) : null;
|
||||
if (cursorPoint) {
|
||||
const cx = cursorPoint.cx + this.config.cursorHighlight.offsetXNorm;
|
||||
const cy = cursorPoint.cy + this.config.cursorHighlight.offsetYNorm;
|
||||
const stageX =
|
||||
layoutCache.baseOffset.x + cx * this.config.videoWidth * layoutCache.baseScale;
|
||||
const stageY =
|
||||
layoutCache.baseOffset.y + cy * this.config.videoHeight * layoutCache.baseScale;
|
||||
const appliedScale = this.animationState.appliedScale;
|
||||
const canvasX = stageX * appliedScale + this.animationState.x;
|
||||
const canvasY = stageY * appliedScale + this.animationState.y;
|
||||
const previewW = this.config.previewWidth ?? this.config.width;
|
||||
const previewH = this.config.previewHeight ?? this.config.height;
|
||||
const cursorScale = (this.config.width / previewW + this.config.height / previewH) / 2;
|
||||
drawCursorHighlightCanvas(
|
||||
this.foregroundCtx,
|
||||
canvasX,
|
||||
canvasY,
|
||||
{
|
||||
...this.config.cursorHighlight,
|
||||
opacity: this.config.cursorHighlight.opacity * emphasisAlpha,
|
||||
},
|
||||
appliedScale * cursorScale,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Render annotations on top of foreground (so they rotate with recording).
|
||||
if (
|
||||
this.config.annotationRegions &&
|
||||
this.config.annotationRegions.length > 0 &&
|
||||
this.compositeCtx
|
||||
this.foregroundCtx
|
||||
) {
|
||||
// Calculate scale factor based on export vs preview dimensions
|
||||
const previewWidth = this.config.previewWidth ?? this.config.width;
|
||||
const previewHeight = this.config.previewHeight ?? this.config.height;
|
||||
const scaleX = this.config.width / previewWidth;
|
||||
@@ -401,7 +481,7 @@ export class FrameRenderer {
|
||||
const scaleFactor = (scaleX + scaleY) / 2;
|
||||
|
||||
await renderAnnotations(
|
||||
this.compositeCtx,
|
||||
this.foregroundCtx,
|
||||
this.config.annotationRegions,
|
||||
this.config.width,
|
||||
this.config.height,
|
||||
@@ -409,6 +489,58 @@ export class FrameRenderer {
|
||||
scaleFactor,
|
||||
);
|
||||
}
|
||||
|
||||
// Apply 3D rotation to foreground only. Wallpaper (on compositeCanvas) is untouched.
|
||||
if (willRotate && this.threeDPass && this.foregroundCanvas && this.foregroundCtx) {
|
||||
const passCanvas = this.threeDPass.apply(this.foregroundCanvas, this.currentRotation3D);
|
||||
const w = this.foregroundCanvas.width;
|
||||
const h = this.foregroundCanvas.height;
|
||||
this.foregroundCtx.clearRect(0, 0, w, h);
|
||||
if (this.isLinux) {
|
||||
// drawImage(webglCanvas) is unreliable on Linux/Wayland — use readPixels.
|
||||
const pixels = this.threeDPass.readPixels();
|
||||
const imageData = this.foregroundCtx.createImageData(w, h);
|
||||
imageData.data.set(pixels);
|
||||
this.foregroundCtx.putImageData(imageData, 0, 0);
|
||||
} else {
|
||||
this.foregroundCtx.drawImage(passCanvas, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply shadow fresh on the rotated silhouette (flat path already baked it
|
||||
// in compositeWithShadows, so guard on willRotate to avoid doubling).
|
||||
// Same 3-layer filter chain as `main` — keeps the soft Gaussian intact.
|
||||
if (
|
||||
willRotate &&
|
||||
this.config.showShadow &&
|
||||
this.config.shadowIntensity > 0 &&
|
||||
this.shadowCanvas &&
|
||||
this.shadowCtx &&
|
||||
this.foregroundCanvas
|
||||
) {
|
||||
const shadowCtx = this.shadowCtx;
|
||||
const w = this.foregroundCanvas.width;
|
||||
const h = this.foregroundCanvas.height;
|
||||
shadowCtx.clearRect(0, 0, w, h);
|
||||
shadowCtx.save();
|
||||
const intensity = this.config.shadowIntensity;
|
||||
const baseBlur1 = 48 * intensity;
|
||||
const baseBlur2 = 16 * intensity;
|
||||
const baseBlur3 = 8 * intensity;
|
||||
const baseAlpha1 = 0.7 * intensity;
|
||||
const baseAlpha2 = 0.5 * intensity;
|
||||
const baseAlpha3 = 0.3 * intensity;
|
||||
const baseOffset = 12 * intensity;
|
||||
shadowCtx.filter = `drop-shadow(0 ${baseOffset}px ${baseBlur1}px rgba(0,0,0,${baseAlpha1})) drop-shadow(0 ${baseOffset / 3}px ${baseBlur2}px rgba(0,0,0,${baseAlpha2})) drop-shadow(0 ${baseOffset / 6}px ${baseBlur3}px rgba(0,0,0,${baseAlpha3}))`;
|
||||
shadowCtx.drawImage(this.foregroundCanvas, 0, 0, w, h);
|
||||
shadowCtx.restore();
|
||||
if (this.compositeCtx) {
|
||||
this.compositeCtx.drawImage(this.shadowCanvas, 0, 0);
|
||||
}
|
||||
} else if (this.compositeCtx && this.foregroundCanvas) {
|
||||
// Flat path or 3D-without-shadow: stamp foreground directly.
|
||||
this.compositeCtx.drawImage(this.foregroundCanvas, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
private updateLayout(webcamFrame?: VideoFrame | null): void {
|
||||
@@ -494,29 +626,28 @@ export class FrameRenderer {
|
||||
this.maskGraphics.roundRect(0, 0, screenRect.width, screenRect.height, scaledBorderRadius);
|
||||
this.maskGraphics.fill({ color: 0xffffff });
|
||||
|
||||
// Cache layout info
|
||||
// Cache layout info. baseOffset is the stage position of the FULL
|
||||
// (uncropped) video sprite's top-left — matches preview semantics so
|
||||
// downstream consumers (e.g. cursor highlight) can map normalized
|
||||
// recording-space coordinates to stage coordinates uniformly:
|
||||
// stagePos = baseOffset + (cx, cy) * (videoWidth, videoHeight) * baseScale
|
||||
this.layoutCache = {
|
||||
stageSize: { width, height },
|
||||
videoSize: { width: croppedVideoWidth, height: croppedVideoHeight },
|
||||
baseScale: scale,
|
||||
baseOffset: { x: compositeLayout.screenRect.x, y: compositeLayout.screenRect.y },
|
||||
baseOffset: {
|
||||
x: compositeLayout.screenRect.x + coverOffsetX - cropPixelX,
|
||||
y: compositeLayout.screenRect.y + coverOffsetY - cropPixelY,
|
||||
},
|
||||
maskRect: compositeLayout.screenRect,
|
||||
webcamRect: compositeLayout.webcamRect,
|
||||
};
|
||||
}
|
||||
|
||||
private clampFocusToStage(
|
||||
focus: { cx: number; cy: number },
|
||||
depth: ZoomDepth,
|
||||
): { cx: number; cy: number } {
|
||||
if (!this.layoutCache) return focus;
|
||||
return clampFocusToStageUtil(focus, depth, this.layoutCache.stageSize);
|
||||
}
|
||||
|
||||
private updateAnimationState(timeMs: number): number {
|
||||
if (!this.cameraContainer || !this.layoutCache) return 0;
|
||||
|
||||
const { region, strength, blendedScale, transition } = findDominantRegion(
|
||||
const { region, strength, blendedScale, rotation3D, transition } = findDominantRegion(
|
||||
this.config.zoomRegions,
|
||||
timeMs,
|
||||
{ connectZooms: true, cursorTelemetry: this.config.cursorTelemetry },
|
||||
@@ -527,9 +658,14 @@ export class FrameRenderer {
|
||||
let targetFocus = { ...defaultFocus };
|
||||
let targetProgress = 0;
|
||||
|
||||
this.currentRotation3D =
|
||||
region && strength > 0
|
||||
? lerpRotation3D(DEFAULT_ROTATION_3D, rotation3D, strength)
|
||||
: { ...DEFAULT_ROTATION_3D };
|
||||
|
||||
if (region && strength > 0) {
|
||||
const zoomScale = blendedScale ?? ZOOM_DEPTH_SCALES[region.depth];
|
||||
const regionFocus = this.clampFocusToStage(region.focus, region.depth);
|
||||
const zoomScale = blendedScale ?? getZoomScale(region);
|
||||
const regionFocus = clampFocusToScale(region.focus, zoomScale);
|
||||
|
||||
targetScaleFactor = zoomScale;
|
||||
targetFocus = regionFocus;
|
||||
@@ -699,38 +835,52 @@ export class FrameRenderer {
|
||||
return this.rasterCanvas;
|
||||
}
|
||||
|
||||
private compositeWithShadows(webcamFrame?: VideoFrame | null): void {
|
||||
if (!this.compositeCanvas || !this.compositeCtx || !this.app) return;
|
||||
// `applyShadowToRecording` is false when the 3D pass will rotate this canvas
|
||||
// next — the shadow gets re-applied after rotation to avoid aliasing.
|
||||
private compositeWithShadows(
|
||||
webcamFrame: VideoFrame | null | undefined,
|
||||
applyShadowToRecording: boolean,
|
||||
): void {
|
||||
if (
|
||||
!this.compositeCanvas ||
|
||||
!this.compositeCtx ||
|
||||
!this.foregroundCanvas ||
|
||||
!this.foregroundCtx ||
|
||||
!this.app
|
||||
)
|
||||
return;
|
||||
|
||||
const videoCanvas = this.isLinux
|
||||
? this.readbackVideoCanvas()
|
||||
: (this.app.canvas as HTMLCanvasElement);
|
||||
|
||||
const ctx = this.compositeCtx;
|
||||
const bgCtx = this.compositeCtx;
|
||||
const fgCtx = this.foregroundCtx;
|
||||
const w = this.compositeCanvas.width;
|
||||
const h = this.compositeCanvas.height;
|
||||
|
||||
// Clear composite canvas
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
// Step 1: Draw background layer (with optional blur, not affected by zoom)
|
||||
// Background layer (compositeCanvas): wallpaper only. Stays flat — never
|
||||
// touched by the 3D rotation pass, matching preview behavior.
|
||||
bgCtx.clearRect(0, 0, w, h);
|
||||
if (this.backgroundSprite) {
|
||||
const bgCanvas = this.backgroundSprite;
|
||||
|
||||
if (this.config.showBlur) {
|
||||
ctx.save();
|
||||
ctx.filter = "blur(6px)"; // Canvas blur is weaker than CSS
|
||||
ctx.drawImage(bgCanvas, 0, 0, w, h);
|
||||
ctx.restore();
|
||||
bgCtx.save();
|
||||
bgCtx.filter = "blur(6px)"; // Canvas blur is weaker than CSS
|
||||
bgCtx.drawImage(bgCanvas, 0, 0, w, h);
|
||||
bgCtx.restore();
|
||||
} else {
|
||||
ctx.drawImage(bgCanvas, 0, 0, w, h);
|
||||
bgCtx.drawImage(bgCanvas, 0, 0, w, h);
|
||||
}
|
||||
} else {
|
||||
console.warn("[FrameRenderer] No background sprite found during compositing!");
|
||||
}
|
||||
|
||||
// Draw video layer with shadows on top of background
|
||||
// Foreground (transparent): recording + webcam. Shadow only baked here on
|
||||
// the flat path; the 3D path applies it after rotation (see renderFrame).
|
||||
fgCtx.clearRect(0, 0, w, h);
|
||||
if (
|
||||
applyShadowToRecording &&
|
||||
this.config.showShadow &&
|
||||
this.config.shadowIntensity > 0 &&
|
||||
this.shadowCanvas &&
|
||||
@@ -740,7 +890,6 @@ export class FrameRenderer {
|
||||
shadowCtx.clearRect(0, 0, w, h);
|
||||
shadowCtx.save();
|
||||
|
||||
// Calculate shadow parameters based on intensity (0-1)
|
||||
const intensity = this.config.shadowIntensity;
|
||||
const baseBlur1 = 48 * intensity;
|
||||
const baseBlur2 = 16 * intensity;
|
||||
@@ -753,9 +902,9 @@ export class FrameRenderer {
|
||||
shadowCtx.filter = `drop-shadow(0 ${baseOffset}px ${baseBlur1}px rgba(0,0,0,${baseAlpha1})) drop-shadow(0 ${baseOffset / 3}px ${baseBlur2}px rgba(0,0,0,${baseAlpha2})) drop-shadow(0 ${baseOffset / 6}px ${baseBlur3}px rgba(0,0,0,${baseAlpha3}))`;
|
||||
shadowCtx.drawImage(videoCanvas, 0, 0, w, h);
|
||||
shadowCtx.restore();
|
||||
ctx.drawImage(this.shadowCanvas, 0, 0, w, h);
|
||||
fgCtx.drawImage(this.shadowCanvas, 0, 0, w, h);
|
||||
} else {
|
||||
ctx.drawImage(videoCanvas, 0, 0, w, h);
|
||||
fgCtx.drawImage(videoCanvas, 0, 0, w, h);
|
||||
}
|
||||
|
||||
const webcamRect = this.layoutCache?.webcamRect ?? null;
|
||||
@@ -778,9 +927,9 @@ export class FrameRenderer {
|
||||
sourceAspect > targetAspect ? sourceHeight : Math.round(sourceWidth / targetAspect);
|
||||
const sourceCropX = Math.max(0, Math.round((sourceWidth - sourceCropWidth) / 2));
|
||||
const sourceCropY = Math.max(0, Math.round((sourceHeight - sourceCropHeight) / 2));
|
||||
ctx.save();
|
||||
fgCtx.save();
|
||||
drawCanvasClipPath(
|
||||
ctx,
|
||||
fgCtx,
|
||||
webcamRect.x,
|
||||
webcamRect.y,
|
||||
webcamRect.width,
|
||||
@@ -789,15 +938,15 @@ export class FrameRenderer {
|
||||
webcamRect.borderRadius,
|
||||
);
|
||||
if (preset.shadow) {
|
||||
ctx.shadowColor = preset.shadow.color;
|
||||
ctx.shadowBlur = preset.shadow.blur;
|
||||
ctx.shadowOffsetX = preset.shadow.offsetX;
|
||||
ctx.shadowOffsetY = preset.shadow.offsetY;
|
||||
fgCtx.shadowColor = preset.shadow.color;
|
||||
fgCtx.shadowBlur = preset.shadow.blur;
|
||||
fgCtx.shadowOffsetX = preset.shadow.offsetX;
|
||||
fgCtx.shadowOffsetY = preset.shadow.offsetY;
|
||||
}
|
||||
ctx.fillStyle = "#000000";
|
||||
ctx.fill();
|
||||
ctx.clip();
|
||||
ctx.drawImage(
|
||||
fgCtx.fillStyle = "#000000";
|
||||
fgCtx.fill();
|
||||
fgCtx.clip();
|
||||
fgCtx.drawImage(
|
||||
webcamFrame as unknown as CanvasImageSource,
|
||||
sourceCropX,
|
||||
sourceCropY,
|
||||
@@ -808,7 +957,7 @@ export class FrameRenderer {
|
||||
webcamRect.width,
|
||||
webcamRect.height,
|
||||
);
|
||||
ctx.restore();
|
||||
fgCtx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -842,7 +991,13 @@ export class FrameRenderer {
|
||||
this.shadowCtx = null;
|
||||
this.compositeCanvas = null;
|
||||
this.compositeCtx = null;
|
||||
this.foregroundCanvas = null;
|
||||
this.foregroundCtx = null;
|
||||
this.rasterCanvas = null;
|
||||
this.rasterCtx = null;
|
||||
if (this.threeDPass) {
|
||||
this.threeDPass.destroy();
|
||||
this.threeDPass = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,8 @@ interface GifExporterConfig {
|
||||
previewWidth?: number;
|
||||
previewHeight?: number;
|
||||
cursorTelemetry?: import("@/components/video-editor/types").CursorTelemetryPoint[];
|
||||
cursorHighlight?: import("@/components/video-editor/videoPlayback/cursorHighlight").CursorHighlightConfig;
|
||||
cursorClickTimestamps?: number[];
|
||||
onProgress?: (progress: ExportProgress) => void;
|
||||
}
|
||||
|
||||
@@ -161,6 +163,8 @@ export class GifExporter {
|
||||
previewWidth: this.config.previewWidth,
|
||||
previewHeight: this.config.previewHeight,
|
||||
cursorTelemetry: this.config.cursorTelemetry,
|
||||
cursorClickTimestamps: this.config.cursorClickTimestamps,
|
||||
cursorHighlight: this.config.cursorHighlight,
|
||||
platform,
|
||||
});
|
||||
await this.renderer.initialize();
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
import type { Rotation3D } from "@/components/video-editor/types";
|
||||
import {
|
||||
computeRotation3DContainScale,
|
||||
isRotation3DIdentity,
|
||||
rotation3DPerspective,
|
||||
} from "@/components/video-editor/types";
|
||||
|
||||
// CSS uses +y down, WebGL clip space uses +y up. We do all rotation math in CSS
|
||||
// convention (top-left origin, +y down) to match the preview, then flip
|
||||
// gl_Position.y at the end so WebGL's clip space lands the input's top edge at
|
||||
// the top of the output viewport.
|
||||
const VERTEX_SHADER = `#version 300 es
|
||||
in vec2 aPos;
|
||||
in vec2 aUV;
|
||||
out vec2 vUV;
|
||||
uniform mat4 uMvp;
|
||||
uniform vec2 uSize;
|
||||
void main() {
|
||||
vUV = aUV;
|
||||
vec2 px = (aPos - 0.5) * uSize;
|
||||
vec4 clip = uMvp * vec4(px, 0.0, 1.0);
|
||||
clip.y = -clip.y;
|
||||
gl_Position = clip;
|
||||
}
|
||||
`;
|
||||
|
||||
const FRAGMENT_SHADER = `#version 300 es
|
||||
precision highp float;
|
||||
in vec2 vUV;
|
||||
out vec4 fragColor;
|
||||
uniform sampler2D uTex;
|
||||
void main() {
|
||||
fragColor = texture(uTex, vUV);
|
||||
}
|
||||
`;
|
||||
|
||||
function deg2rad(deg: number): number {
|
||||
return (deg * Math.PI) / 180;
|
||||
}
|
||||
|
||||
function multiplyMat4(a: Float32Array, b: Float32Array): Float32Array {
|
||||
const out = new Float32Array(16);
|
||||
for (let i = 0; i < 4; i += 1) {
|
||||
for (let j = 0; j < 4; j += 1) {
|
||||
let s = 0;
|
||||
for (let k = 0; k < 4; k += 1) {
|
||||
s += a[k * 4 + j] * b[i * 4 + k];
|
||||
}
|
||||
out[i * 4 + j] = s;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function rotationXMat(rad: number): Float32Array {
|
||||
const c = Math.cos(rad);
|
||||
const s = Math.sin(rad);
|
||||
return new Float32Array([1, 0, 0, 0, 0, c, s, 0, 0, -s, c, 0, 0, 0, 0, 1]);
|
||||
}
|
||||
|
||||
function rotationYMat(rad: number): Float32Array {
|
||||
const c = Math.cos(rad);
|
||||
const s = Math.sin(rad);
|
||||
return new Float32Array([c, 0, -s, 0, 0, 1, 0, 0, s, 0, c, 0, 0, 0, 0, 1]);
|
||||
}
|
||||
|
||||
function rotationZMat(rad: number): Float32Array {
|
||||
const c = Math.cos(rad);
|
||||
const s = Math.sin(rad);
|
||||
return new Float32Array([c, s, 0, 0, -s, c, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]);
|
||||
}
|
||||
|
||||
function translationMat(x: number, y: number, z: number): Float32Array {
|
||||
return new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, x, y, z, 1]);
|
||||
}
|
||||
|
||||
function perspectiveMat(fovY: number, aspect: number, near: number, far: number): Float32Array {
|
||||
const f = 1 / Math.tan(fovY / 2);
|
||||
const nf = 1 / (near - far);
|
||||
return new Float32Array([
|
||||
f / aspect,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
f,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
(far + near) * nf,
|
||||
-1,
|
||||
0,
|
||||
0,
|
||||
2 * far * near * nf,
|
||||
0,
|
||||
]);
|
||||
}
|
||||
|
||||
function scaleMat(s: number): Float32Array {
|
||||
return new Float32Array([s, 0, 0, 0, 0, s, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]);
|
||||
}
|
||||
|
||||
export function buildMvpMatrix(rot: Rotation3D, w: number, h: number): Float32Array {
|
||||
const rx = rotationXMat(deg2rad(rot.rotationX));
|
||||
const ry = rotationYMat(deg2rad(rot.rotationY));
|
||||
const rz = rotationZMat(deg2rad(rot.rotationZ));
|
||||
const rotMat = multiplyMat4(rz, multiplyMat4(ry, rx));
|
||||
|
||||
const perspective = rotation3DPerspective(w, h);
|
||||
const containScale = computeRotation3DContainScale(rot, w, h, perspective);
|
||||
const rotScaled = multiplyMat4(rotMat, scaleMat(containScale));
|
||||
|
||||
const d = perspective;
|
||||
const fovY = 2 * Math.atan2(h / 2, d);
|
||||
const proj = perspectiveMat(fovY, w / h, 0.1, d * 4 + Math.max(w, h));
|
||||
const view = translationMat(0, 0, -d);
|
||||
return multiplyMat4(proj, multiplyMat4(view, rotScaled));
|
||||
}
|
||||
|
||||
function compileShader(gl: WebGL2RenderingContext, type: number, source: string): WebGLShader {
|
||||
const shader = gl.createShader(type);
|
||||
if (!shader) throw new Error("Failed to create shader");
|
||||
gl.shaderSource(shader, source);
|
||||
gl.compileShader(shader);
|
||||
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
|
||||
const info = gl.getShaderInfoLog(shader);
|
||||
gl.deleteShader(shader);
|
||||
throw new Error(`Shader compile failed: ${info}`);
|
||||
}
|
||||
return shader;
|
||||
}
|
||||
|
||||
function createProgram(gl: WebGL2RenderingContext): WebGLProgram {
|
||||
const vs = compileShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER);
|
||||
const fs = compileShader(gl, gl.FRAGMENT_SHADER, FRAGMENT_SHADER);
|
||||
const program = gl.createProgram();
|
||||
if (!program) throw new Error("Failed to create program");
|
||||
gl.attachShader(program, vs);
|
||||
gl.attachShader(program, fs);
|
||||
gl.linkProgram(program);
|
||||
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
|
||||
const info = gl.getProgramInfoLog(program);
|
||||
gl.deleteProgram(program);
|
||||
throw new Error(`Program link failed: ${info}`);
|
||||
}
|
||||
gl.deleteShader(vs);
|
||||
gl.deleteShader(fs);
|
||||
return program;
|
||||
}
|
||||
|
||||
export interface ThreeDPass {
|
||||
apply(srcCanvas: HTMLCanvasElement | OffscreenCanvas, rot: Rotation3D): HTMLCanvasElement;
|
||||
/**
|
||||
* Reads back the most recent apply() result into a Uint8ClampedArray suitable
|
||||
* for ImageData. Use this on platforms where drawImage(webglCanvas) is unreliable.
|
||||
*/
|
||||
readPixels(): Uint8ClampedArray;
|
||||
resize(width: number, height: number): void;
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
export function createThreeDPass(width: number, height: number): ThreeDPass {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const gl = canvas.getContext("webgl2", { premultipliedAlpha: true, alpha: true });
|
||||
if (!gl) throw new Error("WebGL2 not available for 3D pass");
|
||||
|
||||
const program = createProgram(gl);
|
||||
// biome-ignore lint/correctness/useHookAtTopLevel: WebGL API, not a React hook
|
||||
gl.useProgram(program);
|
||||
|
||||
const aPos = gl.getAttribLocation(program, "aPos");
|
||||
const aUV = gl.getAttribLocation(program, "aUV");
|
||||
const uMvp = gl.getUniformLocation(program, "uMvp");
|
||||
const uSize = gl.getUniformLocation(program, "uSize");
|
||||
const uTex = gl.getUniformLocation(program, "uTex");
|
||||
|
||||
const vao = gl.createVertexArray();
|
||||
gl.bindVertexArray(vao);
|
||||
|
||||
// Quad: two triangles sharing UVs consistently per corner.
|
||||
// pos.y ranges 0 (top of input) → 1 (bottom of input) following CSS convention.
|
||||
// UV.y is inverted (1 - pos.y) so that with UNPACK_FLIP_Y_WEBGL the texture
|
||||
// sample at the top of the input lands at the top of the rendered quad.
|
||||
// TL: pos(0,0) uv(0,1) TR: pos(1,0) uv(1,1)
|
||||
// BL: pos(0,1) uv(0,0) BR: pos(1,1) uv(1,0)
|
||||
const verts = new Float32Array([
|
||||
// aPos.x, aPos.y, aUV.x, aUV.y
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1, // TL
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
1, // TR
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0, // BL
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0, // BL
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
1, // TR (was 1,0,1,0 — broken)
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
0, // BR
|
||||
]);
|
||||
const vbo = gl.createBuffer();
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, verts, gl.STATIC_DRAW);
|
||||
gl.enableVertexAttribArray(aPos);
|
||||
gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 16, 0);
|
||||
gl.enableVertexAttribArray(aUV);
|
||||
gl.vertexAttribPointer(aUV, 2, gl.FLOAT, false, 16, 8);
|
||||
|
||||
const texture = gl.createTexture();
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||
// Plain bilinear, NO mipmaps. Mipmaps pre-blur the texture for downsampling, but
|
||||
// at our moderate rotation angles (≤22°) the receding edge would still pick a
|
||||
// smaller mipmap level, which softens fine details — specifically the few-pixel
|
||||
// rounded-corner anti-alias ramp and the shadow's Gaussian falloff. The result
|
||||
// is "rounding looks like a hard corner / shadow looks grimy". Sampling level 0
|
||||
// directly preserves the source crispness.
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
||||
|
||||
// Anisotropic filtering still helps without mipmaps: at oblique viewing angles
|
||||
// it samples multiple texels along the gradient direction at level 0, recovering
|
||||
// detail that plain bilinear would lose. Cap to the device max (16× typical).
|
||||
const anisoExt =
|
||||
gl.getExtension("EXT_texture_filter_anisotropic") ||
|
||||
gl.getExtension("MOZ_EXT_texture_filter_anisotropic") ||
|
||||
gl.getExtension("WEBKIT_EXT_texture_filter_anisotropic");
|
||||
if (anisoExt) {
|
||||
const maxAniso = gl.getParameter(anisoExt.MAX_TEXTURE_MAX_ANISOTROPY_EXT) as number;
|
||||
gl.texParameterf(gl.TEXTURE_2D, anisoExt.TEXTURE_MAX_ANISOTROPY_EXT, Math.min(16, maxAniso));
|
||||
}
|
||||
gl.uniform1i(uTex, 0);
|
||||
|
||||
let currentSize = { width, height };
|
||||
|
||||
const apply = (
|
||||
srcCanvas: HTMLCanvasElement | OffscreenCanvas,
|
||||
rot: Rotation3D,
|
||||
): HTMLCanvasElement => {
|
||||
gl.viewport(0, 0, currentSize.width, currentSize.height);
|
||||
gl.clearColor(0, 0, 0, 0);
|
||||
gl.clear(gl.COLOR_BUFFER_BIT);
|
||||
gl.useProgram(program);
|
||||
gl.bindVertexArray(vao);
|
||||
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
|
||||
// CRITICAL: premultiply on upload. The source 2D canvas stores non-premultiplied
|
||||
// RGBA (alpha=0 areas have RGB=0). Bilinear filtering between an inside-the-shape
|
||||
// texel (alpha=1, RGB=color) and an outside texel (alpha=0, RGB=0) in
|
||||
// non-premultiplied space yields (color/2, alpha=0.5), which the
|
||||
// premultipliedAlpha:true canvas then interprets as half-strength color — visible
|
||||
// as a dark halo around rounded corners and softened/grimy shadows. Premultiplying
|
||||
// at upload time makes the bilinear math operate in linear-light premultiplied
|
||||
// space, which is exactly the math used for compositing. Edges and shadows then
|
||||
// reproduce the source crisply.
|
||||
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true);
|
||||
gl.texImage2D(
|
||||
gl.TEXTURE_2D,
|
||||
0,
|
||||
gl.RGBA,
|
||||
gl.RGBA,
|
||||
gl.UNSIGNED_BYTE,
|
||||
srcCanvas as TexImageSource,
|
||||
);
|
||||
|
||||
const mvp = isRotation3DIdentity(rot)
|
||||
? buildMvpMatrix(
|
||||
{ rotationX: 0, rotationY: 0, rotationZ: 0 },
|
||||
currentSize.width,
|
||||
currentSize.height,
|
||||
)
|
||||
: buildMvpMatrix(rot, currentSize.width, currentSize.height);
|
||||
gl.uniformMatrix4fv(uMvp, false, mvp);
|
||||
gl.uniform2f(uSize, currentSize.width, currentSize.height);
|
||||
|
||||
gl.drawArrays(gl.TRIANGLES, 0, 6);
|
||||
return canvas;
|
||||
};
|
||||
|
||||
const resize = (w: number, h: number) => {
|
||||
if (w === currentSize.width && h === currentSize.height) return;
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
currentSize = { width: w, height: h };
|
||||
};
|
||||
|
||||
const readPixels = (): Uint8ClampedArray => {
|
||||
const w = currentSize.width;
|
||||
const h = currentSize.height;
|
||||
const buf = new Uint8Array(w * h * 4);
|
||||
gl.readPixels(0, 0, w, h, gl.RGBA, gl.UNSIGNED_BYTE, buf);
|
||||
// gl.readPixels is bottom-up; flip to top-down for ImageData. We also need
|
||||
// to un-premultiply the alpha here: the framebuffer holds premultiplied RGBA
|
||||
// (we set UNPACK_PREMULTIPLY_ALPHA_WEBGL=true on upload), but ImageData /
|
||||
// putImageData expect non-premultiplied. Without this divide, semi-transparent
|
||||
// pixels get interpreted as darker than they should be.
|
||||
const rowSize = w * 4;
|
||||
const out = new Uint8ClampedArray(buf.length);
|
||||
for (let row = 0; row < h; row += 1) {
|
||||
const src = (h - 1 - row) * rowSize;
|
||||
const dst = row * rowSize;
|
||||
for (let col = 0; col < rowSize; col += 4) {
|
||||
const r = buf[src + col];
|
||||
const g = buf[src + col + 1];
|
||||
const b = buf[src + col + 2];
|
||||
const a = buf[src + col + 3];
|
||||
if (a === 0) {
|
||||
out[dst + col] = 0;
|
||||
out[dst + col + 1] = 0;
|
||||
out[dst + col + 2] = 0;
|
||||
out[dst + col + 3] = 0;
|
||||
} else if (a === 255) {
|
||||
out[dst + col] = r;
|
||||
out[dst + col + 1] = g;
|
||||
out[dst + col + 2] = b;
|
||||
out[dst + col + 3] = 255;
|
||||
} else {
|
||||
const inv = 255 / a;
|
||||
out[dst + col] = Math.min(255, Math.round(r * inv));
|
||||
out[dst + col + 1] = Math.min(255, Math.round(g * inv));
|
||||
out[dst + col + 2] = Math.min(255, Math.round(b * inv));
|
||||
out[dst + col + 3] = a;
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const destroy = () => {
|
||||
gl.deleteProgram(program);
|
||||
gl.deleteBuffer(vbo);
|
||||
gl.deleteVertexArray(vao);
|
||||
gl.deleteTexture(texture);
|
||||
};
|
||||
|
||||
return { apply, readPixels, resize, destroy };
|
||||
}
|
||||
@@ -42,6 +42,8 @@ interface VideoExporterConfig extends ExportConfig {
|
||||
previewWidth?: number;
|
||||
previewHeight?: number;
|
||||
cursorTelemetry?: import("@/components/video-editor/types").CursorTelemetryPoint[];
|
||||
cursorHighlight?: import("@/components/video-editor/videoPlayback/cursorHighlight").CursorHighlightConfig;
|
||||
cursorClickTimestamps?: number[];
|
||||
onProgress?: (progress: ExportProgress) => void;
|
||||
}
|
||||
|
||||
@@ -156,6 +158,8 @@ export class VideoExporter {
|
||||
previewWidth: this.config.previewWidth,
|
||||
previewHeight: this.config.previewHeight,
|
||||
cursorTelemetry: this.config.cursorTelemetry,
|
||||
cursorClickTimestamps: this.config.cursorClickTimestamps,
|
||||
cursorHighlight: this.config.cursorHighlight,
|
||||
platform,
|
||||
});
|
||||
this.renderer = renderer;
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parentDirectoryOf } from "./userPreferences";
|
||||
|
||||
describe("parentDirectoryOf", () => {
|
||||
it("returns the directory for a POSIX path", () => {
|
||||
expect(parentDirectoryOf("/Users/me/Movies/clip.mp4")).toBe("/Users/me/Movies");
|
||||
});
|
||||
|
||||
it("returns the directory for a Windows path", () => {
|
||||
expect(parentDirectoryOf("C:\\Users\\me\\Movies\\clip.mp4")).toBe("C:\\Users\\me\\Movies");
|
||||
});
|
||||
|
||||
it("preserves the POSIX root when the file is at /", () => {
|
||||
expect(parentDirectoryOf("/video.mp4")).toBe("/");
|
||||
});
|
||||
|
||||
it("preserves the Windows drive root with its trailing separator", () => {
|
||||
expect(parentDirectoryOf("C:\\video.mp4")).toBe("C:\\");
|
||||
expect(parentDirectoryOf("D:/video.mp4")).toBe("D:/");
|
||||
});
|
||||
|
||||
it("returns null when no separator is present", () => {
|
||||
expect(parentDirectoryOf("video.mp4")).toBeNull();
|
||||
expect(parentDirectoryOf("")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,8 @@ export interface UserPreferences {
|
||||
exportQuality: ExportQuality;
|
||||
/** Default export format */
|
||||
exportFormat: ExportFormat;
|
||||
/** Folder used for the most recent successful export, if any */
|
||||
exportFolder: string | null;
|
||||
}
|
||||
|
||||
const DEFAULT_PREFS: UserPreferences = {
|
||||
@@ -30,6 +32,7 @@ const DEFAULT_PREFS: UserPreferences = {
|
||||
aspectRatio: "16:9",
|
||||
exportQuality: "good",
|
||||
exportFormat: "mp4",
|
||||
exportFolder: null,
|
||||
};
|
||||
|
||||
function safeJsonParse(text: string | null): Record<string, unknown> | null {
|
||||
@@ -76,9 +79,47 @@ export function loadUserPreferences(): UserPreferences {
|
||||
raw.exportFormat === "gif" || raw.exportFormat === "mp4"
|
||||
? (raw.exportFormat as ExportFormat)
|
||||
: DEFAULT_PREFS.exportFormat,
|
||||
exportFolder:
|
||||
typeof raw.exportFolder === "string" && raw.exportFolder.length > 0
|
||||
? raw.exportFolder
|
||||
: DEFAULT_PREFS.exportFolder,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the parent directory from a saved file path. Handles both POSIX
|
||||
* and Windows separators since the path comes from the OS save dialog.
|
||||
*
|
||||
* Root directories are preserved with their trailing separator so that the
|
||||
* value is still a valid directory path:
|
||||
* "/video.mp4" -> "/"
|
||||
* "C:\\video.mp4" -> "C:\\"
|
||||
*
|
||||
* Returns null if no separator is found.
|
||||
*/
|
||||
export function parentDirectoryOf(filePath: string): string | null {
|
||||
const lastSep = Math.max(filePath.lastIndexOf("/"), filePath.lastIndexOf("\\"));
|
||||
if (lastSep < 0) return null;
|
||||
|
||||
// POSIX root, e.g. "/video.mp4" -> "/"
|
||||
if (lastSep === 0) return filePath[0];
|
||||
|
||||
// Windows drive root, e.g. "C:\\video.mp4" -> "C:\\"
|
||||
if (lastSep === 2 && /^[A-Za-z]:[/\\]/.test(filePath)) {
|
||||
return filePath.slice(0, lastSep + 1);
|
||||
}
|
||||
|
||||
return filePath.slice(0, lastSep);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the remembered export folder as `string | undefined`, suitable for
|
||||
* passing directly to IPC handlers that treat absence as "use the default".
|
||||
*/
|
||||
export function getExportFolder(): string | undefined {
|
||||
return loadUserPreferences().exportFolder ?? undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist user preferences to localStorage.
|
||||
* Only the explicitly provided fields are updated.
|
||||
|
||||
@@ -6,6 +6,7 @@ export default defineConfig({
|
||||
globals: true,
|
||||
environment: "jsdom",
|
||||
include: ["src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"],
|
||||
exclude: ["src/**/*.browser.test.{ts,tsx}"],
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
|
||||
Reference in New Issue
Block a user