mirror of
https://github.com/siddharthvaddem/openscreen.git
synced 2026-08-31 02:04:58 +08:00
Merge upstream main into webcam mirror toggle
This commit is contained in:
@@ -46,6 +46,7 @@ release/**
|
||||
test-results
|
||||
playwright-report/
|
||||
|
||||
|
||||
# Vitest browser mode screenshots
|
||||
__screenshots__/
|
||||
|
||||
@@ -58,3 +59,7 @@ result-*
|
||||
|
||||
#kilocode
|
||||
.kilo/
|
||||
|
||||
#others
|
||||
|
||||
**/*.import
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
# Contribution Guidelines
|
||||
# Contribution Guidelines
|
||||
|
||||
Thank you for considering contributing to this project! By contributing, you help make this project better for everyone. Please take a moment to review these guidelines to ensure a smooth contribution process.
|
||||
|
||||
|
||||
Vendored
+23
@@ -81,6 +81,12 @@ interface Window {
|
||||
message?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
openRecordingStream: (fileName: string) => Promise<{ success: boolean; error?: string }>;
|
||||
appendRecordingChunk: (
|
||||
fileName: string,
|
||||
chunk: ArrayBuffer,
|
||||
) => Promise<{ success: boolean; error?: string }>;
|
||||
closeRecordingStream: (fileName: string) => Promise<{ success: boolean; error?: string }>;
|
||||
getRecordedVideoPath: () => Promise<{
|
||||
success: boolean;
|
||||
path?: string;
|
||||
@@ -239,6 +245,17 @@ interface Window {
|
||||
canceled?: boolean;
|
||||
error?: string;
|
||||
}>;
|
||||
getPathForFile: (file: File) => string;
|
||||
loadProjectFileFromPath: (filePath: string) => Promise<{
|
||||
success: boolean;
|
||||
path?: string;
|
||||
project?: unknown;
|
||||
message?: string;
|
||||
canceled?: boolean;
|
||||
error?: string;
|
||||
}>;
|
||||
onMenuNewProject: (callback: () => void) => () => void;
|
||||
onMenuImportVideo: (callback: () => void) => () => void;
|
||||
onMenuLoadProject: (callback: () => void) => () => void;
|
||||
onMenuSaveProject: (callback: () => void) => () => void;
|
||||
onMenuSaveProjectAs: (callback: () => void) => () => void;
|
||||
@@ -248,6 +265,12 @@ interface Window {
|
||||
) => Promise<{ success: boolean; error?: string; message?: string }>;
|
||||
getShortcuts: () => Promise<Record<string, unknown> | null>;
|
||||
saveShortcuts: (shortcuts: unknown) => Promise<{ success: boolean; error?: string }>;
|
||||
updateGlobalShortcut: (binding: {
|
||||
key: string;
|
||||
ctrl?: boolean;
|
||||
shift?: boolean;
|
||||
alt?: boolean;
|
||||
}) => Promise<{ success: boolean }>;
|
||||
hudOverlayHide: () => void;
|
||||
hudOverlayClose: () => void;
|
||||
setHudOverlayIgnoreMouseEvents: (ignore: boolean) => void;
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import fs from "node:fs/promises";
|
||||
import { globalShortcut } from "electron";
|
||||
import { type ShortcutBinding } from "../src/lib/shortcuts";
|
||||
import { SHORTCUTS_FILE } from "./ipc/handlers";
|
||||
|
||||
const DEFAULT_OPEN_APP_BINDING: ShortcutBinding = { key: "o", ctrl: true, shift: true };
|
||||
|
||||
// Maps KeyboardEvent.key values to Electron accelerator key names
|
||||
const KEY_TO_ACCELERATOR: Record<string, string> = {
|
||||
" ": "Space",
|
||||
"+": "Plus",
|
||||
"-": "numsub",
|
||||
"*": "nummult",
|
||||
"/": "numdiv",
|
||||
arrowup: "Up",
|
||||
arrowdown: "Down",
|
||||
arrowleft: "Left",
|
||||
arrowright: "Right",
|
||||
escape: "Escape",
|
||||
enter: "Return",
|
||||
backspace: "Backspace",
|
||||
delete: "Delete",
|
||||
tab: "Tab",
|
||||
};
|
||||
|
||||
function bindingToAccelerator(binding: ShortcutBinding): string {
|
||||
const parts: string[] = [];
|
||||
if (binding.ctrl) parts.push("CommandOrControl");
|
||||
if (binding.shift) parts.push("Shift");
|
||||
if (binding.alt) parts.push("Alt");
|
||||
|
||||
const keyLower = binding.key.toLowerCase();
|
||||
const acceleratorKey = KEY_TO_ACCELERATOR[keyLower] ?? binding.key.toUpperCase();
|
||||
parts.push(acceleratorKey);
|
||||
|
||||
return parts.join("+");
|
||||
}
|
||||
|
||||
let currentAccelerator: string | null = null;
|
||||
|
||||
export function registerOpenAppShortcut(binding: ShortcutBinding, onTrigger: () => void): boolean {
|
||||
const accelerator = bindingToAccelerator(binding);
|
||||
|
||||
// Same shortcut already registered, nothing to do
|
||||
if (accelerator === currentAccelerator) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Try to register new shortcut first (before unregistering old one)
|
||||
const success = globalShortcut.register(accelerator, onTrigger);
|
||||
|
||||
if (success) {
|
||||
// Only unregister old shortcut after new one succeeds
|
||||
if (currentAccelerator) {
|
||||
globalShortcut.unregister(currentAccelerator);
|
||||
}
|
||||
currentAccelerator = accelerator;
|
||||
console.log(`Global shortcut registered: ${accelerator}`);
|
||||
} else {
|
||||
console.warn(`Failed to register global shortcut: ${accelerator}`);
|
||||
}
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
export async function loadAndRegisterGlobalShortcut(onTrigger: () => void): Promise<void> {
|
||||
try {
|
||||
const data = await fs.readFile(SHORTCUTS_FILE, "utf-8");
|
||||
const shortcuts = JSON.parse(data);
|
||||
const binding = shortcuts.openApp || DEFAULT_OPEN_APP_BINDING;
|
||||
registerOpenAppShortcut(binding, onTrigger);
|
||||
} catch {
|
||||
registerOpenAppShortcut(DEFAULT_OPEN_APP_BINDING, onTrigger);
|
||||
}
|
||||
}
|
||||
|
||||
export function unregisterAllGlobalShortcuts(): void {
|
||||
globalShortcut.unregisterAll();
|
||||
}
|
||||
+132
-14
@@ -40,13 +40,25 @@ import { RECORDINGS_DIR } from "../main";
|
||||
import { createCursorRecordingSession } from "../native-bridge/cursor/recording/factory";
|
||||
import { requestMacCursorAccessibilityAccess } from "../native-bridge/cursor/recording/macNativeCursorRecordingSession";
|
||||
import type { CursorRecordingSession } from "../native-bridge/cursor/recording/session";
|
||||
import { patchWebmDurationOnDisk } from "../recording/webm-duration";
|
||||
import { registerNativeBridgeHandlers } from "./nativeBridge";
|
||||
import { RecordingStreamRegistry, registerRecordingStreamHandlers } from "./recordingStream";
|
||||
|
||||
const PROJECT_FILE_EXTENSION = "openscreen";
|
||||
const SHORTCUTS_FILE = path.join(app.getPath("userData"), "shortcuts.json");
|
||||
export const SHORTCUTS_FILE = path.join(app.getPath("userData"), "shortcuts.json");
|
||||
const RECORDING_FILE_PREFIX = "recording-";
|
||||
const RECORDING_SESSION_SUFFIX = ".session.json";
|
||||
const ALLOWED_IMPORT_VIDEO_EXTENSIONS = new Set([".webm", ".mp4", ".mov", ".avi", ".mkv"]);
|
||||
const ALLOWED_IMPORT_VIDEO_EXTENSIONS = new Set([
|
||||
".webm",
|
||||
".mp4",
|
||||
".mov",
|
||||
".avi",
|
||||
".mkv",
|
||||
".m4v",
|
||||
".wmv",
|
||||
".flv",
|
||||
".ts",
|
||||
]);
|
||||
const PREVIEW_AUDIO_DIR = path.join(app.getPath("userData"), "preview-audio");
|
||||
const nativeMacCaptureEvents = new EventEmitter();
|
||||
|
||||
@@ -265,6 +277,30 @@ function resolveRecordingOutputPath(fileName: string): string {
|
||||
return path.join(RECORDINGS_DIR, parsedPath.base);
|
||||
}
|
||||
|
||||
function isValidDurationMs(value: number | undefined): value is number {
|
||||
return typeof value === "number" && Number.isFinite(value) && value > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalize a single recording file: if it was streamed to disk, flush and close
|
||||
* the stream; otherwise (a short recording, or the stream failed to open and the
|
||||
* renderer fell back to in-memory buffering) write the buffered bytes. Returns
|
||||
* whether the file was streamed, which the caller uses to decide whether the
|
||||
* WebM duration needs patching on disk.
|
||||
*/
|
||||
async function finalizeRecordingFile(
|
||||
registry: RecordingStreamRegistry,
|
||||
fileName: string,
|
||||
filePath: string,
|
||||
videoData?: ArrayBuffer,
|
||||
): Promise<boolean> {
|
||||
const streamed = await registry.finalize(fileName);
|
||||
if (!streamed && videoData && videoData.byteLength > 0) {
|
||||
await fs.writeFile(filePath, Buffer.from(videoData));
|
||||
}
|
||||
return streamed;
|
||||
}
|
||||
|
||||
async function getApprovedProjectSession(
|
||||
project: unknown,
|
||||
projectFilePath?: string,
|
||||
@@ -1404,10 +1440,10 @@ export function registerIpcHandlers(
|
||||
});
|
||||
|
||||
ipcMain.handle("switch-to-editor", () => {
|
||||
const mainWin = getMainWindow();
|
||||
if (mainWin) {
|
||||
mainWin.close();
|
||||
}
|
||||
// createEditorWindow is createEditorWindowWrapper — it already closes
|
||||
// the current mainWindow (the HUD) before opening the editor. Closing
|
||||
// it here too causes a double-close which leaves ghost transparent
|
||||
// windows and makes the HUD shadow compound on each cycle.
|
||||
createEditorWindow();
|
||||
});
|
||||
|
||||
@@ -1427,16 +1463,19 @@ export function registerIpcHandlers(
|
||||
return;
|
||||
}
|
||||
|
||||
if (!overlayWindow.isVisible()) {
|
||||
overlayWindow.showInactive();
|
||||
}
|
||||
|
||||
// Wait for the first frame to be painted before showing the window.
|
||||
// Showing before ready-to-show produces a black rectangle flash because
|
||||
// Chromium hasn't rendered any pixels yet.
|
||||
if (overlayWindow.webContents.isLoading()) {
|
||||
await new Promise<void>((resolve) => {
|
||||
overlayWindow.webContents.once("did-finish-load", () => resolve());
|
||||
overlayWindow.once("ready-to-show", resolve);
|
||||
});
|
||||
}
|
||||
|
||||
if (!overlayWindow.isVisible()) {
|
||||
overlayWindow.showInactive();
|
||||
}
|
||||
|
||||
overlayWindow.webContents.send("countdown-overlay-value", value, runId);
|
||||
});
|
||||
|
||||
@@ -2141,6 +2180,12 @@ export function registerIpcHandlers(
|
||||
},
|
||||
);
|
||||
|
||||
// On-disk write streams for in-progress recordings, keyed by output file name.
|
||||
// Chunks are appended as they arrive from ondataavailable so the renderer
|
||||
// never buffers the full video in memory (the #616 fix).
|
||||
const recordingStreams = new RecordingStreamRegistry();
|
||||
registerRecordingStreamHandlers(ipcMain, recordingStreams, resolveRecordingOutputPath);
|
||||
|
||||
ipcMain.handle("store-recorded-session", async (_, payload: StoreRecordedSessionInput) => {
|
||||
try {
|
||||
return await storeRecordedSessionFiles(payload);
|
||||
@@ -2161,12 +2206,37 @@ export function registerIpcHandlers(
|
||||
: Date.now();
|
||||
const cursorCaptureMode = normalizeCursorCaptureMode(payload.cursorCaptureMode);
|
||||
const screenVideoPath = resolveRecordingOutputPath(payload.screen.fileName);
|
||||
await fs.writeFile(screenVideoPath, Buffer.from(payload.screen.videoData));
|
||||
const screenStreamed = await finalizeRecordingFile(
|
||||
recordingStreams,
|
||||
payload.screen.fileName,
|
||||
screenVideoPath,
|
||||
payload.screen.videoData,
|
||||
);
|
||||
|
||||
let webcamVideoPath: string | undefined;
|
||||
let webcamStreamed = false;
|
||||
if (payload.webcam) {
|
||||
webcamVideoPath = resolveRecordingOutputPath(payload.webcam.fileName);
|
||||
await fs.writeFile(webcamVideoPath, Buffer.from(payload.webcam.videoData));
|
||||
webcamStreamed = await finalizeRecordingFile(
|
||||
recordingStreams,
|
||||
payload.webcam.fileName,
|
||||
webcamVideoPath,
|
||||
payload.webcam.videoData,
|
||||
);
|
||||
}
|
||||
|
||||
// Streamed files lack the WebM Duration header (the renderer no longer holds
|
||||
// the blob to patch). Patch on disk so the editor's seek bar and timeline
|
||||
// work. Best-effort and independent per file, so the patches run together.
|
||||
if (isValidDurationMs(payload.durationMs)) {
|
||||
const patches: Promise<unknown>[] = [];
|
||||
if (screenStreamed) {
|
||||
patches.push(patchWebmDurationOnDisk(screenVideoPath, payload.durationMs));
|
||||
}
|
||||
if (webcamStreamed && webcamVideoPath) {
|
||||
patches.push(patchWebmDurationOnDisk(webcamVideoPath, payload.durationMs));
|
||||
}
|
||||
await Promise.all(patches);
|
||||
}
|
||||
|
||||
const session: RecordingSession = webcamVideoPath
|
||||
@@ -2373,7 +2443,7 @@ export function registerIpcHandlers(
|
||||
filters: [
|
||||
{
|
||||
name: mainT("dialogs", "fileDialogs.videoFiles"),
|
||||
extensions: ["webm", "mp4", "mov", "avi", "mkv"],
|
||||
extensions: ["webm", "mp4", "mov", "avi", "mkv", "m4v", "wmv", "flv", "ts"],
|
||||
},
|
||||
{ name: mainT("dialogs", "fileDialogs.allFiles"), extensions: ["*"] },
|
||||
],
|
||||
@@ -2601,6 +2671,51 @@ export function registerIpcHandlers(
|
||||
}
|
||||
}
|
||||
|
||||
ipcMain.handle("load-project-file-from-path", async (_event, filePath: string) => {
|
||||
return loadProjectFileFromPath(filePath);
|
||||
});
|
||||
|
||||
async function loadProjectFileFromPath(filePath: string): Promise<ProjectFileResult> {
|
||||
try {
|
||||
if (!filePath || typeof filePath !== "string") {
|
||||
return { success: false, message: "Invalid file path" };
|
||||
}
|
||||
// Validate extension and readability
|
||||
if (path.extname(filePath).toLowerCase() !== `.${PROJECT_FILE_EXTENSION}`) {
|
||||
return { success: false, message: "Not an Openscreen project file" };
|
||||
}
|
||||
const stats = await fs.stat(filePath).catch(() => null);
|
||||
if (!stats?.isFile()) {
|
||||
return { success: false, message: "File not found" };
|
||||
}
|
||||
const content = await fs.readFile(filePath, "utf-8");
|
||||
const project = JSON.parse(content);
|
||||
currentProjectPath = filePath;
|
||||
|
||||
// Approve session paths; tolerate failures (e.g. video moved outside
|
||||
// trusted dirs) so the project still loads and the renderer can surface
|
||||
// a "video not found" error rather than a generic load failure.
|
||||
let session: import("../../src/lib/recordingSession").RecordingSession | null = null;
|
||||
try {
|
||||
session = await getApprovedProjectSession(project, filePath);
|
||||
} catch (sessionError) {
|
||||
console.warn(
|
||||
"[loadProjectFileFromPath] Could not approve session paths, proceeding without session:",
|
||||
sessionError,
|
||||
);
|
||||
}
|
||||
setCurrentRecordingSessionState(session);
|
||||
return { success: true, path: filePath, project };
|
||||
} catch (error) {
|
||||
console.error("Failed to load project file from path:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed to load project file",
|
||||
error: String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
ipcMain.handle("load-current-project-file", async () => {
|
||||
return loadCurrentProjectFile();
|
||||
});
|
||||
@@ -2683,6 +2798,8 @@ export function registerIpcHandlers(
|
||||
|
||||
function clearCurrentVideoPath(): ProjectPathResult {
|
||||
currentVideoPath = null;
|
||||
currentProjectPath = null;
|
||||
setCurrentRecordingSessionState(null);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -2757,6 +2874,7 @@ export function registerIpcHandlers(
|
||||
saveProjectFile,
|
||||
loadProjectFile,
|
||||
loadCurrentProjectFile,
|
||||
loadProjectFileFromPath,
|
||||
setCurrentVideoPath,
|
||||
getCurrentVideoPathResult,
|
||||
clearCurrentVideoPath,
|
||||
|
||||
@@ -27,6 +27,7 @@ export interface NativeBridgeContext {
|
||||
) => Promise<ProjectFileResult>;
|
||||
loadProjectFile: () => Promise<ProjectFileResult>;
|
||||
loadCurrentProjectFile: () => Promise<ProjectFileResult>;
|
||||
loadProjectFileFromPath: (path: string) => Promise<ProjectFileResult>;
|
||||
setCurrentVideoPath: (path: string) => ProjectPathResult | Promise<ProjectPathResult>;
|
||||
getCurrentVideoPathResult: () => ProjectPathResult;
|
||||
clearCurrentVideoPath: () => ProjectPathResult;
|
||||
@@ -100,6 +101,7 @@ export function registerNativeBridgeHandlers(context: NativeBridgeContext) {
|
||||
saveProjectFile: context.saveProjectFile,
|
||||
loadProjectFile: context.loadProjectFile,
|
||||
loadCurrentProjectFile: context.loadCurrentProjectFile,
|
||||
loadProjectFileFromPath: context.loadProjectFileFromPath,
|
||||
setCurrentVideoPath: context.setCurrentVideoPath,
|
||||
getCurrentVideoPathResult: context.getCurrentVideoPathResult,
|
||||
clearCurrentVideoPath: context.clearCurrentVideoPath,
|
||||
@@ -168,6 +170,11 @@ export function registerNativeBridgeHandlers(context: NativeBridgeContext) {
|
||||
requestId,
|
||||
await projectService.loadCurrentProjectFile(),
|
||||
);
|
||||
case "loadProjectFileFromPath":
|
||||
return createSuccessResponse(
|
||||
requestId,
|
||||
await projectService.loadProjectFileFromPath(request.payload.path),
|
||||
);
|
||||
case "setCurrentVideoPath":
|
||||
return createSuccessResponse(
|
||||
requestId,
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { mkdtemp, readFile, rm, stat } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { RecordingStreamRegistry } from "./recordingStream";
|
||||
|
||||
describe("RecordingStreamRegistry", () => {
|
||||
let dir: string;
|
||||
const pathFor = (name: string) => path.join(dir, name);
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(path.join(tmpdir(), "openscreen-stream-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("streams chunks to disk in order and reports streamed on finalize", async () => {
|
||||
const registry = new RecordingStreamRegistry();
|
||||
await registry.open("rec.webm", pathFor("rec.webm"));
|
||||
await registry.append("rec.webm", Buffer.from("hello "));
|
||||
await registry.append("rec.webm", Buffer.from("world"));
|
||||
|
||||
const streamed = await registry.finalize("rec.webm");
|
||||
|
||||
expect(streamed).toBe(true);
|
||||
expect(await readFile(pathFor("rec.webm"), "utf8")).toBe("hello world");
|
||||
// A second finalize has nothing to close.
|
||||
expect(await registry.finalize("rec.webm")).toBe(false);
|
||||
});
|
||||
|
||||
it("reports not-streamed when no stream was opened", async () => {
|
||||
const registry = new RecordingStreamRegistry();
|
||||
expect(await registry.finalize("missing.webm")).toBe(false);
|
||||
expect(registry.has("missing.webm")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects open when the target path is not writable (open is awaited, not assumed)", async () => {
|
||||
const registry = new RecordingStreamRegistry();
|
||||
// Parent directory does not exist, so createWriteStream emits 'error' on open.
|
||||
await expect(
|
||||
registry.open("rec.webm", path.join(dir, "does-not-exist", "rec.webm")),
|
||||
).rejects.toThrow();
|
||||
// A failed open must not register a stream the renderer would treat as live.
|
||||
expect(registry.has("rec.webm")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects append when no stream is open", async () => {
|
||||
const registry = new RecordingStreamRegistry();
|
||||
await expect(registry.append("rec.webm", Buffer.from("x"))).rejects.toThrow(
|
||||
/No active recording stream/,
|
||||
);
|
||||
});
|
||||
|
||||
it("discard closes the stream and removes the partial file", async () => {
|
||||
const registry = new RecordingStreamRegistry();
|
||||
await registry.open("rec.webm", pathFor("rec.webm"));
|
||||
await registry.append("rec.webm", Buffer.from("partial"));
|
||||
|
||||
await registry.discard("rec.webm", pathFor("rec.webm"));
|
||||
|
||||
expect(registry.has("rec.webm")).toBe(false);
|
||||
await expect(stat(pathFor("rec.webm"))).rejects.toThrow();
|
||||
// Nothing left to finalize after a discard.
|
||||
expect(await registry.finalize("rec.webm")).toBe(false);
|
||||
});
|
||||
|
||||
it("discard tolerates a missing file", async () => {
|
||||
const registry = new RecordingStreamRegistry();
|
||||
await expect(registry.discard("never.webm", pathFor("never.webm"))).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("opening the same file twice replaces the prior stream", async () => {
|
||||
const registry = new RecordingStreamRegistry();
|
||||
await registry.open("rec.webm", pathFor("rec.webm"));
|
||||
await registry.append("rec.webm", Buffer.from("first"));
|
||||
await registry.open("rec.webm", pathFor("rec.webm"));
|
||||
await registry.append("rec.webm", Buffer.from("second"));
|
||||
await registry.finalize("rec.webm");
|
||||
|
||||
expect(await readFile(pathFor("rec.webm"), "utf8")).toBe("second");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
import { createWriteStream, type WriteStream } from "node:fs";
|
||||
import { unlink } from "node:fs/promises";
|
||||
import type { IpcMain } from "electron";
|
||||
|
||||
/**
|
||||
* Owns the lifecycle of on-disk write streams for in-progress recordings, keyed
|
||||
* by the recording's output file name. Browser MediaRecorder chunks are appended
|
||||
* here as they arrive so a long recording never buffers the whole video in the
|
||||
* renderer (the #616 fix).
|
||||
*
|
||||
* The file name is the key because it is the one value the renderer and main
|
||||
* process already exchange and it is globally unique per recording, so there is
|
||||
* no derived/offset key to keep in sync across the IPC boundary.
|
||||
*/
|
||||
export class RecordingStreamRegistry {
|
||||
private readonly streams = new Map<string, WriteStream>();
|
||||
|
||||
/**
|
||||
* Open a write stream and resolve only once the OS confirms it is writable.
|
||||
* Resolving on the `open` event (rather than on `createWriteStream` returning)
|
||||
* means a bad path or permission error rejects here instead of surfacing as a
|
||||
* silent chunk drop later, so the renderer's fallback can take over.
|
||||
*/
|
||||
async open(fileName: string, filePath: string): Promise<void> {
|
||||
await this.endStream(fileName);
|
||||
|
||||
const ws = createWriteStream(filePath, { flags: "w" });
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const onError = (error: Error) => reject(error);
|
||||
ws.once("error", onError);
|
||||
ws.once("open", () => {
|
||||
ws.removeListener("error", onError);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
// Keep a listener for the stream's lifetime so a late error logs rather
|
||||
// than crashing the main process with an unhandled 'error' event. Per-write
|
||||
// failures still surface through the `append` callback below.
|
||||
ws.on("error", (error) => {
|
||||
console.error(`[recording-stream] ${fileName}:`, error);
|
||||
});
|
||||
|
||||
this.streams.set(fileName, ws);
|
||||
}
|
||||
|
||||
has(fileName: string): boolean {
|
||||
return this.streams.has(fileName);
|
||||
}
|
||||
|
||||
/** Append a chunk; rejects if no stream is open or the write fails. */
|
||||
async append(fileName: string, chunk: Buffer): Promise<void> {
|
||||
const ws = this.streams.get(fileName);
|
||||
if (!ws) {
|
||||
throw new Error(`No active recording stream for ${fileName}`);
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
ws.write(chunk, (error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush and close the stream, keeping the file. Returns whether a stream was
|
||||
* open — i.e. whether the recording was streamed to disk (true) or needs its
|
||||
* in-memory buffer written by the caller (false).
|
||||
*/
|
||||
async finalize(fileName: string): Promise<boolean> {
|
||||
const ws = this.streams.get(fileName);
|
||||
if (!ws) {
|
||||
return false;
|
||||
}
|
||||
this.streams.delete(fileName);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
ws.end((error?: Error | null) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the stream (if any) and delete the partial file. Used when a streamed
|
||||
* recording is discarded or fails before a successful save, so cancelled runs
|
||||
* don't leak file descriptors or orphan partial recordings on disk.
|
||||
*/
|
||||
async discard(fileName: string, filePath: string): Promise<void> {
|
||||
await this.endStream(fileName);
|
||||
await unlink(filePath).catch(() => undefined);
|
||||
}
|
||||
|
||||
private async endStream(fileName: string): Promise<void> {
|
||||
const ws = this.streams.get(fileName);
|
||||
if (!ws) {
|
||||
return;
|
||||
}
|
||||
this.streams.delete(fileName);
|
||||
await new Promise<void>((resolve) => ws.end(() => resolve()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the streaming IPC handlers. Thin wrappers that translate the
|
||||
* registry's throw-on-failure contract into the `{ success, error }` shape the
|
||||
* renderer expects.
|
||||
*/
|
||||
export function registerRecordingStreamHandlers(
|
||||
ipcMain: IpcMain,
|
||||
registry: RecordingStreamRegistry,
|
||||
resolveRecordingOutputPath: (fileName: string) => string,
|
||||
): void {
|
||||
ipcMain.handle(
|
||||
"open-recording-stream",
|
||||
async (_, fileName: string): Promise<{ success: boolean; error?: string }> => {
|
||||
try {
|
||||
await registry.open(fileName, resolveRecordingOutputPath(fileName));
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
"append-recording-chunk",
|
||||
async (
|
||||
_,
|
||||
fileName: string,
|
||||
chunk: ArrayBuffer,
|
||||
): Promise<{ success: boolean; error?: string }> => {
|
||||
try {
|
||||
await registry.append(fileName, Buffer.from(chunk));
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
"close-recording-stream",
|
||||
async (_, fileName: string): Promise<{ success: boolean; error?: string }> => {
|
||||
try {
|
||||
await registry.discard(fileName, resolveRecordingOutputPath(fileName));
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
+25
-1
@@ -11,6 +11,12 @@ import {
|
||||
systemPreferences,
|
||||
Tray,
|
||||
} from "electron";
|
||||
import { ShortcutBinding } from "../src/lib/shortcuts";
|
||||
import {
|
||||
loadAndRegisterGlobalShortcut,
|
||||
registerOpenAppShortcut,
|
||||
unregisterAllGlobalShortcuts,
|
||||
} from "./globalShortcut";
|
||||
import { mainT, setMainLocale } from "./i18n";
|
||||
import { getSelectedDesktopSource, registerIpcHandlers } from "./ipc/handlers";
|
||||
import {
|
||||
@@ -108,7 +114,7 @@ function isEditorWindow(window: BrowserWindow) {
|
||||
}
|
||||
|
||||
function sendEditorMenuAction(
|
||||
channel: "menu-load-project" | "menu-save-project" | "menu-save-project-as",
|
||||
channel: "menu-load-project" | "menu-save-project" | "menu-save-project-as" | "menu-new-project",
|
||||
) {
|
||||
let targetWindow = BrowserWindow.getFocusedWindow() ?? mainWindow;
|
||||
|
||||
@@ -167,6 +173,12 @@ function setupApplicationMenu() {
|
||||
{
|
||||
label: mainT("common", "actions.file") || "File",
|
||||
submenu: [
|
||||
{
|
||||
label: mainT("dialogs", "unsavedChanges.newProject") || "New Project",
|
||||
accelerator: "CmdOrCtrl+N",
|
||||
click: () => sendEditorMenuAction("menu-new-project"),
|
||||
},
|
||||
{ type: "separator" as const },
|
||||
{
|
||||
label: mainT("dialogs", "unsavedChanges.loadProject") || "Load Project…",
|
||||
accelerator: "CmdOrCtrl+O",
|
||||
@@ -440,6 +452,10 @@ app.on("activate", () => {
|
||||
}
|
||||
});
|
||||
|
||||
app.on("will-quit", () => {
|
||||
unregisterAllGlobalShortcuts();
|
||||
});
|
||||
|
||||
// Register all IPC handlers when app is ready
|
||||
app.whenReady().then(async () => {
|
||||
// Force the app into "regular" activation policy so the Dock icon appears.
|
||||
@@ -512,6 +528,11 @@ app.whenReady().then(async () => {
|
||||
updateTrayMenu();
|
||||
});
|
||||
|
||||
ipcMain.handle("update-global-shortcut", (_, binding: ShortcutBinding) => {
|
||||
const success = registerOpenAppShortcut(binding, showMainWindow);
|
||||
return { success };
|
||||
});
|
||||
|
||||
createTray();
|
||||
updateTrayMenu();
|
||||
setupApplicationMenu();
|
||||
@@ -545,5 +566,8 @@ app.whenReady().then(async () => {
|
||||
},
|
||||
switchToHudWrapper,
|
||||
);
|
||||
|
||||
await loadAndRegisterGlobalShortcut(showMainWindow);
|
||||
|
||||
createWindow();
|
||||
});
|
||||
|
||||
@@ -6,10 +6,21 @@ import { type Rectangle, screen, systemPreferences } from "electron";
|
||||
import type {
|
||||
CursorRecordingData,
|
||||
CursorRecordingSample,
|
||||
NativeCursorAsset,
|
||||
NativeCursorType,
|
||||
} from "../../../../src/native/contracts";
|
||||
import type { CursorRecordingSession } from "./session";
|
||||
|
||||
interface MacCursorAssetPayload {
|
||||
id: string;
|
||||
imageDataUrl: string;
|
||||
width: number;
|
||||
height: number;
|
||||
hotspotX: number;
|
||||
hotspotY: number;
|
||||
scaleFactor?: number;
|
||||
}
|
||||
|
||||
interface MacNativeCursorRecordingSessionOptions {
|
||||
getDisplayBounds: () => Rectangle | null;
|
||||
maxSamples: number;
|
||||
@@ -28,6 +39,8 @@ type MacCursorEvent =
|
||||
type: "sample";
|
||||
timestampMs: number;
|
||||
cursorType?: NativeCursorType | null;
|
||||
assetId?: string | null;
|
||||
asset?: MacCursorAssetPayload | null;
|
||||
leftButtonDown?: boolean;
|
||||
leftButtonPressed?: boolean;
|
||||
leftButtonReleased?: boolean;
|
||||
@@ -170,6 +183,7 @@ function normalizeCursorType(value: unknown): NativeCursorType | null {
|
||||
|
||||
export class MacNativeCursorRecordingSession implements CursorRecordingSession {
|
||||
private samples: CursorRecordingSample[] = [];
|
||||
private assets = new Map<string, NativeCursorAsset>();
|
||||
private process: ChildProcessByStdio<null, Readable, Readable> | null = null;
|
||||
private lineBuffer = "";
|
||||
private startTimeMs = 0;
|
||||
@@ -187,6 +201,7 @@ export class MacNativeCursorRecordingSession implements CursorRecordingSession {
|
||||
|
||||
async start(): Promise<void> {
|
||||
this.samples = [];
|
||||
this.assets.clear();
|
||||
this.lineBuffer = "";
|
||||
this.startTimeMs = this.options.startTimeMs ?? Date.now();
|
||||
this.previousLeftButtonDown = false;
|
||||
@@ -195,7 +210,8 @@ export class MacNativeCursorRecordingSession implements CursorRecordingSession {
|
||||
try {
|
||||
systemPreferences.isTrustedAccessibilityClient(true);
|
||||
} catch {
|
||||
// Link cursor detection degrades to arrow when Accessibility is unavailable.
|
||||
// Without Accessibility, text/pointer affordance detection is unavailable;
|
||||
// cursor bitmaps are still captured natively via NSCursor.
|
||||
}
|
||||
|
||||
const helperPath = findMacCursorHelperPath();
|
||||
@@ -263,19 +279,38 @@ export class MacNativeCursorRecordingSession implements CursorRecordingSession {
|
||||
|
||||
return {
|
||||
version: 2,
|
||||
provider: "none",
|
||||
provider: this.assets.size > 0 ? "native" : "none",
|
||||
samples: this.samples,
|
||||
assets: [],
|
||||
assets: [...this.assets.values()],
|
||||
};
|
||||
}
|
||||
|
||||
private startPositionOnlyFallback() {
|
||||
this.captureSample(Date.now(), null, false, false, false);
|
||||
this.captureSample(Date.now(), null, null, false, false, false);
|
||||
this.fallbackInterval = setInterval(() => {
|
||||
this.captureSample(Date.now(), null, false, false, false);
|
||||
this.captureSample(Date.now(), null, null, false, false, false);
|
||||
}, this.options.sampleIntervalMs);
|
||||
}
|
||||
|
||||
private rememberAsset(asset: MacCursorAssetPayload | null | undefined) {
|
||||
if (!asset?.id || this.assets.has(asset.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cursor = screen.getCursorScreenPoint();
|
||||
const displayScaleFactor = screen.getDisplayNearestPoint(cursor).scaleFactor;
|
||||
this.assets.set(asset.id, {
|
||||
id: asset.id,
|
||||
platform: "darwin",
|
||||
imageDataUrl: asset.imageDataUrl,
|
||||
width: asset.width,
|
||||
height: asset.height,
|
||||
hotspotX: asset.hotspotX,
|
||||
hotspotY: asset.hotspotY,
|
||||
scaleFactor: asset.scaleFactor ?? displayScaleFactor,
|
||||
});
|
||||
}
|
||||
|
||||
private handleStdoutChunk(chunk: string) {
|
||||
this.lineBuffer += chunk;
|
||||
const lines = this.lineBuffer.split(/\r?\n/);
|
||||
@@ -299,7 +334,7 @@ export class MacNativeCursorRecordingSession implements CursorRecordingSession {
|
||||
if (payload.type === "ready") {
|
||||
if (payload.accessibilityTrusted === false) {
|
||||
console.warn(
|
||||
"[cursor-macos] Accessibility is not trusted; cursor shape detection will be arrow-only.",
|
||||
"[cursor-macos] Accessibility is not trusted; text/pointer affordance detection disabled (bitmap capture still active).",
|
||||
);
|
||||
}
|
||||
this.resolveReady();
|
||||
@@ -307,9 +342,11 @@ export class MacNativeCursorRecordingSession implements CursorRecordingSession {
|
||||
}
|
||||
|
||||
if (payload.type === "sample") {
|
||||
this.rememberAsset(payload.asset);
|
||||
this.captureSample(
|
||||
payload.timestampMs,
|
||||
normalizeCursorType(payload.cursorType),
|
||||
payload.assetId ?? null,
|
||||
payload.leftButtonDown === true,
|
||||
payload.leftButtonPressed === true,
|
||||
payload.leftButtonReleased === true,
|
||||
@@ -320,6 +357,7 @@ export class MacNativeCursorRecordingSession implements CursorRecordingSession {
|
||||
private captureSample(
|
||||
timestampMs: number,
|
||||
cursorType: NativeCursorType | null,
|
||||
assetId: string | null,
|
||||
leftButtonDown: boolean,
|
||||
leftButtonPressed: boolean,
|
||||
leftButtonReleased: boolean,
|
||||
@@ -357,6 +395,7 @@ export class MacNativeCursorRecordingSession implements CursorRecordingSession {
|
||||
cy: clamp(normalizedY, 0, 1),
|
||||
visible,
|
||||
interactionType,
|
||||
...(assetId ? { assetId } : {}),
|
||||
...(cursorType ? { cursorType } : {}),
|
||||
});
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ interface ProjectServiceOptions {
|
||||
) => Promise<ProjectFileResult>;
|
||||
loadProjectFile: () => Promise<ProjectFileResult>;
|
||||
loadCurrentProjectFile: () => Promise<ProjectFileResult>;
|
||||
loadProjectFileFromPath: (path: string) => Promise<ProjectFileResult>;
|
||||
setCurrentVideoPath: (path: string) => ProjectPathResult | Promise<ProjectPathResult>;
|
||||
getCurrentVideoPathResult: () => ProjectPathResult;
|
||||
clearCurrentVideoPath: () => ProjectPathResult;
|
||||
@@ -60,6 +61,12 @@ export class ProjectService {
|
||||
return result;
|
||||
}
|
||||
|
||||
async loadProjectFileFromPath(path: string) {
|
||||
const result = await this.options.loadProjectFileFromPath(path);
|
||||
this.getCurrentContext();
|
||||
return result;
|
||||
}
|
||||
|
||||
async setCurrentVideoPath(path: string) {
|
||||
const result = await this.options.setCurrentVideoPath(path);
|
||||
this.getCurrentContext();
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
import AppKit
|
||||
import ApplicationServices
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
|
||||
struct CursorHelperRequest: Decodable {
|
||||
let sampleIntervalMs: Int?
|
||||
}
|
||||
|
||||
struct CapturedCursorAsset {
|
||||
let id: String
|
||||
let imageDataUrl: String
|
||||
let width: Int
|
||||
let height: Int
|
||||
let hotspotX: Double
|
||||
let hotspotY: Double
|
||||
let scaleFactor: Double
|
||||
}
|
||||
|
||||
final class MouseButtonTracker {
|
||||
private let lock = NSLock()
|
||||
private var leftDownCount = 0
|
||||
@@ -211,10 +222,60 @@ func currentCursorType() -> String? {
|
||||
)
|
||||
|
||||
guard result == .success, let element else {
|
||||
return "arrow"
|
||||
return nil
|
||||
}
|
||||
|
||||
return cursorTypeForElement(element) ?? "arrow"
|
||||
// Returns nil for anything that is not a text/pointer affordance so the
|
||||
// renderer falls through to the natively captured cursor bitmap (this is
|
||||
// what makes default and custom cursors render as their real images).
|
||||
return cursorTypeForElement(element)
|
||||
}
|
||||
|
||||
func currentCursorAsset() -> CapturedCursorAsset? {
|
||||
guard let cursor = NSCursor.currentSystem ?? NSCursor.current as NSCursor? else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let image = cursor.image
|
||||
let pointSize = image.size
|
||||
guard pointSize.width > 0, pointSize.height > 0 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
var proposedRect = NSRect(origin: .zero, size: pointSize)
|
||||
guard let cgImage = image.cgImage(forProposedRect: &proposedRect, context: nil, hints: nil) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let bitmap = NSBitmapImageRep(cgImage: cgImage)
|
||||
guard let png = bitmap.representation(using: .png, properties: [:]) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let pixelsWide = bitmap.pixelsWide
|
||||
let pixelsHigh = bitmap.pixelsHigh
|
||||
guard pixelsWide > 0, pixelsHigh > 0 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Intrinsic backing scale of the cursor image (e.g. 2.0 on Retina). The
|
||||
// renderer divides pixel dimensions/hotspot by this to recover point sizes.
|
||||
let scaleFactor = Double(pixelsWide) / Double(pointSize.width)
|
||||
let hotSpot = cursor.hotSpot
|
||||
|
||||
let digest = SHA256.hash(data: png)
|
||||
let id = digest.map { String(format: "%02x", $0) }.joined()
|
||||
let imageDataUrl = "data:image/png;base64,\(png.base64EncodedString())"
|
||||
|
||||
return CapturedCursorAsset(
|
||||
id: id,
|
||||
imageDataUrl: imageDataUrl,
|
||||
width: pixelsWide,
|
||||
height: pixelsHigh,
|
||||
hotspotX: hotSpot.x * scaleFactor,
|
||||
hotspotY: hotSpot.y * scaleFactor,
|
||||
scaleFactor: scaleFactor
|
||||
)
|
||||
}
|
||||
|
||||
func timestampMs() -> Int {
|
||||
@@ -253,16 +314,39 @@ emit([
|
||||
"mouseTapReady": mouseTapReady,
|
||||
])
|
||||
|
||||
// Process-wide set so each unique cursor shape is serialised at most once,
|
||||
// even if the user alternates between shapes (e.g. arrow → text → arrow).
|
||||
var emittedAssetIds = Set<String>()
|
||||
|
||||
while true {
|
||||
mouseTracker.pump()
|
||||
let mouseEvents = mouseTracker.consume()
|
||||
emit([
|
||||
"type": "sample",
|
||||
"timestampMs": timestampMs(),
|
||||
"cursorType": currentCursorType(),
|
||||
"leftButtonDown": leftButtonDown(),
|
||||
"leftButtonPressed": mouseEvents.leftDownCount > 0,
|
||||
"leftButtonReleased": mouseEvents.leftUpCount > 0,
|
||||
])
|
||||
Thread.sleep(forTimeInterval: Double(intervalMs) / 1000.0)
|
||||
autoreleasepool {
|
||||
mouseTracker.pump()
|
||||
let mouseEvents = mouseTracker.consume()
|
||||
let asset = currentCursorAsset()
|
||||
// Only ship the (large) base64 payload the first time a cursor shape is seen;
|
||||
// subsequent samples reference it by assetId so stdout stays small.
|
||||
var assetPayload: [String: Any]?
|
||||
if let asset, emittedAssetIds.insert(asset.id).inserted {
|
||||
assetPayload = [
|
||||
"id": asset.id,
|
||||
"imageDataUrl": asset.imageDataUrl,
|
||||
"width": asset.width,
|
||||
"height": asset.height,
|
||||
"hotspotX": asset.hotspotX,
|
||||
"hotspotY": asset.hotspotY,
|
||||
"scaleFactor": asset.scaleFactor,
|
||||
]
|
||||
}
|
||||
emit([
|
||||
"type": "sample",
|
||||
"timestampMs": timestampMs(),
|
||||
"cursorType": currentCursorType(),
|
||||
"assetId": asset?.id,
|
||||
"asset": assetPayload,
|
||||
"leftButtonDown": leftButtonDown(),
|
||||
"leftButtonPressed": mouseEvents.leftDownCount > 0,
|
||||
"leftButtonReleased": mouseEvents.leftUpCount > 0,
|
||||
])
|
||||
Thread.sleep(forTimeInterval: Double(intervalMs) / 1000.0)
|
||||
}
|
||||
}
|
||||
|
||||
+34
-1
@@ -1,7 +1,8 @@
|
||||
import { contextBridge, ipcRenderer } from "electron";
|
||||
import { contextBridge, ipcRenderer, webUtils } from "electron";
|
||||
import type { NativeMacRecordingRequest } from "../src/lib/nativeMacRecording";
|
||||
import type { NativeWindowsRecordingRequest } from "../src/lib/nativeWindowsRecording";
|
||||
import type { RecordingSession, StoreRecordedSessionInput } from "../src/lib/recordingSession";
|
||||
import type { ShortcutBinding } from "../src/lib/shortcuts";
|
||||
import { NATIVE_BRIDGE_CHANNEL, type NativeBridgeRequest } from "../src/native/contracts";
|
||||
|
||||
// Asset base URL is passed from the main process via webPreferences.additionalArguments
|
||||
@@ -64,6 +65,15 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
||||
storeRecordedSession: (payload: StoreRecordedSessionInput) => {
|
||||
return ipcRenderer.invoke("store-recorded-session", payload);
|
||||
},
|
||||
openRecordingStream: (fileName: string) => {
|
||||
return ipcRenderer.invoke("open-recording-stream", fileName);
|
||||
},
|
||||
appendRecordingChunk: (fileName: string, chunk: ArrayBuffer) => {
|
||||
return ipcRenderer.invoke("append-recording-chunk", fileName, chunk);
|
||||
},
|
||||
closeRecordingStream: (fileName: string) => {
|
||||
return ipcRenderer.invoke("close-recording-stream", fileName);
|
||||
},
|
||||
|
||||
getRecordedVideoPath: () => {
|
||||
return ipcRenderer.invoke("get-recorded-video-path");
|
||||
@@ -163,9 +173,29 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
||||
loadProjectFile: () => {
|
||||
return ipcRenderer.invoke("load-project-file");
|
||||
},
|
||||
loadProjectFileFromPath: (filePath: string) => {
|
||||
return ipcRenderer.invoke("load-project-file-from-path", filePath);
|
||||
},
|
||||
getPathForFile: (file: File) => {
|
||||
try {
|
||||
return webUtils.getPathForFile(file);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
},
|
||||
loadCurrentProjectFile: () => {
|
||||
return ipcRenderer.invoke("load-current-project-file");
|
||||
},
|
||||
onMenuNewProject: (callback: () => void) => {
|
||||
const listener = () => callback();
|
||||
ipcRenderer.on("menu-new-project", listener);
|
||||
return () => ipcRenderer.removeListener("menu-new-project", listener);
|
||||
},
|
||||
onMenuImportVideo: (callback: () => void) => {
|
||||
const listener = () => callback();
|
||||
ipcRenderer.on("menu-import-video", listener);
|
||||
return () => ipcRenderer.removeListener("menu-import-video", listener);
|
||||
},
|
||||
onMenuLoadProject: (callback: () => void) => {
|
||||
const listener = () => callback();
|
||||
ipcRenderer.on("menu-load-project", listener);
|
||||
@@ -193,6 +223,9 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
||||
saveShortcuts: (shortcuts: unknown) => {
|
||||
return ipcRenderer.invoke("save-shortcuts", shortcuts);
|
||||
},
|
||||
updateGlobalShortcut: (binding: ShortcutBinding) => {
|
||||
return ipcRenderer.invoke("update-global-shortcut", binding);
|
||||
},
|
||||
setLocale: (locale: string) => {
|
||||
return ipcRenderer.invoke("set-locale", locale);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import fs from "node:fs/promises";
|
||||
import { fixParsedWebmDuration } from "@fix-webm-duration/fix";
|
||||
import { WebmFile } from "@fix-webm-duration/parser";
|
||||
|
||||
export type DurationPatchResult =
|
||||
| { patched: true }
|
||||
| { patched: false; reason: "no-section" | "already-valid" | "io-error" | "internal" };
|
||||
|
||||
/**
|
||||
* Patch the WebM Duration header on a finalized recording file.
|
||||
*
|
||||
* Browser MediaRecorder writes WebM with no Duration EBML element. With the
|
||||
* streaming-to-disk path the renderer never holds the blob, so the historical
|
||||
* `fixWebmDuration(blob, durationMs)` call can't run. Patching on disk after
|
||||
* `WriteStream.end()` produces an equivalent result: the editor's seek bar and
|
||||
* timeline read a real duration instead of `N/A`.
|
||||
*
|
||||
* Atomic by design: writes the patched bytes to `<filePath>.duration-patch.tmp`
|
||||
* and renames in place. If the process crashes mid-rewrite, the original file
|
||||
* survives intact, so the user never loses their recording to a partial write.
|
||||
*
|
||||
* Best-effort by intent: any failure (read, parse, write) logs and returns a
|
||||
* non-`patched` result rather than throwing. The file is still playable without
|
||||
* the patch (decoders walk frames sequentially); the only cost is that the
|
||||
* editor's seek bar and timeline break until it is patched.
|
||||
*
|
||||
* Memory: reads the whole file into a main-process Buffer, the same footprint
|
||||
* as the pre-streaming renderer path, just on the side without V8's heap cap.
|
||||
*/
|
||||
export async function patchWebmDurationOnDisk(
|
||||
filePath: string,
|
||||
durationMs: number,
|
||||
): Promise<DurationPatchResult> {
|
||||
try {
|
||||
const fileBytes = await fs.readFile(filePath);
|
||||
const webm = new WebmFile(new Uint8Array(fileBytes));
|
||||
|
||||
const patched = fixParsedWebmDuration(webm, durationMs, { logger: false });
|
||||
if (!patched) {
|
||||
// fixParsedWebmDuration returns false for: missing Segment, missing
|
||||
// Info, or a Duration that is already valid. The first two mean a
|
||||
// malformed (most likely truncated) file; the third is a no-op.
|
||||
const reason = inferUnpatchedReason(webm);
|
||||
if (reason === "no-section") {
|
||||
console.warn(
|
||||
`[webm-duration] no Segment/Info section in ${filePath}; file may be truncated`,
|
||||
);
|
||||
}
|
||||
return { patched: false, reason };
|
||||
}
|
||||
|
||||
if (!webm.source) {
|
||||
console.error(`[webm-duration] patched but source missing for ${filePath}`);
|
||||
return { patched: false, reason: "internal" };
|
||||
}
|
||||
|
||||
const tmpPath = `${filePath}.duration-patch.tmp`;
|
||||
const patchedBytes = Buffer.from(
|
||||
webm.source.buffer,
|
||||
webm.source.byteOffset,
|
||||
webm.source.byteLength,
|
||||
);
|
||||
try {
|
||||
await fs.writeFile(tmpPath, patchedBytes);
|
||||
await fs.rename(tmpPath, filePath);
|
||||
return { patched: true };
|
||||
} catch (writeError) {
|
||||
console.error(`[webm-duration] failed to write patched ${filePath}:`, writeError);
|
||||
// Best-effort cleanup of the temp file; if unlink also fails, leave it.
|
||||
// The original recording is untouched because the rename never ran.
|
||||
await fs.unlink(tmpPath).catch(() => undefined);
|
||||
return { patched: false, reason: "io-error" };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[webm-duration] failed to patch ${filePath}:`, error);
|
||||
return { patched: false, reason: "io-error" };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Distinguish "no Segment/Info section" (malformed/truncated file) from "Info
|
||||
* present but Duration already valid" (patch unnecessary).
|
||||
*
|
||||
* The IDs are the length-descriptor-stripped form that @fix-webm-duration/parser
|
||||
* uses as its lookup keys (Segment `0x8538067`, Info `0x549a966`), verified
|
||||
* against the parser's `src/lib/sections.js` — not the canonical 4-byte EBML
|
||||
* IDs (`0x18538067` / `0x1549A966`), which this parser's `getSectionById` would
|
||||
* never match.
|
||||
*/
|
||||
function inferUnpatchedReason(webm: WebmFile): "no-section" | "already-valid" {
|
||||
const segment = webm.getSectionById?.(0x8538067);
|
||||
if (!segment) return "no-section";
|
||||
const info = (
|
||||
segment as unknown as { getSectionById?: (id: number) => unknown }
|
||||
).getSectionById?.(0x549a966);
|
||||
return info ? "already-valid" : "no-section";
|
||||
}
|
||||
+22
-3
@@ -74,7 +74,7 @@ export function createHudOverlayWindow(): BrowserWindow {
|
||||
alwaysOnTop: true,
|
||||
skipTaskbar: true,
|
||||
hasShadow: false,
|
||||
show: !HEADLESS,
|
||||
show: false, // shown via ready-to-show to avoid black rectangle flash
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, "preload.mjs"),
|
||||
additionalArguments: [ASSET_BASE_URL_ARG],
|
||||
@@ -91,6 +91,12 @@ export function createHudOverlayWindow(): BrowserWindow {
|
||||
win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
|
||||
}
|
||||
|
||||
// Show only once content is painted — prevents the black rectangle flash
|
||||
// that appears when a transparent window is shown before its first paint.
|
||||
win.once("ready-to-show", () => {
|
||||
if (!HEADLESS) win.show();
|
||||
});
|
||||
|
||||
win.webContents.on("did-finish-load", () => {
|
||||
win?.webContents.send("main-process-message", new Date().toLocaleString());
|
||||
});
|
||||
@@ -135,8 +141,8 @@ export function createEditorWindow(): BrowserWindow {
|
||||
alwaysOnTop: false,
|
||||
skipTaskbar: false,
|
||||
title: "OpenScreen",
|
||||
backgroundColor: "#000000",
|
||||
show: !HEADLESS,
|
||||
backgroundColor: "#09090b",
|
||||
show: false, // shown via ready-to-show to avoid white flash on first load
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, "preload.mjs"),
|
||||
additionalArguments: [ASSET_BASE_URL_ARG],
|
||||
@@ -150,6 +156,19 @@ export function createEditorWindow(): BrowserWindow {
|
||||
// Maximize the window by default
|
||||
win.maximize();
|
||||
|
||||
// Show only once content is painted — prevents white flash on cold Vite start.
|
||||
win.once("ready-to-show", () => {
|
||||
if (!HEADLESS) win.show();
|
||||
});
|
||||
|
||||
// Inject dark background before any React paint so the sub-titlebar area
|
||||
// never flashes white even on the very first cold Vite load.
|
||||
win.webContents.on("dom-ready", () => {
|
||||
win.webContents.insertCSS("html, body, #root { background: #09090b !important; }").catch(() => {
|
||||
// Best-effort cosmetic; ignore if the page is mid-teardown.
|
||||
});
|
||||
});
|
||||
|
||||
win.webContents.on("did-finish-load", () => {
|
||||
win?.webContents.send("main-process-message", new Date().toLocaleString());
|
||||
});
|
||||
|
||||
+2
-2
@@ -1,12 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title></title>
|
||||
</head>
|
||||
<body>
|
||||
<body style="background:#09090b;margin:0">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
|
||||
+31
-1
@@ -4,6 +4,7 @@ import { LaunchWindow } from "./components/launch/LaunchWindow";
|
||||
import { SourceSelector } from "./components/launch/SourceSelector";
|
||||
import { Toaster } from "./components/ui/sonner";
|
||||
import { TooltipProvider } from "./components/ui/tooltip";
|
||||
import { useScopedT } from "./contexts/I18nContext";
|
||||
import { ShortcutsProvider } from "./contexts/ShortcutsContext";
|
||||
import { loadAllCustomFonts } from "./lib/customFonts";
|
||||
|
||||
@@ -18,6 +19,7 @@ export default function App() {
|
||||
const [windowType, setWindowType] = useState(
|
||||
() => new URLSearchParams(window.location.search).get("windowType") || "",
|
||||
);
|
||||
const tEditor = useScopedT("editor");
|
||||
|
||||
useEffect(() => {
|
||||
const type = new URLSearchParams(window.location.search).get("windowType") || "";
|
||||
@@ -64,7 +66,35 @@ export default function App() {
|
||||
case "editor":
|
||||
return (
|
||||
<ShortcutsProvider>
|
||||
<Suspense fallback={<div className="h-screen bg-background" />}>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex flex-col items-center justify-center gap-3 h-screen bg-[#09090b]">
|
||||
<svg
|
||||
className="animate-spin text-[#34B27B]"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
width={28}
|
||||
height={28}
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-white/50 text-sm">{tEditor("loadingEditor")}</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<VideoEditor />
|
||||
<ShortcutsConfigDialog />
|
||||
</Suspense>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Check, ChevronDown, Languages } from "lucide-react";
|
||||
import { Check, ChevronDown, Clapperboard, Columns3, Languages, Rows3 } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { BsPauseCircle, BsPlayCircle, BsRecordCircle } from "react-icons/bs";
|
||||
@@ -27,11 +27,13 @@ import { useCameraDevices } from "../../hooks/useCameraDevices";
|
||||
import { useMicrophoneDevices } from "../../hooks/useMicrophoneDevices";
|
||||
import { useScreenRecorder } from "../../hooks/useScreenRecorder";
|
||||
import { requestCameraAccess } from "../../lib/requestCameraAccess";
|
||||
import { loadUserPreferences, saveUserPreferences } from "../../lib/userPreferences";
|
||||
import { formatTimePadded } from "../../utils/timeUtils";
|
||||
import { AudioLevelMeter } from "../ui/audio-level-meter";
|
||||
import { Button } from "../ui/button";
|
||||
import { Tooltip } from "../ui/tooltip";
|
||||
import styles from "./LaunchWindow.module.css";
|
||||
import { openSourceSelectorWithPermissionRetry } from "./openSourceSelectorFlow";
|
||||
|
||||
const ICON_SIZE = 20;
|
||||
|
||||
@@ -59,6 +61,7 @@ const ICON_CONFIG = {
|
||||
|
||||
type IconName = keyof typeof ICON_CONFIG;
|
||||
|
||||
/** Renders the configured icon for a HUD control. */
|
||||
function getIcon(name: IconName, className?: string) {
|
||||
const { icon: Icon, size } = ICON_CONFIG[name];
|
||||
return <Icon size={size} className={className} />;
|
||||
@@ -77,7 +80,10 @@ const windowBtnClasses =
|
||||
"flex h-8 w-8 items-center justify-center rounded-lg transition-all duration-150 cursor-pointer opacity-50 hover:opacity-90 hover:bg-white/[0.08]";
|
||||
|
||||
const hudSidebarClasses = "ml-0.5 pl-1.5 border-l border-white/10 flex items-center gap-0.5";
|
||||
const hudSidebarVerticalClasses =
|
||||
"mt-0.5 pt-1.5 border-t border-white/10 flex flex-col items-center gap-0.5";
|
||||
|
||||
/** Launches the floating recording HUD and its recorder controls. */
|
||||
export function LaunchWindow() {
|
||||
const t = useScopedT("launch");
|
||||
const availableLocales = getAvailableLocales();
|
||||
@@ -128,6 +134,9 @@ export function LaunchWindow() {
|
||||
const [isWebcamFocused, setIsWebcamFocused] = useState(false);
|
||||
const webcamExpanded = isWebcamHovered || isWebcamFocused;
|
||||
const [isLanguageMenuOpen, setIsLanguageMenuOpen] = useState(false);
|
||||
const [trayLayout, setTrayLayout] = useState<"horizontal" | "vertical">(
|
||||
() => loadUserPreferences().trayLayout,
|
||||
);
|
||||
const [supportsCursorModeToggle, setSupportsCursorModeToggle] = useState(false);
|
||||
const languageTriggerRef = useRef<HTMLButtonElement | null>(null);
|
||||
const languageMenuPanelRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -326,35 +335,15 @@ export function LaunchWindow() {
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const openSourceSelector = () => {
|
||||
const openSourceSelector = async () => {
|
||||
if (window.electronAPI) {
|
||||
window.electronAPI.openSourceSelector();
|
||||
await openSourceSelectorWithPermissionRetry({
|
||||
openSourceSelector: () => window.electronAPI.openSourceSelector(),
|
||||
requestScreenAccess: () => window.electronAPI.requestScreenAccess(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const openVideoFile = async () => {
|
||||
const result = await window.electronAPI.openVideoFilePicker();
|
||||
|
||||
if (result.canceled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.success && result.path) {
|
||||
const setVideoPathResult = await nativeBridgeClient.project.setCurrentVideoPath(result.path);
|
||||
if (!setVideoPathResult.success) {
|
||||
console.error("Failed to set current video path:", setVideoPathResult);
|
||||
return;
|
||||
}
|
||||
await window.electronAPI.switchToEditor();
|
||||
}
|
||||
};
|
||||
|
||||
const openProjectFile = async () => {
|
||||
const result = await nativeBridgeClient.project.loadProjectFile();
|
||||
if (result.canceled || !result.success) return;
|
||||
await window.electronAPI.switchToEditor();
|
||||
};
|
||||
|
||||
const sendHudOverlayHide = () => {
|
||||
if (window.electronAPI && window.electronAPI.hudOverlayHide) {
|
||||
window.electronAPI.hudOverlayHide();
|
||||
@@ -365,6 +354,12 @@ export function LaunchWindow() {
|
||||
window.electronAPI.hudOverlayClose();
|
||||
}
|
||||
};
|
||||
/** Switches the HUD between horizontal and vertical tray layouts. */
|
||||
const toggleTrayLayout = () => {
|
||||
const nextLayout = trayLayout === "horizontal" ? "vertical" : "horizontal";
|
||||
setTrayLayout(nextLayout);
|
||||
saveUserPreferences({ trayLayout: nextLayout });
|
||||
};
|
||||
|
||||
const toggleMicrophone = () => {
|
||||
if (!recording) {
|
||||
@@ -589,7 +584,12 @@ export function LaunchWindow() {
|
||||
{/* HUD bar — fixed at bottom center, viewport-relative, never moves */}
|
||||
<div
|
||||
data-hud-interactive="true"
|
||||
className={`fixed bottom-5 left-1/2 -translate-x-1/2 flex items-center gap-1.5 rounded-2xl border border-white/[0.10] bg-[#07080a]/90 px-2 py-1.5 shadow-[0_20px_60px_rgba(0,0,0,0.42),inset_0_1px_0_rgba(255,255,255,0.06)] backdrop-blur-2xl backdrop-saturate-[140%]`}
|
||||
data-tray-layout={trayLayout}
|
||||
className={`fixed bottom-5 left-1/2 -translate-x-1/2 flex rounded-2xl border border-white/[0.10] bg-[#07080a]/90 shadow-[0_20px_60px_rgba(0,0,0,0.42),inset_0_1px_0_rgba(255,255,255,0.06)] backdrop-blur-2xl backdrop-saturate-[140%] ${
|
||||
trayLayout === "vertical"
|
||||
? "max-h-[calc(100vh-2.5rem)] flex-col items-center gap-1 overflow-y-auto px-1 py-1.5"
|
||||
: "items-center gap-1.5 px-2 py-1.5"
|
||||
}`}
|
||||
onPointerEnter={() => setHudMouseEventsEnabled(true)}
|
||||
onPointerDown={() => setHudMouseEventsEnabled(true)}
|
||||
onMouseEnter={() => setHudMouseEventsEnabled(true)}
|
||||
@@ -601,7 +601,7 @@ export function LaunchWindow() {
|
||||
>
|
||||
{/* Drag handle */}
|
||||
<div
|
||||
className={`flex h-8 w-7 cursor-grab items-center justify-center active:cursor-grabbing ${styles.electronNoDrag}`}
|
||||
className={`flex ${trayLayout === "vertical" ? "h-6 w-8" : "h-8 w-7"} cursor-grab items-center justify-center active:cursor-grabbing ${styles.electronNoDrag}`}
|
||||
onPointerDown={handleHudDragPointerDown}
|
||||
onPointerMove={handleHudDragPointerMove}
|
||||
onPointerUp={handleHudDragPointerEnd}
|
||||
@@ -610,21 +610,53 @@ export function LaunchWindow() {
|
||||
{getIcon("drag", "text-white/30")}
|
||||
</div>
|
||||
|
||||
<Tooltip
|
||||
content={
|
||||
trayLayout === "horizontal"
|
||||
? t("tooltips.useVerticalTray")
|
||||
: t("tooltips.useHorizontalTray")
|
||||
}
|
||||
>
|
||||
<button
|
||||
data-testid="launch-tray-layout-button"
|
||||
type="button"
|
||||
aria-label={
|
||||
trayLayout === "horizontal"
|
||||
? t("tooltips.useVerticalTray")
|
||||
: t("tooltips.useHorizontalTray")
|
||||
}
|
||||
aria-pressed={trayLayout === "vertical"}
|
||||
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
|
||||
onClick={toggleTrayLayout}
|
||||
>
|
||||
{trayLayout === "horizontal" ? (
|
||||
<Columns3 size={ICON_SIZE} className="text-white/60" />
|
||||
) : (
|
||||
<Rows3 size={ICON_SIZE} className="text-white/60" />
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
{/* Source selector */}
|
||||
<button
|
||||
className={`${hudGroupClasses} h-8 px-2.5 ${styles.electronNoDrag}`}
|
||||
className={`${hudGroupClasses} h-8 ${trayLayout === "vertical" ? "w-8 justify-center px-0" : "px-2.5"} ${styles.electronNoDrag}`}
|
||||
onClick={openSourceSelector}
|
||||
disabled={recording}
|
||||
title={selectedSource}
|
||||
aria-label={selectedSource}
|
||||
>
|
||||
{getIcon("monitor", "text-white/80")}
|
||||
<span className="max-w-[86px] truncate text-[11px] font-medium text-white/75">
|
||||
<span
|
||||
className={`${trayLayout === "vertical" ? "sr-only" : "max-w-[86px]"} truncate text-[11px] font-medium text-white/75`}
|
||||
>
|
||||
{selectedSource}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Audio controls group */}
|
||||
<div className={`${hudGroupClasses} ${styles.electronNoDrag}`}>
|
||||
<div
|
||||
className={`${hudGroupClasses} ${trayLayout === "vertical" ? "flex-col py-1" : ""} ${styles.electronNoDrag}`}
|
||||
>
|
||||
<button
|
||||
data-testid="launch-system-audio-button"
|
||||
className={`${hudIconBtnClasses} ${systemAudioEnabled ? "drop-shadow-[0_0_4px_rgba(74,222,128,0.4)]" : ""}`}
|
||||
@@ -697,7 +729,7 @@ export function LaunchWindow() {
|
||||
{/* Record/Stop group */}
|
||||
<button
|
||||
data-testid="launch-record-button"
|
||||
className={`flex items-center justify-center rounded-full p-2 transition-[min-width,background-color] duration-150 ${recording ? "min-w-[78px]" : "min-w-[36px]"} ${styles.electronNoDrag} ${
|
||||
className={`flex items-center justify-center rounded-full p-2 transition-[min-width,background-color] duration-150 ${recording ? "min-w-[78px]" : "min-w-[36px]"} ${trayLayout === "vertical" ? "min-h-9" : ""} ${styles.electronNoDrag} ${
|
||||
recording
|
||||
? paused
|
||||
? "bg-amber-500/10 hover:bg-amber-500/15"
|
||||
@@ -723,7 +755,9 @@ export function LaunchWindow() {
|
||||
</button>
|
||||
|
||||
{recording && (
|
||||
<div className={`flex items-center gap-0.5 ${styles.electronNoDrag}`}>
|
||||
<div
|
||||
className={`flex items-center gap-0.5 ${trayLayout === "vertical" ? "flex-col" : ""} ${styles.electronNoDrag}`}
|
||||
>
|
||||
{canPauseRecording && (
|
||||
<Tooltip
|
||||
content={paused ? t("tooltips.resumeRecording") : t("tooltips.pauseRecording")}
|
||||
@@ -750,33 +784,21 @@ export function LaunchWindow() {
|
||||
)}
|
||||
|
||||
{!recording && (
|
||||
<>
|
||||
{/* Open video file */}
|
||||
<Tooltip content={t("tooltips.openVideoFile")}>
|
||||
<button
|
||||
data-testid="launch-open-video-button"
|
||||
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
|
||||
onClick={openVideoFile}
|
||||
>
|
||||
{getIcon("videoFile", "text-white/60")}
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
{/* Open project */}
|
||||
<Tooltip content={t("tooltips.openProject")}>
|
||||
<button
|
||||
data-testid="launch-open-project-button"
|
||||
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
|
||||
onClick={openProjectFile}
|
||||
>
|
||||
{getIcon("folder", "text-white/60")}
|
||||
</button>
|
||||
</Tooltip>
|
||||
</>
|
||||
<Tooltip content={t("tooltips.openStudio")}>
|
||||
<button
|
||||
data-testid="launch-open-studio-button"
|
||||
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
|
||||
onClick={() => window.electronAPI.switchToEditor()}
|
||||
>
|
||||
<Clapperboard size={ICON_SIZE} className="text-white/60" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Right sidebar controls */}
|
||||
<div className={`${hudSidebarClasses} ${styles.electronNoDrag}`}>
|
||||
<div
|
||||
className={`${trayLayout === "vertical" ? hudSidebarVerticalClasses : hudSidebarClasses} ${styles.electronNoDrag}`}
|
||||
>
|
||||
<div className={`${styles.languageMenuContainer} ${styles.electronNoDrag}`}>
|
||||
<button
|
||||
ref={languageTriggerRef}
|
||||
@@ -785,10 +807,15 @@ export function LaunchWindow() {
|
||||
aria-expanded={isLanguageMenuOpen}
|
||||
aria-haspopup="menu"
|
||||
onClick={() => setIsLanguageMenuOpen((open) => !open)}
|
||||
className={`flex h-8 items-center gap-1.5 rounded-lg border border-white/10 bg-white/[0.045] px-2 text-white/85 shadow-none transition-colors hover:bg-white/10 ${styles.electronNoDrag}`}
|
||||
title={activeLanguageLabel}
|
||||
className={`flex h-8 items-center rounded-lg border border-white/10 bg-white/[0.045] text-white/85 shadow-none transition-colors hover:bg-white/10 ${
|
||||
trayLayout === "vertical" ? "w-8 justify-center px-0" : "gap-1.5 px-2"
|
||||
} ${styles.electronNoDrag}`}
|
||||
>
|
||||
<Languages size={13} className="text-white/70" />
|
||||
<span className="max-w-[54px] truncate text-[10px] font-semibold text-white/75">
|
||||
<span
|
||||
className={`${trayLayout === "vertical" ? "sr-only" : "max-w-[54px]"} truncate text-[10px] font-semibold text-white/75`}
|
||||
>
|
||||
{activeLanguageLabel}
|
||||
</span>
|
||||
</button>
|
||||
@@ -841,7 +868,9 @@ export function LaunchWindow() {
|
||||
: null}
|
||||
|
||||
{/* Window controls */}
|
||||
<div className="flex items-center gap-0.5">
|
||||
<div
|
||||
className={`flex items-center gap-0.5 ${trayLayout === "vertical" ? "flex-col" : ""}`}
|
||||
>
|
||||
<button
|
||||
className={windowBtnClasses}
|
||||
title={t("tooltips.hideHUD")}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import "@testing-library/jest-dom";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { SourceSelector } from "./SourceSelector";
|
||||
|
||||
vi.mock("@/contexts/I18nContext", () => ({
|
||||
useScopedT: (namespace: string) => {
|
||||
if (namespace === "common") {
|
||||
return (key: string) => {
|
||||
if (key === "actions.cancel") return "Cancel";
|
||||
if (key === "actions.share") return "Share";
|
||||
if (key === "actions.reload") return "Reload";
|
||||
return key;
|
||||
};
|
||||
}
|
||||
|
||||
return (key: string, vars?: Record<string, string>) => {
|
||||
if (key === "sourceSelector.loading") return "Loading sources...";
|
||||
if (key === "sourceSelector.emptyTitle") return "No screens or windows found";
|
||||
if (key === "sourceSelector.emptyDescription") {
|
||||
return "If you just granted screen recording permission, reload this picker. On macOS you may need to reopen OpenScreen.";
|
||||
}
|
||||
if (key === "sourceSelector.loadFailedDescription") {
|
||||
return "OpenScreen could not load capture sources. Reload this picker and try again.";
|
||||
}
|
||||
if (key === "sourceSelector.screens") return `Screens (${vars?.count ?? "0"})`;
|
||||
if (key === "sourceSelector.windows") return `Windows (${vars?.count ?? "0"})`;
|
||||
return key;
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
describe("SourceSelector", () => {
|
||||
beforeEach(() => {
|
||||
window.electronAPI = {
|
||||
...window.electronAPI,
|
||||
getSources: vi.fn().mockResolvedValue([]),
|
||||
selectSource: vi.fn(),
|
||||
} as typeof window.electronAPI;
|
||||
});
|
||||
|
||||
it("shows a retry state when no capture sources are available", async () => {
|
||||
render(<SourceSelector />);
|
||||
|
||||
await screen.findByText("No screens or windows found");
|
||||
expect(screen.getByRole("button", { name: "Reload" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("reloads capture sources from the empty state", async () => {
|
||||
const getSources = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
id: "screen:1:0",
|
||||
name: "Display 1",
|
||||
thumbnail: "data:image/png;base64,abc",
|
||||
display_id: "1",
|
||||
appIcon: null,
|
||||
},
|
||||
]);
|
||||
window.electronAPI = {
|
||||
...window.electronAPI,
|
||||
getSources,
|
||||
selectSource: vi.fn(),
|
||||
} as typeof window.electronAPI;
|
||||
|
||||
render(<SourceSelector />);
|
||||
|
||||
await screen.findByText("No screens or windows found");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Reload" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Display 1")).toBeInTheDocument();
|
||||
});
|
||||
expect(getSources).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { MdCheck } from "react-icons/md";
|
||||
import { useScopedT } from "@/contexts/I18nContext";
|
||||
import { Button } from "../ui/button";
|
||||
@@ -19,39 +19,49 @@ export function SourceSelector() {
|
||||
const [sources, setSources] = useState<DesktopSource[]>([]);
|
||||
const [selectedSource, setSelectedSource] = useState<DesktopSource | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadFailed, setLoadFailed] = useState(false);
|
||||
|
||||
const fetchSources = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setLoadFailed(false);
|
||||
try {
|
||||
const rawSources = await window.electronAPI.getSources({
|
||||
types: ["screen", "window"],
|
||||
thumbnailSize: { width: 320, height: 180 },
|
||||
fetchWindowIcons: true,
|
||||
});
|
||||
setSources(
|
||||
rawSources.map((source) => ({
|
||||
id: source.id,
|
||||
name:
|
||||
source.id.startsWith("window:") && source.name.includes(" — ")
|
||||
? source.name.split(" — ")[1] || source.name
|
||||
: source.name,
|
||||
thumbnail: source.thumbnail,
|
||||
display_id: source.display_id,
|
||||
appIcon: source.appIcon,
|
||||
})),
|
||||
);
|
||||
setSelectedSource((current) =>
|
||||
current && rawSources.some((source) => source.id === current.id) ? current : null,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error loading sources:", error);
|
||||
setSources([]);
|
||||
setSelectedSource(null);
|
||||
setLoadFailed(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchSources() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const rawSources = await window.electronAPI.getSources({
|
||||
types: ["screen", "window"],
|
||||
thumbnailSize: { width: 320, height: 180 },
|
||||
fetchWindowIcons: true,
|
||||
});
|
||||
setSources(
|
||||
rawSources.map((source) => ({
|
||||
id: source.id,
|
||||
name:
|
||||
source.id.startsWith("window:") && source.name.includes(" — ")
|
||||
? source.name.split(" — ")[1] || source.name
|
||||
: source.name,
|
||||
thumbnail: source.thumbnail,
|
||||
display_id: source.display_id,
|
||||
appIcon: source.appIcon,
|
||||
})),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error loading sources:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
fetchSources();
|
||||
}, []);
|
||||
void fetchSources();
|
||||
}, [fetchSources]);
|
||||
|
||||
const screenSources = sources.filter((s) => s.id.startsWith("screen:"));
|
||||
const windowSources = sources.filter((s) => s.id.startsWith("window:"));
|
||||
const hasNoSources = !loading && sources.length === 0;
|
||||
|
||||
const handleSourceSelect = (source: DesktopSource) => setSelectedSource(source);
|
||||
const handleShare = async () => {
|
||||
@@ -72,6 +82,30 @@ export function SourceSelector() {
|
||||
);
|
||||
}
|
||||
|
||||
if (hasNoSources) {
|
||||
return (
|
||||
<div
|
||||
className={`h-full flex items-center justify-center ${styles.glassContainer}`}
|
||||
style={{ minHeight: "100vh" }}
|
||||
>
|
||||
<div className="max-w-[320px] px-6 text-center">
|
||||
<h2 className="text-sm font-semibold text-white">{t("sourceSelector.emptyTitle")}</h2>
|
||||
<p className="mt-2 text-xs leading-5 text-zinc-400">
|
||||
{loadFailed
|
||||
? t("sourceSelector.loadFailedDescription")
|
||||
: t("sourceSelector.emptyDescription")}
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => void fetchSources()}
|
||||
className="mt-4 h-8 rounded-lg bg-[#34B27B] px-5 text-[11px] font-semibold text-white transition-transform duration-150 hover:bg-[#34B27B]/85 active:scale-95"
|
||||
>
|
||||
{tc("actions.reload")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const renderSourceCard = (source: DesktopSource) => {
|
||||
const isSelected = selectedSource?.id === source.id;
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { openSourceSelectorWithPermissionRetry } from "./openSourceSelectorFlow";
|
||||
|
||||
describe("openSourceSelectorWithPermissionRetry", () => {
|
||||
it("returns immediately when the source selector opens on the first attempt", async () => {
|
||||
const openSourceSelector = vi.fn().mockResolvedValue({ opened: true });
|
||||
const requestScreenAccess = vi.fn();
|
||||
|
||||
const result = await openSourceSelectorWithPermissionRetry({
|
||||
openSourceSelector,
|
||||
requestScreenAccess,
|
||||
wait: vi.fn(),
|
||||
});
|
||||
|
||||
expect(result).toEqual({ opened: true });
|
||||
expect(openSourceSelector).toHaveBeenCalledTimes(1);
|
||||
expect(requestScreenAccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("retries opening after macOS screen permission becomes granted", async () => {
|
||||
const openSourceSelector = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
opened: false,
|
||||
reason: "screen-access-required",
|
||||
access: { success: true, granted: false, status: "not-determined" },
|
||||
})
|
||||
.mockResolvedValueOnce({ opened: true });
|
||||
const requestScreenAccess = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ success: true, granted: false, status: "not-determined" })
|
||||
.mockResolvedValueOnce({ success: true, granted: true, status: "granted" });
|
||||
const wait = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
const result = await openSourceSelectorWithPermissionRetry({
|
||||
openSourceSelector,
|
||||
requestScreenAccess,
|
||||
wait,
|
||||
maxAttempts: 4,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ opened: true });
|
||||
expect(wait).toHaveBeenCalledTimes(2);
|
||||
expect(requestScreenAccess).toHaveBeenCalledTimes(2);
|
||||
expect(openSourceSelector).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("stops retrying once macOS permission is explicitly denied", async () => {
|
||||
const openSourceSelector = vi.fn().mockResolvedValue({
|
||||
opened: false,
|
||||
reason: "screen-access-required",
|
||||
access: { success: true, granted: false, status: "not-determined" },
|
||||
});
|
||||
const requestScreenAccess = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ success: true, granted: false, status: "denied" });
|
||||
|
||||
const result = await openSourceSelectorWithPermissionRetry({
|
||||
openSourceSelector,
|
||||
requestScreenAccess,
|
||||
wait: vi.fn(),
|
||||
maxAttempts: 4,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
opened: false,
|
||||
reason: "screen-access-required",
|
||||
access: { success: true, granted: false, status: "denied" },
|
||||
});
|
||||
expect(requestScreenAccess).toHaveBeenCalledTimes(1);
|
||||
expect(openSourceSelector).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
export type ScreenAccessResult = {
|
||||
success: boolean;
|
||||
granted: boolean;
|
||||
status: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type OpenSourceSelectorResult = {
|
||||
opened: boolean;
|
||||
reason?: string;
|
||||
access?: ScreenAccessResult;
|
||||
};
|
||||
|
||||
type OpenSourceSelectorFlowOptions = {
|
||||
openSourceSelector: () => Promise<OpenSourceSelectorResult>;
|
||||
requestScreenAccess: () => Promise<ScreenAccessResult>;
|
||||
wait?: (ms: number) => Promise<void>;
|
||||
retryDelayMs?: number;
|
||||
maxAttempts?: number;
|
||||
};
|
||||
|
||||
const defaultWait = (ms: number) => new Promise<void>((resolve) => window.setTimeout(resolve, ms));
|
||||
|
||||
function shouldRetryAfterPermissionPrompt(result: OpenSourceSelectorResult): boolean {
|
||||
return (
|
||||
result.opened === false &&
|
||||
result.reason === "screen-access-required" &&
|
||||
result.access?.status === "not-determined"
|
||||
);
|
||||
}
|
||||
|
||||
export async function openSourceSelectorWithPermissionRetry({
|
||||
openSourceSelector,
|
||||
requestScreenAccess,
|
||||
wait = defaultWait,
|
||||
retryDelayMs = 750,
|
||||
maxAttempts = 8,
|
||||
}: OpenSourceSelectorFlowOptions): Promise<OpenSourceSelectorResult> {
|
||||
const initialResult = await openSourceSelector();
|
||||
if (!shouldRetryAfterPermissionPrompt(initialResult)) {
|
||||
return initialResult;
|
||||
}
|
||||
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
||||
await wait(retryDelayMs);
|
||||
const access = await requestScreenAccess();
|
||||
|
||||
if (access.granted) {
|
||||
return openSourceSelector();
|
||||
}
|
||||
|
||||
if (access.status !== "not-determined") {
|
||||
return {
|
||||
opened: false,
|
||||
reason: "screen-access-required",
|
||||
access,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return initialResult;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { type CSSProperties, type PointerEvent, useEffect, useRef, useState } from "react";
|
||||
import { Rnd } from "react-rnd";
|
||||
import { getTextAnimationState } from "@/lib/annotationTextAnimation";
|
||||
import {
|
||||
getBlurOverlayColor,
|
||||
getMosaicGridOverlayColor,
|
||||
@@ -50,6 +51,7 @@ interface AnnotationOverlayProps {
|
||||
isSelectedBoost: boolean; // Boost z-index when selected for easy editing
|
||||
previewSourceCanvas?: PreviewCanvasSource | null;
|
||||
previewFrameVersion?: number;
|
||||
currentTimeMs: number;
|
||||
}
|
||||
|
||||
export function AnnotationOverlay({
|
||||
@@ -66,6 +68,7 @@ export function AnnotationOverlay({
|
||||
isSelectedBoost,
|
||||
previewSourceCanvas,
|
||||
previewFrameVersion,
|
||||
currentTimeMs,
|
||||
}: AnnotationOverlayProps) {
|
||||
const committedX = (annotation.position.x / 100) * containerWidth;
|
||||
const committedY = (annotation.position.y / 100) * containerHeight;
|
||||
@@ -283,7 +286,12 @@ export function AnnotationOverlay({
|
||||
|
||||
const renderContent = () => {
|
||||
switch (annotation.type) {
|
||||
case "text":
|
||||
case "text": {
|
||||
const animationState = getTextAnimationState(annotation, currentTimeMs);
|
||||
const typewriterClip =
|
||||
animationState.revealProgress < 1
|
||||
? `inset(0 ${100 - animationState.revealProgress * 100}% 0 0)`
|
||||
: undefined;
|
||||
return (
|
||||
<div
|
||||
className="w-full h-full flex items-center p-2 overflow-hidden"
|
||||
@@ -307,6 +315,11 @@ export function AnnotationOverlay({
|
||||
fontStyle: annotation.style.fontStyle,
|
||||
textDecoration: annotation.style.textDecoration,
|
||||
textAlign: annotation.style.textAlign,
|
||||
opacity: animationState.opacity,
|
||||
transform: `translate(${animationState.translateX}px, ${animationState.translateY}px) scale(${animationState.scale})`,
|
||||
transformOrigin: "center",
|
||||
clipPath: typewriterClip,
|
||||
WebkitClipPath: typewriterClip,
|
||||
wordBreak: "break-word",
|
||||
whiteSpace: "pre-wrap",
|
||||
boxDecorationBreak: "clone",
|
||||
@@ -320,6 +333,7 @@ export function AnnotationOverlay({
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case "image":
|
||||
if (annotation.content && annotation.content.startsWith("data:image")) {
|
||||
|
||||
@@ -28,6 +28,7 @@ import { Slider } from "@/components/ui/slider";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
import { useScopedT } from "@/contexts/I18nContext";
|
||||
import { normalizeTextAnimation, TEXT_ANIMATION_OPTIONS } from "@/lib/annotationTextAnimation";
|
||||
import { type CustomFont, getCustomFonts } from "@/lib/customFonts";
|
||||
import { cn } from "@/lib/utils";
|
||||
import ColorPicker from "../ui/color-picker";
|
||||
@@ -50,7 +51,11 @@ interface AnnotationSettingsPanelProps {
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
const FONT_FAMILIES = [
|
||||
const FONT_FAMILIES: Array<
|
||||
| { value: string; labelKey: string; name?: never }
|
||||
| { value: string; labelKey?: never; name: string }
|
||||
> = [
|
||||
{ value: "Inter", name: "Inter" },
|
||||
{ value: "system-ui, -apple-system, sans-serif", labelKey: "classic" },
|
||||
{ value: "Georgia, serif", labelKey: "editor" },
|
||||
{ value: "Impact, Arial Black, sans-serif", labelKey: "strong" },
|
||||
@@ -59,6 +64,21 @@ const FONT_FAMILIES = [
|
||||
{ value: "Arial, sans-serif", labelKey: "simple" },
|
||||
{ value: "Verdana, sans-serif", labelKey: "modern" },
|
||||
{ value: "Trebuchet MS, sans-serif", labelKey: "clean" },
|
||||
{ value: '"Plus Jakarta Sans", sans-serif', name: "Plus Jakarta Sans" },
|
||||
{ value: '"Space Grotesk", sans-serif', name: "Space Grotesk" },
|
||||
{ value: '"DM Sans", sans-serif', name: "DM Sans" },
|
||||
{ value: "Sora, sans-serif", name: "Sora" },
|
||||
{ value: "Manrope, sans-serif", name: "Manrope" },
|
||||
{ value: '"IBM Plex Sans", sans-serif', name: "IBM Plex Sans" },
|
||||
{ value: '"Playfair Display", Georgia, serif', name: "Playfair Display" },
|
||||
{ value: "Merriweather, Georgia, serif", name: "Merriweather" },
|
||||
{ value: "Lora, Georgia, serif", name: "Lora" },
|
||||
{ value: '"IBM Plex Mono", monospace', name: "IBM Plex Mono" },
|
||||
{ value: '"Fira Code", monospace', name: "Fira Code" },
|
||||
{ value: '"Bebas Neue", sans-serif', name: "Bebas Neue" },
|
||||
{ value: "Oswald, sans-serif", name: "Oswald" },
|
||||
{ value: "Caveat, cursive", name: "Caveat" },
|
||||
{ value: '"Permanent Marker", cursive', name: "Permanent Marker" },
|
||||
];
|
||||
|
||||
const FONT_SIZES = [12, 14, 16, 18, 20, 24, 28, 32, 36, 40, 48, 56, 64, 72, 80, 96, 128];
|
||||
@@ -85,6 +105,8 @@ export function AnnotationSettingsPanel({
|
||||
modern: t("fontStyles.modern"),
|
||||
clean: t("fontStyles.clean"),
|
||||
};
|
||||
const getFontLabel = (font: (typeof FONT_FAMILIES)[number]) =>
|
||||
font.labelKey ? fontStyleLabels[font.labelKey] : font.name;
|
||||
|
||||
// Load custom fonts on mount
|
||||
useEffect(() => {
|
||||
@@ -231,7 +253,7 @@ export function AnnotationSettingsPanel({
|
||||
value={font.value}
|
||||
style={{ fontFamily: font.value }}
|
||||
>
|
||||
{fontStyleLabels[font.labelKey]}
|
||||
{getFontLabel(font)}
|
||||
</SelectItem>
|
||||
))}
|
||||
{customFonts.length > 0 && (
|
||||
@@ -285,6 +307,29 @@ export function AnnotationSettingsPanel({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium text-slate-200">
|
||||
{t("annotation.textAnimation")}
|
||||
</label>
|
||||
<Select
|
||||
value={normalizeTextAnimation(annotation.style.textAnimation)}
|
||||
onValueChange={(value) =>
|
||||
onStyleChange({ textAnimation: normalizeTextAnimation(value) })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-9 w-full border-white/10 bg-white/5 text-xs text-slate-200">
|
||||
<SelectValue placeholder={t("annotation.selectAnimation")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-[240px] border-white/10 bg-[#1a1a1c] text-slate-200">
|
||||
{TEXT_ANIMATION_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{t(option.translationKey)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Formatting Toggles */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<ToggleGroup
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import { AlertCircle, Film, FolderOpen, Upload, X } from "lucide-react";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { useScopedT } from "@/contexts/I18nContext";
|
||||
import { nativeBridgeClient } from "@/native";
|
||||
|
||||
interface EditorEmptyStateProps {
|
||||
onVideoImported: (videoPath: string) => void;
|
||||
/** Called with the loaded project data — handles both button click and drag-drop */
|
||||
onProjectOpened: (project: unknown, path: string | null) => void;
|
||||
}
|
||||
|
||||
type DropError = "unsupported-format" | "load-failed" | null;
|
||||
|
||||
export function EditorEmptyState({ onVideoImported, onProjectOpened }: EditorEmptyStateProps) {
|
||||
const te = useScopedT("editor");
|
||||
const tc = useScopedT("common");
|
||||
const [isDraggingOver, setIsDraggingOver] = useState(false);
|
||||
const [dropError, setDropError] = useState<DropError>(null);
|
||||
// Freeze the last non-null error type so dialog content doesn't snap to the
|
||||
// else-branch during the closing animation (same pattern as UnsavedChangesDialog).
|
||||
const lastDropErrorRef = useRef<Exclude<DropError, null>>("unsupported-format");
|
||||
if (dropError !== null) {
|
||||
lastDropErrorRef.current = dropError;
|
||||
}
|
||||
|
||||
const handleImportVideo = useCallback(async () => {
|
||||
const result = await window.electronAPI.openVideoFilePicker();
|
||||
if (result.canceled || !result.success || !result.path) return;
|
||||
|
||||
const setResult = await nativeBridgeClient.project.setCurrentVideoPath(result.path);
|
||||
if (!setResult.success) return;
|
||||
|
||||
onVideoImported(result.path);
|
||||
}, [onVideoImported]);
|
||||
|
||||
const handleLoadProject = useCallback(async () => {
|
||||
const result = await nativeBridgeClient.project.loadProjectFile();
|
||||
if (result.canceled || !result.success || !result.project) return;
|
||||
onProjectOpened(result.project, result.path ?? null);
|
||||
}, [onProjectOpened]);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
if (e.dataTransfer.items.length > 0) {
|
||||
setIsDraggingOver(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
|
||||
setIsDraggingOver(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
async (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDraggingOver(false);
|
||||
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
if (files.length === 0) return;
|
||||
|
||||
const projectFile = files.find((f) => f.name.endsWith(".openscreen"));
|
||||
if (!projectFile) {
|
||||
setDropError("unsupported-format");
|
||||
return;
|
||||
}
|
||||
|
||||
// Use Electron's webUtils.getPathForFile — File.path was removed in Electron 32+
|
||||
let filePath: string;
|
||||
try {
|
||||
filePath = window.electronAPI.getPathForFile(projectFile);
|
||||
} catch {
|
||||
setDropError("load-failed");
|
||||
return;
|
||||
}
|
||||
if (!filePath) {
|
||||
setDropError("load-failed");
|
||||
return;
|
||||
}
|
||||
|
||||
let result: Awaited<ReturnType<typeof window.electronAPI.loadProjectFileFromPath>>;
|
||||
try {
|
||||
result = await window.electronAPI.loadProjectFileFromPath(filePath);
|
||||
} catch {
|
||||
setDropError("load-failed");
|
||||
return;
|
||||
}
|
||||
if (!result.success || !result.project) {
|
||||
setDropError("load-failed");
|
||||
return;
|
||||
}
|
||||
|
||||
onProjectOpened(result.project, result.path ?? null);
|
||||
},
|
||||
[onProjectOpened],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex h-full w-full flex-col items-center justify-center bg-[#09090b]"
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{/* Drop overlay */}
|
||||
{isDraggingOver && (
|
||||
<div className="pointer-events-none absolute inset-0 z-50 flex flex-col items-center justify-center rounded-xl border-2 border-dashed border-[#34B27B] bg-[#34B27B]/10">
|
||||
<Upload className="mb-3 h-10 w-10 text-[#34B27B]" />
|
||||
<p className="text-base font-semibold text-[#34B27B]">{te("emptyState.dropOverlay")}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Drop error dialog */}
|
||||
<Dialog open={dropError !== null} onOpenChange={(open) => !open && setDropError(null)}>
|
||||
<DialogContent className="bg-[#09090b] border-white/10 rounded-2xl max-w-sm p-6 gap-0">
|
||||
<DialogHeader className="mb-4">
|
||||
<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">
|
||||
{lastDropErrorRef.current === "unsupported-format"
|
||||
? te("emptyState.dropErrors.unsupportedFormatTitle")
|
||||
: te("emptyState.dropErrors.couldNotOpenTitle")}
|
||||
</DialogTitle>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col items-center gap-3 mb-6 text-center">
|
||||
<div className="flex items-center justify-center w-10 h-10 rounded-full bg-white/5 ring-1 ring-white/10">
|
||||
<AlertCircle className="w-5 h-5 text-slate-400 flex-shrink-0" />
|
||||
</div>
|
||||
<p className="text-sm text-slate-400 leading-relaxed">
|
||||
{lastDropErrorRef.current === "unsupported-format"
|
||||
? te("emptyState.dropErrors.unsupportedFormatMessage")
|
||||
: te("emptyState.dropErrors.couldNotOpenMessage")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDropError(null)}
|
||||
className="flex items-center justify-center gap-2 w-full px-4 py-2.5 rounded-lg bg-white/5 hover:bg-white/10 border border-white/10 text-slate-300 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]"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
{tc("actions.close")}
|
||||
</button>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<div className="relative flex flex-col items-center gap-8 px-6 text-center">
|
||||
{/* Logo */}
|
||||
<img
|
||||
src="./openscreen.png"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="h-16 w-16 rounded-2xl opacity-90"
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<h2 className="text-xl font-semibold text-slate-200">{te("emptyState.title")}</h2>
|
||||
<p className="max-w-sm text-sm leading-relaxed text-slate-500">
|
||||
{te("emptyState.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-col gap-3 w-full max-w-xs">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleImportVideo}
|
||||
className="flex items-center justify-center gap-2.5 w-full px-4 py-3 rounded-xl 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]"
|
||||
>
|
||||
<Film className="h-4 w-4" />
|
||||
{te("emptyState.importVideoButton")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLoadProject}
|
||||
className="flex items-center justify-center gap-2.5 w-full px-4 py-3 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 text-slate-300 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]"
|
||||
>
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
{te("emptyState.loadProjectButton")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<p className="text-xs text-slate-600">{te("emptyState.supportedFormats")}</p>
|
||||
<div className="flex items-center gap-1.5 text-xs text-slate-700 mt-4">
|
||||
<Upload className="h-3 w-3" />
|
||||
<span>{te("emptyState.dragDropHint")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as SliderPrimitive from "@radix-ui/react-slider";
|
||||
import {
|
||||
Brackets,
|
||||
Bug,
|
||||
Crop,
|
||||
Download,
|
||||
@@ -259,6 +260,8 @@ interface SettingsPanelProps {
|
||||
onShadowCommit?: () => void;
|
||||
showBlur?: boolean;
|
||||
onBlurChange?: (showBlur: boolean) => void;
|
||||
showTrimWaveform?: boolean;
|
||||
onTrimWaveformChange?: (show: boolean) => void;
|
||||
motionBlurAmount?: number;
|
||||
onMotionBlurChange?: (amount: number) => void;
|
||||
onMotionBlurCommit?: () => void;
|
||||
@@ -285,6 +288,7 @@ interface SettingsPanelProps {
|
||||
onGifSizePresetChange?: (preset: GifSizePreset) => void;
|
||||
gifOutputDimensions?: { width: number; height: number };
|
||||
onExport?: () => void;
|
||||
onExportPanelOpen?: () => void;
|
||||
unsavedExport?: {
|
||||
arrayBuffer: ArrayBuffer;
|
||||
fileName: string;
|
||||
@@ -346,7 +350,7 @@ const ZOOM_DEPTH_OPTIONS: Array<{ depth: ZoomDepth; label: string }> = [
|
||||
{ depth: 6, label: "5×" },
|
||||
];
|
||||
|
||||
type SettingsPanelMode = "background" | "effects" | "layout" | "cursor" | "export";
|
||||
type SettingsPanelMode = "background" | "effects" | "layout" | "cursor" | "export" | "timeline";
|
||||
|
||||
const MP4_EXPORT_SHORT_SIDES = {
|
||||
medium: 720,
|
||||
@@ -392,6 +396,8 @@ export function SettingsPanel({
|
||||
onShadowCommit,
|
||||
showBlur,
|
||||
onBlurChange,
|
||||
showTrimWaveform = false,
|
||||
onTrimWaveformChange,
|
||||
motionBlurAmount = 0,
|
||||
onMotionBlurChange,
|
||||
onMotionBlurCommit,
|
||||
@@ -417,6 +423,7 @@ export function SettingsPanel({
|
||||
onGifSizePresetChange,
|
||||
gifOutputDimensions = DEFAULT_GIF_SETTINGS.outputDimensions,
|
||||
onExport,
|
||||
onExportPanelOpen,
|
||||
unsavedExport,
|
||||
onSaveUnsavedExport,
|
||||
selectedAnnotationId,
|
||||
@@ -608,6 +615,7 @@ export function SettingsPanel({
|
||||
{ id: "background", label: t("background.title"), icon: Palette },
|
||||
{ id: "effects", label: t("effects.title"), icon: SlidersHorizontal },
|
||||
{ id: "layout", label: t("layout.title"), icon: LayoutPanelTop, disabled: !hasWebcam },
|
||||
{ id: "timeline", label: t("timeline.title"), icon: Brackets },
|
||||
...(hasCursorPanel
|
||||
? [
|
||||
{
|
||||
@@ -629,8 +637,10 @@ export function SettingsPanel({
|
||||
: selectedSpeedId
|
||||
? t("speed.playbackSpeed")
|
||||
: t("trim.deleteRegion")
|
||||
: ([...panelModes, exportPanelMode].find((mode) => mode.id === activePanelMode)?.label ??
|
||||
t("background.title"));
|
||||
: activePanelMode === "timeline"
|
||||
? t("timeline.title")
|
||||
: ([...panelModes, exportPanelMode].find((mode) => mode.id === activePanelMode)?.label ??
|
||||
t("background.title"));
|
||||
|
||||
const handleDeleteClick = () => {
|
||||
if (selectedZoomId && onZoomDelete) {
|
||||
@@ -827,7 +837,10 @@ export function SettingsPanel({
|
||||
data-testid={getTestId("export-panel-button")}
|
||||
type="button"
|
||||
title={exportPanelMode.label}
|
||||
onClick={() => setActivePanelMode(exportPanelMode.id)}
|
||||
onClick={() => {
|
||||
setActivePanelMode(exportPanelMode.id);
|
||||
onExportPanelOpen?.();
|
||||
}}
|
||||
className={cn(
|
||||
"mt-auto flex h-8 w-8 items-center justify-center rounded-lg border transition-all",
|
||||
activePanelMode === "export" && !hasTimelineSelection
|
||||
@@ -1428,7 +1441,7 @@ export function SettingsPanel({
|
||||
onValueChange={(values) => onBorderRadiusChange?.(values[0])}
|
||||
onValueCommit={() => onBorderRadiusCommit?.()}
|
||||
min={0}
|
||||
max={16}
|
||||
max={64}
|
||||
step={0.5}
|
||||
className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3"
|
||||
/>
|
||||
@@ -1714,6 +1727,28 @@ export function SettingsPanel({
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
)}
|
||||
{activePanelMode === "timeline" && (
|
||||
<AccordionItem value="timeline" className="editor-panel-section px-3">
|
||||
<AccordionTrigger className="py-2.5 hover:no-underline">
|
||||
<div className="flex items-center gap-2">
|
||||
<Brackets className="w-4 h-4 text-[#34B27B]" />
|
||||
<span className="text-xs font-medium">{t("timeline.title")}</span>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-3">
|
||||
<div className="flex items-center justify-between p-2 rounded-lg editor-control-surface">
|
||||
<div className="text-[10px] font-medium text-slate-300">
|
||||
{t("timeline.waveform")}
|
||||
</div>
|
||||
<Switch
|
||||
checked={showTrimWaveform}
|
||||
onCheckedChange={onTrimWaveformChange}
|
||||
className="data-[state=checked]:bg-[#34B27B] scale-90 ml-2 shrink-0"
|
||||
/>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
)}
|
||||
</Accordion>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -102,10 +102,14 @@ export function ShortcutsConfigDialog() {
|
||||
const handleCancelConflict = useCallback(() => setConflict(null), []);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
setShortcuts(draft);
|
||||
await persistShortcuts(draft);
|
||||
toast.success(t("savedToast"));
|
||||
closeConfig();
|
||||
const success = await persistShortcuts(draft);
|
||||
if (success) {
|
||||
setShortcuts(draft);
|
||||
toast.success(t("savedToast"));
|
||||
closeConfig();
|
||||
} else {
|
||||
toast.error(t("registrationFailed"));
|
||||
}
|
||||
}, [draft, setShortcuts, persistShortcuts, closeConfig, t]);
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
@@ -222,7 +226,7 @@ export function ShortcutsConfigDialog() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-slate-400 hover:text-white gap-1.5"
|
||||
className="text-slate-400 gap-1.5"
|
||||
onClick={handleReset}
|
||||
>
|
||||
<RotateCcw className="w-3 h-3" />
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useScopedT } from "@/contexts/I18nContext";
|
||||
|
||||
interface UnsavedChangesDialogProps {
|
||||
isOpen: boolean;
|
||||
variant?: "close" | "newProject" | "loadProject";
|
||||
onSaveAndClose: () => void;
|
||||
onDiscardAndClose: () => void;
|
||||
onCancel: () => void;
|
||||
@@ -17,6 +18,7 @@ interface UnsavedChangesDialogProps {
|
||||
|
||||
export function UnsavedChangesDialog({
|
||||
isOpen,
|
||||
variant = "close",
|
||||
onSaveAndClose,
|
||||
onDiscardAndClose,
|
||||
onCancel,
|
||||
@@ -24,6 +26,25 @@ export function UnsavedChangesDialog({
|
||||
const td = useScopedT("dialogs");
|
||||
const tc = useScopedT("common");
|
||||
|
||||
const detail =
|
||||
variant === "newProject"
|
||||
? td("unsavedChanges.detailNewProject")
|
||||
: variant === "loadProject"
|
||||
? td("unsavedChanges.detailLoadProject")
|
||||
: td("unsavedChanges.detail");
|
||||
const saveLabel =
|
||||
variant === "newProject"
|
||||
? td("unsavedChanges.saveAndNewProject")
|
||||
: variant === "loadProject"
|
||||
? td("unsavedChanges.saveAndLoadProject")
|
||||
: td("unsavedChanges.saveAndClose");
|
||||
const discardLabel =
|
||||
variant === "newProject"
|
||||
? td("unsavedChanges.discardAndNewProject")
|
||||
: variant === "loadProject"
|
||||
? td("unsavedChanges.discardAndLoadProject")
|
||||
: td("unsavedChanges.discardAndClose");
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onCancel()}>
|
||||
<DialogContent className="bg-[#09090b] border-white/10 rounded-2xl max-w-sm p-6 gap-0">
|
||||
@@ -42,9 +63,7 @@ export function UnsavedChangesDialog({
|
||||
</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>
|
||||
<DialogDescription className="text-sm text-slate-500 mb-6">{detail}</DialogDescription>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
@@ -53,7 +72,7 @@ export function UnsavedChangesDialog({
|
||||
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")}
|
||||
{saveLabel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -61,7 +80,7 @@ export function UnsavedChangesDialog({
|
||||
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")}
|
||||
{discardLabel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
getNativeAspectRatioValue,
|
||||
isPortraitAspectRatio,
|
||||
} from "@/utils/aspectRatioUtils";
|
||||
import { EditorEmptyState } from "./EditorEmptyState";
|
||||
import { ExportDialog } from "./ExportDialog";
|
||||
import {
|
||||
DEFAULT_CURSOR_SETTINGS,
|
||||
@@ -158,6 +159,7 @@ export default function VideoEditor() {
|
||||
commitState,
|
||||
undo,
|
||||
redo,
|
||||
resetState,
|
||||
} = useEditorHistory(INITIAL_EDITOR_STATE);
|
||||
|
||||
const {
|
||||
@@ -169,6 +171,7 @@ export default function VideoEditor() {
|
||||
wallpaper,
|
||||
shadowIntensity,
|
||||
showBlur,
|
||||
showTrimWaveform,
|
||||
motionBlurAmount,
|
||||
borderRadius,
|
||||
padding,
|
||||
@@ -224,6 +227,11 @@ export default function VideoEditor() {
|
||||
} | null>(null);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [showCloseConfirmDialog, setShowCloseConfirmDialog] = useState(false);
|
||||
// Unsaved-changes confirmation for New Project / Load Project actions.
|
||||
// (The window-close flow uses showCloseConfirmDialog above.)
|
||||
const [confirmDialogVariant, setConfirmDialogVariant] = useState<
|
||||
"newProject" | "loadProject" | null
|
||||
>(null);
|
||||
const playerContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const cursorTelemetrySourcePath = videoSourcePath ?? (videoPath ? fromFileUrl(videoPath) : null);
|
||||
const { samples: cursorTelemetry, error: cursorTelemetryError } =
|
||||
@@ -356,6 +364,7 @@ export default function VideoEditor() {
|
||||
wallpaper: normalizedEditor.wallpaper,
|
||||
shadowIntensity: normalizedEditor.shadowIntensity,
|
||||
showBlur: normalizedEditor.showBlur,
|
||||
showTrimWaveform: normalizedEditor.showTrimWaveform,
|
||||
motionBlurAmount: normalizedEditor.motionBlurAmount,
|
||||
borderRadius: normalizedEditor.borderRadius,
|
||||
padding: normalizedEditor.padding,
|
||||
@@ -428,6 +437,7 @@ export default function VideoEditor() {
|
||||
wallpaper,
|
||||
shadowIntensity,
|
||||
showBlur,
|
||||
showTrimWaveform,
|
||||
motionBlurAmount,
|
||||
borderRadius,
|
||||
padding,
|
||||
@@ -453,6 +463,7 @@ export default function VideoEditor() {
|
||||
wallpaper,
|
||||
shadowIntensity,
|
||||
showBlur,
|
||||
showTrimWaveform,
|
||||
motionBlurAmount,
|
||||
borderRadius,
|
||||
padding,
|
||||
@@ -527,9 +538,9 @@ export default function VideoEditor() {
|
||||
setLastSavedSnapshot(
|
||||
createProjectSnapshot({ screenVideoPath: result.path }, INITIAL_EDITOR_STATE),
|
||||
);
|
||||
} else {
|
||||
setError("No video to load. Please record or select a video.");
|
||||
}
|
||||
// No video/project/session — leave videoPath null so the
|
||||
// EditorEmptyState dashboard renders instead of an error screen.
|
||||
} catch (err) {
|
||||
setError("Error loading video: " + String(err));
|
||||
} finally {
|
||||
@@ -578,6 +589,7 @@ export default function VideoEditor() {
|
||||
wallpaper,
|
||||
shadowIntensity,
|
||||
showBlur,
|
||||
showTrimWaveform,
|
||||
motionBlurAmount,
|
||||
borderRadius,
|
||||
padding,
|
||||
@@ -638,6 +650,7 @@ export default function VideoEditor() {
|
||||
wallpaper,
|
||||
shadowIntensity,
|
||||
showBlur,
|
||||
showTrimWaveform,
|
||||
motionBlurAmount,
|
||||
borderRadius,
|
||||
padding,
|
||||
@@ -713,7 +726,7 @@ export default function VideoEditor() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleLoadProject = useCallback(async () => {
|
||||
const doLoadProject = useCallback(async () => {
|
||||
const result = await nativeBridgeClient.project.loadProjectFile();
|
||||
|
||||
if (result.canceled) {
|
||||
@@ -734,17 +747,97 @@ export default function VideoEditor() {
|
||||
toast.success(t("project.loadedFrom", { path: result.path ?? "" }));
|
||||
}, [applyLoadedProject, t]);
|
||||
|
||||
const handleLoadProject = useCallback(async () => {
|
||||
if (hasUnsavedChanges) {
|
||||
setConfirmDialogVariant("loadProject");
|
||||
return;
|
||||
}
|
||||
await doLoadProject();
|
||||
}, [hasUnsavedChanges, doLoadProject]);
|
||||
|
||||
const handleLoadProjectConfirmSave = useCallback(async () => {
|
||||
setConfirmDialogVariant(null);
|
||||
const saved = await saveProject(false);
|
||||
if (saved) {
|
||||
await doLoadProject();
|
||||
}
|
||||
}, [saveProject, doLoadProject]);
|
||||
|
||||
const handleLoadProjectConfirmDiscard = useCallback(async () => {
|
||||
setConfirmDialogVariant(null);
|
||||
await doLoadProject();
|
||||
}, [doLoadProject]);
|
||||
|
||||
// New Project: clear all media/project/editor state back to the empty
|
||||
// Studio dashboard. Prompts to save first when there are unsaved changes.
|
||||
const doNewProject = useCallback(async () => {
|
||||
await nativeBridgeClient.project.clearCurrentVideoPath();
|
||||
setVideoPath(null);
|
||||
setVideoSourcePath(null);
|
||||
setWebcamVideoPath(null);
|
||||
setWebcamVideoSourcePath(null);
|
||||
setCurrentProjectPath(null);
|
||||
setLastSavedSnapshot(null);
|
||||
// Reset undoable editor state + undo/redo history to a clean slate.
|
||||
resetState();
|
||||
// Reset non-undoable selection state.
|
||||
setSelectedZoomId(null);
|
||||
setSelectedTrimId(null);
|
||||
setSelectedSpeedId(null);
|
||||
setSelectedAnnotationId(null);
|
||||
setSelectedBlurId(null);
|
||||
// Reset playback.
|
||||
setCurrentTime(0);
|
||||
setIsPlaying(false);
|
||||
// Reset cursor preferences to defaults.
|
||||
setShowCursor(DEFAULT_CURSOR_SETTINGS.show);
|
||||
setCursorSize(DEFAULT_CURSOR_SETTINGS.size);
|
||||
setCursorSmoothing(DEFAULT_CURSOR_SETTINGS.smoothing);
|
||||
setCursorMotionBlur(DEFAULT_CURSOR_SETTINGS.motionBlur);
|
||||
setCursorClickBounce(DEFAULT_CURSOR_SETTINGS.clickBounce);
|
||||
setCursorClipToBounds(DEFAULT_CURSOR_SETTINGS.clipToBounds);
|
||||
// Reset region ID counters.
|
||||
nextZoomIdRef.current = 1;
|
||||
nextTrimIdRef.current = 1;
|
||||
nextSpeedIdRef.current = 1;
|
||||
nextAnnotationIdRef.current = 1;
|
||||
nextAnnotationZIndexRef.current = 1;
|
||||
}, [resetState]);
|
||||
|
||||
const handleNewProject = useCallback(async () => {
|
||||
if (hasUnsavedChanges) {
|
||||
setConfirmDialogVariant("newProject");
|
||||
return;
|
||||
}
|
||||
await doNewProject();
|
||||
}, [hasUnsavedChanges, doNewProject]);
|
||||
|
||||
const handleNewProjectConfirmSave = useCallback(async () => {
|
||||
setConfirmDialogVariant(null);
|
||||
const saved = await saveProject(false);
|
||||
if (saved) {
|
||||
await doNewProject();
|
||||
}
|
||||
}, [saveProject, doNewProject]);
|
||||
|
||||
const handleNewProjectConfirmDiscard = useCallback(async () => {
|
||||
setConfirmDialogVariant(null);
|
||||
await doNewProject();
|
||||
}, [doNewProject]);
|
||||
|
||||
useEffect(() => {
|
||||
const removeNewProjectListener = window.electronAPI.onMenuNewProject(handleNewProject);
|
||||
const removeLoadListener = window.electronAPI.onMenuLoadProject(handleLoadProject);
|
||||
const removeSaveListener = window.electronAPI.onMenuSaveProject(handleSaveProject);
|
||||
const removeSaveAsListener = window.electronAPI.onMenuSaveProjectAs(handleSaveProjectAs);
|
||||
|
||||
return () => {
|
||||
removeNewProjectListener?.();
|
||||
removeLoadListener?.();
|
||||
removeSaveListener?.();
|
||||
removeSaveAsListener?.();
|
||||
};
|
||||
}, [handleLoadProject, handleSaveProject, handleSaveProjectAs]);
|
||||
}, [handleNewProject, handleLoadProject, handleSaveProject, handleSaveProjectAs]);
|
||||
|
||||
useEffect(() => {
|
||||
let canceled = false;
|
||||
@@ -816,6 +909,7 @@ export default function VideoEditor() {
|
||||
setSelectedZoomId(id);
|
||||
if (id) {
|
||||
setSelectedTrimId(null);
|
||||
setSelectedSpeedId(null);
|
||||
setSelectedAnnotationId(null);
|
||||
setSelectedBlurId(null);
|
||||
}
|
||||
@@ -825,6 +919,7 @@ export default function VideoEditor() {
|
||||
setSelectedTrimId(id);
|
||||
if (id) {
|
||||
setSelectedZoomId(null);
|
||||
setSelectedSpeedId(null);
|
||||
setSelectedAnnotationId(null);
|
||||
setSelectedBlurId(null);
|
||||
}
|
||||
@@ -835,6 +930,7 @@ export default function VideoEditor() {
|
||||
if (id) {
|
||||
setSelectedZoomId(null);
|
||||
setSelectedTrimId(null);
|
||||
setSelectedSpeedId(null);
|
||||
setSelectedBlurId(null);
|
||||
}
|
||||
}, []);
|
||||
@@ -863,6 +959,7 @@ export default function VideoEditor() {
|
||||
pushState((prev) => ({ zoomRegions: [...prev.zoomRegions, newRegion] }));
|
||||
setSelectedZoomId(id);
|
||||
setSelectedTrimId(null);
|
||||
setSelectedSpeedId(null);
|
||||
setSelectedAnnotationId(null);
|
||||
setSelectedBlurId(null);
|
||||
},
|
||||
@@ -899,6 +996,7 @@ export default function VideoEditor() {
|
||||
pushState((prev) => ({ trimRegions: [...prev.trimRegions, newRegion] }));
|
||||
setSelectedTrimId(id);
|
||||
setSelectedZoomId(null);
|
||||
setSelectedSpeedId(null);
|
||||
setSelectedAnnotationId(null);
|
||||
setSelectedBlurId(null);
|
||||
},
|
||||
@@ -1134,6 +1232,7 @@ export default function VideoEditor() {
|
||||
setSelectedAnnotationId(id);
|
||||
setSelectedZoomId(null);
|
||||
setSelectedTrimId(null);
|
||||
setSelectedSpeedId(null);
|
||||
setSelectedBlurId(null);
|
||||
},
|
||||
[pushState],
|
||||
@@ -1207,6 +1306,8 @@ export default function VideoEditor() {
|
||||
setSelectedAnnotationId(duplicateId);
|
||||
setSelectedZoomId(null);
|
||||
setSelectedTrimId(null);
|
||||
setSelectedSpeedId(null);
|
||||
setSelectedBlurId(null);
|
||||
},
|
||||
[pushState],
|
||||
);
|
||||
@@ -2047,333 +2148,367 @@ export default function VideoEditor() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="editor-workspace flex-1 min-h-0 relative">
|
||||
<PanelGroup direction="vertical" className="gap-3 min-h-0">
|
||||
{/* Top section: preview and contextual settings */}
|
||||
<Panel defaultSize={67} maxSize={76} minSize={46} className="min-h-[300px]">
|
||||
<div className="editor-main-deck h-full min-h-0">
|
||||
<div className="editor-preview-zone min-w-0 h-full">
|
||||
<div
|
||||
ref={playerContainerRef}
|
||||
className={
|
||||
isFullscreen
|
||||
? "fixed inset-0 z-[99999] w-full h-full flex flex-col items-center justify-center bg-[#09090b]"
|
||||
: "editor-preview-panel w-full h-full flex flex-col items-center justify-center overflow-hidden relative"
|
||||
}
|
||||
>
|
||||
{/* Video preview */}
|
||||
<div className="w-full min-h-0 flex justify-center items-center flex-auto px-4 pt-4">
|
||||
<div
|
||||
className="relative flex justify-center items-center w-auto h-full max-w-full box-border"
|
||||
style={{
|
||||
aspectRatio:
|
||||
aspectRatio === "native"
|
||||
? getNativeAspectRatioValue(
|
||||
videoPlaybackRef.current?.video?.videoWidth ||
|
||||
DEFAULT_SOURCE_DIMENSIONS.width,
|
||||
videoPlaybackRef.current?.video?.videoHeight ||
|
||||
DEFAULT_SOURCE_DIMENSIONS.height,
|
||||
cropRegion,
|
||||
)
|
||||
: getAspectRatioValue(aspectRatio),
|
||||
}}
|
||||
>
|
||||
<VideoPlayback
|
||||
key={`${videoPath || "no-video"}:${webcamVideoPath || "no-webcam"}`}
|
||||
aspectRatio={aspectRatio}
|
||||
ref={videoPlaybackRef}
|
||||
videoPath={videoPath || ""}
|
||||
webcamVideoPath={webcamVideoPath || undefined}
|
||||
webcamLayoutPreset={webcamLayoutPreset}
|
||||
webcamMaskShape={webcamMaskShape}
|
||||
webcamMirrored={webcamMirrored}
|
||||
webcamSizePreset={webcamSizePreset}
|
||||
webcamPosition={webcamPosition}
|
||||
onWebcamPositionChange={(pos) => updateState({ webcamPosition: pos })}
|
||||
onWebcamPositionDragEnd={commitState}
|
||||
onDurationChange={setDuration}
|
||||
onTimeUpdate={setCurrentTime}
|
||||
currentTime={currentTime}
|
||||
onPlayStateChange={setIsPlaying}
|
||||
onError={setError}
|
||||
wallpaper={wallpaper}
|
||||
zoomRegions={zoomRegions}
|
||||
selectedZoomId={selectedZoomId}
|
||||
onSelectZoom={handleSelectZoom}
|
||||
onZoomFocusChange={handleZoomFocusChange}
|
||||
onZoomFocusDragEnd={commitState}
|
||||
isPlaying={isPlaying}
|
||||
showShadow={shadowIntensity > 0}
|
||||
shadowIntensity={shadowIntensity}
|
||||
showBlur={showBlur}
|
||||
motionBlurAmount={motionBlurAmount}
|
||||
borderRadius={borderRadius}
|
||||
padding={padding}
|
||||
cropRegion={cropRegion}
|
||||
cursorRecordingData={cursorRecordingData}
|
||||
trimRegions={trimRegions}
|
||||
speedRegions={speedRegions}
|
||||
annotationRegions={annotationOnlyRegions}
|
||||
selectedAnnotationId={selectedAnnotationId}
|
||||
onSelectAnnotation={handleSelectAnnotation}
|
||||
onAnnotationPositionChange={handleAnnotationPositionChange}
|
||||
onAnnotationSizeChange={handleAnnotationSizeChange}
|
||||
blurRegions={blurRegions}
|
||||
selectedBlurId={selectedBlurId}
|
||||
onSelectBlur={handleSelectBlur}
|
||||
onBlurPositionChange={handleAnnotationPositionChange}
|
||||
onBlurSizeChange={handleAnnotationSizeChange}
|
||||
onBlurDataChange={handleBlurDataPreviewChange}
|
||||
onBlurDataCommit={commitState}
|
||||
cursorTelemetry={cursorTelemetry}
|
||||
cursorClickTimestamps={cursorClickTimestamps}
|
||||
showCursor={effectiveShowCursor}
|
||||
cursorSize={cursorSize}
|
||||
cursorSmoothing={cursorSmoothing}
|
||||
cursorMotionBlur={cursorMotionBlur}
|
||||
cursorClickBounce={cursorClickBounce}
|
||||
cursorClipToBounds={cursorClipToBounds}
|
||||
isPreviewingZoom={isPreviewingZoom}
|
||||
/>
|
||||
{/* Empty state — shown when no video is loaded */}
|
||||
{!videoPath && (
|
||||
<div className="flex-1 min-h-0 relative">
|
||||
<EditorEmptyState
|
||||
onVideoImported={(path) => {
|
||||
setVideoPath(toFileUrl(path));
|
||||
setVideoSourcePath(path);
|
||||
setWebcamVideoPath(null);
|
||||
setWebcamVideoSourcePath(null);
|
||||
}}
|
||||
onProjectOpened={async (project, path) => {
|
||||
const restored = await applyLoadedProject(project, path);
|
||||
if (!restored) {
|
||||
toast.error(t("project.invalidFormat"));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{videoPath && (
|
||||
<div className="editor-workspace flex-1 min-h-0 relative">
|
||||
<PanelGroup direction="vertical" className="gap-3 min-h-0">
|
||||
{/* Top section: preview and contextual settings */}
|
||||
<Panel defaultSize={67} maxSize={76} minSize={46} className="min-h-[300px]">
|
||||
<div className="editor-main-deck h-full min-h-0">
|
||||
<div className="editor-preview-zone min-w-0 h-full">
|
||||
<div
|
||||
ref={playerContainerRef}
|
||||
className={
|
||||
isFullscreen
|
||||
? "fixed inset-0 z-[99999] w-full h-full flex flex-col items-center justify-center bg-[#09090b]"
|
||||
: "editor-preview-panel w-full h-full flex flex-col items-center justify-center overflow-hidden relative"
|
||||
}
|
||||
>
|
||||
{/* Video preview */}
|
||||
<div className="w-full min-h-0 flex justify-center items-center flex-auto px-4 pt-4">
|
||||
<div
|
||||
className="relative flex justify-center items-center w-auto h-full max-w-full box-border"
|
||||
style={{
|
||||
aspectRatio:
|
||||
aspectRatio === "native"
|
||||
? getNativeAspectRatioValue(
|
||||
videoPlaybackRef.current?.video?.videoWidth ||
|
||||
DEFAULT_SOURCE_DIMENSIONS.width,
|
||||
videoPlaybackRef.current?.video?.videoHeight ||
|
||||
DEFAULT_SOURCE_DIMENSIONS.height,
|
||||
cropRegion,
|
||||
)
|
||||
: getAspectRatioValue(aspectRatio),
|
||||
}}
|
||||
>
|
||||
<VideoPlayback
|
||||
key={`${videoPath || "no-video"}:${webcamVideoPath || "no-webcam"}`}
|
||||
aspectRatio={aspectRatio}
|
||||
ref={videoPlaybackRef}
|
||||
videoPath={videoPath || ""}
|
||||
webcamVideoPath={webcamVideoPath || undefined}
|
||||
webcamLayoutPreset={webcamLayoutPreset}
|
||||
webcamMaskShape={webcamMaskShape}
|
||||
webcamMirrored={webcamMirrored}
|
||||
webcamSizePreset={webcamSizePreset}
|
||||
webcamPosition={webcamPosition}
|
||||
onWebcamPositionChange={(pos) => updateState({ webcamPosition: pos })}
|
||||
onWebcamPositionDragEnd={commitState}
|
||||
onDurationChange={setDuration}
|
||||
onTimeUpdate={setCurrentTime}
|
||||
currentTime={currentTime}
|
||||
onPlayStateChange={setIsPlaying}
|
||||
onError={setError}
|
||||
wallpaper={wallpaper}
|
||||
zoomRegions={zoomRegions}
|
||||
selectedZoomId={selectedZoomId}
|
||||
onSelectZoom={handleSelectZoom}
|
||||
onZoomFocusChange={handleZoomFocusChange}
|
||||
onZoomFocusDragEnd={commitState}
|
||||
isPlaying={isPlaying}
|
||||
showShadow={shadowIntensity > 0}
|
||||
shadowIntensity={shadowIntensity}
|
||||
showBlur={showBlur}
|
||||
motionBlurAmount={motionBlurAmount}
|
||||
borderRadius={borderRadius}
|
||||
padding={padding}
|
||||
cropRegion={cropRegion}
|
||||
cursorRecordingData={cursorRecordingData}
|
||||
trimRegions={trimRegions}
|
||||
speedRegions={speedRegions}
|
||||
annotationRegions={annotationOnlyRegions}
|
||||
selectedAnnotationId={selectedAnnotationId}
|
||||
onSelectAnnotation={handleSelectAnnotation}
|
||||
onAnnotationPositionChange={handleAnnotationPositionChange}
|
||||
onAnnotationSizeChange={handleAnnotationSizeChange}
|
||||
blurRegions={blurRegions}
|
||||
selectedBlurId={selectedBlurId}
|
||||
onSelectBlur={handleSelectBlur}
|
||||
onBlurPositionChange={handleAnnotationPositionChange}
|
||||
onBlurSizeChange={handleAnnotationSizeChange}
|
||||
onBlurDataChange={handleBlurDataPreviewChange}
|
||||
onBlurDataCommit={commitState}
|
||||
cursorTelemetry={cursorTelemetry}
|
||||
cursorClickTimestamps={cursorClickTimestamps}
|
||||
showCursor={effectiveShowCursor}
|
||||
cursorSize={cursorSize}
|
||||
cursorSmoothing={cursorSmoothing}
|
||||
cursorMotionBlur={cursorMotionBlur}
|
||||
cursorClickBounce={cursorClickBounce}
|
||||
cursorClipToBounds={cursorClipToBounds}
|
||||
isPreviewingZoom={isPreviewingZoom}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Playback controls */}
|
||||
<div className="w-full flex justify-center items-center h-14 flex-shrink-0 px-4 py-2">
|
||||
<div className="w-full max-w-[760px]">
|
||||
<PlaybackControls
|
||||
isPlaying={isPlaying}
|
||||
currentTime={currentTime}
|
||||
duration={duration}
|
||||
isFullscreen={isFullscreen}
|
||||
onToggleFullscreen={toggleFullscreen}
|
||||
onTogglePlayPause={togglePlayPause}
|
||||
onSeek={handleSeek}
|
||||
/>
|
||||
{/* Playback controls */}
|
||||
<div className="w-full flex justify-center items-center h-14 flex-shrink-0 px-4 py-2">
|
||||
<div className="w-full max-w-[760px]">
|
||||
<PlaybackControls
|
||||
isPlaying={isPlaying}
|
||||
currentTime={currentTime}
|
||||
duration={duration}
|
||||
isFullscreen={isFullscreen}
|
||||
onToggleFullscreen={toggleFullscreen}
|
||||
onTogglePlayPause={togglePlayPause}
|
||||
onSeek={handleSeek}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="editor-settings-rail min-w-0 h-full">
|
||||
<SettingsPanel
|
||||
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}
|
||||
onZoomPreviewStart={() => setIsPreviewingZoom(true)}
|
||||
onZoomPreviewEnd={() => setIsPreviewingZoom(false)}
|
||||
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}
|
||||
<div className="editor-settings-rail min-w-0 h-full">
|
||||
<SettingsPanel
|
||||
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}
|
||||
onZoomPreviewStart={() => setIsPreviewingZoom(true)}
|
||||
onZoomPreviewEnd={() => setIsPreviewingZoom(false)}
|
||||
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}
|
||||
onShadowChange={(v) => updateState({ shadowIntensity: v })}
|
||||
onShadowCommit={commitState}
|
||||
showBlur={showBlur}
|
||||
onBlurChange={(v) => pushState({ showBlur: v })}
|
||||
showTrimWaveform={showTrimWaveform}
|
||||
onTrimWaveformChange={(v) => pushState({ showTrimWaveform: v })}
|
||||
motionBlurAmount={motionBlurAmount}
|
||||
onMotionBlurChange={(v) => updateState({ motionBlurAmount: v })}
|
||||
onMotionBlurCommit={commitState}
|
||||
borderRadius={borderRadius}
|
||||
onBorderRadiusChange={(v) => updateState({ borderRadius: v })}
|
||||
onBorderRadiusCommit={commitState}
|
||||
padding={padding}
|
||||
onPaddingChange={(v) => updateState({ padding: v })}
|
||||
onPaddingCommit={commitState}
|
||||
cropRegion={cropRegion}
|
||||
onCropChange={(r) => pushState({ cropRegion: r })}
|
||||
aspectRatio={aspectRatio}
|
||||
hasWebcam={Boolean(webcamVideoPath)}
|
||||
webcamLayoutPreset={webcamLayoutPreset}
|
||||
onWebcamLayoutPresetChange={(preset) =>
|
||||
pushState({
|
||||
webcamLayoutPreset: preset,
|
||||
webcamPosition: preset === "picture-in-picture" ? webcamPosition : null,
|
||||
})
|
||||
}
|
||||
webcamMaskShape={webcamMaskShape}
|
||||
onWebcamMaskShapeChange={(shape) => pushState({ webcamMaskShape: shape })}
|
||||
webcamMirrored={webcamMirrored}
|
||||
onWebcamMirroredChange={(mirrored) => pushState({ webcamMirrored: mirrored })}
|
||||
webcamSizePreset={webcamSizePreset}
|
||||
onWebcamSizePresetChange={(v) => updateState({ webcamSizePreset: v })}
|
||||
onWebcamSizePresetCommit={commitState}
|
||||
videoElement={videoPlaybackRef.current?.video || null}
|
||||
exportQuality={exportQuality}
|
||||
onExportQualityChange={setExportQuality}
|
||||
exportFormat={exportFormat}
|
||||
onExportFormatChange={setExportFormat}
|
||||
gifFrameRate={gifFrameRate}
|
||||
onGifFrameRateChange={setGifFrameRate}
|
||||
gifLoop={gifLoop}
|
||||
onGifLoopChange={setGifLoop}
|
||||
gifSizePreset={gifSizePreset}
|
||||
onGifSizePresetChange={setGifSizePreset}
|
||||
gifOutputDimensions={calculateOutputDimensions(
|
||||
calculateEffectiveSourceDimensions(
|
||||
videoPlaybackRef.current?.video?.videoWidth ||
|
||||
DEFAULT_SOURCE_DIMENSIONS.width,
|
||||
videoPlaybackRef.current?.video?.videoHeight ||
|
||||
DEFAULT_SOURCE_DIMENSIONS.height,
|
||||
cropRegion,
|
||||
).width,
|
||||
calculateEffectiveSourceDimensions(
|
||||
videoPlaybackRef.current?.video?.videoWidth ||
|
||||
DEFAULT_SOURCE_DIMENSIONS.width,
|
||||
videoPlaybackRef.current?.video?.videoHeight ||
|
||||
DEFAULT_SOURCE_DIMENSIONS.height,
|
||||
cropRegion,
|
||||
).height,
|
||||
gifSizePreset,
|
||||
GIF_SIZE_PRESETS,
|
||||
aspectRatio === "native"
|
||||
? getNativeAspectRatioValue(
|
||||
videoPlaybackRef.current?.video?.videoWidth ||
|
||||
DEFAULT_SOURCE_DIMENSIONS.width,
|
||||
videoPlaybackRef.current?.video?.videoHeight ||
|
||||
DEFAULT_SOURCE_DIMENSIONS.height,
|
||||
cropRegion,
|
||||
)
|
||||
: getAspectRatioValue(aspectRatio),
|
||||
)}
|
||||
onExport={handleOpenExportDialog}
|
||||
onExportPanelOpen={() => {
|
||||
setSelectedZoomId(null);
|
||||
setSelectedTrimId(null);
|
||||
setSelectedSpeedId(null);
|
||||
}}
|
||||
selectedAnnotationId={selectedAnnotationId}
|
||||
annotationRegions={annotationOnlyRegions}
|
||||
onAnnotationContentChange={handleAnnotationContentChange}
|
||||
onAnnotationTypeChange={handleAnnotationTypeChange}
|
||||
onAnnotationStyleChange={handleAnnotationStyleChange}
|
||||
onAnnotationFigureDataChange={handleAnnotationFigureDataChange}
|
||||
onAnnotationDuplicate={handleAnnotationDuplicate}
|
||||
onAnnotationDelete={handleAnnotationDelete}
|
||||
selectedBlurId={selectedBlurId}
|
||||
blurRegions={blurRegions}
|
||||
onBlurDataChange={handleBlurDataPanelChange}
|
||||
onBlurDataCommit={commitState}
|
||||
onBlurDelete={handleAnnotationDelete}
|
||||
selectedSpeedId={selectedSpeedId}
|
||||
selectedSpeedValue={
|
||||
selectedSpeedId
|
||||
? (speedRegions.find((r) => r.id === selectedSpeedId)?.speed ?? null)
|
||||
: null
|
||||
}
|
||||
onSpeedChange={handleSpeedChange}
|
||||
onSpeedDelete={handleSpeedDelete}
|
||||
unsavedExport={unsavedExport}
|
||||
onSaveUnsavedExport={handleSaveUnsavedExport}
|
||||
onSaveDiagnostic={handleSaveDiagnostic}
|
||||
showCursor={showCursor}
|
||||
onShowCursorChange={setShowCursor}
|
||||
cursorSize={cursorSize}
|
||||
onCursorSizeChange={setCursorSize}
|
||||
cursorSmoothing={cursorSmoothing}
|
||||
onCursorSmoothingChange={setCursorSmoothing}
|
||||
cursorMotionBlur={cursorMotionBlur}
|
||||
onCursorMotionBlurChange={setCursorMotionBlur}
|
||||
cursorClickBounce={cursorClickBounce}
|
||||
onCursorClickBounceChange={setCursorClickBounce}
|
||||
cursorClipToBounds={cursorClipToBounds}
|
||||
onCursorClipToBoundsChange={setCursorClipToBounds}
|
||||
hasCursorData={
|
||||
cursorTelemetry.length > 0 ||
|
||||
hasNativeCursorRecordingData(cursorRecordingData)
|
||||
}
|
||||
showCursorSettings={showCursorSettings}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<PanelResizeHandle className="editor-resize-handle group">
|
||||
<div className="w-10 h-1 bg-white/20 rounded-full transition-colors group-hover:bg-[#34B27B]/70"></div>
|
||||
</PanelResizeHandle>
|
||||
|
||||
{/* Full-width timeline */}
|
||||
<Panel defaultSize={33} maxSize={54} minSize={24} className="min-h-[210px]">
|
||||
<div className="editor-timeline-panel h-full overflow-hidden flex flex-col">
|
||||
<TimelineEditor
|
||||
videoDuration={duration}
|
||||
currentTime={currentTime}
|
||||
onSeek={handleSeek}
|
||||
cursorTelemetry={cursorTelemetry}
|
||||
zoomRegions={zoomRegions}
|
||||
onZoomAdded={handleZoomAdded}
|
||||
onZoomSuggested={handleZoomSuggested}
|
||||
onZoomSpanChange={handleZoomSpanChange}
|
||||
onZoomDelete={handleZoomDelete}
|
||||
selectedZoomRotationPreset={
|
||||
selectedZoomId
|
||||
? (zoomRegions.find((z) => z.id === selectedZoomId)?.rotationPreset ?? null)
|
||||
: null
|
||||
}
|
||||
onZoomRotationPresetChange={handleZoomRotationPresetChange}
|
||||
selectedTrimId={selectedTrimId}
|
||||
selectedZoomId={selectedZoomId}
|
||||
onSelectZoom={handleSelectZoom}
|
||||
trimRegions={trimRegions}
|
||||
onTrimAdded={handleTrimAdded}
|
||||
onTrimSpanChange={handleTrimSpanChange}
|
||||
onTrimDelete={handleTrimDelete}
|
||||
shadowIntensity={shadowIntensity}
|
||||
onShadowChange={(v) => updateState({ shadowIntensity: v })}
|
||||
onShadowCommit={commitState}
|
||||
showBlur={showBlur}
|
||||
onBlurChange={(v) => pushState({ showBlur: v })}
|
||||
motionBlurAmount={motionBlurAmount}
|
||||
onMotionBlurChange={(v) => updateState({ motionBlurAmount: v })}
|
||||
onMotionBlurCommit={commitState}
|
||||
borderRadius={borderRadius}
|
||||
onBorderRadiusChange={(v) => updateState({ borderRadius: v })}
|
||||
onBorderRadiusCommit={commitState}
|
||||
padding={padding}
|
||||
onPaddingChange={(v) => updateState({ padding: v })}
|
||||
onPaddingCommit={commitState}
|
||||
cropRegion={cropRegion}
|
||||
onCropChange={(r) => pushState({ cropRegion: r })}
|
||||
selectedTrimId={selectedTrimId}
|
||||
onSelectTrim={handleSelectTrim}
|
||||
speedRegions={speedRegions}
|
||||
onSpeedAdded={handleSpeedAdded}
|
||||
onSpeedSpanChange={handleSpeedSpanChange}
|
||||
onSpeedDelete={handleSpeedDelete}
|
||||
selectedSpeedId={selectedSpeedId}
|
||||
onSelectSpeed={handleSelectSpeed}
|
||||
annotationRegions={annotationOnlyRegions}
|
||||
onAnnotationAdded={handleAnnotationAdded}
|
||||
onAnnotationSpanChange={handleAnnotationSpanChange}
|
||||
onAnnotationDelete={handleAnnotationDelete}
|
||||
selectedAnnotationId={selectedAnnotationId}
|
||||
onSelectAnnotation={handleSelectAnnotation}
|
||||
blurRegions={blurRegions}
|
||||
onBlurAdded={handleBlurAdded}
|
||||
onBlurSpanChange={handleAnnotationSpanChange}
|
||||
onBlurDelete={handleAnnotationDelete}
|
||||
selectedBlurId={selectedBlurId}
|
||||
onSelectBlur={handleSelectBlur}
|
||||
aspectRatio={aspectRatio}
|
||||
hasWebcam={Boolean(webcamVideoPath)}
|
||||
webcamLayoutPreset={webcamLayoutPreset}
|
||||
onWebcamLayoutPresetChange={(preset) =>
|
||||
onAspectRatioChange={(ar) =>
|
||||
pushState({
|
||||
webcamLayoutPreset: preset,
|
||||
webcamPosition: preset === "picture-in-picture" ? webcamPosition : null,
|
||||
aspectRatio: ar,
|
||||
webcamLayoutPreset:
|
||||
(isPortraitAspectRatio(ar) && webcamLayoutPreset === "dual-frame") ||
|
||||
(!isPortraitAspectRatio(ar) && webcamLayoutPreset === "vertical-stack")
|
||||
? "picture-in-picture"
|
||||
: webcamLayoutPreset,
|
||||
})
|
||||
}
|
||||
webcamMaskShape={webcamMaskShape}
|
||||
onWebcamMaskShapeChange={(shape) => pushState({ webcamMaskShape: shape })}
|
||||
webcamMirrored={webcamMirrored}
|
||||
onWebcamMirroredChange={(mirrored) => pushState({ webcamMirrored: mirrored })}
|
||||
webcamSizePreset={webcamSizePreset}
|
||||
onWebcamSizePresetChange={(v) => updateState({ webcamSizePreset: v })}
|
||||
onWebcamSizePresetCommit={commitState}
|
||||
videoElement={videoPlaybackRef.current?.video || null}
|
||||
exportQuality={exportQuality}
|
||||
onExportQualityChange={setExportQuality}
|
||||
exportFormat={exportFormat}
|
||||
onExportFormatChange={setExportFormat}
|
||||
gifFrameRate={gifFrameRate}
|
||||
onGifFrameRateChange={setGifFrameRate}
|
||||
gifLoop={gifLoop}
|
||||
onGifLoopChange={setGifLoop}
|
||||
gifSizePreset={gifSizePreset}
|
||||
onGifSizePresetChange={setGifSizePreset}
|
||||
gifOutputDimensions={calculateOutputDimensions(
|
||||
calculateEffectiveSourceDimensions(
|
||||
videoPlaybackRef.current?.video?.videoWidth ||
|
||||
DEFAULT_SOURCE_DIMENSIONS.width,
|
||||
videoPlaybackRef.current?.video?.videoHeight ||
|
||||
DEFAULT_SOURCE_DIMENSIONS.height,
|
||||
cropRegion,
|
||||
).width,
|
||||
calculateEffectiveSourceDimensions(
|
||||
videoPlaybackRef.current?.video?.videoWidth ||
|
||||
DEFAULT_SOURCE_DIMENSIONS.width,
|
||||
videoPlaybackRef.current?.video?.videoHeight ||
|
||||
DEFAULT_SOURCE_DIMENSIONS.height,
|
||||
cropRegion,
|
||||
).height,
|
||||
gifSizePreset,
|
||||
GIF_SIZE_PRESETS,
|
||||
aspectRatio === "native"
|
||||
? getNativeAspectRatioValue(
|
||||
videoPlaybackRef.current?.video?.videoWidth ||
|
||||
DEFAULT_SOURCE_DIMENSIONS.width,
|
||||
videoPlaybackRef.current?.video?.videoHeight ||
|
||||
DEFAULT_SOURCE_DIMENSIONS.height,
|
||||
cropRegion,
|
||||
)
|
||||
: getAspectRatioValue(aspectRatio),
|
||||
)}
|
||||
onExport={handleOpenExportDialog}
|
||||
selectedAnnotationId={selectedAnnotationId}
|
||||
annotationRegions={annotationOnlyRegions}
|
||||
onAnnotationContentChange={handleAnnotationContentChange}
|
||||
onAnnotationTypeChange={handleAnnotationTypeChange}
|
||||
onAnnotationStyleChange={handleAnnotationStyleChange}
|
||||
onAnnotationFigureDataChange={handleAnnotationFigureDataChange}
|
||||
onAnnotationDuplicate={handleAnnotationDuplicate}
|
||||
onAnnotationDelete={handleAnnotationDelete}
|
||||
selectedBlurId={selectedBlurId}
|
||||
blurRegions={blurRegions}
|
||||
onBlurDataChange={handleBlurDataPanelChange}
|
||||
onBlurDataCommit={commitState}
|
||||
onBlurDelete={handleAnnotationDelete}
|
||||
selectedSpeedId={selectedSpeedId}
|
||||
selectedSpeedValue={
|
||||
selectedSpeedId
|
||||
? (speedRegions.find((r) => r.id === selectedSpeedId)?.speed ?? null)
|
||||
: null
|
||||
}
|
||||
onSpeedChange={handleSpeedChange}
|
||||
onSpeedDelete={handleSpeedDelete}
|
||||
unsavedExport={unsavedExport}
|
||||
onSaveUnsavedExport={handleSaveUnsavedExport}
|
||||
onSaveDiagnostic={handleSaveDiagnostic}
|
||||
showCursor={showCursor}
|
||||
onShowCursorChange={setShowCursor}
|
||||
cursorSize={cursorSize}
|
||||
onCursorSizeChange={setCursorSize}
|
||||
cursorSmoothing={cursorSmoothing}
|
||||
onCursorSmoothingChange={setCursorSmoothing}
|
||||
cursorMotionBlur={cursorMotionBlur}
|
||||
onCursorMotionBlurChange={setCursorMotionBlur}
|
||||
cursorClickBounce={cursorClickBounce}
|
||||
onCursorClickBounceChange={setCursorClickBounce}
|
||||
cursorClipToBounds={cursorClipToBounds}
|
||||
onCursorClipToBoundsChange={setCursorClipToBounds}
|
||||
hasCursorData={
|
||||
cursorTelemetry.length > 0 || hasNativeCursorRecordingData(cursorRecordingData)
|
||||
}
|
||||
showCursorSettings={showCursorSettings}
|
||||
videoUrl={videoPath ?? undefined}
|
||||
showTrimWaveform={showTrimWaveform}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<PanelResizeHandle className="editor-resize-handle group">
|
||||
<div className="w-10 h-1 bg-white/20 rounded-full transition-colors group-hover:bg-[#34B27B]/70"></div>
|
||||
</PanelResizeHandle>
|
||||
|
||||
{/* Full-width timeline */}
|
||||
<Panel defaultSize={33} maxSize={54} minSize={24} className="min-h-[210px]">
|
||||
<div className="editor-timeline-panel h-full overflow-hidden flex flex-col">
|
||||
<TimelineEditor
|
||||
videoDuration={duration}
|
||||
currentTime={currentTime}
|
||||
onSeek={handleSeek}
|
||||
cursorTelemetry={cursorTelemetry}
|
||||
zoomRegions={zoomRegions}
|
||||
onZoomAdded={handleZoomAdded}
|
||||
onZoomSuggested={handleZoomSuggested}
|
||||
onZoomSpanChange={handleZoomSpanChange}
|
||||
onZoomDelete={handleZoomDelete}
|
||||
selectedZoomId={selectedZoomId}
|
||||
onSelectZoom={handleSelectZoom}
|
||||
trimRegions={trimRegions}
|
||||
onTrimAdded={handleTrimAdded}
|
||||
onTrimSpanChange={handleTrimSpanChange}
|
||||
onTrimDelete={handleTrimDelete}
|
||||
selectedTrimId={selectedTrimId}
|
||||
onSelectTrim={handleSelectTrim}
|
||||
speedRegions={speedRegions}
|
||||
onSpeedAdded={handleSpeedAdded}
|
||||
onSpeedSpanChange={handleSpeedSpanChange}
|
||||
onSpeedDelete={handleSpeedDelete}
|
||||
selectedSpeedId={selectedSpeedId}
|
||||
onSelectSpeed={handleSelectSpeed}
|
||||
annotationRegions={annotationOnlyRegions}
|
||||
onAnnotationAdded={handleAnnotationAdded}
|
||||
onAnnotationSpanChange={handleAnnotationSpanChange}
|
||||
onAnnotationDelete={handleAnnotationDelete}
|
||||
selectedAnnotationId={selectedAnnotationId}
|
||||
onSelectAnnotation={handleSelectAnnotation}
|
||||
blurRegions={blurRegions}
|
||||
onBlurAdded={handleBlurAdded}
|
||||
onBlurSpanChange={handleAnnotationSpanChange}
|
||||
onBlurDelete={handleAnnotationDelete}
|
||||
selectedBlurId={selectedBlurId}
|
||||
onSelectBlur={handleSelectBlur}
|
||||
aspectRatio={aspectRatio}
|
||||
onAspectRatioChange={(ar) =>
|
||||
pushState({
|
||||
aspectRatio: ar,
|
||||
webcamLayoutPreset:
|
||||
(isPortraitAspectRatio(ar) && webcamLayoutPreset === "dual-frame") ||
|
||||
(!isPortraitAspectRatio(ar) && webcamLayoutPreset === "vertical-stack")
|
||||
? "picture-in-picture"
|
||||
: webcamLayoutPreset,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Panel>
|
||||
</PanelGroup>
|
||||
</div>
|
||||
</Panel>
|
||||
</PanelGroup>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ExportDialog
|
||||
isOpen={showExportDialog}
|
||||
@@ -2395,6 +2530,22 @@ export default function VideoEditor() {
|
||||
onDiscardAndClose={handleCloseConfirmDiscard}
|
||||
onCancel={handleCloseConfirmCancel}
|
||||
/>
|
||||
|
||||
<UnsavedChangesDialog
|
||||
isOpen={confirmDialogVariant !== null}
|
||||
variant={confirmDialogVariant ?? "newProject"}
|
||||
onSaveAndClose={
|
||||
confirmDialogVariant === "loadProject"
|
||||
? handleLoadProjectConfirmSave
|
||||
: handleNewProjectConfirmSave
|
||||
}
|
||||
onDiscardAndClose={
|
||||
confirmDialogVariant === "loadProject"
|
||||
? handleLoadProjectConfirmDiscard
|
||||
: handleNewProjectConfirmDiscard
|
||||
}
|
||||
onCancel={() => setConfirmDialogVariant(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -60,13 +60,13 @@ import {
|
||||
type CursorTelemetryPoint,
|
||||
computeRotation3DContainScale,
|
||||
DEFAULT_ROTATION_3D,
|
||||
getZoomScale,
|
||||
isRotation3DIdentity,
|
||||
lerpRotation3D,
|
||||
rotation3DPerspective,
|
||||
type SpeedRegion,
|
||||
type TrimRegion,
|
||||
ZOOM_DEPTH_SCALES,
|
||||
type ZoomDepth,
|
||||
type ZoomFocus,
|
||||
type ZoomRegion,
|
||||
} from "./types";
|
||||
@@ -84,7 +84,7 @@ import {
|
||||
PixiCursorOverlay,
|
||||
preloadCursorAssets,
|
||||
} from "./videoPlayback/cursorRenderer";
|
||||
import { clampFocusToStage as clampFocusToStageUtil } from "./videoPlayback/focusUtils";
|
||||
import { clampFocusToScale } from "./videoPlayback/focusUtils";
|
||||
import { layoutVideoContent as layoutVideoContentUtil } from "./videoPlayback/layoutUtils";
|
||||
import { clamp01 } from "./videoPlayback/mathUtils";
|
||||
import { updateOverlayIndicator } from "./videoPlayback/overlayUtils";
|
||||
@@ -479,8 +479,19 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
[onDurationChange, syncResolvedDuration],
|
||||
);
|
||||
|
||||
const clampFocusToStage = useCallback((focus: ZoomFocus, depth: ZoomDepth) => {
|
||||
return clampFocusToStageUtil(focus, depth, stageSizeRef.current);
|
||||
// IMPORTANT: must use clampFocusToScale(focus, getZoomScale(region)) here,
|
||||
// NOT clampFocusToStage(focus, region.depth).
|
||||
//
|
||||
// region.depth is the preset slot (1×/2×/4×) and ignores customScale entirely.
|
||||
// getZoomScale(region) returns customScale when set, falling back to the preset
|
||||
// depth scale — so drag-to-reposition respects the actual zoom level the user
|
||||
// configured, not the preset bucket it sits in.
|
||||
//
|
||||
// This was previously broken (invisible drag boundaries near canvas edges) and
|
||||
// has been fixed twice. If you're refactoring this drag handler, keep this call
|
||||
// as clampFocusForRegion(focus, region) — do not switch it back to region.depth.
|
||||
const clampFocusForRegion = useCallback((focus: ZoomFocus, region: ZoomRegion) => {
|
||||
return clampFocusToScale(focus, getZoomScale(region));
|
||||
}, []);
|
||||
|
||||
const updateOverlayForRegion = useCallback(
|
||||
@@ -674,7 +685,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
cx: clamp01(localX / stageWidth),
|
||||
cy: clamp01(localY / stageHeight),
|
||||
};
|
||||
const clampedFocus = clampFocusToStage(unclampedFocus, region.depth);
|
||||
const clampedFocus = clampFocusForRegion(unclampedFocus, region);
|
||||
|
||||
onZoomFocusChange(region.id, clampedFocus);
|
||||
updateOverlayForRegion({ ...region, focus: clampedFocus }, clampedFocus);
|
||||
@@ -2047,6 +2058,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
}
|
||||
previewSourceCanvas={previewSnapshotCanvas}
|
||||
previewFrameVersion={Math.round(currentTime * 1000)}
|
||||
currentTimeMs={Math.round(currentTime * 1000)}
|
||||
/>
|
||||
));
|
||||
})()}
|
||||
|
||||
@@ -34,11 +34,13 @@ export const DEFAULT_EDITOR_APPEARANCE_SETTINGS: {
|
||||
showBlur: boolean;
|
||||
motionBlurAmount: number;
|
||||
borderRadius: number;
|
||||
showTrimWaveform: boolean;
|
||||
} = {
|
||||
shadowIntensity: 0,
|
||||
showBlur: false,
|
||||
motionBlurAmount: 0,
|
||||
borderRadius: 0,
|
||||
showTrimWaveform: false,
|
||||
};
|
||||
|
||||
export const DEFAULT_EDITOR_LAYOUT_SETTINGS: {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { normalizeTextAnimation } from "@/lib/annotationTextAnimation";
|
||||
import { normalizeBlurColor, normalizeBlurType } from "@/lib/blurEffects";
|
||||
import type { ExportFormat, ExportQuality, GifFrameRate, GifSizePreset } from "@/lib/exporter";
|
||||
import type { ProjectMedia } from "@/lib/recordingSession";
|
||||
@@ -66,6 +67,7 @@ export interface ProjectEditorState {
|
||||
wallpaper: string;
|
||||
shadowIntensity: number;
|
||||
showBlur: boolean;
|
||||
showTrimWaveform: boolean;
|
||||
motionBlurAmount: number;
|
||||
borderRadius: number;
|
||||
padding: number;
|
||||
@@ -368,6 +370,7 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
|
||||
style: {
|
||||
...DEFAULT_ANNOTATION_STYLE,
|
||||
...(region.style && typeof region.style === "object" ? region.style : {}),
|
||||
textAnimation: normalizeTextAnimation(region.style?.textAnimation),
|
||||
},
|
||||
zIndex: isFiniteNumber(region.zIndex) ? region.zIndex : index + 1,
|
||||
figureData: region.figureData
|
||||
@@ -447,6 +450,10 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
|
||||
typeof editor.showBlur === "boolean"
|
||||
? editor.showBlur
|
||||
: DEFAULT_EDITOR_APPEARANCE_SETTINGS.showBlur,
|
||||
showTrimWaveform:
|
||||
typeof editor.showTrimWaveform === "boolean"
|
||||
? editor.showTrimWaveform
|
||||
: DEFAULT_EDITOR_APPEARANCE_SETTINGS.showTrimWaveform,
|
||||
motionBlurAmount: isFiniteNumber(editor.motionBlurAmount)
|
||||
? clamp(editor.motionBlurAmount, 0, 1)
|
||||
: typeof (editor as { motionBlurEnabled?: unknown }).motionBlurEnabled === "boolean"
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { useTimelineContext } from "dnd-timeline";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
export interface BackgroundWaveformProps {
|
||||
/** Pre-computed peaks array: pairs of [min, max] per block (length = 2 * N). */
|
||||
peaks: Float32Array | null;
|
||||
videoDurationMs: number;
|
||||
/**
|
||||
* Pixels to inset the drawn waveform from the top of the canvas row,
|
||||
* so it aligns with the item content top edge. Defaults to 0.
|
||||
*/
|
||||
topInset?: number;
|
||||
/**
|
||||
* Pixels to inset the drawn waveform from the bottom of the canvas row,
|
||||
* so it aligns with the item content bottom edge. Defaults to 0.
|
||||
*/
|
||||
bottomInset?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a rectified (half-wave) audio waveform on a `<canvas>` that fills
|
||||
* its containing block. Designed to be passed as the `background` prop of
|
||||
* `<Row>`, which already provides `relative overflow-hidden` — no wrapper
|
||||
* element needed.
|
||||
*
|
||||
* The canvas always uses `inset-0` (full row height). Vertical alignment with
|
||||
* the item content is achieved via `topInset`/`bottomInset` in the draw calls
|
||||
* rather than CSS positioning, so the result is immune to sub-pixel CSS layout
|
||||
* differences.
|
||||
*
|
||||
* - Accepts pre-computed `peaks` from the caller (see `useAudioPeaks`).
|
||||
* - Redraws whenever the timeline zoom/pan range changes.
|
||||
* - `pointer-events: none` — never blocks drag-to-create interactions.
|
||||
*/
|
||||
export default function BackgroundWaveform({
|
||||
peaks,
|
||||
videoDurationMs,
|
||||
topInset = 0,
|
||||
bottomInset = 0,
|
||||
}: BackgroundWaveformProps) {
|
||||
const { range } = useTimelineContext();
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const [canvasSize, setCanvasSize] = useState({ w: 0, h: 0 });
|
||||
|
||||
// Observe the canvas itself — Row's `relative overflow-hidden` parent
|
||||
// makes it fill the row exactly, so no wrapper div is needed.
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
const { width, height } = entries[0].contentRect;
|
||||
setCanvasSize({ w: width, h: height });
|
||||
});
|
||||
ro.observe(canvas);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
// Redraw whenever peaks, range, or canvas size changes.
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas || canvasSize.w <= 0 || canvasSize.h <= 0) return;
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = Math.round(canvasSize.w * dpr);
|
||||
canvas.height = Math.round(canvasSize.h * dpr);
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
ctx.scale(dpr, dpr);
|
||||
ctx.clearRect(0, 0, canvasSize.w, canvasSize.h);
|
||||
|
||||
if (!peaks || peaks.length === 0) return;
|
||||
|
||||
const W = canvasSize.w;
|
||||
const H = canvasSize.h;
|
||||
const rangeMs = range.end - range.start;
|
||||
if (rangeMs <= 0 || videoDurationMs <= 0) return;
|
||||
|
||||
// Draw within [topY, bottomY] so the waveform aligns with item bounds
|
||||
// regardless of CSS sub-pixel rounding on the canvas element itself.
|
||||
const topY = topInset;
|
||||
const bottomY = H - bottomInset;
|
||||
const drawHeight = bottomY - topY;
|
||||
if (drawHeight <= 0) return;
|
||||
|
||||
const N = peaks.length / 2;
|
||||
const amp = drawHeight * 0.9;
|
||||
|
||||
// Rectified (half-wave): amplitude = max(|min|, |max|), drawn upward from bottomY.
|
||||
const colAmp = new Float32Array(W);
|
||||
for (let x = 0; x < W; x++) {
|
||||
const startMs = range.start + (x / W) * rangeMs;
|
||||
const endMs = range.start + ((x + 1) / W) * rangeMs;
|
||||
const lo = Math.max(0, Math.floor((startMs / videoDurationMs) * N));
|
||||
const hi = Math.min(N - 1, Math.ceil((endMs / videoDurationMs) * N));
|
||||
|
||||
let absMax = 0;
|
||||
for (let i = lo; i <= hi; i++) {
|
||||
const a = Math.abs(peaks[i * 2]);
|
||||
const b = Math.abs(peaks[i * 2 + 1]);
|
||||
if (a > absMax) absMax = a;
|
||||
if (b > absMax) absMax = b;
|
||||
}
|
||||
colAmp[x] = absMax;
|
||||
}
|
||||
|
||||
// Filled polygon: bottom-left → top silhouette → bottom-right.
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, bottomY);
|
||||
for (let x = 0; x < W; x++) {
|
||||
ctx.lineTo(x, bottomY - colAmp[x] * amp);
|
||||
}
|
||||
ctx.lineTo(W, bottomY);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = "rgba(74, 222, 128, 0.55)";
|
||||
ctx.fill();
|
||||
|
||||
// Crisp top-edge stroke for the sharp silhouette.
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, bottomY - colAmp[0] * amp);
|
||||
for (let x = 1; x < W; x++) {
|
||||
ctx.lineTo(x, bottomY - colAmp[x] * amp);
|
||||
}
|
||||
ctx.strokeStyle = "rgba(74, 222, 128, 0.85)";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.stroke();
|
||||
}, [peaks, range, canvasSize, videoDurationMs, topInset, bottomInset]);
|
||||
|
||||
return <canvas ref={canvasRef} className="absolute inset-0 pointer-events-none w-full h-full" />;
|
||||
}
|
||||
@@ -5,9 +5,15 @@ interface RowProps extends RowDefinition {
|
||||
children: React.ReactNode;
|
||||
hint?: string;
|
||||
isEmpty?: boolean;
|
||||
background?: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function Row({ id, children, hint, isEmpty }: RowProps) {
|
||||
/**
|
||||
* A single horizontal lane in the timeline. Wraps the dnd-timeline `useRow`
|
||||
* hook and adds an optional `background` layer (e.g. `BackgroundWaveform`),
|
||||
* an empty-state hint label, and a minimum height.
|
||||
*/
|
||||
export default function Row({ id, children, hint, isEmpty, background }: RowProps) {
|
||||
const { setNodeRef, rowWrapperStyle, rowStyle } = useRow({ id });
|
||||
|
||||
return (
|
||||
@@ -15,6 +21,7 @@ export default function Row({ id, children, hint, isEmpty }: RowProps) {
|
||||
className="border-b border-white/[0.055] bg-[#101116] relative overflow-hidden"
|
||||
style={{ ...rowWrapperStyle, minHeight: 36 }}
|
||||
>
|
||||
{background}
|
||||
{isEmpty && hint && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none select-none z-10">
|
||||
<span className="text-[11px] text-white/[0.12] font-medium">{hint}</span>
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useScopedT } from "@/contexts/I18nContext";
|
||||
import { useShortcuts } from "@/contexts/ShortcutsContext";
|
||||
import { useAudioPeaks } from "@/hooks/useAudioPeaks";
|
||||
import { matchesShortcut } from "@/lib/shortcuts";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ASPECT_RATIOS, type AspectRatio, getAspectRatioLabel } from "@/utils/aspectRatioUtils";
|
||||
@@ -34,6 +35,7 @@ import type {
|
||||
ZoomFocus,
|
||||
ZoomRegion,
|
||||
} from "../types";
|
||||
import BackgroundWaveform from "./BackgroundWaveform";
|
||||
import Item from "./Item";
|
||||
import KeyframeMarkers from "./KeyframeMarkers";
|
||||
import Row from "./Row";
|
||||
@@ -88,6 +90,8 @@ interface TimelineEditorProps {
|
||||
onSelectSpeed?: (id: string | null) => void;
|
||||
aspectRatio: AspectRatio;
|
||||
onAspectRatioChange: (aspectRatio: AspectRatio) => void;
|
||||
videoUrl?: string;
|
||||
showTrimWaveform?: boolean;
|
||||
}
|
||||
|
||||
interface TimelineScaleConfig {
|
||||
@@ -567,6 +571,8 @@ function Timeline({
|
||||
selectedBlurId,
|
||||
selectedSpeedId,
|
||||
keyframes = [],
|
||||
videoUrl,
|
||||
showTrimWaveform = false,
|
||||
}: {
|
||||
items: TimelineRenderItem[];
|
||||
videoDurationMs: number;
|
||||
@@ -584,12 +590,15 @@ function Timeline({
|
||||
selectedBlurId?: string | null;
|
||||
selectedSpeedId?: string | null;
|
||||
keyframes?: { id: string; time: number }[];
|
||||
videoUrl?: string;
|
||||
showTrimWaveform?: boolean;
|
||||
}) {
|
||||
const t = useScopedT("timeline");
|
||||
const { setTimelineRef, style, sidebarWidth, range, pixelsToValue } = useTimelineContext();
|
||||
const localTimelineRef = useRef<HTMLDivElement | null>(null);
|
||||
const isScrubbingTimelineRef = useRef(false);
|
||||
const scrubPointerIdRef = useRef<number | null>(null);
|
||||
const peaks = useAudioPeaks(showTrimWaveform ? videoUrl : undefined);
|
||||
|
||||
const setRefs = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
@@ -788,7 +797,21 @@ function Timeline({
|
||||
))}
|
||||
</Row>
|
||||
|
||||
<Row id={TRIM_ROW_ID} isEmpty={trimItems.length === 0} hint={t("hints.pressTrim")}>
|
||||
<Row
|
||||
id={TRIM_ROW_ID}
|
||||
isEmpty={trimItems.length === 0}
|
||||
hint={t("hints.pressTrim")}
|
||||
background={
|
||||
showTrimWaveform ? (
|
||||
<BackgroundWaveform
|
||||
peaks={peaks}
|
||||
videoDurationMs={videoDurationMs}
|
||||
topInset={3}
|
||||
bottomInset={3}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{trimItems.map((item) => (
|
||||
<Item
|
||||
id={item.id}
|
||||
@@ -899,6 +922,8 @@ export default function TimelineEditor({
|
||||
onSelectSpeed,
|
||||
aspectRatio,
|
||||
onAspectRatioChange,
|
||||
videoUrl,
|
||||
showTrimWaveform = false,
|
||||
}: TimelineEditorProps) {
|
||||
const t = useScopedT("timeline");
|
||||
const totalMs = useMemo(() => Math.max(0, Math.round(videoDuration * 1000)), [videoDuration]);
|
||||
@@ -1492,7 +1517,10 @@ export default function TimelineEditor({
|
||||
return [...zooms, ...trims, ...annotations, ...blurs, ...speeds];
|
||||
}, [zoomRegions, trimRegions, annotationRegions, blurRegions, speedRegions, t]);
|
||||
|
||||
// Flat list of all non-annotation region spans for neighbour-clamping during drag/resize
|
||||
// Spans that participate in overlap resolution (clampToNeighbours).
|
||||
// Excludes annotation/blur deliberately — those are allowed to overlap and
|
||||
// must NOT act as hard constraints when a zoom/trim/speed drag is being
|
||||
// resolved.
|
||||
const allRegionSpans = useMemo(() => {
|
||||
const zooms = zoomRegions.map((r) => ({ id: r.id, start: r.startMs, end: r.endMs }));
|
||||
const trims = trimRegions.map((r) => ({ id: r.id, start: r.startMs, end: r.endMs }));
|
||||
@@ -1500,6 +1528,20 @@ export default function TimelineEditor({
|
||||
return [...zooms, ...trims, ...speeds];
|
||||
}, [zoomRegions, trimRegions, speedRegions]);
|
||||
|
||||
// Additional snap targets that are NOT clamping constraints. Their edges
|
||||
// pull during snap, but they don't push anyone away.
|
||||
const softSnapSpans = useMemo(() => {
|
||||
const annotations = annotationRegions.map((r) => ({
|
||||
id: r.id,
|
||||
start: r.startMs,
|
||||
end: r.endMs,
|
||||
}));
|
||||
const blurs = blurRegions.map((r) => ({ id: r.id, start: r.startMs, end: r.endMs }));
|
||||
return [...annotations, ...blurs];
|
||||
}, [annotationRegions, blurRegions]);
|
||||
|
||||
const keyframeTimesMs = useMemo(() => keyframes.map((kf) => kf.time), [keyframes]);
|
||||
|
||||
const handleItemSpanChange = useCallback(
|
||||
(id: string, span: Span) => {
|
||||
// Check if it's a zoom, trim, speed, or annotation item
|
||||
@@ -1674,6 +1716,9 @@ export default function TimelineEditor({
|
||||
minVisibleRangeMs={timelineScale.minVisibleRangeMs}
|
||||
onItemSpanChange={handleItemSpanChange}
|
||||
allRegionSpans={allRegionSpans}
|
||||
softSnapSpans={softSnapSpans}
|
||||
currentTimeMs={currentTimeMs}
|
||||
keyframeTimesMs={keyframeTimesMs}
|
||||
>
|
||||
<KeyframeMarkers
|
||||
keyframes={keyframes}
|
||||
@@ -1700,6 +1745,8 @@ export default function TimelineEditor({
|
||||
selectedBlurId={selectedBlurId}
|
||||
selectedSpeedId={selectedSpeedId}
|
||||
keyframes={keyframes}
|
||||
videoUrl={videoUrl}
|
||||
showTrimWaveform={showTrimWaveform}
|
||||
/>
|
||||
</TimelineWrapper>
|
||||
</div>
|
||||
|
||||
@@ -7,9 +7,9 @@ import type {
|
||||
ResizeMoveEvent,
|
||||
Span,
|
||||
} from "dnd-timeline";
|
||||
import { TimelineContext } from "dnd-timeline";
|
||||
import { TimelineContext, useTimelineContext } from "dnd-timeline";
|
||||
import type { Dispatch, ReactNode, SetStateAction } from "react";
|
||||
import { useCallback, useRef } from "react";
|
||||
import { forwardRef, useCallback, useImperativeHandle, useRef } from "react";
|
||||
|
||||
interface TimelineWrapperProps {
|
||||
children: ReactNode;
|
||||
@@ -21,9 +21,75 @@ interface TimelineWrapperProps {
|
||||
minVisibleRangeMs: number;
|
||||
gridSizeMs?: number;
|
||||
onItemSpanChange: (id: string, span: Span) => void;
|
||||
// Spans that act as hard overlap constraints (zoom/trim/speed). Used by
|
||||
// clampToNeighbours AND as snap targets.
|
||||
allRegionSpans?: { id: string; start: number; end: number }[];
|
||||
// Spans that act ONLY as snap targets (annotation/blur). They never push
|
||||
// other items away during overlap resolution.
|
||||
softSnapSpans?: { id: string; start: number; end: number }[];
|
||||
currentTimeMs?: number;
|
||||
keyframeTimesMs?: number[];
|
||||
}
|
||||
|
||||
interface SnapGuideHandle {
|
||||
showAt: (timeMs: number) => void;
|
||||
hide: () => void;
|
||||
}
|
||||
|
||||
// Lives inside TimelineContext so it can read valueToPixels. Updates DOM
|
||||
// directly via an imperative handle — same pattern as the drag tooltip — to
|
||||
// avoid re-rendering the timeline on every pointer move.
|
||||
const SnapGuide = forwardRef<SnapGuideHandle>((_, ref) => {
|
||||
const { sidebarWidth, direction, range, valueToPixels } = useTimelineContext();
|
||||
const elRef = useRef<HTMLDivElement>(null);
|
||||
const sideProperty = direction === "rtl" ? "right" : "left";
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
showAt(timeMs: number) {
|
||||
const el = elRef.current;
|
||||
if (!el) return;
|
||||
const offset = valueToPixels(timeMs - range.start) + sidebarWidth;
|
||||
el.style[sideProperty] = `${offset}px`;
|
||||
el.style.opacity = "1";
|
||||
},
|
||||
hide() {
|
||||
const el = elRef.current;
|
||||
if (!el) return;
|
||||
el.style.opacity = "0";
|
||||
},
|
||||
}),
|
||||
[range.start, sidebarWidth, sideProperty, valueToPixels],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={elRef}
|
||||
className="absolute top-0 bottom-0 w-[2px] bg-[#fbbf24] shadow-[0_0_10px_rgba(251,191,36,0.85),0_0_2px_rgba(251,191,36,1)] pointer-events-none z-[55]"
|
||||
style={{ opacity: 0, transition: "opacity 0.08s" }}
|
||||
>
|
||||
<div
|
||||
className="absolute -top-[1px] left-1/2 -translate-x-1/2 w-0 h-0"
|
||||
style={{
|
||||
borderLeft: "4px solid transparent",
|
||||
borderRight: "4px solid transparent",
|
||||
borderTop: "6px solid #fbbf24",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute -bottom-[1px] left-1/2 -translate-x-1/2 w-0 h-0"
|
||||
style={{
|
||||
borderLeft: "4px solid transparent",
|
||||
borderRight: "4px solid transparent",
|
||||
borderBottom: "6px solid #fbbf24",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
SnapGuide.displayName = "SnapGuide";
|
||||
|
||||
export default function TimelineWrapper({
|
||||
children,
|
||||
range,
|
||||
@@ -35,6 +101,9 @@ export default function TimelineWrapper({
|
||||
gridSizeMs: _gridSizeMs,
|
||||
onItemSpanChange,
|
||||
allRegionSpans = [],
|
||||
softSnapSpans = [],
|
||||
currentTimeMs,
|
||||
keyframeTimesMs = [],
|
||||
}: TimelineWrapperProps) {
|
||||
const totalMs = Math.max(0, Math.round(videoDuration * 1000));
|
||||
|
||||
@@ -127,6 +196,142 @@ export default function TimelineWrapper({
|
||||
[allRegionSpans, minItemDurationMs, totalMs],
|
||||
);
|
||||
|
||||
const snapGuideRef = useRef<SnapGuideHandle>(null);
|
||||
|
||||
// Pull the active span's edges to nearby region boundaries, timeline bounds,
|
||||
// the playhead, and keyframes. Threshold scales with zoom (~1% of visible
|
||||
// range, min 50ms) so snap feels right at any zoom level.
|
||||
// Returns the snapped span plus the actual snap target used (for guide rendering).
|
||||
const snapSpanToTargets = useCallback(
|
||||
(
|
||||
span: Span,
|
||||
activeItemId: string,
|
||||
mode: "drag" | "resize-left" | "resize-right",
|
||||
): { span: Span; snapPoint: number | null } => {
|
||||
if (totalMs === 0) return { span, snapPoint: null };
|
||||
|
||||
const visibleMs = Math.max(range.end - range.start, 1);
|
||||
const thresholdMs = Math.max(50, Math.round(visibleMs / 100));
|
||||
|
||||
const targetSet = new Set<number>();
|
||||
targetSet.add(0);
|
||||
targetSet.add(totalMs);
|
||||
for (const r of allRegionSpans) {
|
||||
if (r.id === activeItemId) continue;
|
||||
targetSet.add(r.start);
|
||||
targetSet.add(r.end);
|
||||
}
|
||||
for (const r of softSnapSpans) {
|
||||
if (r.id === activeItemId) continue;
|
||||
targetSet.add(r.start);
|
||||
targetSet.add(r.end);
|
||||
}
|
||||
if (currentTimeMs !== undefined) targetSet.add(currentTimeMs);
|
||||
for (const kf of keyframeTimesMs) targetSet.add(kf);
|
||||
const targets = Array.from(targetSet);
|
||||
|
||||
const findNearest = (value: number): number | null => {
|
||||
let best: number | null = null;
|
||||
let bestDistance = thresholdMs;
|
||||
for (const target of targets) {
|
||||
const distance = Math.abs(target - value);
|
||||
if (distance <= bestDistance) {
|
||||
best = target;
|
||||
bestDistance = distance;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
};
|
||||
|
||||
if (mode === "resize-left") {
|
||||
const snap = findNearest(span.start);
|
||||
if (snap === null || span.end - snap < minItemDurationMs) {
|
||||
return { span, snapPoint: null };
|
||||
}
|
||||
return { span: { start: snap, end: span.end }, snapPoint: snap };
|
||||
}
|
||||
|
||||
if (mode === "resize-right") {
|
||||
const snap = findNearest(span.end);
|
||||
if (snap === null || snap - span.start < minItemDurationMs) {
|
||||
return { span, snapPoint: null };
|
||||
}
|
||||
return { span: { start: span.start, end: snap }, snapPoint: snap };
|
||||
}
|
||||
|
||||
// Drag: preserve duration; snap whichever edge is closer to a target.
|
||||
const startSnap = findNearest(span.start);
|
||||
const endSnap = findNearest(span.end);
|
||||
const startDelta = startSnap !== null ? Math.abs(startSnap - span.start) : Infinity;
|
||||
const endDelta = endSnap !== null ? Math.abs(endSnap - span.end) : Infinity;
|
||||
|
||||
if (startDelta === Infinity && endDelta === Infinity) {
|
||||
return { span, snapPoint: null };
|
||||
}
|
||||
|
||||
const duration = span.end - span.start;
|
||||
if (startDelta <= endDelta && startSnap !== null) {
|
||||
return {
|
||||
span: { start: startSnap, end: startSnap + duration },
|
||||
snapPoint: startSnap,
|
||||
};
|
||||
}
|
||||
if (endSnap !== null) {
|
||||
return {
|
||||
span: { start: endSnap - duration, end: endSnap },
|
||||
snapPoint: endSnap,
|
||||
};
|
||||
}
|
||||
return { span, snapPoint: null };
|
||||
},
|
||||
[
|
||||
allRegionSpans,
|
||||
softSnapSpans,
|
||||
currentTimeMs,
|
||||
keyframeTimesMs,
|
||||
minItemDurationMs,
|
||||
range.end,
|
||||
range.start,
|
||||
totalMs,
|
||||
],
|
||||
);
|
||||
|
||||
// dnd-timeline's resize event doesn't expose direction. Compare the live
|
||||
// span to the committed one (committed spans only update on commit, so
|
||||
// during a single resize they still reflect the pre-resize state).
|
||||
// Returns null when the deltas are equal — including the common clamped
|
||||
// case where both are 0 — because we can't tell which handle the user
|
||||
// grabbed, and guessing wrong would snap the other edge.
|
||||
const inferResizeMode = useCallback(
|
||||
(activeItemId: string, span: Span): "resize-left" | "resize-right" | null => {
|
||||
const old =
|
||||
allRegionSpans.find((r) => r.id === activeItemId) ??
|
||||
softSnapSpans.find((r) => r.id === activeItemId);
|
||||
if (!old) return "resize-right";
|
||||
const startDelta = Math.abs(old.start - span.start);
|
||||
const endDelta = Math.abs(old.end - span.end);
|
||||
if (startDelta === endDelta) return null;
|
||||
return startDelta > endDelta ? "resize-left" : "resize-right";
|
||||
},
|
||||
[allRegionSpans, softSnapSpans],
|
||||
);
|
||||
|
||||
const updateSnapGuide = useCallback(
|
||||
(snapPoint: number | null) => {
|
||||
if (snapPoint === null) {
|
||||
snapGuideRef.current?.hide();
|
||||
return;
|
||||
}
|
||||
// Hide the amber guide when it would coincide with the green playhead.
|
||||
if (currentTimeMs !== undefined && Math.abs(snapPoint - currentTimeMs) < 1) {
|
||||
snapGuideRef.current?.hide();
|
||||
return;
|
||||
}
|
||||
snapGuideRef.current?.showAt(snapPoint);
|
||||
},
|
||||
[currentTimeMs],
|
||||
);
|
||||
|
||||
const onResizeEnd = useCallback(
|
||||
(event: ResizeEndEvent) => {
|
||||
const updatedSpan = event.active.data.current.getSpanFromResizeEvent?.(event);
|
||||
@@ -135,6 +340,11 @@ export default function TimelineWrapper({
|
||||
const activeItemId = event.active.id as string;
|
||||
let clampedSpan = clampSpanToBounds(updatedSpan);
|
||||
|
||||
const mode = inferResizeMode(activeItemId, clampedSpan);
|
||||
if (mode !== null) {
|
||||
clampedSpan = snapSpanToTargets(clampedSpan, activeItemId, mode).span;
|
||||
}
|
||||
|
||||
const effectiveMinDuration =
|
||||
totalMs > 0 ? Math.min(minItemDurationMs, totalMs) : minItemDurationMs;
|
||||
if (clampedSpan.end - clampedSpan.start < effectiveMinDuration) {
|
||||
@@ -156,8 +366,10 @@ export default function TimelineWrapper({
|
||||
clampSpanToBounds,
|
||||
clampToNeighbours,
|
||||
hasOverlap,
|
||||
inferResizeMode,
|
||||
minItemDurationMs,
|
||||
onItemSpanChange,
|
||||
snapSpanToTargets,
|
||||
totalMs,
|
||||
],
|
||||
);
|
||||
@@ -171,6 +383,8 @@ export default function TimelineWrapper({
|
||||
const activeItemId = event.active.id as string;
|
||||
let clampedSpan = clampSpanToBounds(updatedSpan);
|
||||
|
||||
clampedSpan = snapSpanToTargets(clampedSpan, activeItemId, "drag").span;
|
||||
|
||||
// Clamp to neighbour boundaries instead of rejecting
|
||||
if (hasOverlap(clampedSpan, activeItemId)) {
|
||||
clampedSpan = clampToNeighbours(clampedSpan, activeItemId);
|
||||
@@ -181,7 +395,7 @@ export default function TimelineWrapper({
|
||||
|
||||
onItemSpanChange(activeItemId, clampedSpan);
|
||||
},
|
||||
[clampSpanToBounds, clampToNeighbours, hasOverlap, onItemSpanChange],
|
||||
[clampSpanToBounds, clampToNeighbours, hasOverlap, onItemSpanChange, snapSpanToTargets],
|
||||
);
|
||||
|
||||
// Drag/resize tooltip (direct DOM updates, no re-renders)
|
||||
@@ -226,44 +440,63 @@ export default function TimelineWrapper({
|
||||
|
||||
const onDragMove = useCallback(
|
||||
(event: DragMoveEvent) => {
|
||||
const span = event.active.data.current.getSpanFromDragEvent?.(event);
|
||||
const rawSpan = event.active.data.current.getSpanFromDragEvent?.(event);
|
||||
if (!rawSpan) return;
|
||||
const activeItemId = event.active.id as string;
|
||||
const clamped = totalMs > 0 ? clampSpanToBounds(rawSpan) : rawSpan;
|
||||
const { span, snapPoint } = snapSpanToTargets(clamped, activeItemId, "drag");
|
||||
updateSnapGuide(snapPoint);
|
||||
const screenX =
|
||||
event.activatorEvent && "clientX" in event.activatorEvent
|
||||
? (event.activatorEvent as PointerEvent).clientX + (event.delta?.x ?? 0)
|
||||
: undefined;
|
||||
if (span) showTooltip(span, screenX);
|
||||
showTooltip(span, screenX);
|
||||
},
|
||||
[showTooltip],
|
||||
[clampSpanToBounds, showTooltip, snapSpanToTargets, totalMs, updateSnapGuide],
|
||||
);
|
||||
|
||||
const onResizeMove = useCallback(
|
||||
(event: ResizeMoveEvent) => {
|
||||
const span = event.active.data.current.getSpanFromResizeEvent?.(event);
|
||||
const rawSpan = event.active.data.current.getSpanFromResizeEvent?.(event);
|
||||
if (!rawSpan) return;
|
||||
const activeItemId = event.active.id as string;
|
||||
const clamped = totalMs > 0 ? clampSpanToBounds(rawSpan) : rawSpan;
|
||||
const mode = inferResizeMode(activeItemId, clamped);
|
||||
const { span, snapPoint } =
|
||||
mode !== null
|
||||
? snapSpanToTargets(clamped, activeItemId, mode)
|
||||
: { span: clamped, snapPoint: null };
|
||||
updateSnapGuide(snapPoint);
|
||||
const screenX =
|
||||
event.activatorEvent && "clientX" in event.activatorEvent
|
||||
? (event.activatorEvent as PointerEvent).clientX + (event.delta?.x ?? 0)
|
||||
: undefined;
|
||||
if (span) showTooltip(span, screenX);
|
||||
showTooltip(span, screenX);
|
||||
},
|
||||
[showTooltip],
|
||||
[clampSpanToBounds, inferResizeMode, showTooltip, snapSpanToTargets, totalMs, updateSnapGuide],
|
||||
);
|
||||
|
||||
const hideTooltip = useCallback(() => showTooltip(null), [showTooltip]);
|
||||
|
||||
const hideOverlays = useCallback(() => {
|
||||
hideTooltip();
|
||||
snapGuideRef.current?.hide();
|
||||
}, [hideTooltip]);
|
||||
|
||||
const onResizeEndWithTooltip = useCallback(
|
||||
(event: ResizeEndEvent) => {
|
||||
hideTooltip();
|
||||
hideOverlays();
|
||||
onResizeEnd(event);
|
||||
},
|
||||
[hideTooltip, onResizeEnd],
|
||||
[hideOverlays, onResizeEnd],
|
||||
);
|
||||
|
||||
const onDragEndWithTooltip = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
hideTooltip();
|
||||
hideOverlays();
|
||||
onDragEnd(event);
|
||||
},
|
||||
[hideTooltip, onDragEnd],
|
||||
[hideOverlays, onDragEnd],
|
||||
);
|
||||
|
||||
const handleRangeChange = useCallback(
|
||||
@@ -305,6 +538,7 @@ export default function TimelineWrapper({
|
||||
>
|
||||
<div className="relative">
|
||||
{children}
|
||||
<SnapGuide ref={snapGuideRef} />
|
||||
{/* Floating tooltip shown during drag/resize */}
|
||||
<div
|
||||
ref={tooltipRef}
|
||||
|
||||
@@ -257,6 +257,15 @@ export interface AnnotationSize {
|
||||
height: number;
|
||||
}
|
||||
|
||||
export type AnnotationTextAnimation =
|
||||
| "none"
|
||||
| "fade"
|
||||
| "rise"
|
||||
| "pop"
|
||||
| "slide-left"
|
||||
| "typewriter"
|
||||
| "pulse";
|
||||
|
||||
export interface AnnotationTextStyle {
|
||||
color: string;
|
||||
backgroundColor: string;
|
||||
@@ -266,6 +275,7 @@ export interface AnnotationTextStyle {
|
||||
fontStyle: "normal" | "italic";
|
||||
textDecoration: "none" | "underline";
|
||||
textAlign: "left" | "center" | "right";
|
||||
textAnimation?: AnnotationTextAnimation;
|
||||
}
|
||||
|
||||
export interface AnnotationRegion {
|
||||
@@ -303,6 +313,7 @@ export const DEFAULT_ANNOTATION_STYLE: AnnotationTextStyle = {
|
||||
fontStyle: "normal",
|
||||
textDecoration: "none",
|
||||
textAlign: "center",
|
||||
textAnimation: "none",
|
||||
};
|
||||
|
||||
export const DEFAULT_FIGURE_DATA: FigureData = {
|
||||
|
||||
@@ -14,7 +14,7 @@ interface ShortcutsContextValue {
|
||||
shortcuts: ShortcutsConfig;
|
||||
isMac: boolean;
|
||||
setShortcuts: (config: ShortcutsConfig) => void;
|
||||
persistShortcuts: (config?: ShortcutsConfig) => Promise<void>;
|
||||
persistShortcuts: (config?: ShortcutsConfig) => Promise<boolean>;
|
||||
isConfigOpen: boolean;
|
||||
openConfig: () => void;
|
||||
closeConfig: () => void;
|
||||
@@ -54,7 +54,11 @@ export function ShortcutsProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
const persistShortcuts = useCallback(
|
||||
async (config?: ShortcutsConfig) => {
|
||||
await window.electronAPI.saveShortcuts?.(config ?? shortcuts);
|
||||
const configToSave = config ?? shortcuts;
|
||||
await window.electronAPI.saveShortcuts?.(configToSave);
|
||||
|
||||
const result = await window.electronAPI.updateGlobalShortcut?.(configToSave.openApp);
|
||||
return result ? result.success : true;
|
||||
},
|
||||
[shortcuts],
|
||||
);
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Web Worker: computes min/max peak pairs from raw audio channel data.
|
||||
*
|
||||
* Input message: { channels: Float32Array[]; duration: number }
|
||||
* Output message: Float32Array of length 2*N — [min0, max0, min1, max1, …]
|
||||
*
|
||||
* Channel buffers are transferred (zero-copy) from the caller.
|
||||
* The peaks buffer is transferred back.
|
||||
*/
|
||||
self.onmessage = (event: MessageEvent<{ channels: Float32Array[]; duration: number }>) => {
|
||||
const { channels, duration } = event.data;
|
||||
const nCh = channels.length;
|
||||
if (nCh === 0) {
|
||||
(self as unknown as Worker).postMessage(new Float32Array(0));
|
||||
return;
|
||||
}
|
||||
|
||||
const totalSamples = channels[0].length;
|
||||
const N = Math.min(24000, Math.ceil(duration * 200));
|
||||
const blockSize = totalSamples / N;
|
||||
const peaks = new Float32Array(N * 2); // [min0, max0, min1, max1, …]
|
||||
|
||||
for (let i = 0; i < N; i++) {
|
||||
const start = Math.floor(i * blockSize);
|
||||
const end = Math.floor((i + 1) * blockSize);
|
||||
let minVal = 0;
|
||||
let maxVal = 0;
|
||||
for (let j = start; j < end; j++) {
|
||||
let sample = 0;
|
||||
for (let c = 0; c < nCh; c++) sample += channels[c][j];
|
||||
sample /= nCh;
|
||||
if (sample < minVal) minVal = sample;
|
||||
if (sample > maxVal) maxVal = sample;
|
||||
}
|
||||
peaks[i * 2] = minVal;
|
||||
peaks[i * 2 + 1] = maxVal;
|
||||
}
|
||||
|
||||
(self as unknown as Worker).postMessage(peaks, [peaks.buffer]);
|
||||
};
|
||||
@@ -0,0 +1,264 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createRecorderHandle } from "./recorderHandle";
|
||||
|
||||
type ElectronAPI = Window["electronAPI"];
|
||||
|
||||
const tick = () => new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
const decode = (buffer: ArrayBuffer) => new TextDecoder().decode(new Uint8Array(buffer));
|
||||
|
||||
/** Minimal MediaRecorder stand-in the tests can drive directly. */
|
||||
class FakeMediaRecorder {
|
||||
ondataavailable: ((event: BlobEvent) => void) | null = null;
|
||||
onstop: (() => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
state: "inactive" | "recording" = "inactive";
|
||||
|
||||
start(): void {
|
||||
this.state = "recording";
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.state = "inactive";
|
||||
this.onstop?.();
|
||||
}
|
||||
|
||||
emit(data: Blob): void {
|
||||
this.ondataavailable?.({ data } as BlobEvent);
|
||||
}
|
||||
}
|
||||
|
||||
function stubElectronAPI(api: Partial<ElectronAPI>): void {
|
||||
window.electronAPI = api as unknown as ElectronAPI;
|
||||
}
|
||||
|
||||
function driver(handle: { recorder: MediaRecorder }): FakeMediaRecorder {
|
||||
return handle.recorder as unknown as FakeMediaRecorder;
|
||||
}
|
||||
|
||||
describe("createRecorderHandle", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("MediaRecorder", FakeMediaRecorder);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
window.electronAPI = undefined as unknown as ElectronAPI;
|
||||
});
|
||||
|
||||
it("streams chunks to disk in arrival order and resolves an empty blob", async () => {
|
||||
const appended: string[] = [];
|
||||
const openRecordingStream = vi.fn(async () => ({ success: true }));
|
||||
const appendRecordingChunk = vi.fn(async (_fileName: string, buffer: ArrayBuffer) => {
|
||||
appended.push(decode(buffer));
|
||||
return { success: true };
|
||||
});
|
||||
stubElectronAPI({ openRecordingStream, appendRecordingChunk });
|
||||
|
||||
const handle = createRecorderHandle({} as MediaStream, { mimeType: "video/webm" }, "rec.webm");
|
||||
const fake = driver(handle);
|
||||
|
||||
fake.emit(new Blob(["a"])); // arrives before open resolves -> buffered
|
||||
await tick(); // open resolves -> buffered chunk flushes, mode becomes streaming
|
||||
fake.emit(new Blob(["b"]));
|
||||
fake.emit(new Blob(["c"]));
|
||||
fake.stop();
|
||||
|
||||
const blob = await handle.recordedBlobPromise;
|
||||
expect(openRecordingStream).toHaveBeenCalledWith("rec.webm");
|
||||
expect(appended).toEqual(["a", "b", "c"]);
|
||||
expect(blob.size).toBe(0);
|
||||
expect(handle.isStreaming()).toBe(true);
|
||||
});
|
||||
|
||||
it("falls back to a complete in-memory blob when the stream fails to open", async () => {
|
||||
const openRecordingStream = vi.fn(async () => ({ success: false, error: "nope" }));
|
||||
const appendRecordingChunk = vi.fn(async () => ({ success: true }));
|
||||
stubElectronAPI({ openRecordingStream, appendRecordingChunk });
|
||||
|
||||
const handle = createRecorderHandle({} as MediaStream, { mimeType: "video/webm" }, "rec.webm");
|
||||
const fake = driver(handle);
|
||||
|
||||
fake.emit(new Blob(["a"]));
|
||||
await tick(); // open resolves false -> buffering, keep everything in memory
|
||||
fake.emit(new Blob(["bc"]));
|
||||
fake.stop();
|
||||
|
||||
const blob = await handle.recordedBlobPromise;
|
||||
expect(appendRecordingChunk).not.toHaveBeenCalled();
|
||||
expect(handle.isStreaming()).toBe(false);
|
||||
expect(blob.size).toBe(3);
|
||||
expect(decode(await blob.arrayBuffer())).toBe("abc");
|
||||
});
|
||||
|
||||
it("falls back to in-memory buffering when the open IPC call rejects", async () => {
|
||||
const openRecordingStream = vi.fn(async () => {
|
||||
throw new Error("ipc channel closed");
|
||||
});
|
||||
stubElectronAPI({
|
||||
openRecordingStream,
|
||||
appendRecordingChunk: vi.fn(async () => ({ success: true })),
|
||||
});
|
||||
|
||||
const handle = createRecorderHandle({} as MediaStream, { mimeType: "video/webm" }, "rec.webm");
|
||||
const fake = driver(handle);
|
||||
|
||||
fake.emit(new Blob(["a"]));
|
||||
await tick(); // open rejects -> treated as a failed open, keep buffering
|
||||
fake.emit(new Blob(["b"]));
|
||||
fake.stop();
|
||||
|
||||
const blob = await handle.recordedBlobPromise;
|
||||
expect(handle.isStreaming()).toBe(false);
|
||||
expect(blob.size).toBe(2);
|
||||
expect(decode(await blob.arrayBuffer())).toBe("ab");
|
||||
});
|
||||
|
||||
it("waits for in-flight chunk writes before stop resolves (no truncation)", async () => {
|
||||
let releaseAppend: () => void = () => undefined;
|
||||
const appendGate = new Promise<void>((resolve) => {
|
||||
releaseAppend = resolve;
|
||||
});
|
||||
const appendRecordingChunk = vi.fn(async () => {
|
||||
await appendGate;
|
||||
return { success: true };
|
||||
});
|
||||
stubElectronAPI({
|
||||
openRecordingStream: vi.fn(async () => ({ success: true })),
|
||||
appendRecordingChunk,
|
||||
});
|
||||
|
||||
const handle = createRecorderHandle({} as MediaStream, { mimeType: "video/webm" }, "rec.webm");
|
||||
const fake = driver(handle);
|
||||
|
||||
await tick(); // open resolves
|
||||
fake.emit(new Blob(["a"])); // write blocks on the gate
|
||||
fake.stop();
|
||||
|
||||
let resolved = false;
|
||||
void handle.recordedBlobPromise.then(() => {
|
||||
resolved = true;
|
||||
});
|
||||
await tick();
|
||||
expect(resolved).toBe(false); // must not resolve while the write is in flight
|
||||
|
||||
releaseAppend();
|
||||
await handle.recordedBlobPromise;
|
||||
expect(resolved).toBe(true);
|
||||
expect(appendRecordingChunk).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("rejects when a chunk fails to write mid-stream", async () => {
|
||||
stubElectronAPI({
|
||||
openRecordingStream: vi.fn(async () => ({ success: true })),
|
||||
appendRecordingChunk: vi.fn(async () => ({ success: false, error: "disk full" })),
|
||||
closeRecordingStream: vi.fn(async () => ({ success: true })),
|
||||
});
|
||||
|
||||
const handle = createRecorderHandle({} as MediaStream, { mimeType: "video/webm" }, "rec.webm");
|
||||
const fake = driver(handle);
|
||||
|
||||
await tick();
|
||||
fake.emit(new Blob(["a"]));
|
||||
fake.stop();
|
||||
|
||||
await expect(handle.recordedBlobPromise).rejects.toThrow(/disk full/);
|
||||
expect(handle.isStreaming()).toBe(false);
|
||||
});
|
||||
|
||||
it("treats a rejected append the same as a failed write", async () => {
|
||||
stubElectronAPI({
|
||||
openRecordingStream: vi.fn(async () => ({ success: true })),
|
||||
appendRecordingChunk: vi.fn(async () => {
|
||||
throw new Error("kernel said no");
|
||||
}),
|
||||
closeRecordingStream: vi.fn(async () => ({ success: true })),
|
||||
});
|
||||
|
||||
const handle = createRecorderHandle({} as MediaStream, { mimeType: "video/webm" }, "rec.webm");
|
||||
const fake = driver(handle);
|
||||
|
||||
await tick();
|
||||
fake.emit(new Blob(["a"]));
|
||||
fake.stop();
|
||||
|
||||
await expect(handle.recordedBlobPromise).rejects.toThrow(/kernel said no/);
|
||||
expect(handle.isStreaming()).toBe(false);
|
||||
});
|
||||
|
||||
it("buffers in memory and never opens a stream when no file name is given", async () => {
|
||||
const openRecordingStream = vi.fn(async () => ({ success: true }));
|
||||
stubElectronAPI({
|
||||
openRecordingStream,
|
||||
appendRecordingChunk: vi.fn(async () => ({ success: true })),
|
||||
});
|
||||
|
||||
const handle = createRecorderHandle({} as MediaStream, { mimeType: "video/webm" });
|
||||
const fake = driver(handle);
|
||||
|
||||
fake.emit(new Blob(["xy"]));
|
||||
await tick();
|
||||
fake.stop();
|
||||
|
||||
const blob = await handle.recordedBlobPromise;
|
||||
expect(openRecordingStream).not.toHaveBeenCalled();
|
||||
expect(handle.isStreaming()).toBe(false);
|
||||
expect(blob.size).toBe(2);
|
||||
});
|
||||
|
||||
it("buffers in memory when appendRecordingChunk is unavailable (version skew)", async () => {
|
||||
const openRecordingStream = vi.fn(async () => ({ success: true }));
|
||||
// appendRecordingChunk intentionally omitted to simulate renderer/main skew.
|
||||
stubElectronAPI({ openRecordingStream });
|
||||
|
||||
const handle = createRecorderHandle({} as MediaStream, { mimeType: "video/webm" }, "rec.webm");
|
||||
const fake = driver(handle);
|
||||
|
||||
fake.emit(new Blob(["a"]));
|
||||
await tick();
|
||||
fake.emit(new Blob(["b"]));
|
||||
fake.stop();
|
||||
|
||||
const blob = await handle.recordedBlobPromise;
|
||||
// Never even attempts to open the stream when it can't append to it.
|
||||
expect(openRecordingStream).not.toHaveBeenCalled();
|
||||
expect(handle.isStreaming()).toBe(false);
|
||||
expect(blob.size).toBe(2);
|
||||
});
|
||||
|
||||
it("discard closes the disk stream for a streamed recording", async () => {
|
||||
const closeRecordingStream = vi.fn(async () => ({ success: true }));
|
||||
stubElectronAPI({
|
||||
openRecordingStream: vi.fn(async () => ({ success: true })),
|
||||
appendRecordingChunk: vi.fn(async () => ({ success: true })),
|
||||
closeRecordingStream,
|
||||
});
|
||||
|
||||
const handle = createRecorderHandle({} as MediaStream, { mimeType: "video/webm" }, "rec.webm");
|
||||
const fake = driver(handle);
|
||||
await tick();
|
||||
fake.emit(new Blob(["a"]));
|
||||
fake.stop();
|
||||
await handle.recordedBlobPromise;
|
||||
|
||||
await handle.discard();
|
||||
expect(closeRecordingStream).toHaveBeenCalledWith("rec.webm");
|
||||
});
|
||||
|
||||
it("discard is a no-op when the stream never opened", async () => {
|
||||
const closeRecordingStream = vi.fn(async () => ({ success: true }));
|
||||
stubElectronAPI({
|
||||
openRecordingStream: vi.fn(async () => ({ success: false })),
|
||||
appendRecordingChunk: vi.fn(async () => ({ success: true })),
|
||||
closeRecordingStream,
|
||||
});
|
||||
|
||||
const handle = createRecorderHandle({} as MediaStream, { mimeType: "video/webm" }, "rec.webm");
|
||||
const fake = driver(handle);
|
||||
await tick();
|
||||
fake.stop();
|
||||
await handle.recordedBlobPromise;
|
||||
|
||||
await handle.discard();
|
||||
expect(closeRecordingStream).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
const RECORDER_TIMESLICE_MS = 1000;
|
||||
|
||||
export type RecorderHandle = {
|
||||
recorder: MediaRecorder;
|
||||
/**
|
||||
* Resolves once the recording has fully drained. For a streamed recording the
|
||||
* blob is empty (the bytes are already on disk); for an in-memory recording it
|
||||
* holds the full WebM. Rejects if a chunk failed to write to disk mid-stream,
|
||||
* so a truncated recording surfaces as an error instead of a silent partial save.
|
||||
*/
|
||||
recordedBlobPromise: Promise<Blob>;
|
||||
/**
|
||||
* Whether the recording's bytes went to disk via the streaming path. Computed
|
||||
* at finalize time rather than construction, so a stream that fails to open is
|
||||
* correctly reported as not-streamed and its in-memory fallback is used.
|
||||
*/
|
||||
isStreaming: () => boolean;
|
||||
/**
|
||||
* Close the disk stream (if one opened) and delete its partial file. Called
|
||||
* when a recording is discarded or fails before a successful save, so cancelled
|
||||
* runs don't leak the stream or orphan a partial file. No-op for in-memory
|
||||
* recorders.
|
||||
*/
|
||||
discard: () => Promise<void>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Wrap a MediaRecorder, optionally streaming its chunks to disk.
|
||||
*
|
||||
* When `fileName` is given, chunks are written to disk in arrival order through
|
||||
* the main process as they arrive, so a long recording never buffers the whole
|
||||
* video in the renderer (the #616 fix). Until the disk stream confirms it is
|
||||
* open, chunks are held in memory; if the open fails, that buffer becomes a
|
||||
* complete in-memory fallback so nothing is lost. Native-capture webcam sidecars
|
||||
* omit `fileName` and always buffer in memory, since their finalize path reads
|
||||
* the blob directly to attach the webcam track.
|
||||
*/
|
||||
export function createRecorderHandle(
|
||||
stream: MediaStream,
|
||||
options: MediaRecorderOptions,
|
||||
fileName?: string,
|
||||
): RecorderHandle {
|
||||
const recorder = new MediaRecorder(stream, options);
|
||||
const mimeType = options.mimeType || "video/webm";
|
||||
const api = window.electronAPI;
|
||||
|
||||
// Chunks held in memory: everything before the stream opens, plus everything
|
||||
// when not streaming at all. On a successful open these flush to disk and are
|
||||
// dropped; on open failure they remain as the complete fallback recording.
|
||||
const memoryChunks: Blob[] = [];
|
||||
let mode: "pending" | "streaming" | "buffering" = fileName ? "pending" : "buffering";
|
||||
let streamOpened = false;
|
||||
let appendError: Error | null = null;
|
||||
|
||||
// Serialize chunk writes so they land on disk in arrival order, and so stop
|
||||
// can await every in-flight write before the main process closes the stream
|
||||
// (otherwise a late chunk arrives after close and truncates the recording).
|
||||
let writeChain: Promise<void> = Promise.resolve();
|
||||
const enqueueWrite = (chunk: Blob) => {
|
||||
writeChain = writeChain.then(async () => {
|
||||
if (appendError || !fileName || !api?.appendRecordingChunk) {
|
||||
return;
|
||||
}
|
||||
// Capture both outcomes — a `{ success: false }` result and an outright
|
||||
// rejection (channel/handler error) — into appendError, so writeChain
|
||||
// never rejects and isStreaming() stays consistent after a failure.
|
||||
try {
|
||||
const buffer = await chunk.arrayBuffer();
|
||||
const result = await api.appendRecordingChunk(fileName, buffer);
|
||||
if (!result.success) {
|
||||
appendError = new Error(result.error ?? "Failed to write recording chunk to disk");
|
||||
}
|
||||
} catch (error) {
|
||||
appendError = error instanceof Error ? error : new Error(String(error));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Require BOTH stream IPC methods before attempting to stream. If only
|
||||
// openRecordingStream exists (renderer/main version skew), streaming would
|
||||
// open but every append would silently no-op, saving an empty file — so in
|
||||
// that case fall through to in-memory buffering instead.
|
||||
const openPromise: Promise<{ success: boolean; error?: string }> =
|
||||
fileName !== undefined &&
|
||||
typeof api?.openRecordingStream === "function" &&
|
||||
typeof api?.appendRecordingChunk === "function"
|
||||
? api.openRecordingStream(fileName)
|
||||
: Promise.resolve({ success: false });
|
||||
|
||||
void openPromise.then(
|
||||
(result) => {
|
||||
if (result.success) {
|
||||
streamOpened = true;
|
||||
mode = "streaming";
|
||||
for (const chunk of memoryChunks) {
|
||||
enqueueWrite(chunk);
|
||||
}
|
||||
memoryChunks.length = 0;
|
||||
} else {
|
||||
mode = "buffering";
|
||||
}
|
||||
},
|
||||
() => {
|
||||
// The IPC call itself rejected (channel or handler error). Treat it the
|
||||
// same as a failed open: keep buffering in memory so nothing is lost.
|
||||
mode = "buffering";
|
||||
},
|
||||
);
|
||||
|
||||
const recordedBlobPromise = new Promise<Blob>((resolve, reject) => {
|
||||
recorder.ondataavailable = (event: BlobEvent) => {
|
||||
if (!event.data || event.data.size === 0) {
|
||||
return;
|
||||
}
|
||||
if (mode === "streaming") {
|
||||
enqueueWrite(event.data);
|
||||
} else {
|
||||
// "pending" (stream not open yet) or "buffering" (not streaming).
|
||||
memoryChunks.push(event.data);
|
||||
}
|
||||
};
|
||||
|
||||
recorder.onerror = () => {
|
||||
reject(new Error("Recording failed"));
|
||||
};
|
||||
|
||||
recorder.onstop = () => {
|
||||
resolve(finalizeBlob());
|
||||
};
|
||||
});
|
||||
|
||||
async function finalizeBlob(): Promise<Blob> {
|
||||
// Wait for the open attempt to settle so its flush (or fallback switch) has
|
||||
// been applied, then for every queued write to land, so we never resolve
|
||||
// while chunks are still in flight to the about-to-close disk stream.
|
||||
await openPromise.catch(() => undefined);
|
||||
await writeChain;
|
||||
if (appendError) {
|
||||
throw appendError;
|
||||
}
|
||||
if (mode === "streaming") {
|
||||
return new Blob([], { type: mimeType });
|
||||
}
|
||||
return new Blob(memoryChunks, { type: mimeType });
|
||||
}
|
||||
|
||||
async function discard(): Promise<void> {
|
||||
if (streamOpened && fileName && api?.closeRecordingStream) {
|
||||
await api.closeRecordingStream(fileName);
|
||||
}
|
||||
}
|
||||
|
||||
recorder.start(RECORDER_TIMESLICE_MS);
|
||||
return {
|
||||
recorder,
|
||||
recordedBlobPromise,
|
||||
isStreaming: () => mode === "streaming" && !appendError,
|
||||
discard,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { loadFileAsArrayBuffer } from "@/lib/exporter/streamingDecoder";
|
||||
|
||||
let _audioCtx: AudioContext | null = null;
|
||||
/** Returns the shared AudioContext, creating it lazily on first call. */
|
||||
function getAudioCtx(): AudioContext {
|
||||
if (!_audioCtx) _audioCtx = new AudioContext();
|
||||
return _audioCtx;
|
||||
}
|
||||
|
||||
/**
|
||||
* Offloads peak computation to a Web Worker (zero-copy via Transferable).
|
||||
* Accepts an optional AbortSignal — if aborted, the worker is terminated
|
||||
* immediately and the promise rejects with an AbortError.
|
||||
*/
|
||||
function computePeaksInWorker(
|
||||
audioBuffer: AudioBuffer,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Float32Array> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal?.aborted) {
|
||||
reject(new DOMException("Aborted", "AbortError"));
|
||||
return;
|
||||
}
|
||||
|
||||
const worker = new Worker(new URL("./audioPeaksWorker.ts", import.meta.url), {
|
||||
type: "module",
|
||||
});
|
||||
|
||||
const onAbort = () => {
|
||||
worker.terminate();
|
||||
reject(new DOMException("Aborted", "AbortError"));
|
||||
};
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
|
||||
// slice() creates an owned copy so the transfer is safe and the
|
||||
// AudioBuffer remains valid if anything else holds a reference.
|
||||
const channels: Float32Array[] = [];
|
||||
for (let c = 0; c < audioBuffer.numberOfChannels; c++) {
|
||||
channels.push(audioBuffer.getChannelData(c).slice());
|
||||
}
|
||||
|
||||
worker.onmessage = (e: MessageEvent<Float32Array>) => {
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
worker.terminate();
|
||||
resolve(e.data);
|
||||
};
|
||||
|
||||
worker.onerror = (e) => {
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
worker.terminate();
|
||||
reject(e);
|
||||
};
|
||||
|
||||
worker.postMessage(
|
||||
{ channels, duration: audioBuffer.duration },
|
||||
channels.map((ch) => ch.buffer),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes audio from `videoUrl` and returns a Float32Array of paired
|
||||
* [min, max] peak values (length = 2 * N blocks). Returns `null` while
|
||||
* decoding is in progress, and stays `null` when the file has no audio
|
||||
* track or decoding fails (silent degradation).
|
||||
*
|
||||
* - File loading uses the Electron IPC bridge for local paths (same as the exporter).
|
||||
* - Peak computation runs in a Web Worker to avoid blocking the main thread.
|
||||
* - Results are cached in a ref scoped to the hook instance (survives re-renders
|
||||
* and waveform toggle off/on, but not component unmount).
|
||||
*/
|
||||
export function useAudioPeaks(videoUrl?: string): Float32Array | null {
|
||||
const cacheRef = useRef<Map<string, Float32Array>>(new Map());
|
||||
const [peaks, setPeaks] = useState<Float32Array | null>(() =>
|
||||
videoUrl ? (cacheRef.current.get(videoUrl) ?? null) : null,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!videoUrl) {
|
||||
setPeaks(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const cached = cacheRef.current.get(videoUrl);
|
||||
if (cached) {
|
||||
setPeaks(cached);
|
||||
return;
|
||||
}
|
||||
|
||||
setPeaks(null);
|
||||
let cancelled = false;
|
||||
const controller = new AbortController();
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const { data: arrayBuffer } = await loadFileAsArrayBuffer(videoUrl);
|
||||
if (cancelled) return;
|
||||
const audioBuffer = await getAudioCtx().decodeAudioData(arrayBuffer);
|
||||
if (cancelled) return;
|
||||
const p = await computePeaksInWorker(audioBuffer, controller.signal);
|
||||
if (cancelled) return;
|
||||
cacheRef.current.set(videoUrl, p);
|
||||
setPeaks(p);
|
||||
} catch (err) {
|
||||
// AbortError means the effect cleaned up — no state update needed.
|
||||
if (err instanceof DOMException && err.name === "AbortError") return;
|
||||
// No audio track or unsupported format — clear stale data silently.
|
||||
if (!cancelled) setPeaks(null);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
controller.abort();
|
||||
};
|
||||
}, [videoUrl]);
|
||||
|
||||
return peaks;
|
||||
}
|
||||
@@ -29,6 +29,7 @@ export interface EditorState {
|
||||
wallpaper: string;
|
||||
shadowIntensity: number;
|
||||
showBlur: boolean;
|
||||
showTrimWaveform: boolean;
|
||||
motionBlurAmount: number;
|
||||
borderRadius: number;
|
||||
padding: number;
|
||||
@@ -49,6 +50,7 @@ export const INITIAL_EDITOR_STATE: EditorState = {
|
||||
wallpaper: DEFAULT_EDITOR_LAYOUT_SETTINGS.wallpaper,
|
||||
shadowIntensity: DEFAULT_EDITOR_APPEARANCE_SETTINGS.shadowIntensity,
|
||||
showBlur: DEFAULT_EDITOR_APPEARANCE_SETTINGS.showBlur,
|
||||
showTrimWaveform: DEFAULT_EDITOR_APPEARANCE_SETTINGS.showTrimWaveform,
|
||||
motionBlurAmount: DEFAULT_EDITOR_APPEARANCE_SETTINGS.motionBlurAmount,
|
||||
borderRadius: DEFAULT_EDITOR_APPEARANCE_SETTINGS.borderRadius,
|
||||
padding: DEFAULT_EDITOR_LAYOUT_SETTINGS.padding,
|
||||
@@ -130,6 +132,11 @@ export function useEditorHistory(initial: EditorState = INITIAL_EDITOR_STATE) {
|
||||
dirtyRef.current = false;
|
||||
}, []);
|
||||
|
||||
const resetState = useCallback((newInitial: EditorState = INITIAL_EDITOR_STATE) => {
|
||||
setHistory({ past: [], present: newInitial, future: [] });
|
||||
dirtyRef.current = false;
|
||||
}, []);
|
||||
|
||||
return {
|
||||
state: history.present,
|
||||
pushState,
|
||||
@@ -137,6 +144,7 @@ export function useEditorHistory(initial: EditorState = INITIAL_EDITOR_STATE) {
|
||||
commitState,
|
||||
undo,
|
||||
redo,
|
||||
resetState,
|
||||
canUndo: history.past.length > 0,
|
||||
canRedo: history.future.length > 0,
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from "@/lib/nativeWindowsRecording";
|
||||
import type { CursorCaptureMode, RecordedVideoAssetInput } from "@/lib/recordingSession";
|
||||
import { requestCameraAccess } from "@/lib/requestCameraAccess";
|
||||
import { createRecorderHandle, type RecorderHandle } from "./recorderHandle";
|
||||
|
||||
const TARGET_FRAME_RATE = 60;
|
||||
const MIN_FRAME_RATE = 30;
|
||||
@@ -34,7 +35,6 @@ const DEFAULT_HEIGHT = 1080;
|
||||
|
||||
const CODEC_ALIGNMENT = 2;
|
||||
|
||||
const RECORDER_TIMESLICE_MS = 1000;
|
||||
const BITS_PER_MEGABIT = 1_000_000;
|
||||
const CHROME_MEDIA_SOURCE = "desktop";
|
||||
const RECORDING_FILE_PREFIX = "recording-";
|
||||
@@ -74,11 +74,6 @@ type UseScreenRecorderReturn = {
|
||||
setCursorCaptureMode: (mode: CursorCaptureMode) => void;
|
||||
};
|
||||
|
||||
type RecorderHandle = {
|
||||
recorder: MediaRecorder;
|
||||
recordedBlobPromise: Promise<Blob>;
|
||||
};
|
||||
|
||||
type NativeWindowsRecordingHandle = {
|
||||
recordingId: number;
|
||||
finalizing: boolean;
|
||||
@@ -92,28 +87,6 @@ type NativeMacRecordingHandle = {
|
||||
paused: boolean;
|
||||
};
|
||||
|
||||
function createRecorderHandle(stream: MediaStream, options: MediaRecorderOptions): RecorderHandle {
|
||||
const recorder = new MediaRecorder(stream, options);
|
||||
const chunks: Blob[] = [];
|
||||
const mimeType = options.mimeType || "video/webm";
|
||||
const recordedBlobPromise = new Promise<Blob>((resolve, reject) => {
|
||||
recorder.ondataavailable = (event: BlobEvent) => {
|
||||
if (event.data && event.data.size > 0) {
|
||||
chunks.push(event.data);
|
||||
}
|
||||
};
|
||||
recorder.onerror = () => {
|
||||
reject(new Error("Recording failed"));
|
||||
};
|
||||
recorder.onstop = () => {
|
||||
resolve(new Blob(chunks, { type: mimeType }));
|
||||
};
|
||||
});
|
||||
|
||||
recorder.start(RECORDER_TIMESLICE_MS);
|
||||
return { recorder, recordedBlobPromise };
|
||||
}
|
||||
|
||||
export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
const t = useScopedT("editor");
|
||||
const [recording, setRecording] = useState(false);
|
||||
@@ -355,46 +328,65 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
window.electronAPI?.setRecordingState(false);
|
||||
|
||||
void (async () => {
|
||||
// Each disk stream must end up either saved or explicitly discarded.
|
||||
// store-recorded-session finalizes the streams included in a successful
|
||||
// save; the finally block discards everything else.
|
||||
let storeSucceeded = false;
|
||||
let webcamIncludedInSave = false;
|
||||
try {
|
||||
const screenBlob = await activeScreenRecorder.recordedBlobPromise;
|
||||
if (discardRecordingId.current === activeRecordingId) {
|
||||
window.electronAPI?.discardCursorTelemetry(activeRecordingId);
|
||||
return;
|
||||
}
|
||||
if (screenBlob.size === 0) {
|
||||
// When streaming succeeded the blob is empty — the data is already on disk.
|
||||
if (!activeScreenRecorder.isStreaming() && screenBlob.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fixedScreenBlob = await fixWebmDuration(screenBlob, duration);
|
||||
let fixedWebcamBlob: Blob | null = null;
|
||||
if (activeWebcamRecorder) {
|
||||
const webcamBlob = await activeWebcamRecorder.recordedBlobPromise.catch(() => null);
|
||||
if (webcamBlob && webcamBlob.size > 0) {
|
||||
fixedWebcamBlob = await fixWebmDuration(webcamBlob, duration);
|
||||
}
|
||||
}
|
||||
|
||||
const screenFileName = `${RECORDING_FILE_PREFIX}${activeRecordingId}${VIDEO_FILE_EXTENSION}`;
|
||||
const webcamFileName = `${RECORDING_FILE_PREFIX}${activeRecordingId}${WEBCAM_FILE_SUFFIX}${VIDEO_FILE_EXTENSION}`;
|
||||
|
||||
// Only fix duration / convert to ArrayBuffer for in-memory data;
|
||||
// streamed recordings are patched on disk by the main process.
|
||||
let screenVideoData: ArrayBuffer = new ArrayBuffer(0);
|
||||
if (!activeScreenRecorder.isStreaming() && screenBlob.size > 0) {
|
||||
const fixedScreenBlob = await fixWebmDuration(screenBlob, duration);
|
||||
screenVideoData = await fixedScreenBlob.arrayBuffer();
|
||||
}
|
||||
|
||||
let webcamVideoData: ArrayBuffer | undefined;
|
||||
if (activeWebcamRecorder) {
|
||||
const webcamBlob = await activeWebcamRecorder.recordedBlobPromise.catch(() => null);
|
||||
if (!activeWebcamRecorder.isStreaming() && webcamBlob && webcamBlob.size > 0) {
|
||||
const fixedWebcamBlob = await fixWebmDuration(webcamBlob, duration);
|
||||
webcamVideoData = await fixedWebcamBlob.arrayBuffer();
|
||||
} else if (activeWebcamRecorder.isStreaming()) {
|
||||
webcamVideoData = new ArrayBuffer(0);
|
||||
}
|
||||
}
|
||||
webcamIncludedInSave = webcamVideoData !== undefined;
|
||||
|
||||
const result = await window.electronAPI.storeRecordedSession({
|
||||
screen: {
|
||||
videoData: await fixedScreenBlob.arrayBuffer(),
|
||||
videoData: screenVideoData,
|
||||
fileName: screenFileName,
|
||||
},
|
||||
webcam: fixedWebcamBlob
|
||||
? {
|
||||
videoData: await fixedWebcamBlob.arrayBuffer(),
|
||||
fileName: webcamFileName,
|
||||
}
|
||||
: undefined,
|
||||
webcam:
|
||||
webcamVideoData !== undefined
|
||||
? { videoData: webcamVideoData, fileName: webcamFileName }
|
||||
: undefined,
|
||||
createdAt: activeRecordingId,
|
||||
cursorCaptureMode,
|
||||
durationMs: duration,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
console.error("Failed to store recording session:", result.message);
|
||||
return;
|
||||
}
|
||||
// store-recorded-session has flushed and closed the saved streams.
|
||||
storeSucceeded = true;
|
||||
|
||||
if (result.session) {
|
||||
await window.electronAPI.setCurrentRecordingSession(result.session);
|
||||
@@ -406,6 +398,16 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
} catch (error) {
|
||||
console.error("Error saving recording:", error);
|
||||
} finally {
|
||||
// Discard any recorder whose data was not part of a successful save
|
||||
// — a discarded run, a failed save, or a webcam whose disk write
|
||||
// failed (so it was omitted while the screen still saved) — so no
|
||||
// stream or partial file is left open or orphaned.
|
||||
if (!storeSucceeded) {
|
||||
await activeScreenRecorder.discard().catch(() => undefined);
|
||||
}
|
||||
if (activeWebcamRecorder && !(storeSucceeded && webcamIncludedInSave)) {
|
||||
await activeWebcamRecorder.discard().catch(() => undefined);
|
||||
}
|
||||
if (finalizingRecordingId.current === activeRecordingId) {
|
||||
finalizingRecordingId.current = null;
|
||||
}
|
||||
@@ -1336,13 +1338,17 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
|
||||
recordingId.current = Date.now();
|
||||
const activeRecordingId = recordingId.current;
|
||||
screenRecorder.current = createRecorderHandle(stream.current, {
|
||||
mimeType,
|
||||
videoBitsPerSecond,
|
||||
...(hasAudio
|
||||
? { audioBitsPerSecond: systemAudioTrack ? AUDIO_BITRATE_SYSTEM : AUDIO_BITRATE_VOICE }
|
||||
: {}),
|
||||
});
|
||||
screenRecorder.current = createRecorderHandle(
|
||||
stream.current,
|
||||
{
|
||||
mimeType,
|
||||
videoBitsPerSecond,
|
||||
...(hasAudio
|
||||
? { audioBitsPerSecond: systemAudioTrack ? AUDIO_BITRATE_SYSTEM : AUDIO_BITRATE_VOICE }
|
||||
: {}),
|
||||
},
|
||||
`${RECORDING_FILE_PREFIX}${activeRecordingId}${VIDEO_FILE_EXTENSION}`,
|
||||
);
|
||||
screenRecorder.current.recorder.addEventListener(
|
||||
"error",
|
||||
() => {
|
||||
@@ -1352,10 +1358,11 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
);
|
||||
|
||||
if (webcamStream.current) {
|
||||
webcamRecorder.current = createRecorderHandle(webcamStream.current, {
|
||||
mimeType,
|
||||
videoBitsPerSecond: Math.min(videoBitsPerSecond, BITRATE_BASE),
|
||||
});
|
||||
webcamRecorder.current = createRecorderHandle(
|
||||
webcamStream.current,
|
||||
{ mimeType, videoBitsPerSecond: Math.min(videoBitsPerSecond, BITRATE_BASE) },
|
||||
`${RECORDING_FILE_PREFIX}${activeRecordingId}${WEBCAM_FILE_SUFFIX}${VIDEO_FILE_EXTENSION}`,
|
||||
);
|
||||
}
|
||||
|
||||
accumulatedDurationMs.current = 0;
|
||||
|
||||
@@ -7,6 +7,7 @@ import frDialogs from "@/i18n/locales/fr/dialogs.json";
|
||||
import itDialogs from "@/i18n/locales/it/dialogs.json";
|
||||
import jaJPDialogs from "@/i18n/locales/ja-JP/dialogs.json";
|
||||
import koKRDialogs from "@/i18n/locales/ko-KR/dialogs.json";
|
||||
import ptBRDialogs from "@/i18n/locales/pt-BR/dialogs.json";
|
||||
import ruDialogs from "@/i18n/locales/ru/dialogs.json";
|
||||
import trDialogs from "@/i18n/locales/tr/dialogs.json";
|
||||
import viDialogs from "@/i18n/locales/vi/dialogs.json";
|
||||
@@ -49,6 +50,7 @@ const dialogsByLocale = {
|
||||
ru: ruDialogs,
|
||||
tr: trDialogs,
|
||||
vi: viDialogs,
|
||||
"pt-BR": ptBRDialogs,
|
||||
"zh-CN": zhCNDialogs,
|
||||
"zh-TW": zhTWDialogs,
|
||||
} satisfies Record<Locale, { tutorial: Record<string, unknown> }>;
|
||||
|
||||
@@ -10,6 +10,7 @@ export const SUPPORTED_LOCALES = [
|
||||
"ru",
|
||||
"tr",
|
||||
"vi",
|
||||
"pt-BR",
|
||||
"zh-CN",
|
||||
"zh-TW",
|
||||
] as const;
|
||||
|
||||
@@ -51,6 +51,12 @@
|
||||
"detail": "هل تريد حفظ مشروعك قبل الإغلاق؟",
|
||||
"saveAndClose": "حفظ وإغلاق",
|
||||
"discardAndClose": "تجاهل وإغلاق",
|
||||
"detailNewProject": "هل تريد حفظ مشروعك قبل إنشاء مشروع جديد؟",
|
||||
"saveAndNewProject": "حفظ وإنشاء مشروع جديد",
|
||||
"discardAndNewProject": "تجاهل وإنشاء مشروع جديد",
|
||||
"detailLoadProject": "هل تريد حفظ مشروعك قبل تحميل مشروع آخر؟",
|
||||
"saveAndLoadProject": "حفظ وتحميل مشروع",
|
||||
"discardAndLoadProject": "تجاهل وتحميل مشروع",
|
||||
"loadProject": "تحميل مشروع...",
|
||||
"saveProject": "حفظ المشروع...",
|
||||
"saveProjectAs": "حفظ المشروع باسم..."
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"confirm": "تأكيد"
|
||||
},
|
||||
"loadingVideo": "جاري تحميل الفيديو...",
|
||||
"loadingEditor": "جارٍ تحميل المحرر...",
|
||||
"errors": {
|
||||
"noVideoLoaded": "لم يتم تحميل أي فيديو",
|
||||
"videoNotReady": "الفيديو غير جاهز",
|
||||
@@ -42,5 +43,20 @@
|
||||
"cameraNotFound": "لم يتم العثور على كاميرا.",
|
||||
"permissionDenied": "تم رفض إذن التسجيل. يرجى السماح بتسجيل الشاشة.",
|
||||
"accessibilityAllowAndRetry": "اسمح بوصول تسهيلات الاستخدام لـ OpenScreen، ثم اضغط على التسجيل مرة أخرى لبدء العد التنازلي."
|
||||
},
|
||||
"emptyState": {
|
||||
"title": "لا يوجد مشروع مفتوح",
|
||||
"description": "استورد مقطع فيديو للبدء في التحرير، أو حمّل مشروع OpenScreen موجود.",
|
||||
"importVideoButton": "استيراد ملف فيديو...",
|
||||
"loadProjectButton": "تحميل مشروع...",
|
||||
"supportedFormats": "الصيغ المدعومة: MP4، MOV، WebM، MKV، AVI، M4V، WMV",
|
||||
"dragDropHint": "أو اسحب وأفلت ملف مشروع .openscreen هنا",
|
||||
"dropOverlay": "أفلت ملف المشروع لفتحه",
|
||||
"dropErrors": {
|
||||
"unsupportedFormatTitle": "تنسيق غير مدعوم",
|
||||
"unsupportedFormatMessage": "يمكن إسقاط ملفات مشروع .openscreen فقط هنا. لاستيراد مقطع فيديو، استخدم زر \"استيراد ملف فيديو...\" بدلاً من ذلك.",
|
||||
"couldNotOpenTitle": "تعذّر فتح الملف",
|
||||
"couldNotOpenMessage": "تعذّر فتح ملف المشروع. ربما تم نقل الفيديو المرجعي أو حذفه."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
"pauseRecording": "إيقاف التسجيل مؤقتاً",
|
||||
"resumeRecording": "استئناف التسجيل",
|
||||
"openVideoFile": "فتح ملف فيديو",
|
||||
"openProject": "فتح مشروع"
|
||||
"openProject": "فتح مشروع",
|
||||
"useVerticalTray": "استخدام الشريط العمودي",
|
||||
"useHorizontalTray": "استخدام الشريط الأفقي"
|
||||
},
|
||||
"audio": {
|
||||
"enableSystemAudio": "تفعيل صوت النظام",
|
||||
|
||||
@@ -99,7 +99,8 @@
|
||||
},
|
||||
"project": {
|
||||
"save": "حفظ المشروع",
|
||||
"load": "تحميل المشروع"
|
||||
"load": "تحميل المشروع",
|
||||
"new": "مشروع جديد"
|
||||
},
|
||||
"export": {
|
||||
"videoButton": "تصدير الفيديو",
|
||||
@@ -129,6 +130,8 @@
|
||||
"textPlaceholder": "أدخل النص هنا...",
|
||||
"fontStyle": "نمط الخط",
|
||||
"selectStyle": "حدد النمط",
|
||||
"textAnimation": "تحريك النص",
|
||||
"selectAnimation": "حدد الحركة",
|
||||
"size": "الحجم",
|
||||
"customFonts": "خطوط مخصصة",
|
||||
"textColor": "لون النص",
|
||||
@@ -204,5 +207,9 @@
|
||||
},
|
||||
"language": {
|
||||
"title": "اللغة"
|
||||
},
|
||||
"timeline": {
|
||||
"title": "المخطط الزمني",
|
||||
"waveform": "عرض الموجة الصوتية على مسار القطع"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,9 @@
|
||||
"reservedShortcut": "هذا الاختصار محجوز لـ \"{{label}}\" ولا يمكن إعادة تعيينه.",
|
||||
"savedToast": "تم حفظ اختصارات لوحة المفاتيح",
|
||||
"resetToast": "إعادة تعيين إلى الاختصارات الافتراضية — انقر فوق حفظ للتطبيق",
|
||||
"registrationFailed": "فشل في تسجيل الاختصار. قد يكون مستخدمًا من قبل تطبيق آخر. جرب مفتاحًا مختلفًا.",
|
||||
"actions": {
|
||||
"openApp": "فتح التطبيق",
|
||||
"addZoom": "إضافة تكبير",
|
||||
"addTrim": "إضافة قص",
|
||||
"addSpeed": "إضافة سرعة",
|
||||
|
||||
@@ -52,6 +52,14 @@
|
||||
"detail": "Do you want to save your project before closing?",
|
||||
"saveAndClose": "Save & Close",
|
||||
"discardAndClose": "Discard & Close",
|
||||
"detailNewProject": "Do you want to save your project before creating a new one?",
|
||||
"saveAndNewProject": "Save & New Project",
|
||||
"discardAndNewProject": "Discard & New Project",
|
||||
"detailLoadProject": "Do you want to save your project before loading another one?",
|
||||
"saveAndLoadProject": "Save & Load Project",
|
||||
"discardAndLoadProject": "Discard & Load Project",
|
||||
"newProject": "New Project",
|
||||
"importVideo": "Import Video File…",
|
||||
"loadProject": "Load Project…",
|
||||
"saveProject": "Save Project…",
|
||||
"saveProjectAs": "Save Project As…"
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"confirm": "Confirm"
|
||||
},
|
||||
"loadingVideo": "Loading video...",
|
||||
"loadingEditor": "Loading editor...",
|
||||
"errors": {
|
||||
"noVideoLoaded": "No video loaded",
|
||||
"videoNotReady": "Video not ready",
|
||||
@@ -42,5 +43,20 @@
|
||||
"cameraNotFound": "Camera not found.",
|
||||
"permissionDenied": "Recording permission denied. Please allow screen recording.",
|
||||
"accessibilityAllowAndRetry": "Allow Accessibility access for OpenScreen, then press record again to start the countdown."
|
||||
},
|
||||
"emptyState": {
|
||||
"title": "No project open",
|
||||
"description": "Import a video to start editing, or load an existing OpenScreen project.",
|
||||
"importVideoButton": "Import Video File…",
|
||||
"loadProjectButton": "Load Project…",
|
||||
"supportedFormats": "Supported formats: MP4, MOV, WebM, MKV, AVI, M4V, WMV",
|
||||
"dragDropHint": "or drag & drop a .openscreen project file here",
|
||||
"dropOverlay": "Drop project file to open",
|
||||
"dropErrors": {
|
||||
"unsupportedFormatTitle": "Unsupported Format",
|
||||
"unsupportedFormatMessage": "Only .openscreen project files can be dropped here. To import a video file, use the \"Import Video File…\" button on this screen.",
|
||||
"couldNotOpenTitle": "Could Not Open File",
|
||||
"couldNotOpenMessage": "The project file could not be opened. The video it references may have been moved or deleted."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,10 @@
|
||||
"pauseRecording": "Pause recording",
|
||||
"resumeRecording": "Resume recording",
|
||||
"openVideoFile": "Open video file",
|
||||
"openProject": "Open project"
|
||||
"openProject": "Open project",
|
||||
"useVerticalTray": "Use vertical tray",
|
||||
"useHorizontalTray": "Use horizontal tray",
|
||||
"openStudio": "Open Studio"
|
||||
},
|
||||
"audio": {
|
||||
"enableSystemAudio": "Enable system audio",
|
||||
@@ -32,6 +35,9 @@
|
||||
"loading": "Loading sources...",
|
||||
"screens": "Screens ({{count}})",
|
||||
"windows": "Windows ({{count}})",
|
||||
"emptyTitle": "No screens or windows found",
|
||||
"emptyDescription": "If you just granted screen recording permission, reload this picker. On macOS you may need to reopen OpenScreen.",
|
||||
"loadFailedDescription": "OpenScreen could not load capture sources. Reload this picker and try again.",
|
||||
"defaultSourceName": "Screen"
|
||||
},
|
||||
"recording": {
|
||||
|
||||
@@ -99,7 +99,8 @@
|
||||
},
|
||||
"project": {
|
||||
"save": "Save Project",
|
||||
"load": "Load Project"
|
||||
"load": "Load Project",
|
||||
"new": "New Project"
|
||||
},
|
||||
"export": {
|
||||
"videoButton": "Export Video",
|
||||
@@ -175,6 +176,17 @@
|
||||
"modern": "Modern",
|
||||
"clean": "Clean"
|
||||
},
|
||||
"textAnimation": {
|
||||
"title": "Text Animation",
|
||||
"selectAnimation": "Select animation",
|
||||
"none": "None",
|
||||
"fade": "Fade",
|
||||
"rise": "Rise",
|
||||
"pop": "Pop",
|
||||
"slideLeft": "Slide Left",
|
||||
"typewriter": "Typewriter",
|
||||
"pulse": "Pulse"
|
||||
},
|
||||
"customFont": {
|
||||
"dialogTitle": "Add Google Font",
|
||||
"urlLabel": "Google Fonts Import URL",
|
||||
@@ -204,5 +216,9 @@
|
||||
},
|
||||
"language": {
|
||||
"title": "Language"
|
||||
},
|
||||
"timeline": {
|
||||
"title": "Timeline",
|
||||
"waveform": "Show Audio Waveform on Trim Track"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,9 @@
|
||||
"reservedShortcut": "This shortcut is reserved for \"{{label}}\" and cannot be reassigned.",
|
||||
"savedToast": "Keyboard shortcuts saved",
|
||||
"resetToast": "Reset to default shortcuts — click Save to apply",
|
||||
"registrationFailed": "Failed to register shortcut. It may be in use by another app. Try a different key.",
|
||||
"actions": {
|
||||
"openApp": "Open App",
|
||||
"addZoom": "Add Zoom",
|
||||
"addTrim": "Add Trim",
|
||||
"addSpeed": "Add Speed",
|
||||
|
||||
@@ -51,6 +51,12 @@
|
||||
"detail": "¿Deseas guardar tu proyecto antes de cerrar?",
|
||||
"saveAndClose": "Guardar y cerrar",
|
||||
"discardAndClose": "Descartar y cerrar",
|
||||
"detailNewProject": "¿Deseas guardar tu proyecto antes de crear uno nuevo?",
|
||||
"saveAndNewProject": "Guardar y nuevo proyecto",
|
||||
"discardAndNewProject": "Descartar y nuevo proyecto",
|
||||
"detailLoadProject": "¿Deseas guardar tu proyecto antes de cargar otro?",
|
||||
"saveAndLoadProject": "Guardar y cargar proyecto",
|
||||
"discardAndLoadProject": "Descartar y cargar proyecto",
|
||||
"loadProject": "Cargar proyecto…",
|
||||
"saveProject": "Guardar proyecto…",
|
||||
"saveProjectAs": "Guardar proyecto como…"
|
||||
|
||||
@@ -37,10 +37,26 @@
|
||||
"accessibilityAllowAndRetry": "Permite el acceso de accesibilidad para OpenScreen y luego pulsa grabar de nuevo para iniciar la cuenta atrás."
|
||||
},
|
||||
"loadingVideo": "Cargando video...",
|
||||
"loadingEditor": "Cargando editor...",
|
||||
"newRecording": {
|
||||
"title": "Volver a la grabadora",
|
||||
"description": "Tu sesión actual ha sido guardada.",
|
||||
"cancel": "Cancelar",
|
||||
"confirm": "Confirmar"
|
||||
},
|
||||
"emptyState": {
|
||||
"title": "No hay proyecto abierto",
|
||||
"description": "Importa un video para empezar a editar o carga un proyecto de OpenScreen existente.",
|
||||
"importVideoButton": "Importar archivo de video…",
|
||||
"loadProjectButton": "Cargar proyecto…",
|
||||
"supportedFormats": "Formatos compatibles: MP4, MOV, WebM, MKV, AVI, M4V, WMV",
|
||||
"dragDropHint": "o arrastra y suelta un archivo .openscreen aquí",
|
||||
"dropOverlay": "Suelta el archivo de proyecto para abrirlo",
|
||||
"dropErrors": {
|
||||
"unsupportedFormatTitle": "Formato no compatible",
|
||||
"unsupportedFormatMessage": "Solo se pueden soltar aquí archivos de proyecto .openscreen. Para importar un video, usa el botón \"Importar archivo de video...\" en su lugar.",
|
||||
"couldNotOpenTitle": "No se pudo abrir el archivo",
|
||||
"couldNotOpenMessage": "No se pudo abrir el archivo de proyecto. El video al que hace referencia puede haber sido movido o eliminado."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
"pauseRecording": "Pausar grabación",
|
||||
"resumeRecording": "Reanudar grabación",
|
||||
"openVideoFile": "Abrir archivo de video",
|
||||
"openProject": "Abrir proyecto"
|
||||
"openProject": "Abrir proyecto",
|
||||
"useVerticalTray": "Usar bandeja vertical",
|
||||
"useHorizontalTray": "Usar bandeja horizontal"
|
||||
},
|
||||
"audio": {
|
||||
"enableSystemAudio": "Activar audio del sistema",
|
||||
|
||||
@@ -99,7 +99,8 @@
|
||||
},
|
||||
"project": {
|
||||
"save": "Guardar proyecto",
|
||||
"load": "Cargar proyecto"
|
||||
"load": "Cargar proyecto",
|
||||
"new": "Nuevo proyecto"
|
||||
},
|
||||
"export": {
|
||||
"videoButton": "Exportar video",
|
||||
@@ -175,6 +176,17 @@
|
||||
"modern": "Moderno",
|
||||
"clean": "Limpio"
|
||||
},
|
||||
"textAnimation": {
|
||||
"title": "Animación de texto",
|
||||
"selectAnimation": "Seleccionar animación",
|
||||
"none": "Ninguna",
|
||||
"fade": "Desvanecimiento",
|
||||
"rise": "Ascender",
|
||||
"pop": "Aparecer",
|
||||
"slideLeft": "Deslizar izquierda",
|
||||
"typewriter": "Máquina de escribir",
|
||||
"pulse": "Pulso"
|
||||
},
|
||||
"customFont": {
|
||||
"dialogTitle": "Agregar fuente de Google",
|
||||
"urlLabel": "URL de importación de Google Fonts",
|
||||
@@ -204,5 +216,9 @@
|
||||
},
|
||||
"language": {
|
||||
"title": "Idioma"
|
||||
},
|
||||
"timeline": {
|
||||
"title": "Línea de tiempo",
|
||||
"waveform": "Mostrar forma de onda en pista de recorte"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,9 @@
|
||||
"reservedShortcut": "Este atajo está reservado para \"{{label}}\" y no se puede reasignar.",
|
||||
"savedToast": "Atajos de teclado guardados",
|
||||
"resetToast": "Restablecido a los atajos predeterminados — haz clic en Guardar para aplicar",
|
||||
"registrationFailed": "Error al registrar el atajo. Puede estar en uso por otra aplicación. Prueba otra tecla.",
|
||||
"actions": {
|
||||
"openApp": "Abrir aplicación",
|
||||
"addZoom": "Agregar zoom",
|
||||
"addTrim": "Agregar recorte",
|
||||
"addSpeed": "Agregar velocidad",
|
||||
|
||||
@@ -51,6 +51,12 @@
|
||||
"detail": "Voulez-vous enregistrer votre projet avant de fermer ?",
|
||||
"saveAndClose": "Enregistrer et fermer",
|
||||
"discardAndClose": "Ignorer et fermer",
|
||||
"detailNewProject": "Voulez-vous enregistrer votre projet avant d'en créer un nouveau ?",
|
||||
"saveAndNewProject": "Enregistrer et nouveau projet",
|
||||
"discardAndNewProject": "Ignorer et nouveau projet",
|
||||
"detailLoadProject": "Voulez-vous enregistrer votre projet avant d'en charger un autre ?",
|
||||
"saveAndLoadProject": "Enregistrer et charger un projet",
|
||||
"discardAndLoadProject": "Ignorer et charger un projet",
|
||||
"loadProject": "Charger un projet…",
|
||||
"saveProject": "Enregistrer le projet…",
|
||||
"saveProjectAs": "Enregistrer le projet sous…"
|
||||
|
||||
@@ -42,5 +42,21 @@
|
||||
"permissionDenied": "Permission d'enregistrement refusée. Veuillez autoriser l'enregistrement d'écran.",
|
||||
"accessibilityAllowAndRetry": "Autorisez l'accès Accessibilité pour OpenScreen, puis appuyez de nouveau sur enregistrer pour lancer le compte à rebours."
|
||||
},
|
||||
"loadingVideo": "Chargement de la vidéo..."
|
||||
"loadingVideo": "Chargement de la vidéo...",
|
||||
"loadingEditor": "Chargement de l'éditeur...",
|
||||
"emptyState": {
|
||||
"title": "Aucun projet ouvert",
|
||||
"description": "Importez une vidéo pour commencer à éditer, ou chargez un projet OpenScreen existant.",
|
||||
"importVideoButton": "Importer un fichier vidéo…",
|
||||
"loadProjectButton": "Charger un projet…",
|
||||
"supportedFormats": "Formats pris en charge : MP4, MOV, WebM, MKV, AVI, M4V, WMV",
|
||||
"dragDropHint": "ou glissez-déposez un fichier .openscreen ici",
|
||||
"dropOverlay": "Déposez le fichier de projet pour l'ouvrir",
|
||||
"dropErrors": {
|
||||
"unsupportedFormatTitle": "Format non pris en charge",
|
||||
"unsupportedFormatMessage": "Seuls les fichiers .openscreen peuvent être déposés ici. Pour importer une vidéo, utilisez plutôt le bouton \"Importer un fichier vidéo...\".",
|
||||
"couldNotOpenTitle": "Impossible d'ouvrir le fichier",
|
||||
"couldNotOpenMessage": "Le fichier de projet n'a pas pu être ouvert. La vidéo qu'il référence a peut-être été déplacée ou supprimée."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
"pauseRecording": "Mettre en pause l'enregistrement",
|
||||
"resumeRecording": "Reprendre l'enregistrement",
|
||||
"openVideoFile": "Ouvrir un fichier vidéo",
|
||||
"openProject": "Ouvrir un projet"
|
||||
"openProject": "Ouvrir un projet",
|
||||
"useVerticalTray": "Utiliser la barre verticale",
|
||||
"useHorizontalTray": "Utiliser la barre horizontale"
|
||||
},
|
||||
"audio": {
|
||||
"enableSystemAudio": "Activer l'audio système",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"zoom": {
|
||||
"previewHold": "Maintenir pour prévisualiser l’effet de zoom",
|
||||
"previewHold": "Maintenir pour prévisualiser l'effet de zoom",
|
||||
"level": "Niveau de zoom",
|
||||
"selectRegion": "Sélectionnez une région de zoom à ajuster",
|
||||
"deleteZoom": "Supprimer le zoom",
|
||||
@@ -10,7 +10,6 @@
|
||||
"auto": "Auto",
|
||||
"autoDescription": "La caméra suit la position du curseur enregistré"
|
||||
},
|
||||
"speed": {},
|
||||
"threeD": {
|
||||
"title": "Rotation 3D",
|
||||
"preset": {
|
||||
@@ -100,7 +99,8 @@
|
||||
},
|
||||
"project": {
|
||||
"save": "Enregistrer le projet",
|
||||
"load": "Charger un projet"
|
||||
"load": "Charger un projet",
|
||||
"new": "Nouveau projet"
|
||||
},
|
||||
"export": {
|
||||
"videoButton": "Exporter la vidéo",
|
||||
@@ -176,6 +176,17 @@
|
||||
"modern": "Moderne",
|
||||
"clean": "Épuré"
|
||||
},
|
||||
"textAnimation": {
|
||||
"title": "Animation de texte",
|
||||
"selectAnimation": "Sélectionner une animation",
|
||||
"none": "Aucune",
|
||||
"fade": "Fondu",
|
||||
"rise": "Monter",
|
||||
"pop": "Apparition",
|
||||
"slideLeft": "Glisser à gauche",
|
||||
"typewriter": "Machine à écrire",
|
||||
"pulse": "Pulsation"
|
||||
},
|
||||
"customFont": {
|
||||
"dialogTitle": "Ajouter une police Google",
|
||||
"urlLabel": "URL d'import Google Fonts",
|
||||
@@ -205,5 +216,9 @@
|
||||
},
|
||||
"language": {
|
||||
"title": "Langue"
|
||||
},
|
||||
"timeline": {
|
||||
"title": "Montage",
|
||||
"waveform": "Afficher la forme d'onde sur la piste de découpe"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,9 @@
|
||||
"reservedShortcut": "Ce raccourci est réservé pour « {{label}} » et ne peut pas être réassigné.",
|
||||
"savedToast": "Raccourcis clavier enregistrés",
|
||||
"resetToast": "Réinitialisé aux raccourcis par défaut — cliquez sur Enregistrer pour appliquer",
|
||||
"registrationFailed": "Échec de l'enregistrement du raccourci. Il est peut-être utilisé par une autre application. Essayez une autre touche.",
|
||||
"actions": {
|
||||
"openApp": "Ouvrir l'application",
|
||||
"addZoom": "Ajouter un zoom",
|
||||
"addTrim": "Ajouter une coupe",
|
||||
"addSpeed": "Ajouter une vitesse",
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
"pauseRecording": "Metti in pausa registrazione",
|
||||
"resumeRecording": "Riprendi registrazione",
|
||||
"openVideoFile": "Apri file video",
|
||||
"openProject": "Apri progetto"
|
||||
"openProject": "Apri progetto",
|
||||
"useVerticalTray": "Usa barra verticale",
|
||||
"useHorizontalTray": "Usa barra orizzontale"
|
||||
},
|
||||
"audio": {
|
||||
"enableSystemAudio": "Abilita audio di sistema",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"zoom": {
|
||||
"previewHold": "Tieni premuto per vedere lo zoom",
|
||||
"previewHold": "Tieni premuto per vedere l'anteprima dell'effetto zoom",
|
||||
"level": "Livello zoom",
|
||||
"customScale": "Zoom personalizzato",
|
||||
"selectRegion": "Seleziona una regione zoom da regolare",
|
||||
@@ -175,6 +175,17 @@
|
||||
"modern": "Moderno",
|
||||
"clean": "Pulito"
|
||||
},
|
||||
"textAnimation": {
|
||||
"title": "Animazione testo",
|
||||
"selectAnimation": "Seleziona animazione",
|
||||
"none": "Nessuna",
|
||||
"fade": "Dissolvenza",
|
||||
"rise": "Ascesa",
|
||||
"pop": "Apparizione",
|
||||
"slideLeft": "Scivola a sinistra",
|
||||
"typewriter": "Macchina da scrivere",
|
||||
"pulse": "Pulsazione"
|
||||
},
|
||||
"customFont": {
|
||||
"dialogTitle": "Aggiungi font Google",
|
||||
"urlLabel": "URL importazione Google Fonts",
|
||||
@@ -204,5 +215,9 @@
|
||||
},
|
||||
"language": {
|
||||
"title": "Lingua"
|
||||
},
|
||||
"timeline": {
|
||||
"title": "Timeline",
|
||||
"waveform": "Mostra la forma d'onda sulla traccia di ritaglio"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,9 @@
|
||||
"reservedShortcut": "Questa scorciatoia è riservata a \"{{label}}\" e non può essere riassegnata.",
|
||||
"savedToast": "Scorciatoie tastiera salvate",
|
||||
"resetToast": "Ripristino alle scorciatoie predefinite — clicca Salva per applicare",
|
||||
"registrationFailed": "Impossibile registrare la scorciatoia. Potrebbe essere in uso da un'altra applicazione. Prova un altro tasto.",
|
||||
"actions": {
|
||||
"openApp": "Apri applicazione",
|
||||
"addZoom": "Aggiungi zoom",
|
||||
"addTrim": "Aggiungi taglio",
|
||||
"addSpeed": "Aggiungi velocità",
|
||||
|
||||
@@ -52,6 +52,12 @@
|
||||
"detail": "閉じる前にプロジェクトを保存しますか?",
|
||||
"saveAndClose": "保存して閉じる",
|
||||
"discardAndClose": "破棄して閉じる",
|
||||
"detailNewProject": "新しいプロジェクトを作成する前に保存しますか?",
|
||||
"saveAndNewProject": "保存して新規プロジェクト",
|
||||
"discardAndNewProject": "破棄して新規プロジェクト",
|
||||
"detailLoadProject": "別のプロジェクトを読み込む前にプロジェクトを保存しますか?",
|
||||
"saveAndLoadProject": "保存してプロジェクトを読み込む",
|
||||
"discardAndLoadProject": "破棄してプロジェクトを読み込む",
|
||||
"loadProject": "プロジェクトを読み込む…",
|
||||
"saveProject": "プロジェクトを保存…",
|
||||
"saveProjectAs": "プロジェクトを名前を付けて保存…"
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"confirm": "確認"
|
||||
},
|
||||
"loadingVideo": "動画を読み込み中...",
|
||||
"loadingEditor": "エディターを読み込み中...",
|
||||
"errors": {
|
||||
"noVideoLoaded": "動画が読み込まれていません",
|
||||
"videoNotReady": "動画の準備ができていません",
|
||||
@@ -42,5 +43,20 @@
|
||||
"cameraDisconnected": "ウェブカメラが切断されました。",
|
||||
"cameraNotFound": "カメラが見つかりません。",
|
||||
"accessibilityAllowAndRetry": "OpenScreenにアクセシビリティアクセスを許可してから、もう一度録画を押してカウントダウンを開始してください。"
|
||||
},
|
||||
"emptyState": {
|
||||
"title": "プロジェクトが開かれていません",
|
||||
"description": "動画をインポートして編集を開始するか、既存の OpenScreen プロジェクトを読み込んでください。",
|
||||
"importVideoButton": "動画ファイルをインポート…",
|
||||
"loadProjectButton": "プロジェクトを読み込む…",
|
||||
"supportedFormats": "対応フォーマット:MP4、MOV、WebM、MKV、AVI、M4V、WMV",
|
||||
"dragDropHint": ".openscreen プロジェクトファイルをここにドラッグ&ドロップ",
|
||||
"dropOverlay": "プロジェクトファイルをドロップして開く",
|
||||
"dropErrors": {
|
||||
"unsupportedFormatTitle": "非対応フォーマット",
|
||||
"unsupportedFormatMessage": "ここにドロップできるのは .openscreen プロジェクトファイルのみです。動画をインポートするには「動画ファイルをインポート...」ボタンをご使用ください。",
|
||||
"couldNotOpenTitle": "ファイルを開けませんでした",
|
||||
"couldNotOpenMessage": "プロジェクトファイルを開けませんでした。参照している動画が移動または削除された可能性があります。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
"pauseRecording": "録画を一時停止",
|
||||
"resumeRecording": "録画を再開",
|
||||
"openVideoFile": "動画ファイルを開く",
|
||||
"openProject": "プロジェクトを開く"
|
||||
"openProject": "プロジェクトを開く",
|
||||
"useVerticalTray": "縦型トレイを使用",
|
||||
"useHorizontalTray": "横型トレイを使用"
|
||||
},
|
||||
"audio": {
|
||||
"enableSystemAudio": "システム音声を有効にする",
|
||||
|
||||
@@ -99,7 +99,8 @@
|
||||
},
|
||||
"project": {
|
||||
"save": "プロジェクトを保存",
|
||||
"load": "プロジェクトを読み込む"
|
||||
"load": "プロジェクトを読み込む",
|
||||
"new": "新規プロジェクト"
|
||||
},
|
||||
"export": {
|
||||
"videoButton": "動画をエクスポート",
|
||||
@@ -175,6 +176,17 @@
|
||||
"modern": "モダン",
|
||||
"clean": "クリーン"
|
||||
},
|
||||
"textAnimation": {
|
||||
"title": "テキストアニメーション",
|
||||
"selectAnimation": "アニメーションを選択",
|
||||
"none": "なし",
|
||||
"fade": "フェード",
|
||||
"rise": "上昇",
|
||||
"pop": "ポップ",
|
||||
"slideLeft": "左へスライド",
|
||||
"typewriter": "タイプライター",
|
||||
"pulse": "パルス"
|
||||
},
|
||||
"customFont": {
|
||||
"dialogTitle": "Googleフォントを追加",
|
||||
"urlLabel": "GoogleフォントのインポートURL",
|
||||
@@ -204,5 +216,9 @@
|
||||
},
|
||||
"language": {
|
||||
"title": "言語"
|
||||
},
|
||||
"timeline": {
|
||||
"title": "タイムライン",
|
||||
"waveform": "トリムトラックにオーディオ波形を表示"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,9 @@
|
||||
"reservedShortcut": "このショートカットは \"{{label}}\" に予約されており、再割り当てできません。",
|
||||
"savedToast": "キーボードショートカットが保存されました",
|
||||
"resetToast": "デフォルトのショートカットにリセット — 保存をクリックして適用",
|
||||
"registrationFailed": "ショートカットの登録に失敗しました。他のアプリで使用中の可能性があります。別のキーをお試しください。",
|
||||
"actions": {
|
||||
"openApp": "アプリを開く",
|
||||
"addZoom": "ズームを追加",
|
||||
"addTrim": "トリムを追加",
|
||||
"addSpeed": "速度を追加",
|
||||
|
||||
@@ -51,6 +51,12 @@
|
||||
"detail": "닫기 전에 프로젝트를 저장하시겠습니까?",
|
||||
"saveAndClose": "저장 후 닫기",
|
||||
"discardAndClose": "저장하지 않고 닫기",
|
||||
"detailNewProject": "새 프로젝트를 만들기 전에 저장하시겠습니까?",
|
||||
"saveAndNewProject": "저장 후 새 프로젝트",
|
||||
"discardAndNewProject": "저장하지 않고 새 프로젝트",
|
||||
"detailLoadProject": "다른 프로젝트를 불러오기 전에 저장하시겠습니까?",
|
||||
"saveAndLoadProject": "저장 후 프로젝트 불러오기",
|
||||
"discardAndLoadProject": "저장하지 않고 프로젝트 불러오기",
|
||||
"loadProject": "프로젝트 불러오기...",
|
||||
"saveProject": "프로젝트 저장...",
|
||||
"saveProjectAs": "다른 이름으로 프로젝트 저장..."
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"confirm": "확인"
|
||||
},
|
||||
"loadingVideo": "비디오 로드 중...",
|
||||
"loadingEditor": "편집기 로드 중...",
|
||||
"errors": {
|
||||
"noVideoLoaded": "불러온 비디오가 없습니다",
|
||||
"videoNotReady": "비디오가 준비되지 않았습니다",
|
||||
@@ -42,5 +43,20 @@
|
||||
"cameraDisconnected": "웹캠 연결이 끊어졌습니다.",
|
||||
"cameraNotFound": "카메라를 찾을 수 없습니다.",
|
||||
"accessibilityAllowAndRetry": "OpenScreen의 손쉬운 사용 접근을 허용한 다음, 카운트다운을 시작하려면 다시 녹화를 누르세요."
|
||||
},
|
||||
"emptyState": {
|
||||
"title": "열린 프로젝트 없음",
|
||||
"description": "동영상을 가져와 편집을 시작하거나 기존 OpenScreen 프로젝트를 불러오세요.",
|
||||
"importVideoButton": "동영상 파일 가져오기…",
|
||||
"loadProjectButton": "프로젝트 불러오기…",
|
||||
"supportedFormats": "지원 형식: MP4, MOV, WebM, MKV, AVI, M4V, WMV",
|
||||
"dragDropHint": ".openscreen 프로젝트 파일을 여기에 드래그 앤 드롭",
|
||||
"dropOverlay": "프로젝트 파일을 드롭하여 열기",
|
||||
"dropErrors": {
|
||||
"unsupportedFormatTitle": "지원되지 않는 형식",
|
||||
"unsupportedFormatMessage": ".openscreen 프로젝트 파일만 여기에 드롭할 수 있습니다. 동영상을 가져오려면 \"동영상 파일 가져오기...\" 버튼을 사용하세요.",
|
||||
"couldNotOpenTitle": "파일을 열 수 없음",
|
||||
"couldNotOpenMessage": "프로젝트 파일을 열 수 없습니다. 참조된 동영상이 이동되었거나 삭제되었을 수 있습니다."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
"pauseRecording": "녹화 일시정지",
|
||||
"resumeRecording": "녹화 재개",
|
||||
"openVideoFile": "비디오 파일 열기",
|
||||
"openProject": "프로젝트 열기"
|
||||
"openProject": "프로젝트 열기",
|
||||
"useVerticalTray": "세로 트레이 사용",
|
||||
"useHorizontalTray": "가로 트레이 사용"
|
||||
},
|
||||
"audio": {
|
||||
"enableSystemAudio": "시스템 오디오 활성화",
|
||||
|
||||
@@ -99,7 +99,8 @@
|
||||
},
|
||||
"project": {
|
||||
"save": "프로젝트 저장",
|
||||
"load": "프로젝트 불러오기"
|
||||
"load": "프로젝트 불러오기",
|
||||
"new": "새 프로젝트"
|
||||
},
|
||||
"export": {
|
||||
"videoButton": "비디오 내보내기",
|
||||
@@ -124,6 +125,7 @@
|
||||
"typeText": "텍스트",
|
||||
"typeImage": "이미지",
|
||||
"typeArrow": "화살표",
|
||||
"typeBlur": "블러",
|
||||
"textContent": "텍스트 내용",
|
||||
"textPlaceholder": "텍스트를 입력하세요...",
|
||||
"fontStyle": "폰트 스타일",
|
||||
@@ -142,6 +144,18 @@
|
||||
"arrowDirection": "화살표 방향",
|
||||
"strokeWidth": "선 두께: {{width}}px",
|
||||
"arrowColor": "화살표 색상",
|
||||
"blurType": "블러 종류",
|
||||
"blurTypeBlur": "블러",
|
||||
"blurTypeMosaic": "모자이크 블러",
|
||||
"blurColor": "블러 색상",
|
||||
"blurColorWhite": "흰색",
|
||||
"blurColorBlack": "검정",
|
||||
"blurShape": "블러 모양",
|
||||
"blurIntensity": "블러 강도",
|
||||
"mosaicBlockSize": "모자이크 블록 크기",
|
||||
"blurShapeRectangle": "사각형",
|
||||
"blurShapeOval": "타원",
|
||||
"blurShapeFreehand": "자유 곡선",
|
||||
"deleteAnnotation": "주석 삭제",
|
||||
"shortcutsAndTips": "단축키 및 팁",
|
||||
"tipMovePlayhead": "재생 헤드를 주석 구간으로 옮겨 항목을 선택하세요.",
|
||||
@@ -150,20 +164,7 @@
|
||||
"invalidImageType": "지원하지 않는 파일 형식입니다",
|
||||
"imageFormatsOnly": "JPG, PNG, GIF 또는 WebP 이미지 파일을 업로드해 주세요.",
|
||||
"imageUploadSuccess": "이미지가 성공적으로 업로드되었습니다!",
|
||||
"failedImageUpload": "이미지 업로드에 실패했습니다",
|
||||
"blurColor": "블러 색상",
|
||||
"blurColorBlack": "검정",
|
||||
"blurColorWhite": "흰색",
|
||||
"blurIntensity": "블러 강도",
|
||||
"blurShape": "블러 모양",
|
||||
"blurShapeFreehand": "자유 곡선",
|
||||
"blurShapeOval": "타원",
|
||||
"blurShapeRectangle": "사각형",
|
||||
"blurType": "블러 종류",
|
||||
"blurTypeBlur": "블러",
|
||||
"blurTypeMosaic": "모자이크 블러",
|
||||
"mosaicBlockSize": "모자이크 블록 크기",
|
||||
"typeBlur": "블러"
|
||||
"failedImageUpload": "이미지 업로드에 실패했습니다"
|
||||
},
|
||||
"fontStyles": {
|
||||
"classic": "클래식",
|
||||
@@ -175,6 +176,17 @@
|
||||
"modern": "모던",
|
||||
"clean": "클린"
|
||||
},
|
||||
"textAnimation": {
|
||||
"title": "텍스트 애니메이션",
|
||||
"selectAnimation": "애니메이션 선택",
|
||||
"none": "없음",
|
||||
"fade": "페이드",
|
||||
"rise": "상승",
|
||||
"pop": "팝",
|
||||
"slideLeft": "왼쪽 슬라이드",
|
||||
"typewriter": "타자기",
|
||||
"pulse": "펄스"
|
||||
},
|
||||
"customFont": {
|
||||
"dialogTitle": "Google 폰트 추가",
|
||||
"urlLabel": "Google Fonts 가져오기 URL",
|
||||
@@ -204,5 +216,9 @@
|
||||
},
|
||||
"language": {
|
||||
"title": "언어"
|
||||
},
|
||||
"timeline": {
|
||||
"title": "타임라인",
|
||||
"waveform": "트림 트랙에 오디오 파형 표시"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,9 @@
|
||||
"reservedShortcut": "이 단축키는 \"{{label}}\"에 예약되어 있어 변경할 수 없습니다.",
|
||||
"savedToast": "키보드 단축키가 저장되었습니다",
|
||||
"resetToast": "기본 단축키로 초기화되었습니다 — 저장을 클릭해 적용하세요",
|
||||
"registrationFailed": "단축키 등록에 실패했습니다. 다른 앱에서 사용 중일 수 있습니다. 다른 키를 시도하세요.",
|
||||
"actions": {
|
||||
"openApp": "앱 열기",
|
||||
"addZoom": "줌 추가",
|
||||
"addTrim": "트림 추가",
|
||||
"addSpeed": "속도 추가",
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"actions": {
|
||||
"cancel": "Cancelar",
|
||||
"save": "Salvar",
|
||||
"delete": "Deletar",
|
||||
"close": "Fechar",
|
||||
"share": "Compartilhar",
|
||||
"done": "Concluir",
|
||||
"open": "Abrir",
|
||||
"upload": "Upload",
|
||||
"export": "Exportar",
|
||||
"showInFolder": "Mostrar na Pasta",
|
||||
"file": "Arquivo",
|
||||
"edit": "Editar",
|
||||
"view": "Visualizar",
|
||||
"window": "Janela",
|
||||
"quit": "Sair",
|
||||
"stopRecording": "Parar Gravação",
|
||||
"undo": "Desfazer",
|
||||
"redo": "Refazer",
|
||||
"cut": "Recortar",
|
||||
"copy": "Copiar",
|
||||
"paste": "Colar",
|
||||
"selectAll": "Selecionar Tudo",
|
||||
"minimize": "Minimizar",
|
||||
"reload": "Recarregar",
|
||||
"forceReload": "Forçar Recarregar",
|
||||
"toggleDevTools": "Alternar ferramentas de desenvolvedor",
|
||||
"actualSize": "Tamanho Original",
|
||||
"zoomIn": "Aumentar Zoom",
|
||||
"zoomOut": "Diminuir Zoom",
|
||||
"toggleFullScreen": "Tela Cheia",
|
||||
"recordingStatus": "Gravando: {{source}}",
|
||||
"about": "Sobre o OpenScreen",
|
||||
"services": "Serviços",
|
||||
"hide": "Ocultar OpenScreen",
|
||||
"hideOthers": "Ocultar Outros",
|
||||
"unhide": "Mostrar Todos"
|
||||
},
|
||||
"playback": {
|
||||
"play": "Play",
|
||||
"pause": "Pause",
|
||||
"fullscreen": "Tela Cheia",
|
||||
"exitFullscreen": "Sair da Tela Cheia"
|
||||
},
|
||||
"locale": {
|
||||
"name": "Português Brasileiro",
|
||||
"short": "PT-BR"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"export": {
|
||||
"complete": "Exportação Concluída",
|
||||
"yourFormatReady": "Seu {{format}} está pronto",
|
||||
"showInFolder": "Mostrar na Pasta",
|
||||
"finalizingVideo": "Finalizando exportação do vídeo...",
|
||||
"compilingGifProgress": "Compilando GIF... {{progress}}%",
|
||||
"compilingGifWait": "Compilando GIF... Isso pode demorar um pouco",
|
||||
"takeMoment": "Isso pode levar um momento...",
|
||||
"failed": "Falha na Exportação",
|
||||
"tryAgain": "Por favor, tente novamente",
|
||||
"finalizingVideoTitle": "Finalizando Vídeo",
|
||||
"compilingGif": "Compilando GIF",
|
||||
"exportingFormat": "Exportando {{format}}",
|
||||
"compiling": "Compilando",
|
||||
"renderingFrames": "Renderizando Quadros",
|
||||
"processing": "Processando...",
|
||||
"finalizing": "Finalizando...",
|
||||
"compilingStatus": "Compilando...",
|
||||
"status": "Status",
|
||||
"format": "Formato",
|
||||
"frames": "Quadros",
|
||||
"cancelExport": "Cancelar Exportação",
|
||||
"savedSuccessfully": "{{format}} salvo com sucesso!"
|
||||
},
|
||||
"tutorial": {
|
||||
"triggerLabel": "Como funciona o recorte",
|
||||
"title": "Como Funciona o Recorte",
|
||||
"description": "Entendendo como cortar partes indesejadas do seu vídeo.",
|
||||
"explanationBefore": "A ferramenta de Recorte funciona definindo os segmentos que você deseja",
|
||||
"remove": "remover",
|
||||
"explanationMiddle": " — qualquer coisa",
|
||||
"covered": "coberta",
|
||||
"explanationAfter": "por um segmento de recorte vermelho será removida quando você exportar.",
|
||||
"visualExample": "Exemplo Visual",
|
||||
"removed": "REMOVIDO",
|
||||
"kept": "Mantido",
|
||||
"part1": "Parte 1",
|
||||
"part2": "Parte 2",
|
||||
"part3": "Parte 3",
|
||||
"finalVideo": "Vídeo Final",
|
||||
"step1Title": "1. Adicionar Recorte",
|
||||
"step1DescriptionBefore": "Pressione ",
|
||||
"step1DescriptionAfter": " ou clique no ícone da tesoura para marcar uma seção para remoção.",
|
||||
|
||||
"step2Title": "2. Ajustar",
|
||||
"step2Description": "Arraste as bordas da região vermelha para cobrir exatamente o que você deseja cortar."
|
||||
},
|
||||
"unsavedChanges": {
|
||||
"title": "Alterações Não Salvas",
|
||||
"message": "Você tem alterações não salvas.",
|
||||
"detail": "Deseja salvar seu projeto antes de fechar?",
|
||||
"saveAndClose": "Salvar e Fechar",
|
||||
"discardAndClose": "Descartar e Fechar",
|
||||
"loadProject": "Carregar Projeto…",
|
||||
"saveProject": "Salvar Projeto…",
|
||||
"saveProjectAs": "Salvar Projeto Como…"
|
||||
},
|
||||
"fileDialogs": {
|
||||
"saveGif": "Salvar GIF Exportado",
|
||||
"saveVideo": "Salvar Vídeo Exportado",
|
||||
"selectVideo": "Selecionar Arquivo de Vídeo",
|
||||
"saveProject": "Salvar Projeto OpenScreen",
|
||||
"openProject": "Abrir Projeto OpenScreen",
|
||||
"gifImage": "Imagem GIF",
|
||||
"mp4Video": "Vídeo MP4",
|
||||
"videoFiles": "Arquivos de Vídeo",
|
||||
"openscreenProject": "Projeto OpenScreen",
|
||||
"allFiles": "Todos os Arquivos"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"newRecording": {
|
||||
"title": "Voltar ao Gravador",
|
||||
"description": "Sua sessão atual foi salva.",
|
||||
"cancel": "Cancelar",
|
||||
"confirm": "Confirmar"
|
||||
},
|
||||
"loadingVideo": "Carregando vídeo...",
|
||||
"errors": {
|
||||
"noVideoLoaded": "Nenhum vídeo carregado",
|
||||
"videoNotReady": "Vídeo não está pronto",
|
||||
"unableToDetermineSourcePath": "Não foi possível determinar o caminho do vídeo de origem",
|
||||
"failedToSaveGif": "Falha ao salvar GIF",
|
||||
"gifExportFailed": "Falha na exportação do GIF",
|
||||
"failedToSaveVideo": "Falha ao salvar vídeo",
|
||||
"exportFailed": "Falha na exportação",
|
||||
"exportFailedWithError": "Falha na exportação: {{error}}",
|
||||
"exportBackgroundLoadFailed": "Falha na exportação: não foi possível carregar a imagem de fundo ({{url}})",
|
||||
"failedToSaveExport": "Falha ao salvar exportação",
|
||||
"failedToSaveExportedVideo": "Falha ao salvar vídeo exportado",
|
||||
"failedToRevealInFolder": "Erro ao mostrar na pasta: {{error}}"
|
||||
},
|
||||
"export": {
|
||||
"canceled": "Exportação cancelada",
|
||||
"exportedSuccessfully": "{{format}} exportado com sucesso"
|
||||
},
|
||||
"project": {
|
||||
"saveCanceled": "Salvamento do projeto cancelado",
|
||||
"failedToSave": "Falha ao salvar o projeto",
|
||||
"savedTo": "Projeto salvo em {{path}}",
|
||||
"failedToLoad": "Falha ao carregar o projeto",
|
||||
"invalidFormat": "Formato de arquivo de projeto inválido",
|
||||
"loadedFrom": "Projeto carregado de {{path}}"
|
||||
},
|
||||
"recording": {
|
||||
"failedCameraAccess": "Falha ao solicitar acesso à câmera.",
|
||||
"cameraBlocked": "O acesso à câmera está bloqueado. Ative-o nas configurações do sistema para usar a webcam.",
|
||||
"systemAudioUnavailable": "Áudio do sistema não disponível. Gravando sem áudio do sistema.",
|
||||
"microphoneDenied": "Acesso ao microfone negado. A gravação continuará sem áudio.",
|
||||
"cameraDenied": "Acesso à câmera negado. A gravação continuará sem webcam.",
|
||||
"cameraDisconnected": "Webcam desconectada.",
|
||||
"cameraNotFound": "Câmera não encontrada.",
|
||||
"permissionDenied": "Permissão de gravação negada. Por favor, permita a gravação de tela."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"tooltips": {
|
||||
"hideHUD": "Ocultar HUD",
|
||||
"closeApp": "Fechar App",
|
||||
"restartRecording": "Reiniciar gravação",
|
||||
"cancelRecording": "Cancelar gravação",
|
||||
"pauseRecording": "Pausar gravação",
|
||||
"resumeRecording": "Retomar gravação",
|
||||
"openVideoFile": "Abrir arquivo de vídeo",
|
||||
"openProject": "Abrir projeto"
|
||||
},
|
||||
"audio": {
|
||||
"enableSystemAudio": "Ativar áudio do sistema",
|
||||
"disableSystemAudio": "Desativar áudio do sistema",
|
||||
"enableMicrophone": "Ativar microfone",
|
||||
"disableMicrophone": "Desativar microfone",
|
||||
"defaultMicrophone": "Microfone Padrão"
|
||||
},
|
||||
"webcam": {
|
||||
"enableWebcam": "Ativar webcam",
|
||||
"disableWebcam": "Desativar webcam",
|
||||
"defaultCamera": "Câmera Padrão",
|
||||
"searching": "Procurando...",
|
||||
"noneFound": "Nenhuma câmera encontrada",
|
||||
"unavailable": "Câmera indisponível"
|
||||
},
|
||||
"cursor": {
|
||||
"useEditableCursor": "Usar cursor editável",
|
||||
"useSystemCursor": "Usar cursor do sistema"
|
||||
},
|
||||
"sourceSelector": {
|
||||
"loading": "Carregando fontes...",
|
||||
"screens": "Telas ({{count}})",
|
||||
"windows": "Janelas ({{count}})",
|
||||
"defaultSourceName": "Tela"
|
||||
},
|
||||
"recording": {
|
||||
"selectSource": "Por favor, selecione uma fonte para gravar"
|
||||
},
|
||||
"language": "Idioma",
|
||||
"systemLanguagePrompt": {
|
||||
"title": "Usar o idioma do seu sistema?",
|
||||
"description": "Detectamos {{language}} como o idioma do seu sistema. Deseja mudar o OpenScreen para {{language}}?",
|
||||
"switch": "Mudar para {{language}}",
|
||||
"keepDefault": "Manter idioma atual"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
{
|
||||
"zoom": {
|
||||
"level": "Nível de Zoom",
|
||||
"customScale": "Zoom Personalizado",
|
||||
"selectRegion": "Selecione uma região de zoom para ajustar",
|
||||
"deleteZoom": "Excluir Zoom",
|
||||
"focusMode": {
|
||||
"title": "Modo de Foco",
|
||||
"manual": "Manual",
|
||||
"auto": "Automático",
|
||||
"autoDescription": "A câmera segue a posição do cursor gravado"
|
||||
},
|
||||
"threeD": {
|
||||
"title": "Rotação 3D",
|
||||
"preset": {
|
||||
"iso": "Iso",
|
||||
"left": "Esquerda",
|
||||
"right": "Direita"
|
||||
}
|
||||
},
|
||||
"position": {
|
||||
"title": "Posição do Foco",
|
||||
"x": "X (%)",
|
||||
"y": "Y (%)",
|
||||
"hint": "0 = mais à esquerda / topo, 100 = mais à direita / inferior"
|
||||
}
|
||||
},
|
||||
"speed": {
|
||||
"playbackSpeed": "Velocidade de Reprodução",
|
||||
"selectRegion": "Selecione uma região de velocidade para ajustar",
|
||||
"deleteRegion": "Excluir Região de Velocidade",
|
||||
"customPlaybackSpeed": "Velocidade Personalizada",
|
||||
"maxSpeedError": "A velocidade não pode ser superior a 16×"
|
||||
},
|
||||
"trim": {
|
||||
"deleteRegion": "Excluir Região de Recorte"
|
||||
},
|
||||
"layout": {
|
||||
"title": "Layout",
|
||||
"preset": "Predefinição",
|
||||
"selectPreset": "Selecionar predefinição",
|
||||
"pictureInPicture": "Picture in Picture",
|
||||
"verticalStack": "Empilhamento Vertical",
|
||||
"dualFrame": "Quadro Duplo",
|
||||
"noWebcam": "Sem Webcam",
|
||||
"webcamShape": "Formato da Câmera",
|
||||
"webcamSize": "Tamanho da Webcam"
|
||||
},
|
||||
"effects": {
|
||||
"title": "Efeitos de Vídeo",
|
||||
"blurBg": "Desfocar Fundo",
|
||||
"motionBlur": "Desfoque de Movimento",
|
||||
"off": "desativado",
|
||||
"on": "ativado",
|
||||
"shadow": "Sombra",
|
||||
"roundness": "Arredondamento",
|
||||
"padding": "Espaçamento"
|
||||
},
|
||||
"background": {
|
||||
"title": "Fundo",
|
||||
"image": "Imagem",
|
||||
"color": "Cor",
|
||||
"gradient": "Gradiente",
|
||||
"uploadCustom": "Enviar Personalizada",
|
||||
"gradientLabel": "Gradiente {{index}}",
|
||||
"colorWheel": "Roda de Cores",
|
||||
"colorPalette": "Paleta de Cores"
|
||||
},
|
||||
"crop": {
|
||||
"title": "Cortar",
|
||||
"cropVideo": "Cortar Vídeo",
|
||||
"dragInstruction": "Arraste cada lado para ajustar a área de corte",
|
||||
"ratio": "Proporção",
|
||||
"free": "Livre",
|
||||
"done": "Concluir",
|
||||
"lockAspectRatio": "Bloquear proporção",
|
||||
"unlockAspectRatio": "Desbloquear proporção"
|
||||
},
|
||||
"exportFormat": {
|
||||
"mp4": "MP4",
|
||||
"gif": "GIF",
|
||||
"mp4Video": "Vídeo MP4",
|
||||
"mp4Description": "Arquivo de vídeo de alta qualidade",
|
||||
"gifAnimation": "Animação GIF",
|
||||
"gifDescription": "Imagem animada para compartilhamento"
|
||||
},
|
||||
"exportQuality": {
|
||||
"title": "Qualidade de Exportação",
|
||||
"low": "Baixa",
|
||||
"medium": "Média",
|
||||
"high": "Alta"
|
||||
},
|
||||
"gifSettings": {
|
||||
"frameRate": "Taxa de Quadros do GIF",
|
||||
"size": "Tamanho do GIF",
|
||||
"loop": "Loop no GIF"
|
||||
},
|
||||
"project": {
|
||||
"save": "Salvar Projeto",
|
||||
"load": "Carregar Projeto"
|
||||
},
|
||||
"export": {
|
||||
"videoButton": "Exportar Vídeo",
|
||||
"gifButton": "Exportar GIF",
|
||||
"chooseSaveLocation": "Escolher Local para Salvar"
|
||||
},
|
||||
"links": {
|
||||
"reportBug": "Relatar Bug",
|
||||
"starOnGithub": "Dar Estrela no GitHub"
|
||||
},
|
||||
"imageUpload": {
|
||||
"invalidFileType": "Tipo de arquivo inválido",
|
||||
"jpgOnly": "Por favor, envie um arquivo de imagem JPG ou JPEG.",
|
||||
"uploadSuccess": "Imagem personalizada enviada com sucesso!",
|
||||
"failedToUpload": "Falha ao enviar imagem",
|
||||
"errorReading": "Ocorreu um erro ao ler o arquivo."
|
||||
},
|
||||
"annotation": {
|
||||
"title": "Configurações de Anotação",
|
||||
"active": "Ativo",
|
||||
"typeText": "Texto",
|
||||
"typeImage": "Imagem",
|
||||
"typeArrow": "Seta",
|
||||
"typeBlur": "Desfoque",
|
||||
"textContent": "Conteúdo do Texto",
|
||||
"textPlaceholder": "Digite seu texto...",
|
||||
"fontStyle": "Estilo da Fonte",
|
||||
"selectStyle": "Selecionar estilo",
|
||||
"size": "Tamanho",
|
||||
"customFonts": "Fontes Personalizadas",
|
||||
"textColor": "Cor do Texto",
|
||||
"background": "Fundo",
|
||||
"none": "Nenhum",
|
||||
"color": "Cor",
|
||||
"colorWheel": "Roda de Cores",
|
||||
"colorPalette": "Paleta de Cores",
|
||||
"clearBackground": "Limpar Fundo",
|
||||
"uploadImage": "Enviar Imagem",
|
||||
"supportedFormats": "Formatos suportados: JPG, PNG, GIF, WebP",
|
||||
"arrowDirection": "Direção da Seta",
|
||||
"strokeWidth": "Largura do Traço: {{width}}px",
|
||||
"arrowColor": "Cor da Seta",
|
||||
"blurType": "Tipo de Desfoque",
|
||||
"blurTypeBlur": "Desfoque",
|
||||
"blurTypeMosaic": "Mosaico",
|
||||
"blurColor": "Cor do Desfoque",
|
||||
"blurColorWhite": "Branco",
|
||||
"blurColorBlack": "Preto",
|
||||
"blurShape": "Formato do Desfoque",
|
||||
"blurIntensity": "Intensidade do Desfoque",
|
||||
"mosaicBlockSize": "Tamanho do Bloco do Mosaico",
|
||||
"blurShapeRectangle": "Retângulo",
|
||||
"blurShapeOval": "Oval",
|
||||
"blurShapeFreehand": "Mão Livre",
|
||||
"deleteAnnotation": "Excluir Anotação",
|
||||
"shortcutsAndTips": "Atalhos e Dicas",
|
||||
"tipMovePlayhead": "Mova o cursor de reprodução para a seção de anotação sobreposta e selecione um item.",
|
||||
"tipTabCycle": "Use Tab para alternar entre itens sobrepostos.",
|
||||
"tipShiftTabCycle": "Use Shift+Tab para alternar para trás.",
|
||||
"invalidImageType": "Tipo de imagem inválido",
|
||||
"imageFormatsOnly": "Por favor, envie um arquivo de imagem JPG, PNG, GIF ou WebP.",
|
||||
"imageUploadSuccess": "Imagem enviada com sucesso!",
|
||||
"failedImageUpload": "Falha ao enviar imagem"
|
||||
},
|
||||
"fontStyles": {
|
||||
"classic": "Clássico",
|
||||
"editor": "Editor",
|
||||
"strong": "Forte",
|
||||
"typewriter": "Máquina de Escrever",
|
||||
"deco": "Deco",
|
||||
"simple": "Simples",
|
||||
"modern": "Moderno",
|
||||
"clean": "Clean"
|
||||
},
|
||||
"customFont": {
|
||||
"dialogTitle": "Adicionar Google Font",
|
||||
"urlLabel": "URL de Importação do Google Fonts",
|
||||
"urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
|
||||
"urlHelp": "Pegue isso no Google Fonts: Selecione uma fonte → Clique em \"Get font\" → Copie a URL @import",
|
||||
"nameLabel": "Nome de Exibição",
|
||||
"namePlaceholder": "Minha Fonte Personalizada",
|
||||
"nameHelp": "É assim que a fonte aparecerá no seletor de fontes",
|
||||
"addButton": "Adicionar Fonte",
|
||||
"addingButton": "Adicionando...",
|
||||
"errorEmptyUrl": "Por favor, insira uma URL de importação do Google Fonts",
|
||||
"errorInvalidUrl": "Por favor, insira uma URL válida do Google Fonts",
|
||||
"errorEmptyName": "Por favor, insira um nome para a fonte",
|
||||
"errorExtractFailed": "Não foi possível extrair a família da fonte da URL",
|
||||
"successMessage": "Fonte \"{{fontName}}\" adicionada com sucesso",
|
||||
"failedToAdd": "Falha ao adicionar fonte",
|
||||
"errorTimeout": "A fonte demorou muito para carregar. Por favor, verifique a URL e tente novamente.",
|
||||
"errorLoadFailed": "A fonte não pôde ser carregada. Por favor, verifique se a URL do Google Fonts está correta."
|
||||
},
|
||||
"language": {
|
||||
"title": "Idioma"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"title": "Atalhos de Teclado",
|
||||
"customize": "Personalizar",
|
||||
"configurable": "Configurável",
|
||||
"fixed": "Fixo",
|
||||
"pressKey": "Pressione uma tecla…",
|
||||
"clickToChange": "Clique para alterar",
|
||||
"pressEscToCancel": "Pressione Esc para cancelar",
|
||||
"helpText": "Clique em um atalho e pressione a nova combinação de teclas. Pressione Esc para cancelar.",
|
||||
"resetToDefaults": "Redefinir para os padrões",
|
||||
"alreadyUsedBy": "Já utilizado por {{action}}",
|
||||
"swap": "Trocar",
|
||||
"reservedShortcut": "Este atalho é reservado para \"{{label}}\" e não pode ser reatribuído.",
|
||||
"savedToast": "Atalhos de teclado salvos",
|
||||
"resetToast": "Atalhos redefinidos para o padrão — clique em Salvar para aplicar",
|
||||
"actions": {
|
||||
"addZoom": "Adicionar Zoom",
|
||||
"addTrim": "Adicionar Recorte",
|
||||
"addSpeed": "Adicionar Velocidade",
|
||||
"addAnnotation": "Adicionar Anotação",
|
||||
"addBlur": "Adicionar Desfoque",
|
||||
"addKeyframe": "Adicionar Quadro-chave",
|
||||
"deleteSelected": "Excluir Selecionado",
|
||||
"playPause": "Reproduzir / Pausar"
|
||||
},
|
||||
"fixedActions": {
|
||||
"undo": "Desfazer",
|
||||
"redo": "Refazer",
|
||||
"cycleAnnotationsForward": "Alternar Anotações (Próximo)",
|
||||
"cycleAnnotationsBackward": "Alternar Anotações (Anterior)",
|
||||
"deleteSelectedAlt": "Excluir Selecionado (alt)",
|
||||
"panTimeline": "Mover Linha do Tempo",
|
||||
"zoomTimeline": "Zoom na Linha do Tempo",
|
||||
"frameBack": "Quadro Anterior",
|
||||
"frameForward": "Próximo Quadro"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"buttons": {
|
||||
"addZoom": "Adicionar Zoom (Z)",
|
||||
"suggestZooms": "Sugerir Zooms a partir do Cursor",
|
||||
"addTrim": "Adicionar Recorte (T)",
|
||||
"addAnnotation": "Adicionar Anotação (A)",
|
||||
"addBlur": "Adicionar Desfoque (B)",
|
||||
"addSpeed": "Adicionar Velocidade (S)"
|
||||
},
|
||||
"hints": {
|
||||
"pressZoom": "Pressione Z para adicionar zoom",
|
||||
"pressTrim": "Pressione T para adicionar recorte",
|
||||
"pressAnnotation": "Pressione A para adicionar anotação",
|
||||
"pressBlur": "Pressione B para adicionar região de desfoque",
|
||||
"pressSpeed": "Pressione S para adicionar velocidade"
|
||||
},
|
||||
"labels": {
|
||||
"pan": "Mover",
|
||||
"zoom": "Zoom",
|
||||
"trim": "Recorte",
|
||||
"speed": "Velocidade",
|
||||
"zoomItem": "Zoom {{index}}",
|
||||
"trimItem": "Recorte {{index}}",
|
||||
"speedItem": "Velocidade {{index}}",
|
||||
"annotationItem": "Anotação",
|
||||
"blurItem": "Desfoque {{index}}",
|
||||
"imageItem": "Imagem",
|
||||
"emptyText": "Texto vazio"
|
||||
},
|
||||
"emptyState": {
|
||||
"noVideo": "Nenhum Vídeo Carregado",
|
||||
"dragAndDrop": "Arraste e solte um vídeo para começar a editar"
|
||||
},
|
||||
"errors": {
|
||||
"cannotPlaceZoom": "Não é possível colocar zoom aqui",
|
||||
"zoomExistsAtLocation": "Já existe um zoom neste local ou não há espaço suficiente disponível.",
|
||||
"zoomSuggestionUnavailable": "Sugestão de zoom não disponível",
|
||||
"noCursorTelemetry": "Nenhuma telemetria de cursor disponível",
|
||||
"noCursorTelemetryDescription": "Grave um screencast primeiro para gerar sugestões baseadas no cursor.",
|
||||
"noUsableTelemetry": "Nenhuma telemetria de cursor utilizável",
|
||||
"noUsableTelemetryDescription": "A gravação não inclui dados suficientes de movimento do cursor.",
|
||||
"noDwellMoments": "Nenhum momento claro de parada do cursor encontrado",
|
||||
"noDwellMomentsDescription": "Tente uma gravação com pausas mais lentas do cursor em ações importantes.",
|
||||
"noAutoZoomSlots": "Nenhum slot de zoom automático disponível",
|
||||
"noAutoZoomSlotsDescription": "Pontos de parada detectados sobrepõem regiões de zoom existentes.",
|
||||
"cannotPlaceTrim": "Não é possível colocar recorte aqui",
|
||||
"trimExistsAtLocation": "Já existe um recorte neste local ou não há espaço suficiente disponível.",
|
||||
"cannotPlaceSpeed": "Não é possível colocar velocidade aqui",
|
||||
"speedExistsAtLocation": "Já existe uma região de velocidade neste local ou não há espaço suficiente disponível."
|
||||
},
|
||||
"success": {
|
||||
"addedZoomSuggestions": "Adicionada {{count}} sugestão de zoom baseada no cursor",
|
||||
"addedZoomSuggestionsPlural": "Adicionadas {{count}} sugestões de zoom baseadas no cursor"
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,12 @@
|
||||
"detail": "Хотите сохранить проект перед закрытием?",
|
||||
"saveAndClose": "Сохранить и закрыть",
|
||||
"discardAndClose": "Отменить и закрыть",
|
||||
"detailNewProject": "Хотите сохранить проект перед созданием нового?",
|
||||
"saveAndNewProject": "Сохранить и новый проект",
|
||||
"discardAndNewProject": "Отменить и новый проект",
|
||||
"detailLoadProject": "Хотите сохранить проект перед загрузкой другого?",
|
||||
"saveAndLoadProject": "Сохранить и загрузить проект",
|
||||
"discardAndLoadProject": "Отменить и загрузить проект",
|
||||
"loadProject": "Загрузить проект…",
|
||||
"saveProject": "Сохранить проект…",
|
||||
"saveProjectAs": "Сохранить проект как…"
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"confirm": "Подтвердить"
|
||||
},
|
||||
"loadingVideo": "Загрузка видео...",
|
||||
"loadingEditor": "Загрузка редактора...",
|
||||
"errors": {
|
||||
"noVideoLoaded": "Видео не загружено",
|
||||
"videoNotReady": "Видео не готово",
|
||||
@@ -42,5 +43,20 @@
|
||||
"cameraNotFound": "Камера не найдена.",
|
||||
"permissionDenied": "Разрешение на запись запрещено. Пожалуйста, разрешите запись экрана.",
|
||||
"accessibilityAllowAndRetry": "Разрешите OpenScreen доступ к Универсальному доступу, затем снова нажмите запись, чтобы начать обратный отсчет."
|
||||
},
|
||||
"emptyState": {
|
||||
"title": "Нет открытых проектов",
|
||||
"description": "Импортируйте видео для начала редактирования или загрузите существующий проект OpenScreen.",
|
||||
"importVideoButton": "Импортировать видеофайл…",
|
||||
"loadProjectButton": "Загрузить проект…",
|
||||
"supportedFormats": "Поддерживаемые форматы: MP4, MOV, WebM, MKV, AVI, M4V, WMV",
|
||||
"dragDropHint": "или перетащите файл проекта .openscreen сюда",
|
||||
"dropOverlay": "Перетащите файл проекта для открытия",
|
||||
"dropErrors": {
|
||||
"unsupportedFormatTitle": "Неподдерживаемый формат",
|
||||
"unsupportedFormatMessage": "Сюда можно перетаскивать только файлы проекта .openscreen. Для импорта видео используйте кнопку «Импортировать видеофайл...».",
|
||||
"couldNotOpenTitle": "Не удалось открыть файл",
|
||||
"couldNotOpenMessage": "Не удалось открыть файл проекта. Видео, на которое он ссылается, возможно, было перемещено или удалено."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
"pauseRecording": "Приостановить запись",
|
||||
"resumeRecording": "Возобновить запись",
|
||||
"openVideoFile": "Открыть видеофайл",
|
||||
"openProject": "Открыть проект"
|
||||
"openProject": "Открыть проект",
|
||||
"useVerticalTray": "Использовать вертикальную панель",
|
||||
"useHorizontalTray": "Использовать горизонтальную панель"
|
||||
},
|
||||
"audio": {
|
||||
"enableSystemAudio": "Включить системное аудио",
|
||||
|
||||
@@ -99,7 +99,8 @@
|
||||
},
|
||||
"project": {
|
||||
"save": "Сохранить проект",
|
||||
"load": "Загрузить проект"
|
||||
"load": "Загрузить проект",
|
||||
"new": "Новый проект"
|
||||
},
|
||||
"export": {
|
||||
"videoButton": "Экспорт видео",
|
||||
@@ -175,6 +176,17 @@
|
||||
"modern": "Современный",
|
||||
"clean": "Чистый"
|
||||
},
|
||||
"textAnimation": {
|
||||
"title": "Анимация текста",
|
||||
"selectAnimation": "Выбрать анимацию",
|
||||
"none": "Нет",
|
||||
"fade": "Затухание",
|
||||
"rise": "Подъем",
|
||||
"pop": "Всплытие",
|
||||
"slideLeft": "Скольжение влево",
|
||||
"typewriter": "Пишущая машинка",
|
||||
"pulse": "Импульс"
|
||||
},
|
||||
"customFont": {
|
||||
"dialogTitle": "Добавить шрифт Google",
|
||||
"urlLabel": "URL импорта Google Fonts",
|
||||
@@ -204,5 +216,9 @@
|
||||
},
|
||||
"language": {
|
||||
"title": "Язык"
|
||||
},
|
||||
"timeline": {
|
||||
"title": "Шкала времени",
|
||||
"waveform": "Показывать форму волны на треке обрезки"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,9 @@
|
||||
"reservedShortcut": "Эта горячая клавиша зарезервирована для \"{{label}}\" и не может быть переназначена.",
|
||||
"savedToast": "Горячие клавиши сохранены",
|
||||
"resetToast": "Сброс к горячим клавишам по умолчанию — нажмите Сохранить для применения",
|
||||
"registrationFailed": "Не удалось зарегистрировать горячую клавишу. Возможно, она используется другим приложением. Попробуйте другую клавишу.",
|
||||
"actions": {
|
||||
"openApp": "Открыть приложение",
|
||||
"addZoom": "Добавить масштабирование",
|
||||
"addTrim": "Добавить обрезку",
|
||||
"addSpeed": "Изменить скорость",
|
||||
|
||||
@@ -51,6 +51,12 @@
|
||||
"detail": "Kapatmadan önce projenizi kaydetmek ister misiniz?",
|
||||
"saveAndClose": "Kaydet ve Kapat",
|
||||
"discardAndClose": "Kaydetmeden Kapat",
|
||||
"detailNewProject": "Yeni proje oluşturmadan önce kaydetmek ister misiniz?",
|
||||
"saveAndNewProject": "Kaydet ve Yeni Proje",
|
||||
"discardAndNewProject": "Kaydetmeden Yeni Proje",
|
||||
"detailLoadProject": "Başka bir proje yüklemeden önce kaydetmek ister misiniz?",
|
||||
"saveAndLoadProject": "Kaydet ve Proje Yükle",
|
||||
"discardAndLoadProject": "Kaydetmeden Proje Yükle",
|
||||
"loadProject": "Proje Yükle…",
|
||||
"saveProject": "Proje Kaydet…",
|
||||
"saveProjectAs": "Farklı Kaydet…"
|
||||
|
||||
@@ -37,10 +37,26 @@
|
||||
"accessibilityAllowAndRetry": "OpenScreen için Erişilebilirlik erişimine izin verin, ardından geri sayımı başlatmak için tekrar kayda basın."
|
||||
},
|
||||
"loadingVideo": "Video yükleniyor...",
|
||||
"loadingEditor": "Editör yükleniyor...",
|
||||
"newRecording": {
|
||||
"title": "Kaydediciye Dön",
|
||||
"description": "Mevcut oturumunuz kaydedildi.",
|
||||
"cancel": "İptal",
|
||||
"confirm": "Onayla"
|
||||
},
|
||||
"emptyState": {
|
||||
"title": "Açık proje yok",
|
||||
"description": "Düzenlemeye başlamak için bir video içe aktarın veya mevcut bir OpenScreen projesi yükleyin.",
|
||||
"importVideoButton": "Video Dosyası İçe Aktar…",
|
||||
"loadProjectButton": "Proje Yükle…",
|
||||
"supportedFormats": "Desteklenen formatlar: MP4, MOV, WebM, MKV, AVI, M4V, WMV",
|
||||
"dragDropHint": "veya bir .openscreen proje dosyasını buraya sürükleyip bırakın",
|
||||
"dropOverlay": "Açmak için proje dosyasını bırakın",
|
||||
"dropErrors": {
|
||||
"unsupportedFormatTitle": "Desteklenmeyen Format",
|
||||
"unsupportedFormatMessage": "Buraya yalnızca .openscreen proje dosyaları bırakılabilir. Video içe aktarmak için \"Video Dosyası İçe Aktar...\" düğmesini kullanın.",
|
||||
"couldNotOpenTitle": "Dosya Açılamadı",
|
||||
"couldNotOpenMessage": "Proje dosyası açılamadı. Başvurulan video taşınmış veya silinmiş olabilir."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
"pauseRecording": "Kaydı duraklat",
|
||||
"resumeRecording": "Kayda devam et",
|
||||
"openVideoFile": "Video dosyası aç",
|
||||
"openProject": "Proje aç"
|
||||
"openProject": "Proje aç",
|
||||
"useVerticalTray": "Dikey araç çubuğunu kullan",
|
||||
"useHorizontalTray": "Yatay araç çubuğunu kullan"
|
||||
},
|
||||
"audio": {
|
||||
"enableSystemAudio": "Sistem sesini etkinleştir",
|
||||
|
||||
@@ -99,7 +99,8 @@
|
||||
},
|
||||
"project": {
|
||||
"save": "Projeyi Kaydet",
|
||||
"load": "Proje Yükle"
|
||||
"load": "Proje Yükle",
|
||||
"new": "Yeni Proje"
|
||||
},
|
||||
"export": {
|
||||
"videoButton": "Videoyu Dışa Aktar",
|
||||
@@ -129,6 +130,8 @@
|
||||
"textPlaceholder": "Metninizi girin...",
|
||||
"fontStyle": "Yazı Tipi Stili",
|
||||
"selectStyle": "Stil seçin",
|
||||
"textAnimation": "Metin Animasyonu",
|
||||
"selectAnimation": "Animasyon seçin",
|
||||
"size": "Boyut",
|
||||
"customFonts": "Özel Yazı Tipleri",
|
||||
"textColor": "Metin Rengi",
|
||||
@@ -204,5 +207,9 @@
|
||||
},
|
||||
"language": {
|
||||
"title": "Dil"
|
||||
},
|
||||
"timeline": {
|
||||
"title": "Zaman Tüneli",
|
||||
"waveform": "Kırpma Parçasında Ses Dalgasını Göster"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,9 @@
|
||||
"reservedShortcut": "Bu kısayol \"{{label}}\" için ayrılmıştır ve yeniden atanamaz.",
|
||||
"savedToast": "Klavye kısayolları kaydedildi",
|
||||
"resetToast": "Varsayılan kısayollara sıfırlandı — uygulamak için Kaydet'e tıklayın",
|
||||
"registrationFailed": "Kısayol kaydedilemedi. Başka bir uygulama tarafından kullanılıyor olabilir. Farklı bir tuş deneyin.",
|
||||
"actions": {
|
||||
"openApp": "Uygulamayı Aç",
|
||||
"addZoom": "Yakınlaştırma Ekle",
|
||||
"addTrim": "Kırpma Ekle",
|
||||
"addSpeed": "Hız Ekle",
|
||||
|
||||
@@ -51,6 +51,12 @@
|
||||
"detail": "Bạn có muốn lưu dự án của mình trước khi đóng không?",
|
||||
"saveAndClose": "Lưu & Đóng",
|
||||
"discardAndClose": "Bỏ qua & Đóng",
|
||||
"detailNewProject": "Bạn có muốn lưu dự án trước khi tạo dự án mới không?",
|
||||
"saveAndNewProject": "Lưu & Dự án mới",
|
||||
"discardAndNewProject": "Bỏ qua & Dự án mới",
|
||||
"detailLoadProject": "Bạn có muốn lưu dự án của mình trước khi tải dự án khác không?",
|
||||
"saveAndLoadProject": "Lưu & Tải dự án",
|
||||
"discardAndLoadProject": "Bỏ qua & Tải dự án",
|
||||
"loadProject": "Tải dự án…",
|
||||
"saveProject": "Lưu dự án…",
|
||||
"saveProjectAs": "Lưu dự án thành…"
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"confirm": "Xác nhận"
|
||||
},
|
||||
"loadingVideo": "Đang tải video...",
|
||||
"loadingEditor": "Đang tải trình chỉnh sửa...",
|
||||
"errors": {
|
||||
"noVideoLoaded": "Chưa tải video nào",
|
||||
"videoNotReady": "Video chưa sẵn sàng",
|
||||
@@ -42,5 +43,20 @@
|
||||
"cameraNotFound": "Không tìm thấy máy ảnh.",
|
||||
"permissionDenied": "Quyền ghi hình bị từ chối. Vui lòng cho phép ghi màn hình.",
|
||||
"accessibilityAllowAndRetry": "Cho phép OpenScreen truy cập Trợ năng, sau đó nhấn ghi lại để bắt đầu đếm ngược."
|
||||
},
|
||||
"emptyState": {
|
||||
"title": "Không có dự án nào được mở",
|
||||
"description": "Nhập video để bắt đầu chỉnh sửa hoặc tải một dự án OpenScreen hiện có.",
|
||||
"importVideoButton": "Nhập tệp video…",
|
||||
"loadProjectButton": "Tải dự án…",
|
||||
"supportedFormats": "Định dạng được hỗ trợ: MP4, MOV, WebM, MKV, AVI, M4V, WMV",
|
||||
"dragDropHint": "hoặc kéo và thả tệp dự án .openscreen vào đây",
|
||||
"dropOverlay": "Thả tệp dự án để mở",
|
||||
"dropErrors": {
|
||||
"unsupportedFormatTitle": "Định dạng không được hỗ trợ",
|
||||
"unsupportedFormatMessage": "Chỉ có thể thả các tệp dự án .openscreen vào đây. Để nhập video, hãy sử dụng nút \"Nhập tệp video...\" thay thế.",
|
||||
"couldNotOpenTitle": "Không thể mở tệp",
|
||||
"couldNotOpenMessage": "Không thể mở tệp dự án. Video mà nó tham chiếu có thể đã bị di chuyển hoặc xóa."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
"pauseRecording": "Tạm dừng ghi hình",
|
||||
"resumeRecording": "Tiếp tục ghi hình",
|
||||
"openVideoFile": "Mở tệp video",
|
||||
"openProject": "Mở dự án"
|
||||
"openProject": "Mở dự án",
|
||||
"useVerticalTray": "Dùng khay dọc",
|
||||
"useHorizontalTray": "Dùng khay ngang"
|
||||
},
|
||||
"audio": {
|
||||
"enableSystemAudio": "Bật âm thanh hệ thống",
|
||||
|
||||
@@ -99,7 +99,8 @@
|
||||
},
|
||||
"project": {
|
||||
"save": "Lưu dự án",
|
||||
"load": "Tải dự án"
|
||||
"load": "Tải dự án",
|
||||
"new": "Dự án mới"
|
||||
},
|
||||
"export": {
|
||||
"videoButton": "Xuất Video",
|
||||
@@ -129,6 +130,8 @@
|
||||
"textPlaceholder": "Nhập văn bản của bạn...",
|
||||
"fontStyle": "Kiểu phông chữ",
|
||||
"selectStyle": "Chọn kiểu",
|
||||
"textAnimation": "Hoạt ảnh văn bản",
|
||||
"selectAnimation": "Chọn hoạt ảnh",
|
||||
"size": "Kích thước",
|
||||
"customFonts": "Phông chữ tùy chỉnh",
|
||||
"textColor": "Màu văn bản",
|
||||
@@ -204,5 +207,9 @@
|
||||
},
|
||||
"language": {
|
||||
"title": "Ngôn ngữ"
|
||||
},
|
||||
"timeline": {
|
||||
"title": "Dòng thời gian",
|
||||
"waveform": "Hiển thị dạng sóng âm thanh trên rãnh cắt"
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user