Merge upstream/main into feat/remember-last-project-folder

Resolved conflicts:
- LaunchWindow.tsx: kept upstream's removal of openVideoFile/openProjectFile (moved to Studio Dashboard)
- VideoEditor.tsx: used upstream's doLoadProject rename while preserving getProjectFolder() arg
This commit is contained in:
Paulo Henrique Garcia
2026-06-01 08:00:18 +02:00
52 changed files with 1172 additions and 419 deletions
+11
View File
@@ -245,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;
+72 -11
View File
@@ -48,7 +48,17 @@ const PROJECT_FILE_EXTENSION = "openscreen";
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();
@@ -1430,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();
});
@@ -1453,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);
});
@@ -2430,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: ["*"] },
],
@@ -2678,6 +2691,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();
});
@@ -2760,6 +2818,8 @@ export function registerIpcHandlers(
function clearCurrentVideoPath(): ProjectPathResult {
currentVideoPath = null;
currentProjectPath = null;
setCurrentRecordingSessionState(null);
return { success: true };
}
@@ -2834,6 +2894,7 @@ export function registerIpcHandlers(
saveProjectFile,
loadProjectFile,
loadCurrentProjectFile,
loadProjectFileFromPath,
setCurrentVideoPath,
getCurrentVideoPathResult,
clearCurrentVideoPath,
+7
View File
@@ -27,6 +27,7 @@ export interface NativeBridgeContext {
) => Promise<ProjectFileResult>;
loadProjectFile: (projectFolder?: string) => 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,
@@ -171,6 +173,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,
+7 -1
View File
@@ -114,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;
@@ -173,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",
@@ -16,6 +16,7 @@ interface ProjectServiceOptions {
) => Promise<ProjectFileResult>;
loadProjectFile: (projectFolder?: string) => 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();
+21 -1
View File
@@ -1,4 +1,4 @@
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";
@@ -173,9 +173,29 @@ contextBridge.exposeInMainWorld("electronAPI", {
loadProjectFile: (projectFolder?: string) => {
return ipcRenderer.invoke("load-project-file", projectFolder);
},
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);
+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>
+10 -53
View File
@@ -1,4 +1,4 @@
import { Check, ChevronDown, Columns3, Languages, Rows3 } 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";
@@ -349,35 +349,6 @@ export function LaunchWindow() {
}
};
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(getProjectFolder());
if (result.canceled || !result.success) return;
if (result.path) {
const folder = parentDirectoryOf(result.path);
if (folder) {
saveUserPreferences({ projectFolder: folder });
}
}
await window.electronAPI.switchToEditor();
};
const sendHudOverlayHide = () => {
if (window.electronAPI && window.electronAPI.hudOverlayHide) {
window.electronAPI.hudOverlayHide();
@@ -818,29 +789,15 @@ 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 */}
@@ -1,3 +1,4 @@
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";
@@ -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>
);
}
@@ -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"
+452 -324
View File
@@ -49,6 +49,7 @@ import {
getNativeAspectRatioValue,
isPortraitAspectRatio,
} from "@/utils/aspectRatioUtils";
import { EditorEmptyState } from "./EditorEmptyState";
import { ExportDialog } from "./ExportDialog";
import {
DEFAULT_CURSOR_SETTINGS,
@@ -159,6 +160,7 @@ export default function VideoEditor() {
commitState,
undo,
redo,
resetState,
} = useEditorHistory(INITIAL_EDITOR_STATE);
const {
@@ -225,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 } =
@@ -526,9 +533,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 {
@@ -712,7 +719,7 @@ export default function VideoEditor() {
}
}, []);
const handleLoadProject = useCallback(async () => {
const doLoadProject = useCallback(async () => {
const result = await nativeBridgeClient.project.loadProjectFile(getProjectFolder());
if (result.canceled) {
@@ -740,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;
@@ -2058,339 +2145,364 @@ 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}
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}
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 })}
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 })}
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 })}
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 })}
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}
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,
})
}
videoUrl={videoPath ?? undefined}
showTrimWaveform={showTrimWaveform}
/>
</div>
</Panel>
</PanelGroup>
</div>
</Panel>
</PanelGroup>
</div>
)}
<ExportDialog
isOpen={showExportDialog}
@@ -2412,6 +2524,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>
);
}
+16 -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";
@@ -477,8 +477,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(
@@ -672,7 +683,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);
+6
View File
@@ -130,6 +130,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 +142,7 @@ export function useEditorHistory(initial: EditorState = INITIAL_EDITOR_STATE) {
commitState,
undo,
redo,
resetState,
canUndo: history.past.length > 0,
canRedo: history.future.length > 0,
};
+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": "تعذّر فتح ملف المشروع. ربما تم نقل الفيديو المرجعي أو حذفه."
}
}
}
+2 -1
View File
@@ -98,7 +98,8 @@
},
"project": {
"save": "حفظ المشروع",
"load": "تحميل المشروع"
"load": "تحميل المشروع",
"new": "مشروع جديد"
},
"export": {
"videoButton": "تصدير الفيديو",
+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."
}
}
}
+2 -1
View File
@@ -9,7 +9,8 @@
"openVideoFile": "Open video file",
"openProject": "Open project",
"useVerticalTray": "Use vertical tray",
"useHorizontalTray": "Use horizontal tray"
"useHorizontalTray": "Use horizontal tray",
"openStudio": "Open Studio"
},
"audio": {
"enableSystemAudio": "Enable system audio",
+2 -1
View File
@@ -98,7 +98,8 @@
},
"project": {
"save": "Save Project",
"load": "Load Project"
"load": "Load Project",
"new": "New Project"
},
"export": {
"videoButton": "Export Video",
+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."
}
}
}
+2 -1
View File
@@ -98,7 +98,8 @@
},
"project": {
"save": "Guardar proyecto",
"load": "Cargar proyecto"
"load": "Cargar proyecto",
"new": "Nuevo proyecto"
},
"export": {
"videoButton": "Exportar video",
+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."
}
}
}
+2 -1
View File
@@ -98,7 +98,8 @@
},
"project": {
"save": "Enregistrer le projet",
"load": "Charger un projet"
"load": "Charger un projet",
"new": "Nouveau projet"
},
"export": {
"videoButton": "Exporter la vidéo",
+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": "プロジェクトファイルを開けませんでした。参照している動画が移動または削除された可能性があります。"
}
}
}
+2 -1
View File
@@ -98,7 +98,8 @@
},
"project": {
"save": "プロジェクトを保存",
"load": "プロジェクトを読み込む"
"load": "プロジェクトを読み込む",
"new": "新規プロジェクト"
},
"export": {
"videoButton": "動画をエクスポート",
+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": "프로젝트 파일을 열 수 없습니다. 참조된 동영상이 이동되었거나 삭제되었을 수 있습니다."
}
}
}
+2 -1
View File
@@ -98,7 +98,8 @@
},
"project": {
"save": "프로젝트 저장",
"load": "프로젝트 불러오기"
"load": "프로젝트 불러오기",
"new": "새 프로젝트"
},
"export": {
"videoButton": "비디오 내보내기",
+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": "Не удалось открыть файл проекта. Видео, на которое он ссылается, возможно, было перемещено или удалено."
}
}
}
+2 -1
View File
@@ -98,7 +98,8 @@
},
"project": {
"save": "Сохранить проект",
"load": "Загрузить проект"
"load": "Загрузить проект",
"new": "Новый проект"
},
"export": {
"videoButton": "Экспорт видео",
+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."
}
}
}
+2 -1
View File
@@ -98,7 +98,8 @@
},
"project": {
"save": "Projeyi Kaydet",
"load": "Proje Yükle"
"load": "Proje Yükle",
"new": "Yeni Proje"
},
"export": {
"videoButton": "Videoyu Dışa Aktar",
+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."
}
}
}
+2 -1
View File
@@ -98,7 +98,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",
+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": "无法打开项目文件。它引用的视频可能已被移动或删除。"
}
}
}
+2 -1
View File
@@ -98,7 +98,8 @@
},
"project": {
"save": "保存项目",
"load": "加载项目"
"load": "加载项目",
"new": "新建项目"
},
"export": {
"videoButton": "导出视频",
+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": "無法開啟專案檔案。它所參照的影片可能已被移動或刪除。"
}
}
}
+2 -1
View File
@@ -99,7 +99,8 @@
},
"project": {
"save": "儲存專案",
"load": "載入專案"
"load": "載入專案",
"new": "新增專案"
},
"export": {
"videoButton": "匯出影片",
+6
View File
@@ -95,6 +95,12 @@ export const nativeBridgeClient = {
domain: "project",
action: "loadCurrentProjectFile",
}),
loadProjectFileFromPath: (path: string) =>
requireNativeBridgeData<ProjectFileResult>({
domain: "project",
action: "loadProjectFileFromPath",
payload: { path },
}),
setCurrentVideoPath: (path: string) =>
requireNativeBridgeData<ProjectPathResult>({
domain: "project",
+6
View File
@@ -178,6 +178,12 @@ export type NativeBridgeRequest =
payload?: EmptyPayload;
requestId?: string;
}
| {
domain: "project";
action: "loadProjectFileFromPath";
payload: { path: string };
requestId?: string;
}
| {
domain: "project";
action: "setCurrentVideoPath";