Merge upstream main into webcam mirror toggle

This commit is contained in:
AjTheSpidey
2026-06-01 09:39:02 +08:00
125 changed files with 4526 additions and 628 deletions
+5
View File
@@ -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
View File
@@ -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.
+23
View File
@@ -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;
+79
View File
@@ -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
View File
@@ -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,
+7
View File
@@ -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,
+84
View File
@@ -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");
});
});
+147
View File
@@ -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
View File
@@ -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
View File
@@ -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);
},
+97
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+89 -60
View File
@@ -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);
});
});
+63 -29
View File
@@ -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>
);
}
+40 -5
View File
@@ -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"
+469 -318
View File
@@ -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>
);
}
+17 -5
View File
@@ -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" />;
}
+8 -1
View File
@@ -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}
+11
View File
@@ -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 = {
+6 -2
View File
@@ -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],
);
+40
View File
@@ -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]);
};
+264
View File
@@ -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();
});
});
+160
View File
@@ -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,
};
}
+120
View File
@@ -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;
}
+8
View File
@@ -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,
};
+63 -56
View File
@@ -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> }>;
+1
View File
@@ -10,6 +10,7 @@ export const SUPPORTED_LOCALES = [
"ru",
"tr",
"vi",
"pt-BR",
"zh-CN",
"zh-TW",
] as const;
+6
View File
@@ -51,6 +51,12 @@
"detail": "هل تريد حفظ مشروعك قبل الإغلاق؟",
"saveAndClose": "حفظ وإغلاق",
"discardAndClose": "تجاهل وإغلاق",
"detailNewProject": "هل تريد حفظ مشروعك قبل إنشاء مشروع جديد؟",
"saveAndNewProject": "حفظ وإنشاء مشروع جديد",
"discardAndNewProject": "تجاهل وإنشاء مشروع جديد",
"detailLoadProject": "هل تريد حفظ مشروعك قبل تحميل مشروع آخر؟",
"saveAndLoadProject": "حفظ وتحميل مشروع",
"discardAndLoadProject": "تجاهل وتحميل مشروع",
"loadProject": "تحميل مشروع...",
"saveProject": "حفظ المشروع...",
"saveProjectAs": "حفظ المشروع باسم..."
+16
View File
@@ -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": "تعذّر فتح ملف المشروع. ربما تم نقل الفيديو المرجعي أو حذفه."
}
}
}
+3 -1
View File
@@ -7,7 +7,9 @@
"pauseRecording": "إيقاف التسجيل مؤقتاً",
"resumeRecording": "استئناف التسجيل",
"openVideoFile": "فتح ملف فيديو",
"openProject": "فتح مشروع"
"openProject": "فتح مشروع",
"useVerticalTray": "استخدام الشريط العمودي",
"useHorizontalTray": "استخدام الشريط الأفقي"
},
"audio": {
"enableSystemAudio": "تفعيل صوت النظام",
+8 -1
View File
@@ -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": "عرض الموجة الصوتية على مسار القطع"
}
}
+2
View File
@@ -13,7 +13,9 @@
"reservedShortcut": "هذا الاختصار محجوز لـ \"{{label}}\" ولا يمكن إعادة تعيينه.",
"savedToast": "تم حفظ اختصارات لوحة المفاتيح",
"resetToast": "إعادة تعيين إلى الاختصارات الافتراضية — انقر فوق حفظ للتطبيق",
"registrationFailed": "فشل في تسجيل الاختصار. قد يكون مستخدمًا من قبل تطبيق آخر. جرب مفتاحًا مختلفًا.",
"actions": {
"openApp": "فتح التطبيق",
"addZoom": "إضافة تكبير",
"addTrim": "إضافة قص",
"addSpeed": "إضافة سرعة",
+8
View File
@@ -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…"
+16
View File
@@ -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 -1
View File
@@ -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": {
+17 -1
View File
@@ -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"
}
}
+2
View File
@@ -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",
+6
View File
@@ -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…"
+16
View File
@@ -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."
}
}
}
+3 -1
View File
@@ -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",
+17 -1
View File
@@ -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"
}
}
+2
View File
@@ -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",
+6
View File
@@ -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…"
+17 -1
View File
@@ -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."
}
}
}
+3 -1
View File
@@ -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",
+18 -3
View File
@@ -1,6 +1,6 @@
{
"zoom": {
"previewHold": "Maintenir pour prévisualiser leffet 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"
}
}
+2
View File
@@ -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",
+3 -1
View File
@@ -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",
+16 -1
View File
@@ -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"
}
}
+2
View File
@@ -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à",
+6
View File
@@ -52,6 +52,12 @@
"detail": "閉じる前にプロジェクトを保存しますか?",
"saveAndClose": "保存して閉じる",
"discardAndClose": "破棄して閉じる",
"detailNewProject": "新しいプロジェクトを作成する前に保存しますか?",
"saveAndNewProject": "保存して新規プロジェクト",
"discardAndNewProject": "破棄して新規プロジェクト",
"detailLoadProject": "別のプロジェクトを読み込む前にプロジェクトを保存しますか?",
"saveAndLoadProject": "保存してプロジェクトを読み込む",
"discardAndLoadProject": "破棄してプロジェクトを読み込む",
"loadProject": "プロジェクトを読み込む…",
"saveProject": "プロジェクトを保存…",
"saveProjectAs": "プロジェクトを名前を付けて保存…"
+16
View File
@@ -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": "プロジェクトファイルを開けませんでした。参照している動画が移動または削除された可能性があります。"
}
}
}
+3 -1
View File
@@ -7,7 +7,9 @@
"pauseRecording": "録画を一時停止",
"resumeRecording": "録画を再開",
"openVideoFile": "動画ファイルを開く",
"openProject": "プロジェクトを開く"
"openProject": "プロジェクトを開く",
"useVerticalTray": "縦型トレイを使用",
"useHorizontalTray": "横型トレイを使用"
},
"audio": {
"enableSystemAudio": "システム音声を有効にする",
+17 -1
View File
@@ -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": "トリムトラックにオーディオ波形を表示"
}
}
+2
View File
@@ -13,7 +13,9 @@
"reservedShortcut": "このショートカットは \"{{label}}\" に予約されており、再割り当てできません。",
"savedToast": "キーボードショートカットが保存されました",
"resetToast": "デフォルトのショートカットにリセット — 保存をクリックして適用",
"registrationFailed": "ショートカットの登録に失敗しました。他のアプリで使用中の可能性があります。別のキーをお試しください。",
"actions": {
"openApp": "アプリを開く",
"addZoom": "ズームを追加",
"addTrim": "トリムを追加",
"addSpeed": "速度を追加",
+6
View File
@@ -51,6 +51,12 @@
"detail": "닫기 전에 프로젝트를 저장하시겠습니까?",
"saveAndClose": "저장 후 닫기",
"discardAndClose": "저장하지 않고 닫기",
"detailNewProject": "새 프로젝트를 만들기 전에 저장하시겠습니까?",
"saveAndNewProject": "저장 후 새 프로젝트",
"discardAndNewProject": "저장하지 않고 새 프로젝트",
"detailLoadProject": "다른 프로젝트를 불러오기 전에 저장하시겠습니까?",
"saveAndLoadProject": "저장 후 프로젝트 불러오기",
"discardAndLoadProject": "저장하지 않고 프로젝트 불러오기",
"loadProject": "프로젝트 불러오기...",
"saveProject": "프로젝트 저장...",
"saveProjectAs": "다른 이름으로 프로젝트 저장..."
+16
View File
@@ -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": "프로젝트 파일을 열 수 없습니다. 참조된 동영상이 이동되었거나 삭제되었을 수 있습니다."
}
}
}
+3 -1
View File
@@ -7,7 +7,9 @@
"pauseRecording": "녹화 일시정지",
"resumeRecording": "녹화 재개",
"openVideoFile": "비디오 파일 열기",
"openProject": "프로젝트 열기"
"openProject": "프로젝트 열기",
"useVerticalTray": "세로 트레이 사용",
"useHorizontalTray": "가로 트레이 사용"
},
"audio": {
"enableSystemAudio": "시스템 오디오 활성화",
+31 -15
View File
@@ -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": "트림 트랙에 오디오 파형 표시"
}
}
+2
View File
@@ -13,7 +13,9 @@
"reservedShortcut": "이 단축키는 \"{{label}}\"에 예약되어 있어 변경할 수 없습니다.",
"savedToast": "키보드 단축키가 저장되었습니다",
"resetToast": "기본 단축키로 초기화되었습니다 — 저장을 클릭해 적용하세요",
"registrationFailed": "단축키 등록에 실패했습니다. 다른 앱에서 사용 중일 수 있습니다. 다른 키를 시도하세요.",
"actions": {
"openApp": "앱 열기",
"addZoom": "줌 추가",
"addTrim": "트림 추가",
"addSpeed": "속도 추가",
+50
View File
@@ -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"
}
}
+71
View File
@@ -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"
}
}
+45
View File
@@ -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."
}
}
+47
View File
@@ -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"
}
}
+197
View File
@@ -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"
}
}
+37
View File
@@ -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"
}
}
+55
View File
@@ -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"
}
}
+6
View File
@@ -51,6 +51,12 @@
"detail": "Хотите сохранить проект перед закрытием?",
"saveAndClose": "Сохранить и закрыть",
"discardAndClose": "Отменить и закрыть",
"detailNewProject": "Хотите сохранить проект перед созданием нового?",
"saveAndNewProject": "Сохранить и новый проект",
"discardAndNewProject": "Отменить и новый проект",
"detailLoadProject": "Хотите сохранить проект перед загрузкой другого?",
"saveAndLoadProject": "Сохранить и загрузить проект",
"discardAndLoadProject": "Отменить и загрузить проект",
"loadProject": "Загрузить проект…",
"saveProject": "Сохранить проект…",
"saveProjectAs": "Сохранить проект как…"
+16
View File
@@ -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": "Не удалось открыть файл проекта. Видео, на которое он ссылается, возможно, было перемещено или удалено."
}
}
}
+3 -1
View File
@@ -7,7 +7,9 @@
"pauseRecording": "Приостановить запись",
"resumeRecording": "Возобновить запись",
"openVideoFile": "Открыть видеофайл",
"openProject": "Открыть проект"
"openProject": "Открыть проект",
"useVerticalTray": "Использовать вертикальную панель",
"useHorizontalTray": "Использовать горизонтальную панель"
},
"audio": {
"enableSystemAudio": "Включить системное аудио",
+17 -1
View File
@@ -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": "Показывать форму волны на треке обрезки"
}
}
+2
View File
@@ -13,7 +13,9 @@
"reservedShortcut": "Эта горячая клавиша зарезервирована для \"{{label}}\" и не может быть переназначена.",
"savedToast": "Горячие клавиши сохранены",
"resetToast": "Сброс к горячим клавишам по умолчанию — нажмите Сохранить для применения",
"registrationFailed": "Не удалось зарегистрировать горячую клавишу. Возможно, она используется другим приложением. Попробуйте другую клавишу.",
"actions": {
"openApp": "Открыть приложение",
"addZoom": "Добавить масштабирование",
"addTrim": "Добавить обрезку",
"addSpeed": "Изменить скорость",
+6
View File
@@ -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…"
+16
View File
@@ -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."
}
}
}
+3 -1
View File
@@ -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",
+8 -1
View File
@@ -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"
}
}
+2
View File
@@ -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",
+6
View File
@@ -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…"
+16
View File
@@ -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."
}
}
}
+3 -1
View File
@@ -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",
+8 -1
View File
@@ -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