mirror of
https://github.com/siddharthvaddem/openscreen.git
synced 2026-08-29 03:08:30 +08:00
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:
Vendored
+11
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -74,7 +74,7 @@ export function createHudOverlayWindow(): BrowserWindow {
|
||||
alwaysOnTop: true,
|
||||
skipTaskbar: true,
|
||||
hasShadow: false,
|
||||
show: !HEADLESS,
|
||||
show: false, // shown via ready-to-show to avoid black rectangle flash
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, "preload.mjs"),
|
||||
additionalArguments: [ASSET_BASE_URL_ARG],
|
||||
@@ -91,6 +91,12 @@ export function createHudOverlayWindow(): BrowserWindow {
|
||||
win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
|
||||
}
|
||||
|
||||
// Show only once content is painted — prevents the black rectangle flash
|
||||
// that appears when a transparent window is shown before its first paint.
|
||||
win.once("ready-to-show", () => {
|
||||
if (!HEADLESS) win.show();
|
||||
});
|
||||
|
||||
win.webContents.on("did-finish-load", () => {
|
||||
win?.webContents.send("main-process-message", new Date().toLocaleString());
|
||||
});
|
||||
@@ -135,8 +141,8 @@ export function createEditorWindow(): BrowserWindow {
|
||||
alwaysOnTop: false,
|
||||
skipTaskbar: false,
|
||||
title: "OpenScreen",
|
||||
backgroundColor: "#000000",
|
||||
show: !HEADLESS,
|
||||
backgroundColor: "#09090b",
|
||||
show: false, // shown via ready-to-show to avoid white flash on first load
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, "preload.mjs"),
|
||||
additionalArguments: [ASSET_BASE_URL_ARG],
|
||||
@@ -150,6 +156,19 @@ export function createEditorWindow(): BrowserWindow {
|
||||
// Maximize the window by default
|
||||
win.maximize();
|
||||
|
||||
// Show only once content is painted — prevents white flash on cold Vite start.
|
||||
win.once("ready-to-show", () => {
|
||||
if (!HEADLESS) win.show();
|
||||
});
|
||||
|
||||
// Inject dark background before any React paint so the sub-titlebar area
|
||||
// never flashes white even on the very first cold Vite load.
|
||||
win.webContents.on("dom-ready", () => {
|
||||
win.webContents.insertCSS("html, body, #root { background: #09090b !important; }").catch(() => {
|
||||
// Best-effort cosmetic; ignore if the page is mid-teardown.
|
||||
});
|
||||
});
|
||||
|
||||
win.webContents.on("did-finish-load", () => {
|
||||
win?.webContents.send("main-process-message", new Date().toLocaleString());
|
||||
});
|
||||
|
||||
+2
-2
@@ -1,12 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title></title>
|
||||
</head>
|
||||
<body>
|
||||
<body style="background:#09090b;margin:0">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
|
||||
+31
-1
@@ -4,6 +4,7 @@ import { LaunchWindow } from "./components/launch/LaunchWindow";
|
||||
import { SourceSelector } from "./components/launch/SourceSelector";
|
||||
import { Toaster } from "./components/ui/sonner";
|
||||
import { TooltipProvider } from "./components/ui/tooltip";
|
||||
import { useScopedT } from "./contexts/I18nContext";
|
||||
import { ShortcutsProvider } from "./contexts/ShortcutsContext";
|
||||
import { loadAllCustomFonts } from "./lib/customFonts";
|
||||
|
||||
@@ -18,6 +19,7 @@ export default function App() {
|
||||
const [windowType, setWindowType] = useState(
|
||||
() => new URLSearchParams(window.location.search).get("windowType") || "",
|
||||
);
|
||||
const tEditor = useScopedT("editor");
|
||||
|
||||
useEffect(() => {
|
||||
const type = new URLSearchParams(window.location.search).get("windowType") || "";
|
||||
@@ -64,7 +66,35 @@ export default function App() {
|
||||
case "editor":
|
||||
return (
|
||||
<ShortcutsProvider>
|
||||
<Suspense fallback={<div className="h-screen bg-background" />}>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex flex-col items-center justify-center gap-3 h-screen bg-[#09090b]">
|
||||
<svg
|
||||
className="animate-spin text-[#34B27B]"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
width={28}
|
||||
height={28}
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-white/50 text-sm">{tEditor("loadingEditor")}</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<VideoEditor />
|
||||
<ShortcutsConfigDialog />
|
||||
</Suspense>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Check, ChevronDown, 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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -51,6 +51,12 @@
|
||||
"detail": "هل تريد حفظ مشروعك قبل الإغلاق؟",
|
||||
"saveAndClose": "حفظ وإغلاق",
|
||||
"discardAndClose": "تجاهل وإغلاق",
|
||||
"detailNewProject": "هل تريد حفظ مشروعك قبل إنشاء مشروع جديد؟",
|
||||
"saveAndNewProject": "حفظ وإنشاء مشروع جديد",
|
||||
"discardAndNewProject": "تجاهل وإنشاء مشروع جديد",
|
||||
"detailLoadProject": "هل تريد حفظ مشروعك قبل تحميل مشروع آخر؟",
|
||||
"saveAndLoadProject": "حفظ وتحميل مشروع",
|
||||
"discardAndLoadProject": "تجاهل وتحميل مشروع",
|
||||
"loadProject": "تحميل مشروع...",
|
||||
"saveProject": "حفظ المشروع...",
|
||||
"saveProjectAs": "حفظ المشروع باسم..."
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"confirm": "تأكيد"
|
||||
},
|
||||
"loadingVideo": "جاري تحميل الفيديو...",
|
||||
"loadingEditor": "جارٍ تحميل المحرر...",
|
||||
"errors": {
|
||||
"noVideoLoaded": "لم يتم تحميل أي فيديو",
|
||||
"videoNotReady": "الفيديو غير جاهز",
|
||||
@@ -42,5 +43,20 @@
|
||||
"cameraNotFound": "لم يتم العثور على كاميرا.",
|
||||
"permissionDenied": "تم رفض إذن التسجيل. يرجى السماح بتسجيل الشاشة.",
|
||||
"accessibilityAllowAndRetry": "اسمح بوصول تسهيلات الاستخدام لـ OpenScreen، ثم اضغط على التسجيل مرة أخرى لبدء العد التنازلي."
|
||||
},
|
||||
"emptyState": {
|
||||
"title": "لا يوجد مشروع مفتوح",
|
||||
"description": "استورد مقطع فيديو للبدء في التحرير، أو حمّل مشروع OpenScreen موجود.",
|
||||
"importVideoButton": "استيراد ملف فيديو...",
|
||||
"loadProjectButton": "تحميل مشروع...",
|
||||
"supportedFormats": "الصيغ المدعومة: MP4، MOV، WebM، MKV، AVI، M4V، WMV",
|
||||
"dragDropHint": "أو اسحب وأفلت ملف مشروع .openscreen هنا",
|
||||
"dropOverlay": "أفلت ملف المشروع لفتحه",
|
||||
"dropErrors": {
|
||||
"unsupportedFormatTitle": "تنسيق غير مدعوم",
|
||||
"unsupportedFormatMessage": "يمكن إسقاط ملفات مشروع .openscreen فقط هنا. لاستيراد مقطع فيديو، استخدم زر \"استيراد ملف فيديو...\" بدلاً من ذلك.",
|
||||
"couldNotOpenTitle": "تعذّر فتح الملف",
|
||||
"couldNotOpenMessage": "تعذّر فتح ملف المشروع. ربما تم نقل الفيديو المرجعي أو حذفه."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,7 +98,8 @@
|
||||
},
|
||||
"project": {
|
||||
"save": "حفظ المشروع",
|
||||
"load": "تحميل المشروع"
|
||||
"load": "تحميل المشروع",
|
||||
"new": "مشروع جديد"
|
||||
},
|
||||
"export": {
|
||||
"videoButton": "تصدير الفيديو",
|
||||
|
||||
@@ -52,6 +52,14 @@
|
||||
"detail": "Do you want to save your project before closing?",
|
||||
"saveAndClose": "Save & Close",
|
||||
"discardAndClose": "Discard & Close",
|
||||
"detailNewProject": "Do you want to save your project before creating a new one?",
|
||||
"saveAndNewProject": "Save & New Project",
|
||||
"discardAndNewProject": "Discard & New Project",
|
||||
"detailLoadProject": "Do you want to save your project before loading another one?",
|
||||
"saveAndLoadProject": "Save & Load Project",
|
||||
"discardAndLoadProject": "Discard & Load Project",
|
||||
"newProject": "New Project",
|
||||
"importVideo": "Import Video File…",
|
||||
"loadProject": "Load Project…",
|
||||
"saveProject": "Save Project…",
|
||||
"saveProjectAs": "Save Project As…"
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"confirm": "Confirm"
|
||||
},
|
||||
"loadingVideo": "Loading video...",
|
||||
"loadingEditor": "Loading editor...",
|
||||
"errors": {
|
||||
"noVideoLoaded": "No video loaded",
|
||||
"videoNotReady": "Video not ready",
|
||||
@@ -42,5 +43,20 @@
|
||||
"cameraNotFound": "Camera not found.",
|
||||
"permissionDenied": "Recording permission denied. Please allow screen recording.",
|
||||
"accessibilityAllowAndRetry": "Allow Accessibility access for OpenScreen, then press record again to start the countdown."
|
||||
},
|
||||
"emptyState": {
|
||||
"title": "No project open",
|
||||
"description": "Import a video to start editing, or load an existing OpenScreen project.",
|
||||
"importVideoButton": "Import Video File…",
|
||||
"loadProjectButton": "Load Project…",
|
||||
"supportedFormats": "Supported formats: MP4, MOV, WebM, MKV, AVI, M4V, WMV",
|
||||
"dragDropHint": "or drag & drop a .openscreen project file here",
|
||||
"dropOverlay": "Drop project file to open",
|
||||
"dropErrors": {
|
||||
"unsupportedFormatTitle": "Unsupported Format",
|
||||
"unsupportedFormatMessage": "Only .openscreen project files can be dropped here. To import a video file, use the \"Import Video File…\" button on this screen.",
|
||||
"couldNotOpenTitle": "Could Not Open File",
|
||||
"couldNotOpenMessage": "The project file could not be opened. The video it references may have been moved or deleted."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -98,7 +98,8 @@
|
||||
},
|
||||
"project": {
|
||||
"save": "Save Project",
|
||||
"load": "Load Project"
|
||||
"load": "Load Project",
|
||||
"new": "New Project"
|
||||
},
|
||||
"export": {
|
||||
"videoButton": "Export Video",
|
||||
|
||||
@@ -51,6 +51,12 @@
|
||||
"detail": "¿Deseas guardar tu proyecto antes de cerrar?",
|
||||
"saveAndClose": "Guardar y cerrar",
|
||||
"discardAndClose": "Descartar y cerrar",
|
||||
"detailNewProject": "¿Deseas guardar tu proyecto antes de crear uno nuevo?",
|
||||
"saveAndNewProject": "Guardar y nuevo proyecto",
|
||||
"discardAndNewProject": "Descartar y nuevo proyecto",
|
||||
"detailLoadProject": "¿Deseas guardar tu proyecto antes de cargar otro?",
|
||||
"saveAndLoadProject": "Guardar y cargar proyecto",
|
||||
"discardAndLoadProject": "Descartar y cargar proyecto",
|
||||
"loadProject": "Cargar proyecto…",
|
||||
"saveProject": "Guardar proyecto…",
|
||||
"saveProjectAs": "Guardar proyecto como…"
|
||||
|
||||
@@ -37,10 +37,26 @@
|
||||
"accessibilityAllowAndRetry": "Permite el acceso de accesibilidad para OpenScreen y luego pulsa grabar de nuevo para iniciar la cuenta atrás."
|
||||
},
|
||||
"loadingVideo": "Cargando video...",
|
||||
"loadingEditor": "Cargando editor...",
|
||||
"newRecording": {
|
||||
"title": "Volver a la grabadora",
|
||||
"description": "Tu sesión actual ha sido guardada.",
|
||||
"cancel": "Cancelar",
|
||||
"confirm": "Confirmar"
|
||||
},
|
||||
"emptyState": {
|
||||
"title": "No hay proyecto abierto",
|
||||
"description": "Importa un video para empezar a editar o carga un proyecto de OpenScreen existente.",
|
||||
"importVideoButton": "Importar archivo de video…",
|
||||
"loadProjectButton": "Cargar proyecto…",
|
||||
"supportedFormats": "Formatos compatibles: MP4, MOV, WebM, MKV, AVI, M4V, WMV",
|
||||
"dragDropHint": "o arrastra y suelta un archivo .openscreen aquí",
|
||||
"dropOverlay": "Suelta el archivo de proyecto para abrirlo",
|
||||
"dropErrors": {
|
||||
"unsupportedFormatTitle": "Formato no compatible",
|
||||
"unsupportedFormatMessage": "Solo se pueden soltar aquí archivos de proyecto .openscreen. Para importar un video, usa el botón \"Importar archivo de video...\" en su lugar.",
|
||||
"couldNotOpenTitle": "No se pudo abrir el archivo",
|
||||
"couldNotOpenMessage": "No se pudo abrir el archivo de proyecto. El video al que hace referencia puede haber sido movido o eliminado."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,7 +98,8 @@
|
||||
},
|
||||
"project": {
|
||||
"save": "Guardar proyecto",
|
||||
"load": "Cargar proyecto"
|
||||
"load": "Cargar proyecto",
|
||||
"new": "Nuevo proyecto"
|
||||
},
|
||||
"export": {
|
||||
"videoButton": "Exportar video",
|
||||
|
||||
@@ -51,6 +51,12 @@
|
||||
"detail": "Voulez-vous enregistrer votre projet avant de fermer ?",
|
||||
"saveAndClose": "Enregistrer et fermer",
|
||||
"discardAndClose": "Ignorer et fermer",
|
||||
"detailNewProject": "Voulez-vous enregistrer votre projet avant d'en créer un nouveau ?",
|
||||
"saveAndNewProject": "Enregistrer et nouveau projet",
|
||||
"discardAndNewProject": "Ignorer et nouveau projet",
|
||||
"detailLoadProject": "Voulez-vous enregistrer votre projet avant d'en charger un autre ?",
|
||||
"saveAndLoadProject": "Enregistrer et charger un projet",
|
||||
"discardAndLoadProject": "Ignorer et charger un projet",
|
||||
"loadProject": "Charger un projet…",
|
||||
"saveProject": "Enregistrer le projet…",
|
||||
"saveProjectAs": "Enregistrer le projet sous…"
|
||||
|
||||
@@ -42,5 +42,21 @@
|
||||
"permissionDenied": "Permission d'enregistrement refusée. Veuillez autoriser l'enregistrement d'écran.",
|
||||
"accessibilityAllowAndRetry": "Autorisez l'accès Accessibilité pour OpenScreen, puis appuyez de nouveau sur enregistrer pour lancer le compte à rebours."
|
||||
},
|
||||
"loadingVideo": "Chargement de la vidéo..."
|
||||
"loadingVideo": "Chargement de la vidéo...",
|
||||
"loadingEditor": "Chargement de l'éditeur...",
|
||||
"emptyState": {
|
||||
"title": "Aucun projet ouvert",
|
||||
"description": "Importez une vidéo pour commencer à éditer, ou chargez un projet OpenScreen existant.",
|
||||
"importVideoButton": "Importer un fichier vidéo…",
|
||||
"loadProjectButton": "Charger un projet…",
|
||||
"supportedFormats": "Formats pris en charge : MP4, MOV, WebM, MKV, AVI, M4V, WMV",
|
||||
"dragDropHint": "ou glissez-déposez un fichier .openscreen ici",
|
||||
"dropOverlay": "Déposez le fichier de projet pour l'ouvrir",
|
||||
"dropErrors": {
|
||||
"unsupportedFormatTitle": "Format non pris en charge",
|
||||
"unsupportedFormatMessage": "Seuls les fichiers .openscreen peuvent être déposés ici. Pour importer une vidéo, utilisez plutôt le bouton \"Importer un fichier vidéo...\".",
|
||||
"couldNotOpenTitle": "Impossible d'ouvrir le fichier",
|
||||
"couldNotOpenMessage": "Le fichier de projet n'a pas pu être ouvert. La vidéo qu'il référence a peut-être été déplacée ou supprimée."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -52,6 +52,12 @@
|
||||
"detail": "閉じる前にプロジェクトを保存しますか?",
|
||||
"saveAndClose": "保存して閉じる",
|
||||
"discardAndClose": "破棄して閉じる",
|
||||
"detailNewProject": "新しいプロジェクトを作成する前に保存しますか?",
|
||||
"saveAndNewProject": "保存して新規プロジェクト",
|
||||
"discardAndNewProject": "破棄して新規プロジェクト",
|
||||
"detailLoadProject": "別のプロジェクトを読み込む前にプロジェクトを保存しますか?",
|
||||
"saveAndLoadProject": "保存してプロジェクトを読み込む",
|
||||
"discardAndLoadProject": "破棄してプロジェクトを読み込む",
|
||||
"loadProject": "プロジェクトを読み込む…",
|
||||
"saveProject": "プロジェクトを保存…",
|
||||
"saveProjectAs": "プロジェクトを名前を付けて保存…"
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"confirm": "確認"
|
||||
},
|
||||
"loadingVideo": "動画を読み込み中...",
|
||||
"loadingEditor": "エディターを読み込み中...",
|
||||
"errors": {
|
||||
"noVideoLoaded": "動画が読み込まれていません",
|
||||
"videoNotReady": "動画の準備ができていません",
|
||||
@@ -42,5 +43,20 @@
|
||||
"cameraDisconnected": "ウェブカメラが切断されました。",
|
||||
"cameraNotFound": "カメラが見つかりません。",
|
||||
"accessibilityAllowAndRetry": "OpenScreenにアクセシビリティアクセスを許可してから、もう一度録画を押してカウントダウンを開始してください。"
|
||||
},
|
||||
"emptyState": {
|
||||
"title": "プロジェクトが開かれていません",
|
||||
"description": "動画をインポートして編集を開始するか、既存の OpenScreen プロジェクトを読み込んでください。",
|
||||
"importVideoButton": "動画ファイルをインポート…",
|
||||
"loadProjectButton": "プロジェクトを読み込む…",
|
||||
"supportedFormats": "対応フォーマット:MP4、MOV、WebM、MKV、AVI、M4V、WMV",
|
||||
"dragDropHint": ".openscreen プロジェクトファイルをここにドラッグ&ドロップ",
|
||||
"dropOverlay": "プロジェクトファイルをドロップして開く",
|
||||
"dropErrors": {
|
||||
"unsupportedFormatTitle": "非対応フォーマット",
|
||||
"unsupportedFormatMessage": "ここにドロップできるのは .openscreen プロジェクトファイルのみです。動画をインポートするには「動画ファイルをインポート...」ボタンをご使用ください。",
|
||||
"couldNotOpenTitle": "ファイルを開けませんでした",
|
||||
"couldNotOpenMessage": "プロジェクトファイルを開けませんでした。参照している動画が移動または削除された可能性があります。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,7 +98,8 @@
|
||||
},
|
||||
"project": {
|
||||
"save": "プロジェクトを保存",
|
||||
"load": "プロジェクトを読み込む"
|
||||
"load": "プロジェクトを読み込む",
|
||||
"new": "新規プロジェクト"
|
||||
},
|
||||
"export": {
|
||||
"videoButton": "動画をエクスポート",
|
||||
|
||||
@@ -51,6 +51,12 @@
|
||||
"detail": "닫기 전에 프로젝트를 저장하시겠습니까?",
|
||||
"saveAndClose": "저장 후 닫기",
|
||||
"discardAndClose": "저장하지 않고 닫기",
|
||||
"detailNewProject": "새 프로젝트를 만들기 전에 저장하시겠습니까?",
|
||||
"saveAndNewProject": "저장 후 새 프로젝트",
|
||||
"discardAndNewProject": "저장하지 않고 새 프로젝트",
|
||||
"detailLoadProject": "다른 프로젝트를 불러오기 전에 저장하시겠습니까?",
|
||||
"saveAndLoadProject": "저장 후 프로젝트 불러오기",
|
||||
"discardAndLoadProject": "저장하지 않고 프로젝트 불러오기",
|
||||
"loadProject": "프로젝트 불러오기...",
|
||||
"saveProject": "프로젝트 저장...",
|
||||
"saveProjectAs": "다른 이름으로 프로젝트 저장..."
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"confirm": "확인"
|
||||
},
|
||||
"loadingVideo": "비디오 로드 중...",
|
||||
"loadingEditor": "편집기 로드 중...",
|
||||
"errors": {
|
||||
"noVideoLoaded": "불러온 비디오가 없습니다",
|
||||
"videoNotReady": "비디오가 준비되지 않았습니다",
|
||||
@@ -42,5 +43,20 @@
|
||||
"cameraDisconnected": "웹캠 연결이 끊어졌습니다.",
|
||||
"cameraNotFound": "카메라를 찾을 수 없습니다.",
|
||||
"accessibilityAllowAndRetry": "OpenScreen의 손쉬운 사용 접근을 허용한 다음, 카운트다운을 시작하려면 다시 녹화를 누르세요."
|
||||
},
|
||||
"emptyState": {
|
||||
"title": "열린 프로젝트 없음",
|
||||
"description": "동영상을 가져와 편집을 시작하거나 기존 OpenScreen 프로젝트를 불러오세요.",
|
||||
"importVideoButton": "동영상 파일 가져오기…",
|
||||
"loadProjectButton": "프로젝트 불러오기…",
|
||||
"supportedFormats": "지원 형식: MP4, MOV, WebM, MKV, AVI, M4V, WMV",
|
||||
"dragDropHint": ".openscreen 프로젝트 파일을 여기에 드래그 앤 드롭",
|
||||
"dropOverlay": "프로젝트 파일을 드롭하여 열기",
|
||||
"dropErrors": {
|
||||
"unsupportedFormatTitle": "지원되지 않는 형식",
|
||||
"unsupportedFormatMessage": ".openscreen 프로젝트 파일만 여기에 드롭할 수 있습니다. 동영상을 가져오려면 \"동영상 파일 가져오기...\" 버튼을 사용하세요.",
|
||||
"couldNotOpenTitle": "파일을 열 수 없음",
|
||||
"couldNotOpenMessage": "프로젝트 파일을 열 수 없습니다. 참조된 동영상이 이동되었거나 삭제되었을 수 있습니다."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,7 +98,8 @@
|
||||
},
|
||||
"project": {
|
||||
"save": "프로젝트 저장",
|
||||
"load": "프로젝트 불러오기"
|
||||
"load": "프로젝트 불러오기",
|
||||
"new": "새 프로젝트"
|
||||
},
|
||||
"export": {
|
||||
"videoButton": "비디오 내보내기",
|
||||
|
||||
@@ -51,6 +51,12 @@
|
||||
"detail": "Хотите сохранить проект перед закрытием?",
|
||||
"saveAndClose": "Сохранить и закрыть",
|
||||
"discardAndClose": "Отменить и закрыть",
|
||||
"detailNewProject": "Хотите сохранить проект перед созданием нового?",
|
||||
"saveAndNewProject": "Сохранить и новый проект",
|
||||
"discardAndNewProject": "Отменить и новый проект",
|
||||
"detailLoadProject": "Хотите сохранить проект перед загрузкой другого?",
|
||||
"saveAndLoadProject": "Сохранить и загрузить проект",
|
||||
"discardAndLoadProject": "Отменить и загрузить проект",
|
||||
"loadProject": "Загрузить проект…",
|
||||
"saveProject": "Сохранить проект…",
|
||||
"saveProjectAs": "Сохранить проект как…"
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"confirm": "Подтвердить"
|
||||
},
|
||||
"loadingVideo": "Загрузка видео...",
|
||||
"loadingEditor": "Загрузка редактора...",
|
||||
"errors": {
|
||||
"noVideoLoaded": "Видео не загружено",
|
||||
"videoNotReady": "Видео не готово",
|
||||
@@ -42,5 +43,20 @@
|
||||
"cameraNotFound": "Камера не найдена.",
|
||||
"permissionDenied": "Разрешение на запись запрещено. Пожалуйста, разрешите запись экрана.",
|
||||
"accessibilityAllowAndRetry": "Разрешите OpenScreen доступ к Универсальному доступу, затем снова нажмите запись, чтобы начать обратный отсчет."
|
||||
},
|
||||
"emptyState": {
|
||||
"title": "Нет открытых проектов",
|
||||
"description": "Импортируйте видео для начала редактирования или загрузите существующий проект OpenScreen.",
|
||||
"importVideoButton": "Импортировать видеофайл…",
|
||||
"loadProjectButton": "Загрузить проект…",
|
||||
"supportedFormats": "Поддерживаемые форматы: MP4, MOV, WebM, MKV, AVI, M4V, WMV",
|
||||
"dragDropHint": "или перетащите файл проекта .openscreen сюда",
|
||||
"dropOverlay": "Перетащите файл проекта для открытия",
|
||||
"dropErrors": {
|
||||
"unsupportedFormatTitle": "Неподдерживаемый формат",
|
||||
"unsupportedFormatMessage": "Сюда можно перетаскивать только файлы проекта .openscreen. Для импорта видео используйте кнопку «Импортировать видеофайл...».",
|
||||
"couldNotOpenTitle": "Не удалось открыть файл",
|
||||
"couldNotOpenMessage": "Не удалось открыть файл проекта. Видео, на которое он ссылается, возможно, было перемещено или удалено."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,7 +98,8 @@
|
||||
},
|
||||
"project": {
|
||||
"save": "Сохранить проект",
|
||||
"load": "Загрузить проект"
|
||||
"load": "Загрузить проект",
|
||||
"new": "Новый проект"
|
||||
},
|
||||
"export": {
|
||||
"videoButton": "Экспорт видео",
|
||||
|
||||
@@ -51,6 +51,12 @@
|
||||
"detail": "Kapatmadan önce projenizi kaydetmek ister misiniz?",
|
||||
"saveAndClose": "Kaydet ve Kapat",
|
||||
"discardAndClose": "Kaydetmeden Kapat",
|
||||
"detailNewProject": "Yeni proje oluşturmadan önce kaydetmek ister misiniz?",
|
||||
"saveAndNewProject": "Kaydet ve Yeni Proje",
|
||||
"discardAndNewProject": "Kaydetmeden Yeni Proje",
|
||||
"detailLoadProject": "Başka bir proje yüklemeden önce kaydetmek ister misiniz?",
|
||||
"saveAndLoadProject": "Kaydet ve Proje Yükle",
|
||||
"discardAndLoadProject": "Kaydetmeden Proje Yükle",
|
||||
"loadProject": "Proje Yükle…",
|
||||
"saveProject": "Proje Kaydet…",
|
||||
"saveProjectAs": "Farklı Kaydet…"
|
||||
|
||||
@@ -37,10 +37,26 @@
|
||||
"accessibilityAllowAndRetry": "OpenScreen için Erişilebilirlik erişimine izin verin, ardından geri sayımı başlatmak için tekrar kayda basın."
|
||||
},
|
||||
"loadingVideo": "Video yükleniyor...",
|
||||
"loadingEditor": "Editör yükleniyor...",
|
||||
"newRecording": {
|
||||
"title": "Kaydediciye Dön",
|
||||
"description": "Mevcut oturumunuz kaydedildi.",
|
||||
"cancel": "İptal",
|
||||
"confirm": "Onayla"
|
||||
},
|
||||
"emptyState": {
|
||||
"title": "Açık proje yok",
|
||||
"description": "Düzenlemeye başlamak için bir video içe aktarın veya mevcut bir OpenScreen projesi yükleyin.",
|
||||
"importVideoButton": "Video Dosyası İçe Aktar…",
|
||||
"loadProjectButton": "Proje Yükle…",
|
||||
"supportedFormats": "Desteklenen formatlar: MP4, MOV, WebM, MKV, AVI, M4V, WMV",
|
||||
"dragDropHint": "veya bir .openscreen proje dosyasını buraya sürükleyip bırakın",
|
||||
"dropOverlay": "Açmak için proje dosyasını bırakın",
|
||||
"dropErrors": {
|
||||
"unsupportedFormatTitle": "Desteklenmeyen Format",
|
||||
"unsupportedFormatMessage": "Buraya yalnızca .openscreen proje dosyaları bırakılabilir. Video içe aktarmak için \"Video Dosyası İçe Aktar...\" düğmesini kullanın.",
|
||||
"couldNotOpenTitle": "Dosya Açılamadı",
|
||||
"couldNotOpenMessage": "Proje dosyası açılamadı. Başvurulan video taşınmış veya silinmiş olabilir."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -51,6 +51,12 @@
|
||||
"detail": "Bạn có muốn lưu dự án của mình trước khi đóng không?",
|
||||
"saveAndClose": "Lưu & Đóng",
|
||||
"discardAndClose": "Bỏ qua & Đóng",
|
||||
"detailNewProject": "Bạn có muốn lưu dự án trước khi tạo dự án mới không?",
|
||||
"saveAndNewProject": "Lưu & Dự án mới",
|
||||
"discardAndNewProject": "Bỏ qua & Dự án mới",
|
||||
"detailLoadProject": "Bạn có muốn lưu dự án của mình trước khi tải dự án khác không?",
|
||||
"saveAndLoadProject": "Lưu & Tải dự án",
|
||||
"discardAndLoadProject": "Bỏ qua & Tải dự án",
|
||||
"loadProject": "Tải dự án…",
|
||||
"saveProject": "Lưu dự án…",
|
||||
"saveProjectAs": "Lưu dự án thành…"
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"confirm": "Xác nhận"
|
||||
},
|
||||
"loadingVideo": "Đang tải video...",
|
||||
"loadingEditor": "Đang tải trình chỉnh sửa...",
|
||||
"errors": {
|
||||
"noVideoLoaded": "Chưa tải video nào",
|
||||
"videoNotReady": "Video chưa sẵn sàng",
|
||||
@@ -42,5 +43,20 @@
|
||||
"cameraNotFound": "Không tìm thấy máy ảnh.",
|
||||
"permissionDenied": "Quyền ghi hình bị từ chối. Vui lòng cho phép ghi màn hình.",
|
||||
"accessibilityAllowAndRetry": "Cho phép OpenScreen truy cập Trợ năng, sau đó nhấn ghi lại để bắt đầu đếm ngược."
|
||||
},
|
||||
"emptyState": {
|
||||
"title": "Không có dự án nào được mở",
|
||||
"description": "Nhập video để bắt đầu chỉnh sửa hoặc tải một dự án OpenScreen hiện có.",
|
||||
"importVideoButton": "Nhập tệp video…",
|
||||
"loadProjectButton": "Tải dự án…",
|
||||
"supportedFormats": "Định dạng được hỗ trợ: MP4, MOV, WebM, MKV, AVI, M4V, WMV",
|
||||
"dragDropHint": "hoặc kéo và thả tệp dự án .openscreen vào đây",
|
||||
"dropOverlay": "Thả tệp dự án để mở",
|
||||
"dropErrors": {
|
||||
"unsupportedFormatTitle": "Định dạng không được hỗ trợ",
|
||||
"unsupportedFormatMessage": "Chỉ có thể thả các tệp dự án .openscreen vào đây. Để nhập video, hãy sử dụng nút \"Nhập tệp video...\" thay thế.",
|
||||
"couldNotOpenTitle": "Không thể mở tệp",
|
||||
"couldNotOpenMessage": "Không thể mở tệp dự án. Video mà nó tham chiếu có thể đã bị di chuyển hoặc xóa."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -51,6 +51,12 @@
|
||||
"detail": "是否在关闭前保存项目?",
|
||||
"saveAndClose": "保存并关闭",
|
||||
"discardAndClose": "放弃并关闭",
|
||||
"detailNewProject": "是否在创建新项目前保存当前项目?",
|
||||
"saveAndNewProject": "保存并新建项目",
|
||||
"discardAndNewProject": "放弃并新建项目",
|
||||
"detailLoadProject": "是否在加载其他项目前保存当前项目?",
|
||||
"saveAndLoadProject": "保存并加载项目",
|
||||
"discardAndLoadProject": "放弃并加载项目",
|
||||
"loadProject": "加载项目…",
|
||||
"saveProject": "保存项目…",
|
||||
"saveProjectAs": "项目另存为…"
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"confirm": "确认"
|
||||
},
|
||||
"loadingVideo": "正在加载视频...",
|
||||
"loadingEditor": "正在加载编辑器...",
|
||||
"errors": {
|
||||
"noVideoLoaded": "未加载视频",
|
||||
"videoNotReady": "视频未就绪",
|
||||
@@ -42,5 +43,20 @@
|
||||
"cameraNotFound": "未找到摄像头。",
|
||||
"permissionDenied": "录屏权限被拒绝。请允许屏幕录制。",
|
||||
"accessibilityAllowAndRetry": "允许 OpenScreen 使用辅助功能权限,然后再次按录制以开始倒计时。"
|
||||
},
|
||||
"emptyState": {
|
||||
"title": "未打开任何项目",
|
||||
"description": "导入视频开始编辑,或加载已有的 OpenScreen 项目。",
|
||||
"importVideoButton": "导入视频文件…",
|
||||
"loadProjectButton": "加载项目…",
|
||||
"supportedFormats": "支持的格式:MP4、MOV、WebM、MKV、AVI、M4V、WMV",
|
||||
"dragDropHint": "或将 .openscreen 项目文件拖放到此处",
|
||||
"dropOverlay": "将项目文件拖放至此以打开",
|
||||
"dropErrors": {
|
||||
"unsupportedFormatTitle": "不支持的格式",
|
||||
"unsupportedFormatMessage": "此处只能拖放 .openscreen 项目文件。要导入视频,请使用\"导入视频文件...\"按钮。",
|
||||
"couldNotOpenTitle": "无法打开文件",
|
||||
"couldNotOpenMessage": "无法打开项目文件。它引用的视频可能已被移动或删除。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,7 +98,8 @@
|
||||
},
|
||||
"project": {
|
||||
"save": "保存项目",
|
||||
"load": "加载项目"
|
||||
"load": "加载项目",
|
||||
"new": "新建项目"
|
||||
},
|
||||
"export": {
|
||||
"videoButton": "导出视频",
|
||||
|
||||
@@ -51,6 +51,12 @@
|
||||
"detail": "是否在關閉前儲存專案?",
|
||||
"saveAndClose": "儲存並關閉",
|
||||
"discardAndClose": "捨棄並關閉",
|
||||
"detailNewProject": "是否在建立新專案前儲存目前的專案?",
|
||||
"saveAndNewProject": "儲存並建立新專案",
|
||||
"discardAndNewProject": "捨棄並建立新專案",
|
||||
"detailLoadProject": "是否在載入其他專案前儲存目前的專案?",
|
||||
"saveAndLoadProject": "儲存並載入專案",
|
||||
"discardAndLoadProject": "捨棄並載入專案",
|
||||
"loadProject": "載入專案…",
|
||||
"saveProject": "儲存專案…",
|
||||
"saveProjectAs": "專案另存新檔…"
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"confirm": "確認"
|
||||
},
|
||||
"loadingVideo": "正在載入影片...",
|
||||
"loadingEditor": "正在載入編輯器...",
|
||||
"errors": {
|
||||
"noVideoLoaded": "未載入影片",
|
||||
"videoNotReady": "影片未就緒",
|
||||
@@ -42,5 +43,20 @@
|
||||
"cameraDisconnected": "網路攝影機已中斷連線。",
|
||||
"cameraNotFound": "找不到攝影機。",
|
||||
"accessibilityAllowAndRetry": "允許 OpenScreen 使用輔助使用權限,然後再次按下錄製以開始倒數。"
|
||||
},
|
||||
"emptyState": {
|
||||
"title": "未開啟任何專案",
|
||||
"description": "匯入影片以開始編輯,或載入現有的 OpenScreen 專案。",
|
||||
"importVideoButton": "匯入影片檔案…",
|
||||
"loadProjectButton": "載入專案…",
|
||||
"supportedFormats": "支援的格式:MP4、MOV、WebM、MKV、AVI、M4V、WMV",
|
||||
"dragDropHint": "或將 .openscreen 專案檔案拖放至此",
|
||||
"dropOverlay": "將專案檔案拖放至此以開啟",
|
||||
"dropErrors": {
|
||||
"unsupportedFormatTitle": "不支援的格式",
|
||||
"unsupportedFormatMessage": "此處只能拖放 .openscreen 專案檔案。要匯入影片,請使用「匯入影片檔案...」按鈕。",
|
||||
"couldNotOpenTitle": "無法開啟檔案",
|
||||
"couldNotOpenMessage": "無法開啟專案檔案。它所參照的影片可能已被移動或刪除。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,7 +99,8 @@
|
||||
},
|
||||
"project": {
|
||||
"save": "儲存專案",
|
||||
"load": "載入專案"
|
||||
"load": "載入專案",
|
||||
"new": "新增專案"
|
||||
},
|
||||
"export": {
|
||||
"videoButton": "匯出影片",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -178,6 +178,12 @@ export type NativeBridgeRequest =
|
||||
payload?: EmptyPayload;
|
||||
requestId?: string;
|
||||
}
|
||||
| {
|
||||
domain: "project";
|
||||
action: "loadProjectFileFromPath";
|
||||
payload: { path: string };
|
||||
requestId?: string;
|
||||
}
|
||||
| {
|
||||
domain: "project";
|
||||
action: "setCurrentVideoPath";
|
||||
|
||||
Reference in New Issue
Block a user