Compare commits

...
Author SHA1 Message Date
Siddharth 0335dbbae6 fix: resolve type error, formatting, and import order from PR #321 2026-04-05 11:00:41 -07:00
cocoon 5db29f4339 fix(editor): track unsaved changes for new projects 2026-04-05 10:57:33 -07:00
Siddharth c868469be5 fix: auto-finalize duration bug, restore cancelRecording, and add i18n for pause tooltips 2026-04-05 10:17:35 -07:00
Sid e90bba82ef feat: add pause/resume recording (#314)
added a new Feature that allows user to pause/resume while screen rec…
2026-04-05 10:05:38 -07:00
Siddharth 475cbcd76c revert: undo manual merge of PR #314 2026-04-05 10:05:04 -07:00
Siddharth 08f66c7c25 feat: add pause/resume recording with duration fix 2026-04-05 09:57:35 -07:00
Siddharth 7072c05edd fix: duration bug in auto-finalize path and add i18n for pause tooltip 2026-04-05 09:39:28 -07:00
Manish 0bc3bbca6b Merge branch 'main' into feature/pause-button 2026-04-05 22:03:35 +05:30
Sid 5340272530 Merge pull request #313 from theaiagent/feature/frame-step-navigation
feat: add arrow key frame-by-frame playhead navigation
2026-04-05 08:49:43 -07:00
Sid da16872809 Merge pull request #295 from abres33/feature/cancel-recording
feat: add Cancel Recording button to HUD
2026-04-04 22:10:17 -07:00
Sid 11788ad703 Merge pull request #332 from marcgabe15/addDiscord
Add discord
2026-04-04 20:35:23 -07:00
Marc Diaz 66f9172a35 feat: add discord to readme 2026-04-04 20:52:14 -04:00
Marc Diaz bd604cb658 add discord to readme 2026-04-04 20:43:41 -04:00
Sid 21893f07af Merge pull request #288 from gulivan/feature/webcam-mask-shapes
Add webcam mask shape support
2026-04-03 22:56:01 -07:00
Sid 763c187f87 Merge pull request #281 from GuilhermeFaga/main
fix(#264): read raw pixels from canvas for VideoFrame to avoid silent failures on Linux
2026-04-03 22:50:15 -07:00
Sid 20567db245 Merge pull request #257 from xKeCo/feature/auto-follow-zoom
feat: add auto-follow zoom mode with cursor tracking
2026-04-03 22:42:02 -07:00
Sid 7a1113827c Merge pull request #318 from tmchow/feat/219-appimage-update-info
feat: embed AppImage update information for delta updates
2026-04-03 22:32:56 -07:00
Trevin Chow 7e298d3bbf feat: embed AppImage update information for delta updates
Add a top-level publish config in electron-builder.json5 pointing to
GitHub Releases. This embeds the update information URL in the AppImage
header, enabling tools like AppImageUpdate, AppImageLauncher, and
AppManager to perform delta updates instead of full re-downloads.

Also update the Linux build workflow to upload the generated .zsync file
alongside the .AppImage artifact.

Fixes #219
2026-04-03 20:14:20 -07:00
theaiagent 97c9a73578 fix: skip frame-step on ARIA widgets that own arrow keys
Expand the arrow key guard to also skip elements with
role="separator" (PanelResizeHandle), role="slider", and
role="spinbutton" so keyboard panel resizing is not intercepted.
2026-04-03 23:02:12 +03:00
theaiagent 3bfcd8576b fix: read live video.currentTime for rapid frame steps and add JSDoc
- Read currentTime directly from the video element instead of the React
  ref so rapid arrow key presses each advance by exactly one frame
- Add JSDoc docstrings to frameStep.ts exports
2026-04-03 22:44:25 +03:00
maniesh6900 b002f2a485 added a new Feature that allows user to pause/resume while screen recording, 2026-04-04 00:56:14 +05:30
theaiagent cd0f2ab318 fix: expand arrow key guard for form controls and wire i18n for fixed shortcuts
- Add HTMLSelectElement and contentEditable to the arrow key input guard
  to prevent intercepting native keyboard behavior on form controls
- Add i18nKey field to FixedShortcut interface and wire up i18n lookups
  in ShortcutsConfigDialog and KeyboardShortcutsHelp so fixed shortcut
  labels are properly localized
2026-04-03 22:06:45 +03:00
xKeCo 54df597160 ✨ feat: enhance adaptive smoothing for auto-follow zoom in video playback 2026-04-03 12:26:07 -05:00
theaiagent e5430eed39 feat: add arrow key frame-by-frame playhead navigation (#302) 2026-04-03 17:50:53 +03:00
theaiagent baa30a9d6a test: add unit tests for frame step time computation 2026-04-03 17:44:26 +03:00
theaiagent b709d0d240 feat: add frame step entries to FIXED_SHORTCUTS display list 2026-04-03 17:37:13 +03:00
theaiagent 11bad60eb2 feat: add i18n labels for frame step shortcuts (en, es, zh-CN) 2026-04-03 17:36:43 +03:00
Adam 27853cc2c3 fix: await setCurrentVideoPath and narrow catch in gif-export E2E test 2026-04-03 02:32:47 -05:00
Adam d6933813bd fix: move try/catch outside evaluate() in gif-export E2E test 2026-04-03 02:25:29 -05:00
Adam 2b471783c0 feat: add Cancel Recording button to HUD 2026-04-03 02:00:36 -05:00
Sid b101820ab8 Merge pull request #293 from abres33/fix/restart-recording-windows
fix: prevent double-finalize race condition in restartRecording on Windos
2026-04-02 23:35:46 -07:00
Sid 3061c141c6 Merge pull request #249 from EtienneLescot/feat/webcam-selector-optimization
feat: added webcam source selector and optimized horizontal UI
2026-04-02 23:30:30 -07:00
Adam 846cf71e09 fix: prevent double-finalize race condition in restartRecording on Windows 2026-04-03 01:12:26 -05:00
Ivan 9d0ccf3bde Add webcam mask shape support 2026-04-03 00:09:51 +03:00
Faga 914a3c7f7b fix: read raw pixels from canvas for VideoFrame to avoid silent failures on Linux 2026-04-02 11:55:21 -03:00
Siddharth 2f36160174 version bump 2026-04-01 22:08:43 -07:00
xKeCo 05a87a8ab1 Revert "demo: add example project file for auto-follow zoom"
This reverts commit 5c6621293a.
2026-04-01 02:53:03 -05:00
xKeCoandClaude Sonnet 4.6 5c6621293a demo: add example project file for auto-follow zoom
Contains the zoom region configuration used in the PR demo video:
two auto-follow zoom regions and one manual zoom region.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 02:51:25 -05:00
xKeCo 163b12d6fc ♻️ refactor: refactor zoom focus handling in video editor settings and playback 2026-04-01 02:38:42 -05:00
xKeCo 3be195cc15 ✨ feat: smooth auto-follow zoom with export parity 2026-04-01 01:41:20 -05:00
Etienne Lescot baec9a7585 fix: focusable element when webcam expanded with no devices, add error test
- LaunchWindow: render sr-only <select> when webcamExpanded but
  cameraDevices.length === 0 (loading/error/empty), so keyboard users
  always have a focusable element even in no-camera states
- useCameraDevices.test: add error-branch test asserting error message,
  empty devices array and isLoading=false when enumerateDevices rejects
2026-03-27 16:28:53 +01:00
Etienne Lescot 9817c85acf fix: address coderabbit review (concurrent stream, collapsed label, unified select, test quality)
- useCameraDevices: remove getUserMedia label probe to avoid conflict with
  useScreenRecorder acquiring the real stream; use enumerateDevices only and
  fall back to 'Camera <id>' for unlabeled devices; gate effect on enabled flag
- LaunchWindow: fix selectedCameraLabel to reflect loading/error/empty states
  in the collapsed view (was always showing 'Default Camera')
- LaunchWindow: unify webcam <select> to a single always-mounted element
  (sr-only when unavailable); mirrors the mic selector pattern
- useCameraDevices.test.ts: re-seed mockGetUserMedia in beforeEach after
  vi.resetAllMocks(); update permission test to assert fallback label behavior
2026-03-27 15:15:43 +01:00
Etienne Lescot 9762448929 fix: address coderabbit comments (loading state + keyboard access)
- LaunchWindow: expose isLoading/error from useCameraDevices; show
  'Searching...' only while enumeration is in flight, 'Camera unavailable'
  on error, 'No camera found' when list is empty (fixes perpetual loading state)
- LaunchWindow: keep <select> always mounted (sr-only when collapsed) and
  expand panel on focus as well as hover; fixes keyboard inaccessibility for
  both mic and webcam selectors
- i18n: add webcam.noneFound and webcam.unavailable to en/es/zh-CN locales
2026-03-27 14:53:41 +01:00
Etienne Lescot eade28079d fix: address PR review comments
- useCameraDevices: remove selectedDeviceId from useEffect deps (use ref instead)
- useCameraDevices: fall back to first available device when selected device is unplugged
- i18n: add missing keys (audio.defaultMicrophone, webcam.defaultCamera, webcam.searching) to en/es/zh-CN
- LaunchWindow: replace hardcoded strings with t() i18n calls
- tests: add afterEach(vi.resetAllMocks()), improve permission test assertions, add stale device fallback test
2026-03-27 14:39:19 +01:00
Etienne Lescot fed5a44b5a fix: enforce identical badge height with h-[36px] on both selectors 2026-03-27 14:25:28 +01:00
Etienne Lescot a9222c9484 fix: equalize badge heights and reduce window to 160px 2026-03-27 14:15:46 +01:00
Etienne Lescot e72851d4ef fix: use fixed positioning for HUD and device selectors to avoid h-full clipping 2026-03-27 14:09:54 +01:00
Etienne Lescot 317089d57f fix: restore flex layout to ensure HUD renders in transparent Electron window 2026-03-27 14:04:04 +01:00
Etienne Lescot 0a5e57ce76 feat: add webcam source selector with stable HUD layout
- Add useCameraDevices hook to enumerate video input devices
- Update useScreenRecorder to support webcamDeviceId selection
- Add device selector UI above HUD bar (mic + webcam, hover-to-expand)
- All selectors and HUD bar are absolute-positioned to prevent layout shifts
- Increase HUD window to 600x200px to accommodate device panels
- Add unit tests for useCameraDevices hook
2026-03-27 13:45:52 +01:00
Sid e35780bd85 Merge pull request #244 from ateendra24/fix-issue-226
feat: add fullscreen video player
2026-03-22 09:39:54 -07:00
AP Solanki eae3f119a4 feat: Implement PlaybackControls component and add i18n files for common terms in English, Spanish, and Chinese. 2026-03-22 15:33:43 +05:30
AP Solanki 5d561ff06f feat: add fullscreen video player 2026-03-22 14:17:44 +05:30
Siddharth a8bb0e88d5 improved vertical split gated behind 9:16 2026-03-21 23:15:46 -07:00
Siddharth cbbe2d7fbf movable camera pip 2026-03-21 22:04:10 -07:00
Siddharth 7aca8b8bc1 move project settings to top 2026-03-21 20:07:09 -07:00
Siddharth 4a299063c3 lang support 2026-03-21 18:18:43 -07:00
Siddharth 3d680e8521 Merge feat(export): allow re-saving exported video on dialog cancel (PR #181) 2026-03-21 17:06:36 -07:00
Siddharth c322825969 feat(export): allow re-saving exported video on dialog cancel 2026-03-21 17:06:25 -07:00
Sid ece93683b8 Merge pull request #243 from ryujh030820/feature/improve-timeline-navigation
Improve timeline navigation while scrubbing and scrolling
2026-03-21 16:43:25 -07:00
JH 203282be43 fix: pan timeline on row scroll 2026-03-20 16:52:16 +09:00
JH d8871d9228 fix: pan timeline when dragging playhead to edges 2026-03-20 16:25:19 +09:00
Sid dd0b7d6586 Merge pull request #210 from linyqh/codex/exporter-timeout-fallback
Stabilize video export on Windows
2026-03-19 20:53:32 -07:00
linyq 459b71f792 fix: satisfy biome formatting in video exporter 2026-03-20 10:19:49 +08:00
Sid adc0cf795c Merge pull request #237 from ryujh030820/fix/gradient-export-rendering
fix: fix gradient background export rendering
2026-03-19 18:27:43 -07:00
JH 796506819d Merge branch 'main' into fix/gradient-export-rendering 2026-03-20 09:32:55 +09:00
linyqh 2a2d7e7aba Stabilize video export on Windows 2026-03-20 00:04:34 +08:00
Sid 3eeecc46cf Merge pull request #241 from marcusschiesser/codex/add-multiple-layout-presets-for-video
Add selectable webcam layout presets (Picture in Picture, Vertical Stack)
2026-03-19 08:28:20 -07:00
Marcus Schiesser 6236d2a13d fix: handle export and camera access edge cases 2026-03-19 20:03:55 +08:00
Marcus Schiesser c84c244761 Pin Node and npm versions 2026-03-19 19:25:07 +08:00
Marcus Schiesser 83a60926d8 fix: center stacked screen and webcam layout 2026-03-19 17:51:51 +08:00
Marcus Schiesser 579887e2f8 fix: improve camera permission handling 2026-03-19 16:49:46 +08:00
Marcus Schiesser a0682e6716 feat: add selectable webcam layout presets 2026-03-19 13:05:42 +08:00
JH 038d6c40ab fix: fix gradient background export rendering 2026-03-18 14:30:21 +09:00
Sid 45636410fe Merge pull request #234 from siddharthvaddem/codex/issue-231
fix 231
2026-03-17 20:35:50 -07:00
Siddharth 69f1b4d20f fix 231 2026-03-17 20:25:34 -07:00
Sid d968689975 Merge pull request #233 from siddharthvaddem/codex/issue-230
fix: avoid false early decode failures
2026-03-17 20:10:40 -07:00
Siddharth 7e65d52847 fix 2026-03-17 20:07:15 -07:00
Siddharth 1680ef9b77 fix: guard exported file paths in export flow 2026-03-17 19:46:56 -07:00
Siddharth b7070f3ac8 Merge remote-tracking branch 'origin/main' into codex/issue-230 2026-03-17 19:45:27 -07:00
Siddharth de18a2f46f fix: avoid false early decode failures 2026-03-17 19:30:47 -07:00
Sid 7a6efc5df9 Merge pull request #232 from siddharthvaddem/codex/saved-to-location
bring back show folder
2026-03-17 19:06:53 -07:00
87 changed files with 6321 additions and 1197 deletions
+3 -2
View File
@@ -94,6 +94,7 @@ jobs:
uses: actions/upload-artifact@v4
with:
name: linux-installer
path: release/**/*.AppImage
path: |
release/**/*.AppImage
release/**/*.zsync
retention-days: 30
+1
View File
@@ -0,0 +1 @@
22.22.1
+4
View File
@@ -8,6 +8,10 @@
<a href="https://deepwiki.com/siddharthvaddem/openscreen">
<img src="https://deepwiki.com/badge.svg" alt="Ask DeepWiki" />
</a>
&nbsp;
<a href="https://discord.gg/yAQQhRaEeg">
<img src="https://img.shields.io/discord/pHAUbcqNd?logo=discord&label=Discord&color=5865F2" alt="Join Discord" />
</a>
</p>
# <p align="center">OpenScreen</p>
+9 -8
View File
@@ -20,14 +20,15 @@
"!CONTRIBUTING.md",
"!LICENSE"
],
"extraResources": [
{
"from": "public/wallpapers",
"to": "assets/wallpapers"
}
],
"mac": {
"extraResources": [
{
"from": "public/wallpapers",
"to": "assets/wallpapers"
}
],
"publish": [{"provider": "github"}],
"mac": {
"hardenedRuntime": false,
"target": [
{
+1
View File
@@ -136,6 +136,7 @@ interface Window {
setMicrophoneExpanded: (expanded: boolean) => void;
setHasUnsavedChanges: (hasChanges: boolean) => void;
onRequestSaveBeforeClose: (callback: () => Promise<boolean> | boolean) => () => void;
setLocale: (locale: string) => Promise<void>;
};
}
+59
View File
@@ -0,0 +1,59 @@
// Lightweight i18n for the Electron main process.
// Imports the same JSON translation files used by the renderer.
import commonEn from "../src/i18n/locales/en/common.json";
import dialogsEn from "../src/i18n/locales/en/dialogs.json";
import commonEs from "../src/i18n/locales/es/common.json";
import dialogsEs from "../src/i18n/locales/es/dialogs.json";
import commonZh from "../src/i18n/locales/zh-CN/common.json";
import dialogsZh from "../src/i18n/locales/zh-CN/dialogs.json";
type Locale = "en" | "zh-CN" | "es";
type Namespace = "common" | "dialogs";
type MessageMap = Record<string, unknown>;
const messages: Record<Locale, Record<Namespace, MessageMap>> = {
en: { common: commonEn, dialogs: dialogsEn },
"zh-CN": { common: commonZh, dialogs: dialogsZh },
es: { common: commonEs, dialogs: dialogsEs },
};
let currentLocale: Locale = "en";
export function setMainLocale(locale: string) {
if (locale === "en" || locale === "zh-CN" || locale === "es") {
currentLocale = locale;
}
}
export function getMainLocale(): Locale {
return currentLocale;
}
function getMessageValue(obj: unknown, dotPath: string): string | undefined {
const keys = dotPath.split(".");
let current: unknown = obj;
for (const key of keys) {
if (current == null || typeof current !== "object") return undefined;
current = (current as Record<string, unknown>)[key];
}
return typeof current === "string" ? current : undefined;
}
function interpolate(str: string, vars?: Record<string, string | number>): string {
if (!vars) return str;
return str.replace(/\{\{(\w+)\}\}/g, (_, key: string) => String(vars[key] ?? `{{${key}}}`));
}
export function mainT(
namespace: Namespace,
key: string,
vars?: Record<string, string | number>,
): string {
const value =
getMessageValue(messages[currentLocale]?.[namespace], key) ??
getMessageValue(messages.en?.[namespace], key);
if (value == null) return `${namespace}.${key}`;
return interpolate(value, vars);
}
+23 -11
View File
@@ -17,6 +17,7 @@ import {
type RecordingSession,
type StoreRecordedSessionInput,
} from "../../src/lib/recordingSession";
import { mainT } from "../i18n";
import { RECORDINGS_DIR } from "../main";
const PROJECT_FILE_EXTENSION = "openscreen";
@@ -472,11 +473,13 @@ export function registerIpcHandlers(
// Determine file type from extension
const isGif = fileName.toLowerCase().endsWith(".gif");
const filters = isGif
? [{ name: "GIF Image", extensions: ["gif"] }]
: [{ name: "MP4 Video", extensions: ["mp4"] }];
? [{ name: mainT("dialogs", "fileDialogs.gifImage"), extensions: ["gif"] }]
: [{ name: mainT("dialogs", "fileDialogs.mp4Video"), extensions: ["mp4"] }];
const result = await dialog.showSaveDialog({
title: isGif ? "Save Exported GIF" : "Save Exported Video",
title: isGif
? mainT("dialogs", "fileDialogs.saveGif")
: mainT("dialogs", "fileDialogs.saveVideo"),
defaultPath: path.join(app.getPath("downloads"), fileName),
filters,
properties: ["createDirectory", "showOverwriteConfirmation"],
@@ -510,11 +513,14 @@ export function registerIpcHandlers(
ipcMain.handle("open-video-file-picker", async () => {
try {
const result = await dialog.showOpenDialog({
title: "Select Video File",
title: mainT("dialogs", "fileDialogs.selectVideo"),
defaultPath: RECORDINGS_DIR,
filters: [
{ name: "Video Files", extensions: ["webm", "mp4", "mov", "avi", "mkv"] },
{ name: "All Files", extensions: ["*"] },
{
name: mainT("dialogs", "fileDialogs.videoFiles"),
extensions: ["webm", "mp4", "mov", "avi", "mkv"],
},
{ name: mainT("dialogs", "fileDialogs.allFiles"), extensions: ["*"] },
],
properties: ["openFile"],
});
@@ -590,10 +596,13 @@ export function registerIpcHandlers(
: `${safeName}.${PROJECT_FILE_EXTENSION}`;
const result = await dialog.showSaveDialog({
title: "Save OpenScreen Project",
title: mainT("dialogs", "fileDialogs.saveProject"),
defaultPath: path.join(RECORDINGS_DIR, defaultName),
filters: [
{ name: "OpenScreen Project", extensions: [PROJECT_FILE_EXTENSION] },
{
name: mainT("dialogs", "fileDialogs.openscreenProject"),
extensions: [PROJECT_FILE_EXTENSION],
},
{ name: "JSON", extensions: ["json"] },
],
properties: ["createDirectory", "showOverwriteConfirmation"],
@@ -629,12 +638,15 @@ export function registerIpcHandlers(
ipcMain.handle("load-project-file", async () => {
try {
const result = await dialog.showOpenDialog({
title: "Open OpenScreen Project",
title: mainT("dialogs", "fileDialogs.openProject"),
defaultPath: RECORDINGS_DIR,
filters: [
{ name: "OpenScreen Project", extensions: [PROJECT_FILE_EXTENSION] },
{
name: mainT("dialogs", "fileDialogs.openscreenProject"),
extensions: [PROJECT_FILE_EXTENSION],
},
{ name: "JSON", extensions: ["json"] },
{ name: "All Files", extensions: ["*"] },
{ name: mainT("dialogs", "fileDialogs.allFiles"), extensions: ["*"] },
],
properties: ["openFile"],
});
+25 -14
View File
@@ -12,6 +12,7 @@ import {
systemPreferences,
Tray,
} from "electron";
import { mainT, setMainLocale } from "./i18n";
import { registerIpcHandlers } from "./ipc/handlers";
import { createEditorWindow, createHudOverlayWindow, createSourceSelectorWindow } from "./windows";
@@ -130,20 +131,20 @@ function setupApplicationMenu() {
template.push(
{
label: "File",
label: mainT("common", "actions.file") || "File",
submenu: [
{
label: "Load Project…",
label: mainT("dialogs", "unsavedChanges.loadProject") || "Load Project…",
accelerator: "CmdOrCtrl+O",
click: () => sendEditorMenuAction("menu-load-project"),
},
{
label: "Save Project…",
label: mainT("dialogs", "unsavedChanges.saveProject") || "Save Project…",
accelerator: "CmdOrCtrl+S",
click: () => sendEditorMenuAction("menu-save-project"),
},
{
label: "Save Project As…",
label: mainT("dialogs", "unsavedChanges.saveProjectAs") || "Save Project As…",
accelerator: "CmdOrCtrl+Shift+S",
click: () => sendEditorMenuAction("menu-save-project-as"),
},
@@ -151,7 +152,7 @@ function setupApplicationMenu() {
],
},
{
label: "Edit",
label: mainT("common", "actions.edit") || "Edit",
submenu: [
{ role: "undo" },
{ role: "redo" },
@@ -163,7 +164,7 @@ function setupApplicationMenu() {
],
},
{
label: "View",
label: mainT("common", "actions.view") || "View",
submenu: [
{ role: "reload" },
{ role: "forceReload" },
@@ -177,7 +178,7 @@ function setupApplicationMenu() {
],
},
{
label: "Window",
label: mainT("common", "actions.window") || "Window",
submenu: isMac
? [{ role: "minimize" }, { role: "zoom" }, { type: "separator" }, { role: "front" }]
: [{ role: "minimize" }, { role: "close" }],
@@ -215,7 +216,7 @@ function updateTrayMenu(recording: boolean = false) {
const menuTemplate = recording
? [
{
label: "Stop Recording",
label: mainT("common", "actions.stopRecording") || "Stop Recording",
click: () => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send("stop-recording-from-tray");
@@ -225,13 +226,13 @@ function updateTrayMenu(recording: boolean = false) {
]
: [
{
label: "Open",
label: mainT("common", "actions.open") || "Open",
click: () => {
showMainWindow();
},
},
{
label: "Quit",
label: mainT("common", "actions.quit") || "Quit",
click: () => {
app.quit();
},
@@ -281,12 +282,16 @@ function createEditorWindowWrapper() {
const choice = dialog.showMessageBoxSync(mainWindow!, {
type: "warning",
buttons: ["Save & Close", "Discard & Close", "Cancel"],
buttons: [
mainT("dialogs", "unsavedChanges.saveAndClose"),
mainT("dialogs", "unsavedChanges.discardAndClose"),
mainT("common", "actions.cancel"),
],
defaultId: 0,
cancelId: 2,
title: "Unsaved Changes",
message: "You have unsaved changes.",
detail: "Do you want to save your project before closing?",
title: mainT("dialogs", "unsavedChanges.title"),
message: mainT("dialogs", "unsavedChanges.message"),
detail: mainT("dialogs", "unsavedChanges.detail"),
});
const windowToClose = mainWindow;
@@ -354,6 +359,12 @@ app.whenReady().then(async () => {
ipcMain.on("hud-overlay-close", () => {
app.quit();
});
ipcMain.handle("set-locale", (_, locale: string) => {
setMainLocale(locale);
setupApplicationMenu();
updateTrayMenu();
});
createTray();
updateTrayMenu();
setupApplicationMenu();
+3
View File
@@ -115,6 +115,9 @@ contextBridge.exposeInMainWorld("electronAPI", {
saveShortcuts: (shortcuts: unknown) => {
return ipcRenderer.invoke("save-shortcuts", shortcuts);
},
setLocale: (locale: string) => {
return ipcRenderer.invoke("set-locale", locale);
},
setMicrophoneExpanded: (expanded: boolean) => {
ipcRenderer.send("hud:setMicrophoneExpanded", expanded);
},
+6 -6
View File
@@ -21,8 +21,8 @@ export function createHudOverlayWindow(): BrowserWindow {
const primaryDisplay = screen.getPrimaryDisplay();
const { workArea } = primaryDisplay;
const windowWidth = 500;
const windowHeight = 155;
const windowWidth = 600;
const windowHeight = 160;
const x = Math.floor(workArea.x + (workArea.width - windowWidth) / 2);
const y = Math.floor(workArea.y + workArea.height - windowHeight - 5);
@@ -30,10 +30,10 @@ export function createHudOverlayWindow(): BrowserWindow {
const win = new BrowserWindow({
width: windowWidth,
height: windowHeight,
minWidth: 500,
maxWidth: 500,
minHeight: 155,
maxHeight: 155,
minWidth: 600,
maxWidth: 600,
minHeight: 160,
maxHeight: 160,
x: x,
y: y,
frame: false,
+768 -2
View File
@@ -1,12 +1,12 @@
{
"name": "openscreen",
"version": "1.2.0",
"version": "1.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openscreen",
"version": "1.2.0",
"version": "1.3.0",
"dependencies": {
"@fix-webm-duration/fix": "^1.0.1",
"@pixi/filter-drop-shadow": "^5.2.0",
@@ -52,6 +52,9 @@
"devDependencies": {
"@biomejs/biome": "^2.3.13",
"@playwright/test": "^1.58.2",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^25.0.3",
"@types/react": "^18.2.64",
"@types/react-dom": "^18.2.21",
@@ -64,6 +67,7 @@
"electron-rebuild": "^3.2.9",
"fast-check": "^4.5.2",
"husky": "^9.1.7",
"jsdom": "^29.0.1",
"lint-staged": "^16.3.2",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.18",
@@ -73,8 +77,19 @@
"vite-plugin-electron": "^0.28.6",
"vite-plugin-electron-renderer": "^0.14.5",
"vitest": "^4.0.16"
},
"engines": {
"node": "22.22.1",
"npm": "10.9.4"
}
},
"node_modules/@adobe/css-tools": {
"version": "4.4.4",
"resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz",
"integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==",
"dev": true,
"license": "MIT"
},
"node_modules/@alloc/quick-lru": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
@@ -87,6 +102,67 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@asamuzakjp/css-color": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.0.1.tgz",
"integrity": "sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@csstools/css-calc": "^3.1.1",
"@csstools/css-color-parser": "^4.0.2",
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0",
"lru-cache": "^11.2.6"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/@asamuzakjp/css-color/node_modules/lru-cache": {
"version": "11.2.7",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz",
"integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/@asamuzakjp/dom-selector": {
"version": "7.0.4",
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.0.4.tgz",
"integrity": "sha512-jXR6x4AcT3eIrS2fSNAwJpwirOkGcd+E7F7CP3zjdTqz9B/2huHOL8YJZBgekKwLML+u7qB/6P1LXQuMScsx0w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/nwsapi": "^2.3.9",
"bidi-js": "^1.0.3",
"css-tree": "^3.2.1",
"is-potential-custom-element-name": "^1.0.1",
"lru-cache": "^11.2.7"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": {
"version": "11.2.7",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz",
"integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/@asamuzakjp/nwsapi": {
"version": "2.3.9",
"resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
"integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
"dev": true,
"license": "MIT"
},
"node_modules/@babel/code-frame": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
@@ -561,6 +637,159 @@
"node": ">=14.21.3"
}
},
"node_modules/@bramus/specificity": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
"integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
"dev": true,
"license": "MIT",
"dependencies": {
"css-tree": "^3.0.0"
},
"bin": {
"specificity": "bin/cli.js"
}
},
"node_modules/@csstools/color-helpers": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz",
"integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/@csstools/css-calc": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz",
"integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-color-parser": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz",
"integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"dependencies": {
"@csstools/color-helpers": "^6.0.2",
"@csstools/css-calc": "^3.1.1"
},
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-parser-algorithms": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
"integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-syntax-patches-for-csstree": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.1.tgz",
"integrity": "sha512-BvqN0AMWNAnLk9G8jnUT77D+mUbY/H2b3uDTvg2isJkHaOufUE2R3AOwxWo7VBQKT1lOdwdvorddo2B/lk64+w==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"peerDependencies": {
"css-tree": "^3.2.1"
},
"peerDependenciesMeta": {
"css-tree": {
"optional": true
}
}
},
"node_modules/@csstools/css-tokenizer": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
"integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/@develar/schema-utils": {
"version": "2.6.5",
"resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz",
@@ -1821,6 +2050,24 @@
"node": ">=12"
}
},
"node_modules/@exodus/bytes": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz",
"integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
},
"peerDependencies": {
"@noble/hashes": "^1.8.0 || ^2.0.0"
},
"peerDependenciesMeta": {
"@noble/hashes": {
"optional": true
}
}
},
"node_modules/@fix-webm-duration/fix": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@fix-webm-duration/fix/-/fix-1.0.1.tgz",
@@ -4240,6 +4487,96 @@
"node": ">=10"
}
},
"node_modules/@testing-library/dom": {
"version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
"@types/aria-query": "^5.0.1",
"aria-query": "5.3.0",
"dom-accessibility-api": "^0.5.9",
"lz-string": "^1.5.0",
"picocolors": "1.1.1",
"pretty-format": "^27.0.2"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@testing-library/jest-dom": {
"version": "6.9.1",
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz",
"integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@adobe/css-tools": "^4.4.0",
"aria-query": "^5.0.0",
"css.escape": "^1.5.1",
"dom-accessibility-api": "^0.6.3",
"picocolors": "^1.1.1",
"redent": "^3.0.0"
},
"engines": {
"node": ">=14",
"npm": ">=6",
"yarn": ">=1"
}
},
"node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz",
"integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==",
"dev": true,
"license": "MIT"
},
"node_modules/@testing-library/react": {
"version": "16.3.2",
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz",
"integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.12.5"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@testing-library/dom": "^10.0.0",
"@types/react": "^18.0.0 || ^19.0.0",
"@types/react-dom": "^18.0.0 || ^19.0.0",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@testing-library/user-event": {
"version": "14.6.1",
"resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz",
"integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12",
"npm": ">=6"
},
"peerDependencies": {
"@testing-library/dom": ">=7.21.4"
}
},
"node_modules/@tokenizer/token": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz",
@@ -4257,6 +4594,14 @@
"node": ">= 10"
}
},
"node_modules/@types/aria-query": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"dev": true,
"license": "MIT",
"peer": true
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -5338,6 +5683,16 @@
"node": ">=10"
}
},
"node_modules/aria-query": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"dequal": "^2.0.3"
}
},
"node_modules/array-union": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz",
@@ -5525,6 +5880,16 @@
"tweetnacl": "^0.14.3"
}
},
"node_modules/bidi-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
"integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
"dev": true,
"license": "MIT",
"dependencies": {
"require-from-string": "^2.0.2"
}
},
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@@ -6385,6 +6750,27 @@
"node": ">= 8"
}
},
"node_modules/css-tree": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
"integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
"dev": true,
"license": "MIT",
"dependencies": {
"mdn-data": "2.27.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
}
},
"node_modules/css.escape": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
"integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==",
"dev": true,
"license": "MIT"
},
"node_modules/cssesc": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@@ -6417,6 +6803,20 @@
"node": ">=0.10"
}
},
"node_modules/data-urls": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
"integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
"dev": true,
"license": "MIT",
"dependencies": {
"whatwg-mimetype": "^5.0.0",
"whatwg-url": "^16.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -6445,6 +6845,13 @@
"node": ">=0.10.0"
}
},
"node_modules/decimal.js": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
"dev": true,
"license": "MIT"
},
"node_modules/decompress-response": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
@@ -6575,6 +6982,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/dequal": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -6752,6 +7169,14 @@
"resize-observer-polyfill": "^1.5.1"
}
},
"node_modules/dom-accessibility-api": {
"version": "0.5.16",
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true,
"license": "MIT",
"peer": true
},
"node_modules/dom-walk": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/dom-walk/-/dom-walk-0.1.2.tgz",
@@ -7181,6 +7606,19 @@
"once": "^1.4.0"
}
},
"node_modules/entities": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/env-paths": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
@@ -8272,6 +8710,19 @@
"dev": true,
"license": "ISC"
},
"node_modules/html-encoding-sniffer": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
"integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.6.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/http-cache-semantics": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
@@ -8667,6 +9118,13 @@
"node": ">=8"
}
},
"node_modules/is-potential-custom-element-name": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
"dev": true,
"license": "MIT"
},
"node_modules/is-stream": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz",
@@ -8838,6 +9296,70 @@
"dev": true,
"license": "MIT"
},
"node_modules/jsdom": {
"version": "29.0.1",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.1.tgz",
"integrity": "sha512-z6JOK5gRO7aMybVq/y/MlIpKh8JIi68FBKMUtKkK2KH/wMSRlCxQ682d08LB9fYXplyY/UXG8P4XXTScmdjApg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/css-color": "^5.0.1",
"@asamuzakjp/dom-selector": "^7.0.3",
"@bramus/specificity": "^2.4.2",
"@csstools/css-syntax-patches-for-csstree": "^1.1.1",
"@exodus/bytes": "^1.15.0",
"css-tree": "^3.2.1",
"data-urls": "^7.0.0",
"decimal.js": "^10.6.0",
"html-encoding-sniffer": "^6.0.0",
"is-potential-custom-element-name": "^1.0.1",
"lru-cache": "^11.2.7",
"parse5": "^8.0.0",
"saxes": "^6.0.0",
"symbol-tree": "^3.2.4",
"tough-cookie": "^6.0.1",
"undici": "^7.24.5",
"w3c-xmlserializer": "^5.0.0",
"webidl-conversions": "^8.0.1",
"whatwg-mimetype": "^5.0.0",
"whatwg-url": "^16.0.1",
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24.0.0"
},
"peerDependencies": {
"canvas": "^3.0.0"
},
"peerDependenciesMeta": {
"canvas": {
"optional": true
}
}
},
"node_modules/jsdom/node_modules/lru-cache": {
"version": "11.2.7",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz",
"integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/jsdom/node_modules/tough-cookie": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz",
"integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"tldts": "^7.0.5"
},
"engines": {
"node": ">=16"
}
},
"node_modules/jsesc": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
@@ -9545,6 +10067,17 @@
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/lz-string": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"lz-string": "bin/bin.js"
}
},
"node_modules/lzma-native": {
"version": "8.0.6",
"resolved": "https://registry.npmjs.org/lzma-native/-/lzma-native-8.0.6.tgz",
@@ -9662,6 +10195,13 @@
"node": ">= 0.4"
}
},
"node_modules/mdn-data": {
"version": "2.27.1",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
"integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
"dev": true,
"license": "CC0-1.0"
},
"node_modules/mediabunny": {
"version": "1.25.1",
"resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.25.1.tgz",
@@ -9786,6 +10326,16 @@
"dom-walk": "^0.1.0"
}
},
"node_modules/min-indent": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
"integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/minimatch": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
@@ -10563,6 +11113,19 @@
"integrity": "sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==",
"license": "MIT"
},
"node_modules/parse5": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz",
"integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==",
"dev": true,
"license": "MIT",
"dependencies": {
"entities": "^6.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/path-is-absolute": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
@@ -11170,6 +11733,44 @@
"node": "^12.20.0 || >=14"
}
},
"node_modules/pretty-format": {
"version": "27.5.1",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0",
"react-is": "^17.0.1"
},
"engines": {
"node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
}
},
"node_modules/pretty-format/node_modules/ansi-styles": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/pretty-format/node_modules/react-is": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true,
"license": "MIT",
"peer": true
},
"node_modules/proc-log": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/proc-log/-/proc-log-5.0.0.tgz",
@@ -11720,6 +12321,20 @@
"node": ">=8.10.0"
}
},
"node_modules/redent": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
"integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==",
"dev": true,
"license": "MIT",
"dependencies": {
"indent-string": "^4.0.0",
"strip-indent": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/regenerator-runtime": {
"version": "0.13.11",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
@@ -11816,6 +12431,16 @@
"node": ">=0.10.0"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/require-main-filename": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-1.0.1.tgz",
@@ -12081,6 +12706,19 @@
"dev": true,
"license": "ISC"
},
"node_modules/saxes": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
"dev": true,
"license": "ISC",
"dependencies": {
"xmlchars": "^2.2.0"
},
"engines": {
"node": ">=v12.22.7"
}
},
"node_modules/scheduler": {
"version": "0.23.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
@@ -12599,6 +13237,19 @@
"node": ">=0.10.0"
}
},
"node_modules/strip-indent": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
"integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"min-indent": "^1.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strtok3": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/strtok3/-/strtok3-6.3.0.tgz",
@@ -12864,6 +13515,13 @@
"camelcase": "^3.0.0"
}
},
"node_modules/symbol-tree": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
"dev": true,
"license": "MIT"
},
"node_modules/tailwind-merge": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.3.1.tgz",
@@ -13213,6 +13871,26 @@
"node": ">=14.0.0"
}
},
"node_modules/tldts": {
"version": "7.0.27",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.27.tgz",
"integrity": "sha512-I4FZcVFcqCRuT0ph6dCDpPuO4Xgzvh+spkcTr1gK7peIvxWauoloVO0vuy1FQnijT63ss6AsHB6+OIM4aXHbPg==",
"dev": true,
"license": "MIT",
"dependencies": {
"tldts-core": "^7.0.27"
},
"bin": {
"tldts": "bin/cli.js"
}
},
"node_modules/tldts-core": {
"version": "7.0.27",
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.27.tgz",
"integrity": "sha512-YQ7uPjgWUibIK6DW5lrKujGwUKhLevU4hcGbP5O6TcIUb+oTjJYJVWPS4nZsIHrEEEG6myk/oqAJUEQmpZrHsg==",
"dev": true,
"license": "MIT"
},
"node_modules/tmp": {
"version": "0.2.5",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz",
@@ -13277,6 +13955,19 @@
"node": ">=0.8"
}
},
"node_modules/tr46": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
"integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
"dev": true,
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
},
"engines": {
"node": ">=20"
}
},
"node_modules/truncate-utf8-bytes": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz",
@@ -13340,6 +14031,16 @@
"node": ">=14.17"
}
},
"node_modules/undici": {
"version": "7.24.6",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.24.6.tgz",
"integrity": "sha512-Xi4agocCbRzt0yYMZGMA6ApD7gvtUFaxm4ZmeacWI4cZxaF6C+8I8QfofC20NAePiB/IcvZmzkJ7XPa471AEtA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20.18.1"
}
},
"node_modules/undici-types": {
"version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
@@ -14277,6 +14978,19 @@
}
}
},
"node_modules/w3c-xmlserializer": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
"integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
"dev": true,
"license": "MIT",
"dependencies": {
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/wcwidth": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz",
@@ -14296,6 +15010,41 @@
"pnpm": ">=8"
}
},
"node_modules/webidl-conversions": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
"integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=20"
}
},
"node_modules/whatwg-mimetype": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
"integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20"
}
},
"node_modules/whatwg-url": {
"version": "16.0.1",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
"integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.11.0",
"tr46": "^6.0.0",
"webidl-conversions": "^8.0.1"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -14401,6 +15150,16 @@
"xtend": "^4.0.0"
}
},
"node_modules/xml-name-validator": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=18"
}
},
"node_modules/xml-parse-from-string": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/xml-parse-from-string/-/xml-parse-from-string-1.0.1.tgz",
@@ -14442,6 +15201,13 @@
"node": ">=8.0"
}
},
"node_modules/xmlchars": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"dev": true,
"license": "MIT"
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+11 -1
View File
@@ -1,14 +1,20 @@
{
"name": "openscreen",
"private": true,
"version": "1.2.0",
"version": "1.3.0",
"type": "module",
"packageManager": "npm@10.9.4",
"engines": {
"node": "22.22.1",
"npm": "10.9.4"
},
"scripts": {
"dev": "vite",
"build": "tsc && vite build && electron-builder",
"lint": "biome check .",
"lint:fix": "biome check --write .",
"format": "biome format --write .",
"i18n:check": "node scripts/i18n-check.mjs",
"preview": "vite preview",
"build:mac": "tsc && vite build && electron-builder --mac",
"build:win": "tsc && vite build && electron-builder --win",
@@ -64,6 +70,9 @@
"devDependencies": {
"@biomejs/biome": "^2.3.13",
"@playwright/test": "^1.58.2",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^25.0.3",
"@types/react": "^18.2.64",
"@types/react-dom": "^18.2.21",
@@ -76,6 +85,7 @@
"electron-rebuild": "^3.2.9",
"fast-check": "^4.5.2",
"husky": "^9.1.7",
"jsdom": "^29.0.1",
"lint-staged": "^16.3.2",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.18",
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env node
/**
* Validates that all locale translation files have identical key structures.
* Compares zh-CN and es against the en baseline for every namespace.
*
* Usage: node scripts/i18n-check.mjs
*/
import fs from "node:fs";
import path from "node:path";
const LOCALES_DIR = path.resolve("src/i18n/locales");
const BASE_LOCALE = "en";
const COMPARE_LOCALES = ["zh-CN", "es"];
function getKeys(obj, prefix = "") {
const keys = [];
for (const [key, value] of Object.entries(obj)) {
const fullKey = prefix ? `${prefix}.${key}` : key;
if (value && typeof value === "object" && !Array.isArray(value)) {
keys.push(...getKeys(value, fullKey));
} else {
keys.push(fullKey);
}
}
return keys.sort();
}
let hasErrors = false;
const baseDir = path.join(LOCALES_DIR, BASE_LOCALE);
const namespaces = fs
.readdirSync(baseDir)
.filter((f) => f.endsWith(".json"))
.map((f) => f.replace(".json", ""));
for (const namespace of namespaces) {
const basePath = path.join(baseDir, `${namespace}.json`);
const baseData = JSON.parse(fs.readFileSync(basePath, "utf-8"));
const baseKeys = getKeys(baseData);
for (const locale of COMPARE_LOCALES) {
const localePath = path.join(LOCALES_DIR, locale, `${namespace}.json`);
if (!fs.existsSync(localePath)) {
console.error(`MISSING: ${locale}/${namespace}.json does not exist`);
hasErrors = true;
continue;
}
const localeData = JSON.parse(fs.readFileSync(localePath, "utf-8"));
const localeKeys = getKeys(localeData);
const missing = baseKeys.filter((k) => !localeKeys.includes(k));
const extra = localeKeys.filter((k) => !baseKeys.includes(k));
if (missing.length > 0) {
console.error(`MISSING in ${locale}/${namespace}.json:`);
for (const key of missing) {
console.error(` - ${key}`);
}
hasErrors = true;
}
if (extra.length > 0) {
console.error(`EXTRA in ${locale}/${namespace}.json:`);
for (const key of extra) {
console.error(` + ${key}`);
}
hasErrors = true;
}
}
}
if (hasErrors) {
console.error("\ni18n check FAILED — translation files are out of sync.");
process.exit(1);
} else {
console.log(
`i18n check PASSED — all ${COMPARE_LOCALES.length} locales match ${BASE_LOCALE} across ${namespaces.length} namespaces.`,
);
}
+7 -1
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from "react";
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 { ShortcutsConfigDialog } from "./components/video-editor/ShortcutsConfigDialog";
import VideoEditor from "./components/video-editor/VideoEditor";
@@ -48,5 +49,10 @@ export default function App() {
}
})();
return <TooltipProvider>{content}</TooltipProvider>;
return (
<TooltipProvider>
{content}
<Toaster theme="dark" className="pointer-events-auto" />
</TooltipProvider>
);
}
+363 -158
View File
@@ -1,10 +1,11 @@
import { ChevronDown } from "lucide-react";
import { ChevronDown, Languages } from "lucide-react";
import { useEffect, useState } from "react";
import { BsRecordCircle } from "react-icons/bs";
import { BsPauseCircle, BsPlayCircle, BsRecordCircle } from "react-icons/bs";
import { FaRegStopCircle } from "react-icons/fa";
import { FaFolderOpen } from "react-icons/fa6";
import { FiMinus, FiX } from "react-icons/fi";
import {
MdCancel,
MdMic,
MdMicOff,
MdMonitor,
@@ -16,9 +17,15 @@ import {
MdVolumeUp,
} from "react-icons/md";
import { RxDragHandleDots2 } from "react-icons/rx";
import { useI18n, useScopedT } from "@/contexts/I18nContext";
import { type Locale, SUPPORTED_LOCALES } from "@/i18n/config";
import { getLocaleName } from "@/i18n/loader";
import { isMac as getIsMac } from "@/utils/platformUtils";
import { useAudioLevelMeter } from "../../hooks/useAudioLevelMeter";
import { useCameraDevices } from "../../hooks/useCameraDevices";
import { useMicrophoneDevices } from "../../hooks/useMicrophoneDevices";
import { useScreenRecorder } from "../../hooks/useScreenRecorder";
import { requestCameraAccess } from "../../lib/requestCameraAccess";
import { formatTimePadded } from "../../utils/timeUtils";
import { AudioLevelMeter } from "../ui/audio-level-meter";
import { Tooltip } from "../ui/tooltip";
@@ -35,8 +42,11 @@ const ICON_CONFIG = {
micOff: { icon: MdMicOff, size: ICON_SIZE },
webcamOn: { icon: MdVideocam, size: ICON_SIZE },
webcamOff: { icon: MdVideocamOff, size: ICON_SIZE },
pause: { icon: BsPauseCircle, size: ICON_SIZE },
resume: { icon: BsPlayCircle, size: ICON_SIZE },
stop: { icon: FaRegStopCircle, size: ICON_SIZE },
restart: { icon: MdRestartAlt, size: ICON_SIZE },
cancel: { icon: MdCancel, size: ICON_SIZE },
record: { icon: BsRecordCircle, size: ICON_SIZE },
videoFile: { icon: MdVideoFile, size: ICON_SIZE },
folder: { icon: FaFolderOpen, size: ICON_SIZE },
@@ -61,10 +71,22 @@ const windowBtnClasses =
"flex items-center justify-center p-2 rounded-full transition-all duration-150 cursor-pointer opacity-50 hover:opacity-90 hover:bg-white/[0.08]";
export function LaunchWindow() {
const t = useScopedT("launch");
const { locale, setLocale } = useI18n();
const [isMac, setIsMac] = useState(false);
useEffect(() => {
getIsMac().then(setIsMac);
}, []);
const {
recording,
paused,
elapsedSeconds,
toggleRecording,
togglePaused,
restartRecording,
cancelRecording,
microphoneEnabled,
setMicrophoneEnabled,
microphoneDeviceId,
@@ -73,42 +95,72 @@ export function LaunchWindow() {
setSystemAudioEnabled,
webcamEnabled,
setWebcamEnabled,
webcamDeviceId,
setWebcamDeviceId,
} = useScreenRecorder();
const [recordingStart, setRecordingStart] = useState<number | null>(null);
const [elapsed, setElapsed] = useState(0);
const showMicControls = microphoneEnabled && !recording;
const { devices, selectedDeviceId, setSelectedDeviceId } =
useMicrophoneDevices(microphoneEnabled);
const showWebcamControls = webcamEnabled && !recording;
const [isMicHovered, setIsMicHovered] = useState(false);
const [isMicFocused, setIsMicFocused] = useState(false);
const micExpanded = isMicHovered || isMicFocused;
const [isWebcamHovered, setIsWebcamHovered] = useState(false);
const [isWebcamFocused, setIsWebcamFocused] = useState(false);
const webcamExpanded = isWebcamHovered || isWebcamFocused;
const {
devices: micDevices,
selectedDeviceId: selectedMicId,
setSelectedDeviceId: setSelectedMicId,
} = useMicrophoneDevices(microphoneEnabled);
const {
devices: cameraDevices,
selectedDeviceId: selectedCameraId,
setSelectedDeviceId: setSelectedCameraId,
isLoading: isCameraDevicesLoading,
error: cameraDevicesError,
} = useCameraDevices(webcamEnabled);
const selectedMicLabel =
micDevices.find((d) => d.deviceId === (microphoneDeviceId || selectedMicId))?.label ||
t("audio.defaultMicrophone");
const selectedCameraLabel = isCameraDevicesLoading
? t("webcam.searching")
: cameraDevicesError
? t("webcam.unavailable")
: cameraDevices.length === 0
? t("webcam.noneFound")
: cameraDevices.find((d) => d.deviceId === (webcamDeviceId || selectedCameraId))?.label ||
t("webcam.defaultCamera");
const { level } = useAudioLevelMeter({
enabled: showMicControls,
deviceId: microphoneDeviceId,
});
useEffect(() => {
if (selectedDeviceId && selectedDeviceId !== "default") {
setMicrophoneDeviceId(selectedDeviceId);
if (selectedMicId && selectedMicId !== "default") {
setMicrophoneDeviceId(selectedMicId);
}
}, [selectedDeviceId, setMicrophoneDeviceId]);
}, [selectedMicId, setMicrophoneDeviceId]);
useEffect(() => {
let timer: NodeJS.Timeout | null = null;
if (recording) {
if (!recordingStart) setRecordingStart(Date.now());
timer = setInterval(() => {
if (recordingStart) {
setElapsed(Math.floor((Date.now() - recordingStart) / 1000));
}
}, 1000);
} else {
setRecordingStart(null);
setElapsed(0);
if (timer) clearInterval(timer);
if (selectedCameraId) {
setWebcamDeviceId(selectedCameraId);
}
return () => {
if (timer) clearInterval(timer);
};
}, [recording, recordingStart]);
}, [selectedCameraId, setWebcamDeviceId]);
useEffect(() => {
if (!import.meta.env.DEV) {
return;
}
void requestCameraAccess().catch((error) => {
console.warn("Failed to trigger camera access request during development:", error);
});
}, []);
const [selectedSource, setSelectedSource] = useState("Screen");
const [hasSelectedSource, setHasSelectedSource] = useState(false);
@@ -176,156 +228,309 @@ export function LaunchWindow() {
};
return (
<div className="w-full h-full flex items-end justify-center bg-transparent">
<div className={`flex flex-col items-center gap-2 mx-auto ${styles.electronDrag}`}>
{/* Mic controls panel */}
{showMicControls && (
<div
className={`flex items-center gap-2 px-4 py-2 bg-gradient-to-br from-[rgba(28,28,36,0.97)] to-[rgba(18,18,26,0.96)] backdrop-blur-[16px] backdrop-saturate-[140%] border border-[rgba(80,80,120,0.25)] rounded-2xl shadow-mic-panel animate-mic-panel-in ${styles.electronNoDrag}`}
>
<div className="relative flex-1" style={{ maxWidth: "70%" }}>
<select
value={microphoneDeviceId || selectedDeviceId}
onChange={(e) => {
setSelectedDeviceId(e.target.value);
setMicrophoneDeviceId(e.target.value);
}}
className="w-full appearance-none bg-white/10 text-white text-xs rounded-full pl-3 pr-7 py-2 border border-white/20 outline-none truncate"
>
{devices.map((device) => (
<option key={device.deviceId} value={device.deviceId}>
{device.label}
</option>
))}
</select>
<ChevronDown
size={14}
className="absolute right-2 top-1/2 -translate-y-1/2 text-white/60 pointer-events-none"
<div className={`w-screen h-screen bg-transparent ${styles.electronDrag}`}>
{/* Language switcher — top-left, beside traffic lights */}
<div
className={`fixed top-2 flex items-center gap-1 px-2 py-1 rounded-md text-white/50 hover:text-white/90 hover:bg-white/10 transition-all duration-150 ${isMac ? "left-[72px]" : "left-2"} ${styles.electronNoDrag}`}
>
<Languages size={14} />
<select
value={locale}
onChange={(e) => setLocale(e.target.value as Locale)}
className="bg-transparent text-[11px] font-medium outline-none cursor-pointer appearance-none pr-1"
style={{ color: "inherit" }}
>
{SUPPORTED_LOCALES.map((loc) => (
<option key={loc} value={loc} className="bg-[#1c1c24] text-white">
{getLocaleName(loc)}
</option>
))}
</select>
</div>
{/* Device selectors — fixed above HUD bar, viewport-relative, never clipped */}
{(showMicControls || showWebcamControls) && (
<div
className={`fixed bottom-[60px] left-1/2 -translate-x-1/2 flex items-center gap-2 animate-mic-panel-in ${styles.electronNoDrag}`}
>
{/* Mic selector */}
{showMicControls && (
<div
className={`flex items-center gap-2 px-3 py-1.5 h-[36px] bg-gradient-to-br from-[rgba(28,28,36,0.97)] to-[rgba(18,18,26,0.96)] backdrop-blur-[24px] border border-white/10 rounded-xl shadow-2xl transition-all duration-300 overflow-hidden ${!micExpanded ? "opacity-60 grayscale-[0.5]" : "opacity-100"}`}
onMouseEnter={() => setIsMicHovered(true)}
onMouseLeave={() => setIsMicHovered(false)}
onFocus={() => setIsMicFocused(true)}
onBlur={() => setIsMicFocused(false)}
style={{ width: micExpanded ? "240px" : "140px", transition: "width 300ms ease" }}
>
<div className="relative flex-1 min-w-0">
{!micExpanded && (
<div className="text-white/60 text-[10px] font-medium truncate">
{selectedMicLabel}
</div>
)}
<select
value={microphoneDeviceId || selectedMicId}
onChange={(e) => {
setSelectedMicId(e.target.value);
setMicrophoneDeviceId(e.target.value);
}}
className={`w-full appearance-none bg-white/5 text-white text-[11px] rounded-lg pl-2 pr-6 py-1 border border-white/10 outline-none hover:bg-white/10 transition-colors cursor-pointer ${!micExpanded ? "sr-only" : ""}`}
>
{micDevices.map((device) => (
<option key={device.deviceId} value={device.deviceId} className="bg-[#1c1c24]">
{device.label}
</option>
))}
</select>
{micExpanded && (
<ChevronDown
size={12}
className="absolute right-1.5 top-1/2 -translate-y-1/2 text-white/40 pointer-events-none"
/>
)}
</div>
<AudioLevelMeter
level={level}
className={`${micExpanded ? "w-16" : "w-8"} h-2 transition-all duration-300`}
/>
</div>
<AudioLevelMeter level={level} className="w-24 h-4" />
</div>
)}
{/* Main pill bar */}
<div className="flex items-center gap-1.5 px-2 py-1.5 isolate rounded-full shadow-hud-bar bg-gradient-to-br from-[rgba(28,28,36,0.97)] to-[rgba(18,18,26,0.96)] backdrop-blur-[16px] backdrop-saturate-[140%] border border-[rgba(80,80,120,0.25)]">
{/* Drag handle */}
<div className={`flex items-center px-1 ${styles.electronDrag}`}>
{getIcon("drag", "text-white/30")}
</div>
{/* Source selector */}
<button
className={`${hudGroupClasses} p-2 ${styles.electronNoDrag}`}
onClick={openSourceSelector}
disabled={recording}
title={selectedSource}
>
{getIcon("monitor", "text-white/80")}
<span className="text-white/70 text-[11px] max-w-[72px] truncate">
{selectedSource}
</span>
</button>
{/* Audio controls group */}
<div className={`${hudGroupClasses} ${styles.electronNoDrag}`}>
<button
className={`${hudIconBtnClasses} ${systemAudioEnabled ? "drop-shadow-[0_0_4px_rgba(74,222,128,0.4)]" : ""}`}
onClick={() => !recording && setSystemAudioEnabled(!systemAudioEnabled)}
disabled={recording}
title={systemAudioEnabled ? "Disable system audio" : "Enable system audio"}
>
{systemAudioEnabled
? getIcon("volumeOn", "text-green-400")
: getIcon("volumeOff", "text-white/40")}
</button>
<button
className={`${hudIconBtnClasses} ${microphoneEnabled ? "drop-shadow-[0_0_4px_rgba(74,222,128,0.4)]" : ""}`}
onClick={toggleMicrophone}
disabled={recording}
title={microphoneEnabled ? "Disable microphone" : "Enable microphone"}
>
{microphoneEnabled
? getIcon("micOn", "text-green-400")
: getIcon("micOff", "text-white/40")}
</button>
<button
className={`${hudIconBtnClasses} ${webcamEnabled ? "drop-shadow-[0_0_4px_rgba(74,222,128,0.4)]" : ""}`}
onClick={() => {
void setWebcamEnabled(!webcamEnabled);
}}
title={webcamEnabled ? "Disable webcam" : "Enable webcam"}
>
{webcamEnabled
? getIcon("webcamOn", "text-green-400")
: getIcon("webcamOff", "text-white/40")}
</button>
</div>
{/* Record/Stop group */}
<button
className={`flex items-center gap-0.5 rounded-full p-2 transition-colors duration-150 ${styles.electronNoDrag} ${
recording ? "animate-record-pulse bg-red-500/10" : "bg-white/5 hover:bg-white/[0.08]"
}`}
onClick={hasSelectedSource ? toggleRecording : openSourceSelector}
disabled={!hasSelectedSource && !recording}
style={{ flex: "0 0 auto" }}
>
{recording ? (
<>
{getIcon("stop", "text-red-400")}
<span className="text-red-400 text-xs font-semibold tabular-nums">
{formatTimePadded(elapsed)}
</span>
</>
) : (
getIcon("record", hasSelectedSource ? "text-white/80" : "text-white/30")
)}
</button>
{/* Restart recording */}
{recording && (
<Tooltip content="Restart recording">
<button
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
onClick={restartRecording}
>
{getIcon("restart", "text-white/60")}
</button>
</Tooltip>
)}
{/* Open video file */}
<Tooltip content="Open video file">
{/* Webcam selector */}
{showWebcamControls && (
<div
className={`flex items-center gap-2 px-3 py-1.5 h-[36px] bg-gradient-to-br from-[rgba(28,28,36,0.97)] to-[rgba(18,18,26,0.96)] backdrop-blur-[24px] border border-white/10 rounded-xl shadow-2xl transition-all duration-300 overflow-hidden ${!webcamExpanded ? "opacity-60 grayscale-[0.5]" : "opacity-100"}`}
onMouseEnter={() => setIsWebcamHovered(true)}
onMouseLeave={() => setIsWebcamHovered(false)}
onFocus={() => setIsWebcamFocused(true)}
onBlur={() => setIsWebcamFocused(false)}
style={{ width: webcamExpanded ? "240px" : "140px", transition: "width 300ms ease" }}
>
<div className="relative flex-1 min-w-0">
{!webcamExpanded && (
<div className="text-white/60 text-[10px] font-medium truncate">
{selectedCameraLabel}
</div>
)}
{webcamExpanded &&
(isCameraDevicesLoading ? (
<span className="text-white/40 text-[10px] italic">
{t("webcam.searching")}
</span>
) : cameraDevicesError ? (
<span className="text-white/40 text-[10px] italic">
{t("webcam.unavailable")}
</span>
) : cameraDevices.length === 0 ? (
<span className="text-white/40 text-[10px] italic">
{t("webcam.noneFound")}
</span>
) : (
<>
<select
value={webcamDeviceId || selectedCameraId}
onChange={(e) => {
setSelectedCameraId(e.target.value);
setWebcamDeviceId(e.target.value);
}}
className="w-full appearance-none bg-white/5 text-white text-[11px] rounded-lg pl-2 pr-6 py-1 border border-white/10 outline-none hover:bg-white/10 transition-colors cursor-pointer"
>
{cameraDevices.map((device) => (
<option
key={device.deviceId}
value={device.deviceId}
className="bg-[#1c1c24]"
>
{device.label}
</option>
))}
</select>
<ChevronDown
size={12}
className="absolute right-1.5 top-1/2 -translate-y-1/2 text-white/40 pointer-events-none"
/>
</>
))}
{(!webcamExpanded || cameraDevices.length === 0) && (
<select
value={webcamDeviceId || selectedCameraId}
onChange={(e) => {
setSelectedCameraId(e.target.value);
setWebcamDeviceId(e.target.value);
}}
className="sr-only"
>
{cameraDevices.map((device) => (
<option key={device.deviceId} value={device.deviceId}>
{device.label}
</option>
))}
</select>
)}
</div>
</div>
)}
</div>
)}
{/* HUD bar — fixed at bottom center, viewport-relative, never moves */}
<div
className={`fixed bottom-4 left-1/2 -translate-x-1/2 flex items-center gap-1.5 px-2 py-1.5 rounded-full shadow-hud-bar bg-gradient-to-br from-[rgba(28,28,36,0.97)] to-[rgba(18,18,26,0.96)] backdrop-blur-[16px] backdrop-saturate-[140%] border border-[rgba(80,80,120,0.25)]`}
>
{/* Drag handle */}
<div className={`flex items-center px-1 ${styles.electronDrag}`}>
{getIcon("drag", "text-white/30")}
</div>
{/* Source selector */}
<button
className={`${hudGroupClasses} p-2 ${styles.electronNoDrag}`}
onClick={openSourceSelector}
disabled={recording}
title={selectedSource}
>
{getIcon("monitor", "text-white/80")}
<span className="text-white/70 text-[11px] max-w-[72px] truncate">{selectedSource}</span>
</button>
{/* Audio controls group */}
<div className={`${hudGroupClasses} ${styles.electronNoDrag}`}>
<button
className={`${hudIconBtnClasses} ${systemAudioEnabled ? "drop-shadow-[0_0_4px_rgba(74,222,128,0.4)]" : ""}`}
onClick={() => !recording && setSystemAudioEnabled(!systemAudioEnabled)}
disabled={recording}
title={
systemAudioEnabled ? t("audio.disableSystemAudio") : t("audio.enableSystemAudio")
}
>
{systemAudioEnabled
? getIcon("volumeOn", "text-green-400")
: getIcon("volumeOff", "text-white/40")}
</button>
<button
className={`${hudIconBtnClasses} ${microphoneEnabled ? "drop-shadow-[0_0_4px_rgba(74,222,128,0.4)]" : ""}`}
onClick={toggleMicrophone}
disabled={recording}
title={microphoneEnabled ? t("audio.disableMicrophone") : t("audio.enableMicrophone")}
>
{microphoneEnabled
? getIcon("micOn", "text-green-400")
: getIcon("micOff", "text-white/40")}
</button>
<button
className={`${hudIconBtnClasses} ${webcamEnabled ? "drop-shadow-[0_0_4px_rgba(74,222,128,0.4)]" : ""}`}
onClick={async () => {
await setWebcamEnabled(!webcamEnabled);
}}
title={webcamEnabled ? t("webcam.disableWebcam") : t("webcam.enableWebcam")}
>
{webcamEnabled
? getIcon("webcamOn", "text-green-400")
: getIcon("webcamOff", "text-white/40")}
</button>
</div>
{/* Record/Stop group */}
<button
className={`flex items-center gap-0.5 rounded-full p-2 transition-colors duration-150 ${styles.electronNoDrag} ${
recording
? paused
? "bg-amber-500/10 hover:bg-amber-500/15"
: "animate-record-pulse bg-red-500/10"
: "bg-white/5 hover:bg-white/[0.08]"
}`}
onClick={toggleRecording}
disabled={!hasSelectedSource && !recording}
style={{ flex: "0 0 auto" }}
>
{recording ? (
<>
{getIcon("stop", paused ? "text-amber-400" : "text-red-400")}
<span
className={`${paused ? "text-amber-400" : "text-red-400"} text-xs font-semibold tabular-nums`}
>
{formatTimePadded(elapsedSeconds)}
</span>
</>
) : (
getIcon("record", hasSelectedSource ? "text-white/80" : "text-white/30")
)}
</button>
{recording && (
<Tooltip content={paused ? t("tooltips.resumeRecording") : t("tooltips.pauseRecording")}>
<button
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
onClick={openVideoFile}
disabled={recording}
onClick={togglePaused}
>
{getIcon("videoFile", "text-white/60")}
{getIcon(paused ? "resume" : "pause", paused ? "text-amber-400" : "text-white/60")}
</button>
</Tooltip>
)}
{/* Open project */}
<Tooltip content="Open project">
{/* Restart recording */}
{recording && (
<Tooltip content={t("tooltips.restartRecording")}>
<button
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
onClick={openProjectFile}
disabled={recording}
onClick={restartRecording}
>
{getIcon("folder", "text-white/60")}
{getIcon("restart", "text-white/60")}
</button>
</Tooltip>
)}
{/* Window controls */}
<div className={`flex items-center gap-0.5 ${styles.electronNoDrag}`}>
<button className={windowBtnClasses} title="Hide HUD" onClick={sendHudOverlayHide}>
{getIcon("minimize", "text-white")}
{/* Cancel recording */}
{recording && (
<Tooltip content={t("tooltips.cancelRecording")}>
<button
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
onClick={cancelRecording}
>
{getIcon("cancel", "text-white/60")}
</button>
<button className={windowBtnClasses} title="Close App" onClick={sendHudOverlayClose}>
{getIcon("close", "text-white")}
</button>
</div>
</Tooltip>
)}
{/* Open video file */}
<Tooltip content={t("tooltips.openVideoFile")}>
<button
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
onClick={openVideoFile}
disabled={recording}
>
{getIcon("videoFile", "text-white/60")}
</button>
</Tooltip>
{/* Open project */}
<Tooltip content={t("tooltips.openProject")}>
<button
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
onClick={openProjectFile}
disabled={recording}
>
{getIcon("folder", "text-white/60")}
</button>
</Tooltip>
{/* Window controls */}
<div className={`flex items-center gap-0.5 ${styles.electronNoDrag}`}>
<button
className={windowBtnClasses}
title={t("tooltips.hideHUD")}
onClick={sendHudOverlayHide}
>
{getIcon("minimize", "text-white")}
</button>
<button
className={windowBtnClasses}
title={t("tooltips.closeApp")}
onClick={sendHudOverlayClose}
>
{getIcon("close", "text-white")}
</button>
</div>
</div>
</div>
+8 -5
View File
@@ -1,5 +1,6 @@
import { useEffect, useState } from "react";
import { MdCheck } from "react-icons/md";
import { useScopedT } from "@/contexts/I18nContext";
import { Button } from "../ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
import styles from "./SourceSelector.module.css";
@@ -13,6 +14,8 @@ interface DesktopSource {
}
export function SourceSelector() {
const t = useScopedT("launch");
const tc = useScopedT("common");
const [sources, setSources] = useState<DesktopSource[]>([]);
const [selectedSource, setSelectedSource] = useState<DesktopSource | null>(null);
const [loading, setLoading] = useState(true);
@@ -63,7 +66,7 @@ export function SourceSelector() {
>
<div className="text-center">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-[#34B27B] mx-auto mb-2" />
<p className="text-xs text-zinc-400">Loading sources...</p>
<p className="text-xs text-zinc-400">{t("sourceSelector.loading")}</p>
</div>
</div>
);
@@ -113,13 +116,13 @@ export function SourceSelector() {
value="screens"
className="data-[state=active]:bg-white/15 data-[state=active]:text-white text-zinc-400 rounded-full text-xs py-1 transition-all"
>
Screens ({screenSources.length})
{t("sourceSelector.screens", { count: String(screenSources.length) })}
</TabsTrigger>
<TabsTrigger
value="windows"
className="data-[state=active]:bg-white/15 data-[state=active]:text-white text-zinc-400 rounded-full text-xs py-1 transition-all"
>
Windows ({windowSources.length})
{t("sourceSelector.windows", { count: String(windowSources.length) })}
</TabsTrigger>
</TabsList>
<div className="flex-1 min-h-0">
@@ -146,14 +149,14 @@ export function SourceSelector() {
onClick={() => window.close()}
className="px-5 py-1 text-xs text-zinc-400 hover:text-white hover:bg-white/5 rounded-full"
>
Cancel
{tc("actions.cancel")}
</Button>
<Button
onClick={handleShare}
disabled={!selectedSource}
className="px-5 py-1 text-xs bg-[#34B27B] text-white hover:bg-[#34B27B]/80 disabled:opacity-30 disabled:bg-zinc-700 rounded-full"
>
Share
{tc("actions.share")}
</Button>
</div>
</div>
@@ -12,6 +12,7 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useScopedT } from "@/contexts/I18nContext";
import {
addCustomFont,
type CustomFont,
@@ -25,6 +26,8 @@ interface AddCustomFontDialogProps {
}
export function AddCustomFontDialog({ onFontAdded }: AddCustomFontDialogProps) {
const t = useScopedT("settings");
const tc = useScopedT("common");
const [open, setOpen] = useState(false);
const [importUrl, setImportUrl] = useState("");
const [fontName, setFontName] = useState("");
@@ -45,17 +48,17 @@ export function AddCustomFontDialog({ onFontAdded }: AddCustomFontDialogProps) {
const handleAdd = async () => {
// Validate inputs
if (!importUrl.trim()) {
toast.error("Please enter a Google Fonts import URL");
toast.error(t("customFont.errorEmptyUrl"));
return;
}
if (!isValidGoogleFontsUrl(importUrl)) {
toast.error("Please enter a valid Google Fonts URL");
toast.error(t("customFont.errorInvalidUrl"));
return;
}
if (!fontName.trim()) {
toast.error("Please enter a font name");
toast.error(t("customFont.errorEmptyName"));
return;
}
@@ -65,7 +68,7 @@ export function AddCustomFontDialog({ onFontAdded }: AddCustomFontDialogProps) {
// Extract font family from URL
const fontFamily = parseFontFamilyFromImport(importUrl);
if (!fontFamily) {
toast.error("Could not extract font family from URL");
toast.error(t("customFont.errorExtractFailed"));
setLoading(false);
return;
}
@@ -86,7 +89,7 @@ export function AddCustomFontDialog({ onFontAdded }: AddCustomFontDialogProps) {
onFontAdded(newFont);
}
toast.success(`Font "${fontName}" added successfully`);
toast.success(t("customFont.successMessage", { fontName }));
// Reset and close
setImportUrl("");
@@ -95,10 +98,10 @@ export function AddCustomFontDialog({ onFontAdded }: AddCustomFontDialogProps) {
} catch (error) {
console.error("Failed to add custom font:", error);
const errorMessage = error instanceof Error ? error.message : "Failed to load font";
toast.error("Failed to add font", {
toast.error(t("customFont.failedToAdd"), {
description: errorMessage.includes("timeout")
? "Font took too long to load. Please check the URL and try again."
: "The font could not be loaded. Please verify the Google Fonts URL is correct.",
? t("customFont.errorTimeout")
: t("customFont.errorLoadFailed"),
});
} finally {
setLoading(false);
@@ -114,12 +117,12 @@ export function AddCustomFontDialog({ onFontAdded }: AddCustomFontDialogProps) {
className="w-full bg-white/5 border-white/10 text-slate-200 hover:bg-white/10 h-9 text-xs"
>
<Plus className="w-3 h-3 mr-1" />
Add Google Font
{t("customFont.dialogTitle")}
</Button>
</DialogTrigger>
<DialogContent className="bg-[#1a1a1c] border-white/10 text-slate-200">
<DialogHeader>
<DialogTitle>Add Google Font</DialogTitle>
<DialogTitle>{t("customFont.dialogTitle")}</DialogTitle>
<DialogDescription className="text-slate-400">
Add a custom font from Google Fonts to use in your annotations.
</DialogDescription>
@@ -128,34 +131,30 @@ export function AddCustomFontDialog({ onFontAdded }: AddCustomFontDialogProps) {
<div className="space-y-4 mt-4">
<div className="space-y-2">
<Label htmlFor="import-url" className="text-slate-200">
Google Fonts Import URL
{t("customFont.urlLabel")}
</Label>
<Input
id="import-url"
placeholder="https://fonts.googleapis.com/css2?family=Roboto&display=swap"
placeholder={t("customFont.urlPlaceholder")}
value={importUrl}
onChange={(e) => handleImportUrlChange(e.target.value)}
className="bg-white/5 border-white/10 text-slate-200"
/>
<p className="text-xs text-slate-400">
Get this from Google Fonts: Select a font → Click "Get font" → Copy the @import URL
</p>
<p className="text-xs text-slate-400">{t("customFont.urlHelp")}</p>
</div>
<div className="space-y-2">
<Label htmlFor="font-name" className="text-slate-200">
Display Name
{t("customFont.nameLabel")}
</Label>
<Input
id="font-name"
placeholder="My Custom Font"
placeholder={t("customFont.namePlaceholder")}
value={fontName}
onChange={(e) => setFontName(e.target.value)}
className="bg-white/5 border-white/10 text-slate-200"
/>
<p className="text-xs text-slate-400">
This is how the font will appear in the font selector
</p>
<p className="text-xs text-slate-400">{t("customFont.nameHelp")}</p>
</div>
<div className="flex justify-end gap-2 mt-6">
@@ -164,14 +163,14 @@ export function AddCustomFontDialog({ onFontAdded }: AddCustomFontDialogProps) {
onClick={() => setOpen(false)}
className="bg-white/5 border-white/10 text-slate-200 hover:bg-white/10"
>
Cancel
{tc("actions.cancel")}
</Button>
<Button
onClick={handleAdd}
disabled={loading}
className="bg-blue-600 hover:bg-blue-700 text-white"
>
{loading ? "Adding..." : "Add Font"}
{loading ? t("customFont.addingButton") : t("customFont.addButton")}
</Button>
</div>
</div>
@@ -27,6 +27,7 @@ import {
import { Slider } from "@/components/ui/slider";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { useScopedT } from "@/contexts/I18nContext";
import { type CustomFont, getCustomFonts } from "@/lib/customFonts";
import { cn } from "@/lib/utils";
import { AddCustomFontDialog } from "./AddCustomFontDialog";
@@ -43,14 +44,14 @@ interface AnnotationSettingsPanelProps {
}
const FONT_FAMILIES = [
{ value: "system-ui, -apple-system, sans-serif", label: "Classic" },
{ value: "Georgia, serif", label: "Editor" },
{ value: "Impact, Arial Black, sans-serif", label: "Strong" },
{ value: "Courier New, monospace", label: "Typewriter" },
{ value: "Brush Script MT, cursive", label: "Deco" },
{ value: "Arial, sans-serif", label: "Simple" },
{ value: "Verdana, sans-serif", label: "Modern" },
{ value: "Trebuchet MS, sans-serif", label: "Clean" },
{ value: "system-ui, -apple-system, sans-serif", labelKey: "classic" },
{ value: "Georgia, serif", labelKey: "editor" },
{ value: "Impact, Arial Black, sans-serif", labelKey: "strong" },
{ value: "Courier New, monospace", labelKey: "typewriter" },
{ value: "Brush Script MT, cursive", labelKey: "deco" },
{ value: "Arial, sans-serif", labelKey: "simple" },
{ value: "Verdana, sans-serif", labelKey: "modern" },
{ value: "Trebuchet MS, sans-serif", labelKey: "clean" },
];
const FONT_SIZES = [12, 14, 16, 18, 20, 24, 28, 32, 36, 40, 48, 56, 64, 72, 80, 96, 128];
@@ -63,9 +64,21 @@ export function AnnotationSettingsPanel({
onFigureDataChange,
onDelete,
}: AnnotationSettingsPanelProps) {
const t = useScopedT("settings");
const fileInputRef = useRef<HTMLInputElement>(null);
const [customFonts, setCustomFonts] = useState<CustomFont[]>([]);
const fontStyleLabels: Record<string, string> = {
classic: t("fontStyles.classic"),
editor: t("fontStyles.editor"),
strong: t("fontStyles.strong"),
typewriter: t("fontStyles.typewriter"),
deco: t("fontStyles.deco"),
simple: t("fontStyles.simple"),
modern: t("fontStyles.modern"),
clean: t("fontStyles.clean"),
};
// Load custom fonts on mount
useEffect(() => {
setCustomFonts(getCustomFonts());
@@ -99,8 +112,8 @@ export function AnnotationSettingsPanel({
// Validate file type
const validTypes = ["image/jpeg", "image/jpg", "image/png", "image/gif", "image/webp"];
if (!validTypes.includes(file.type)) {
toast.error("Invalid file type", {
description: "Please upload a JPG, PNG, GIF, or WebP image file.",
toast.error(t("annotation.invalidImageType"), {
description: t("annotation.imageFormatsOnly"),
});
event.target.value = "";
return;
@@ -112,12 +125,12 @@ export function AnnotationSettingsPanel({
const dataUrl = e.target?.result as string;
if (dataUrl) {
onContentChange(dataUrl);
toast.success("Image uploaded successfully!");
toast.success(t("annotation.imageUploadSuccess"));
}
};
reader.onerror = () => {
toast.error("Failed to upload image", {
toast.error(t("annotation.failedImageUpload"), {
description: "There was an error reading the file.",
});
};
@@ -130,9 +143,9 @@ export function AnnotationSettingsPanel({
<div className="flex-[2] min-w-0 bg-[#09090b] border border-white/5 rounded-2xl p-4 flex flex-col shadow-xl h-full overflow-y-auto custom-scrollbar">
<div className="mb-6">
<div className="flex items-center justify-between mb-4">
<span className="text-sm font-medium text-slate-200">Annotation Settings</span>
<span className="text-sm font-medium text-slate-200">{t("annotation.title")}</span>
<span className="text-[10px] uppercase tracking-wider font-medium text-[#34B27B] bg-[#34B27B]/10 px-2 py-1 rounded-full">
Active
{t("annotation.active")}
</span>
</div>
@@ -148,14 +161,14 @@ export function AnnotationSettingsPanel({
className="data-[state=active]:bg-[#34B27B] data-[state=active]:text-white text-slate-400 py-2 rounded-lg transition-all gap-2"
>
<Type className="w-4 h-4" />
Text
{t("annotation.typeText")}
</TabsTrigger>
<TabsTrigger
value="image"
className="data-[state=active]:bg-[#34B27B] data-[state=active]:text-white text-slate-400 py-2 rounded-lg transition-all gap-2"
>
<ImageIcon className="w-4 h-4" />
Image
{t("annotation.typeImage")}
</TabsTrigger>
<TabsTrigger
value="figure"
@@ -170,18 +183,20 @@ export function AnnotationSettingsPanel({
>
<path d="M4 12h16m0 0l-6-6m6 6l-6 6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
Arrow
{t("annotation.typeArrow")}
</TabsTrigger>
</TabsList>
{/* Text Content */}
<TabsContent value="text" className="mt-0 space-y-4">
<div>
<label className="text-xs font-medium text-slate-200 mb-2 block">Text Content</label>
<label className="text-xs font-medium text-slate-200 mb-2 block">
{t("annotation.textContent")}
</label>
<textarea
value={annotation.textContent || annotation.content}
onChange={(e) => onContentChange(e.target.value)}
placeholder="Enter your text..."
placeholder={t("annotation.textPlaceholder")}
rows={5}
className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-slate-200 text-sm placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-[#34B27B] focus:border-transparent resize-none"
/>
@@ -193,14 +208,14 @@ export function AnnotationSettingsPanel({
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-xs font-medium text-slate-200 mb-2 block">
Font Style
{t("annotation.fontStyle")}
</label>
<Select
value={annotation.style.fontFamily}
onValueChange={(value) => onStyleChange({ fontFamily: value })}
>
<SelectTrigger className="w-full bg-white/5 border-white/10 text-slate-200 h-9 text-xs">
<SelectValue placeholder="Select style" />
<SelectValue placeholder={t("annotation.selectStyle")} />
</SelectTrigger>
<SelectContent className="bg-[#1a1a1c] border-white/10 text-slate-200 max-h-[300px]">
{FONT_FAMILIES.map((font) => (
@@ -209,13 +224,13 @@ export function AnnotationSettingsPanel({
value={font.value}
style={{ fontFamily: font.value }}
>
{font.label}
{fontStyleLabels[font.labelKey]}
</SelectItem>
))}
{customFonts.length > 0 && (
<>
<div className="px-2 py-1.5 text-[10px] font-medium text-slate-400 uppercase tracking-wider">
Custom Fonts
{t("annotation.customFonts")}
</div>
{customFonts.map((font) => (
<SelectItem
@@ -232,13 +247,15 @@ export function AnnotationSettingsPanel({
</Select>
</div>
<div>
<label className="text-xs font-medium text-slate-200 mb-2 block">Size</label>
<label className="text-xs font-medium text-slate-200 mb-2 block">
{t("annotation.size")}
</label>
<Select
value={annotation.style.fontSize.toString()}
onValueChange={(value) => onStyleChange({ fontSize: parseInt(value) })}
>
<SelectTrigger className="w-full bg-white/5 border-white/10 text-slate-200 h-9 text-xs">
<SelectValue placeholder="Size" />
<SelectValue placeholder={t("annotation.size")} />
</SelectTrigger>
<SelectContent className="bg-[#1a1a1c] border-white/10 text-slate-200 max-h-[200px]">
{FONT_SIZES.map((size) => (
@@ -345,7 +362,7 @@ export function AnnotationSettingsPanel({
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-xs font-medium text-slate-200 mb-2 block">
Text Color
{t("annotation.textColor")}
</label>
<Popover>
<PopoverTrigger asChild>
@@ -379,7 +396,7 @@ export function AnnotationSettingsPanel({
</div>
<div>
<label className="text-xs font-medium text-slate-200 mb-2 block">
Background
{t("annotation.background")}
</label>
<Popover>
<PopoverTrigger asChild>
@@ -395,7 +412,9 @@ export function AnnotationSettingsPanel({
/>
</div>
<span className="text-xs text-slate-300 truncate flex-1 text-left">
{annotation.style.backgroundColor === "transparent" ? "None" : "Color"}
{annotation.style.backgroundColor === "transparent"
? t("annotation.none")
: t("annotation.color")}
</span>
<ChevronDown className="h-3 w-3 opacity-50" />
</Button>
@@ -423,7 +442,7 @@ export function AnnotationSettingsPanel({
onStyleChange({ backgroundColor: "transparent" });
}}
>
Clear Background
{t("annotation.clearBackground")}
</Button>
</PopoverContent>
</Popover>
@@ -447,7 +466,7 @@ export function AnnotationSettingsPanel({
className="w-full gap-2 bg-white/5 text-slate-200 border-white/10 hover:bg-[#34B27B] hover:text-white hover:border-[#34B27B] transition-all py-8"
>
<Upload className="w-5 h-5" />
Upload Image
{t("annotation.uploadImage")}
</Button>
{annotation.content && annotation.content.startsWith("data:image") && (
@@ -461,14 +480,14 @@ export function AnnotationSettingsPanel({
)}
<p className="text-xs text-slate-500 text-center leading-relaxed">
Supported formats: JPG, PNG, GIF, WebP
{t("annotation.supportedFormats")}
</p>
</TabsContent>
<TabsContent value="figure" className="mt-0 space-y-4">
<div>
<label className="text-xs font-medium text-slate-200 mb-3 block">
Arrow Direction
{t("annotation.arrowDirection")}
</label>
<div className="grid grid-cols-4 gap-2">
{(
@@ -517,7 +536,9 @@ export function AnnotationSettingsPanel({
<div>
<label className="text-xs font-medium text-slate-200 mb-2 block">
Stroke Width: {annotation.figureData?.strokeWidth || 4}px
{t("annotation.strokeWidth", {
width: String(annotation.figureData?.strokeWidth || 4),
})}
</label>
<Slider
value={[annotation.figureData?.strokeWidth || 4]}
@@ -536,7 +557,9 @@ export function AnnotationSettingsPanel({
</div>
<div>
<label className="text-xs font-medium text-slate-200 mb-2 block">Arrow Color</label>
<label className="text-xs font-medium text-slate-200 mb-2 block">
{t("annotation.arrowColor")}
</label>
<Popover>
<PopoverTrigger asChild>
<Button
@@ -581,28 +604,18 @@ export function AnnotationSettingsPanel({
className="w-full gap-2 bg-red-500/10 text-red-400 border border-red-500/20 hover:bg-red-500/20 hover:border-red-500/30 transition-all mt-4"
>
<Trash2 className="w-4 h-4" />
Delete Annotation
{t("annotation.deleteAnnotation")}
</Button>
<div className="mt-6 p-3 bg-white/5 rounded-lg border border-white/5">
<div className="flex items-center gap-2 mb-2 text-slate-300">
<Info className="w-3.5 h-3.5" />
<span className="text-xs font-medium">Shortcuts & Tips</span>
<span className="text-xs font-medium">{t("annotation.shortcutsAndTips")}</span>
</div>
<ul className="text-[10px] text-slate-400 space-y-1.5 list-disc pl-3 leading-relaxed">
<li>Move playhead to overlapping annotation section and select an item.</li>
<li>
Use{" "}
<kbd className="px-1 py-0.5 bg-white/10 rounded text-slate-300 font-mono">Tab</kbd> to
cycle through overlapping items.
</li>
<li>
Use{" "}
<kbd className="px-1 py-0.5 bg-white/10 rounded text-slate-300 font-mono">
Shift+Tab
</kbd>{" "}
to cycle backwards.
</li>
<li>{t("annotation.tipMovePlayhead")}</li>
<li>{t("annotation.tipTabCycle")}</li>
<li>{t("annotation.tipShiftTabCycle")}</li>
</ul>
</div>
</div>
+30 -20
View File
@@ -1,6 +1,7 @@
import { Download, Loader2, X } from "lucide-react";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { useScopedT } from "@/contexts/I18nContext";
import type { ExportProgress } from "@/lib/exporter";
interface ExportDialogProps {
@@ -26,6 +27,7 @@ export function ExportDialog({
exportedFilePath,
onShowInFolder,
}: ExportDialogProps) {
const t = useScopedT("dialogs");
const [showSuccess, setShowSuccess] = useState(false);
// Reset showSuccess when a new export starts or dialog reopens
@@ -65,25 +67,25 @@ export function ExportDialog({
// Get status message based on phase
const getStatusMessage = () => {
if (error) return "Please try again";
if (error) return t("export.tryAgain");
if (isCompiling || isFinalizing) {
if (exportFormat === "mp4") {
return "Finalizing video export...";
return t("export.finalizingVideo");
}
if (renderProgress !== undefined && renderProgress > 0) {
return `Compiling GIF... ${renderProgress}%`;
return t("export.compilingGifProgress", { progress: String(renderProgress) });
}
return "Compiling GIF... This may take a while";
return t("export.compilingGifWait");
}
return "This may take a moment...";
return t("export.takeMoment");
};
// Get title based on phase
const getTitle = () => {
if (error) return "Export Failed";
if (isFinalizing && exportFormat === "mp4") return "Finalizing Video";
if (isCompiling || isFinalizing) return "Compiling GIF";
return `Exporting ${formatLabel}`;
if (error) return t("export.failed");
if (isFinalizing && exportFormat === "mp4") return t("export.finalizingVideoTitle");
if (isCompiling || isFinalizing) return t("export.compilingGif");
return t("export.exportingFormat", { format: formatLabel });
};
return (
@@ -101,9 +103,11 @@ export function ExportDialog({
<Download className="w-6 h-6 text-[#34B27B]" />
</div>
<div className="flex flex-col gap-2">
<span className="text-xl font-bold text-slate-200 block">Export Complete</span>
<span className="text-xl font-bold text-slate-200 block">
{t("export.complete")}
</span>
<span className="text-sm text-slate-400">
Your {formatLabel.toLowerCase()} is ready
{t("export.yourFormatReady", { format: formatLabel.toLowerCase() })}
</span>
{exportedFilePath && (
<Button
@@ -111,7 +115,7 @@ export function ExportDialog({
onClick={onShowInFolder}
className="mt-2 w-fit px-3 py-1 text-sm rounded-md bg-white/10 hover:bg-white/20 text-slate-200"
>
Show in Folder
{t("export.showInFolder")}
</Button>
)}
{exportedFilePath && (
@@ -166,7 +170,11 @@ export function ExportDialog({
<div className="space-y-6">
<div className="space-y-2">
<div className="flex justify-between text-xs font-medium text-slate-400 uppercase tracking-wider">
<span>{isCompiling || isFinalizing ? "Compiling" : "Rendering Frames"}</span>
<span>
{isCompiling || isFinalizing
? t("export.compiling")
: t("export.renderingFrames")}
</span>
<span className="font-mono text-slate-200">
{isCompiling || isFinalizing ? (
renderProgress !== undefined && renderProgress > 0 ? (
@@ -174,7 +182,7 @@ export function ExportDialog({
) : (
<span className="flex items-center gap-2">
<Loader2 className="w-3 h-3 animate-spin" />
Processing...
{t("export.processing")}
</span>
)
) : (
@@ -218,19 +226,19 @@ export function ExportDialog({
<div className="grid grid-cols-2 gap-4">
<div className="bg-white/5 rounded-xl p-3 border border-white/5">
<div className="text-[10px] text-slate-500 uppercase tracking-wider mb-1">
{isCompiling || isFinalizing ? "Status" : "Format"}
{isCompiling || isFinalizing ? t("export.status") : t("export.format")}
</div>
<div className="text-slate-200 font-medium text-sm">
{isFinalizing && exportFormat === "mp4"
? "Finalizing..."
? t("export.finalizing")
: isCompiling || isFinalizing
? "Compiling..."
? t("export.compilingStatus")
: formatLabel}
</div>
</div>
<div className="bg-white/5 rounded-xl p-3 border border-white/5">
<div className="text-[10px] text-slate-500 uppercase tracking-wider mb-1">
Frames
{t("export.frames")}
</div>
<div className="text-slate-200 font-medium text-sm">
{progress.currentFrame} / {progress.totalFrames}
@@ -245,7 +253,7 @@ export function ExportDialog({
variant="destructive"
className="w-full py-6 bg-red-500/10 text-red-400 border border-red-500/20 hover:bg-red-500/20 hover:border-red-500/30 transition-all rounded-xl"
>
Cancel Export
{t("export.cancelExport")}
</Button>
</div>
)}
@@ -254,7 +262,9 @@ export function ExportDialog({
{showSuccess && (
<div className="text-center py-4 animate-in zoom-in-95">
<p className="text-lg text-slate-200 font-medium">{formatLabel} saved successfully!</p>
<p className="text-lg text-slate-200 font-medium">
{t("export.savedSuccessfully", { format: formatLabel })}
</p>
</div>
)}
</div>
+14 -22
View File
@@ -1,4 +1,5 @@
import { Film, Image } from "lucide-react";
import { useScopedT } from "@/contexts/I18nContext";
import type { ExportFormat } from "@/lib/exporter/types";
import { cn } from "@/lib/utils";
@@ -8,26 +9,9 @@ interface FormatSelectorProps {
disabled?: boolean;
}
interface FormatOption {
value: ExportFormat;
label: string;
description: string;
icon: React.ReactNode;
}
const formatOptions: FormatOption[] = [
{
value: "mp4",
label: "MP4 Video",
description: "High quality video file",
icon: <Film className="w-5 h-5" />,
},
{
value: "gif",
label: "GIF Animation",
description: "Animated image for sharing",
icon: <Image className="w-5 h-5" />,
},
const formatOptions: Array<{ value: ExportFormat; icon: React.ReactNode }> = [
{ value: "mp4", icon: <Film className="w-5 h-5" /> },
{ value: "gif", icon: <Image className="w-5 h-5" /> },
];
export function FormatSelector({
@@ -35,10 +19,18 @@ export function FormatSelector({
onFormatChange,
disabled = false,
}: FormatSelectorProps) {
const t = useScopedT("settings");
const formatLabels: Record<ExportFormat, { label: string; description: string }> = {
mp4: { label: t("exportFormat.mp4Video"), description: t("exportFormat.mp4Description") },
gif: { label: t("exportFormat.gifAnimation"), description: t("exportFormat.gifDescription") },
};
return (
<div className="grid grid-cols-2 gap-3">
{formatOptions.map((option) => {
const isSelected = selectedFormat === option.value;
const labels = formatLabels[option.value];
return (
<button
key={option.value}
@@ -63,8 +55,8 @@ export function FormatSelector({
{option.icon}
</div>
<div className="text-center">
<div className="font-medium text-sm">{option.label}</div>
<div className="text-xs text-slate-500 mt-0.5">{option.description}</div>
<div className="font-medium text-sm">{labels.label}</div>
<div className="text-xs text-slate-500 mt-0.5">{labels.description}</div>
</div>
{isSelected && (
<div className="absolute top-2 right-2 w-2 h-2 rounded-full bg-[#34B27B]" />
@@ -1,9 +1,11 @@
import { HelpCircle, Settings2 } from "lucide-react";
import { useScopedT } from "@/contexts/I18nContext";
import { useShortcuts } from "@/contexts/ShortcutsContext";
import { FIXED_SHORTCUTS, formatBinding, SHORTCUT_ACTIONS, SHORTCUT_LABELS } from "@/lib/shortcuts";
import { FIXED_SHORTCUTS, formatBinding, SHORTCUT_ACTIONS } from "@/lib/shortcuts";
export function KeyboardShortcutsHelp() {
const { shortcuts, isMac, openConfig } = useShortcuts();
const t = useScopedT("shortcuts");
return (
<div className="relative group">
@@ -11,7 +13,7 @@ export function KeyboardShortcutsHelp() {
<div className="absolute right-0 top-full mt-2 w-64 bg-[#09090b] border border-white/10 rounded-lg p-3 opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 shadow-xl z-50">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-semibold text-slate-200">Keyboard Shortcuts</span>
<span className="text-xs font-semibold text-slate-200">{t("title")}</span>
<button
type="button"
onClick={openConfig}
@@ -19,14 +21,14 @@ export function KeyboardShortcutsHelp() {
className="flex items-center gap-1 text-[10px] text-slate-500 hover:text-[#34B27B] transition-colors"
>
<Settings2 className="w-3 h-3" />
Customize
{t("customize")}
</button>
</div>
<div className="space-y-1.5 text-[10px]">
{SHORTCUT_ACTIONS.map((action) => (
<div key={action} className="flex items-center justify-between">
<span className="text-slate-400">{SHORTCUT_LABELS[action]}</span>
<span className="text-slate-400">{t(`actions.${action}`)}</span>
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">
{formatBinding(shortcuts[action], isMac)}
</kbd>
@@ -35,8 +37,10 @@ export function KeyboardShortcutsHelp() {
<div className="pt-1 border-t border-white/5 mt-1 space-y-1.5">
{FIXED_SHORTCUTS.map((fixed) => (
<div key={fixed.label} className="flex items-center justify-between">
<span className="text-slate-400">{fixed.label}</span>
<div key={fixed.i18nKey} className="flex items-center justify-between">
<span className="text-slate-400">
{t(`fixedActions.${fixed.i18nKey}`, { defaultValue: fixed.label })}
</span>
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">
{isMac
? fixed.display
@@ -1,4 +1,5 @@
import { Pause, Play } from "lucide-react";
import { Maximize, Minimize, Pause, Play } from "lucide-react";
import { useScopedT } from "@/contexts/I18nContext";
import { cn } from "@/lib/utils";
import { Button } from "../ui/button";
@@ -6,6 +7,8 @@ interface PlaybackControlsProps {
isPlaying: boolean;
currentTime: number;
duration: number;
isFullscreen?: boolean;
onToggleFullscreen?: () => void;
onTogglePlayPause: () => void;
onSeek: (time: number) => void;
}
@@ -14,9 +17,13 @@ export default function PlaybackControls({
isPlaying,
currentTime,
duration,
isFullscreen = false,
onToggleFullscreen,
onTogglePlayPause,
onSeek,
}: PlaybackControlsProps) {
const t = useScopedT("common");
function formatTime(seconds: number) {
if (!isFinite(seconds) || isNaN(seconds) || seconds < 0) return "0:00";
const mins = Math.floor(seconds / 60);
@@ -41,7 +48,7 @@ export default function PlaybackControls({
? "bg-white/10 text-white hover:bg-white/20"
: "bg-white text-black hover:bg-white/90 hover:scale-105 shadow-[0_0_15px_rgba(255,255,255,0.3)]",
)}
aria-label={isPlaying ? "Pause" : "Play"}
aria-label={isPlaying ? t("playback.pause") : t("playback.play")}
>
{isPlaying ? (
<Pause className="w-3.5 h-3.5 fill-current" />
@@ -84,6 +91,21 @@ export default function PlaybackControls({
<span className="text-[9px] font-medium text-slate-500 tabular-nums w-[30px]">
{formatTime(duration)}
</span>
{onToggleFullscreen && (
<Button
onClick={onToggleFullscreen}
size="icon"
className="w-7 h-7 rounded-full transition-all duration-200 border border-transparent hover:bg-white/10 text-white hover:border-white/10 shrink-0 shadow-none ml-0.5"
aria-label={isFullscreen ? t("playback.exitFullscreen") : t("playback.fullscreen")}
>
{isFullscreen ? (
<Minimize className="w-3.5 h-3.5" />
) : (
<Maximize className="w-3.5 h-3.5" />
)}
</Button>
)}
</div>
);
}
+267 -75
View File
@@ -4,11 +4,9 @@ import {
Crop,
Download,
Film,
FolderOpen,
Image,
Lock,
Palette,
Save,
Sparkles,
Star,
Trash2,
@@ -25,14 +23,23 @@ import {
AccordionTrigger,
} from "@/components/ui/accordion";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Slider } from "@/components/ui/slider";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useScopedT } from "@/contexts/I18nContext";
import { getAssetPath } from "@/lib/assetPath";
import { WEBCAM_LAYOUT_PRESETS } from "@/lib/compositeLayout";
import type { ExportFormat, ExportQuality, GifFrameRate, GifSizePreset } from "@/lib/exporter";
import { GIF_FRAME_RATES, GIF_SIZE_PRESETS } from "@/lib/exporter";
import { cn } from "@/lib/utils";
import { type AspectRatio } from "@/utils/aspectRatioUtils";
import { type AspectRatio, isPortraitAspectRatio } from "@/utils/aspectRatioUtils";
import { getTestId } from "@/utils/getTestId";
import { AnnotationSettingsPanel } from "./AnnotationSettingsPanel";
import { CropControl } from "./CropControl";
@@ -43,7 +50,10 @@ import type {
CropRegion,
FigureData,
PlaybackSpeed,
WebcamLayoutPreset,
WebcamMaskShape,
ZoomDepth,
ZoomFocusMode,
} from "./types";
import { SPEED_OPTIONS } from "./types";
@@ -84,6 +94,9 @@ interface SettingsPanelProps {
onWallpaperChange: (path: string) => void;
selectedZoomDepth?: ZoomDepth | null;
onZoomDepthChange?: (depth: ZoomDepth) => void;
selectedZoomFocusMode?: ZoomFocusMode | null;
onZoomFocusModeChange?: (mode: ZoomFocusMode) => void;
hasCursorTelemetry?: boolean;
selectedZoomId?: string | null;
onZoomDelete?: (id: string) => void;
selectedTrimId?: string | null;
@@ -118,9 +131,9 @@ interface SettingsPanelProps {
gifSizePreset?: GifSizePreset;
onGifSizePresetChange?: (preset: GifSizePreset) => void;
gifOutputDimensions?: { width: number; height: number };
onSaveProject?: () => void;
onLoadProject?: () => void;
onExport?: () => void;
unsavedExport?: { arrayBuffer: ArrayBuffer; fileName: string; format: string } | null;
onSaveUnsavedExport?: () => void;
selectedAnnotationId?: string | null;
annotationRegions?: AnnotationRegion[];
onAnnotationContentChange?: (id: string, content: string) => void;
@@ -132,6 +145,11 @@ interface SettingsPanelProps {
selectedSpeedValue?: PlaybackSpeed | null;
onSpeedChange?: (speed: PlaybackSpeed) => void;
onSpeedDelete?: (id: string) => void;
hasWebcam?: boolean;
webcamLayoutPreset?: WebcamLayoutPreset;
onWebcamLayoutPresetChange?: (preset: WebcamLayoutPreset) => void;
webcamMaskShape?: import("./types").WebcamMaskShape;
onWebcamMaskShapeChange?: (shape: import("./types").WebcamMaskShape) => void;
}
export default SettingsPanel;
@@ -150,6 +168,9 @@ export function SettingsPanel({
onWallpaperChange,
selectedZoomDepth,
onZoomDepthChange,
selectedZoomFocusMode,
onZoomFocusModeChange,
hasCursorTelemetry = false,
selectedZoomId,
onZoomDelete,
selectedTrimId,
@@ -183,9 +204,9 @@ export function SettingsPanel({
gifSizePreset = "medium",
onGifSizePresetChange,
gifOutputDimensions = { width: 1280, height: 720 },
onSaveProject,
onLoadProject,
onExport,
unsavedExport,
onSaveUnsavedExport,
selectedAnnotationId,
annotationRegions = [],
onAnnotationContentChange,
@@ -197,7 +218,13 @@ export function SettingsPanel({
selectedSpeedValue,
onSpeedChange,
onSpeedDelete,
hasWebcam = false,
webcamLayoutPreset = "picture-in-picture",
onWebcamLayoutPresetChange,
webcamMaskShape = "rectangle",
onWebcamMaskShapeChange,
}: SettingsPanelProps) {
const t = useScopedT("settings");
const [wallpaperPaths, setWallpaperPaths] = useState<string[]>([]);
const [customImages, setCustomImages] = useState<string[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -363,8 +390,8 @@ export function SettingsPanel({
// Validate file type - only allow JPG/JPEG
const validTypes = ["image/jpeg", "image/jpg"];
if (!validTypes.includes(file.type)) {
toast.error("Invalid file type", {
description: "Please upload a JPG or JPEG image file.",
toast.error(t("imageUpload.invalidFileType"), {
description: t("imageUpload.jpgOnly"),
});
event.target.value = "";
return;
@@ -377,13 +404,13 @@ export function SettingsPanel({
if (dataUrl) {
setCustomImages((prev) => [...prev, dataUrl]);
onWallpaperChange(dataUrl);
toast.success("Custom image uploaded successfully!");
toast.success(t("imageUpload.uploadSuccess"));
}
};
reader.onerror = () => {
toast.error("Failed to upload image", {
description: "There was an error reading the file.",
toast.error(t("imageUpload.failedToUpload"), {
description: t("imageUpload.errorReading"),
});
};
@@ -449,7 +476,7 @@ export function SettingsPanel({
<div className="flex-1 overflow-y-auto custom-scrollbar p-4 pb-0">
<div className="mb-4">
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-medium text-slate-200">Zoom Level</span>
<span className="text-sm font-medium text-slate-200">{t("zoom.level")}</span>
<div className="flex items-center gap-2">
{zoomEnabled && selectedZoomDepth && (
<span className="text-[10px] uppercase tracking-wider font-medium text-[#34B27B] bg-[#34B27B]/10 px-2 py-0.5 rounded-full">
@@ -483,9 +510,42 @@ export function SettingsPanel({
})}
</div>
{!zoomEnabled && (
<p className="text-[10px] text-slate-500 mt-2 text-center">
Select a zoom region to adjust
</p>
<p className="text-[10px] text-slate-500 mt-2 text-center">{t("zoom.selectRegion")}</p>
)}
{zoomEnabled && hasCursorTelemetry && (
<div className="mt-3">
<span className="text-sm font-medium text-slate-200 mb-2 block">
{t("zoom.focusMode.title")}
</span>
<div className="grid grid-cols-2 gap-1.5">
{(["manual", "auto"] as const).map((mode) => {
const isActive = selectedZoomFocusMode === mode;
return (
<Button
key={mode}
type="button"
onClick={() => onZoomFocusModeChange?.(mode)}
className={cn(
"h-auto w-full rounded-lg border px-2 py-2 text-center shadow-sm transition-all",
"duration-200 ease-out cursor-pointer",
isActive
? "border-[#34B27B] bg-[#34B27B] text-white shadow-[#34B27B]/20"
: "border-white/5 bg-white/5 text-slate-400 hover:bg-white/10 hover:border-white/10 hover:text-slate-200",
)}
>
<span className="text-xs font-semibold capitalize">
{t(`zoom.focusMode.${mode}`)}
</span>
</Button>
);
})}
</div>
{selectedZoomFocusMode === "auto" && (
<p className="text-[10px] text-slate-500 mt-1.5">
{t("zoom.focusMode.autoDescription")}
</p>
)}
</div>
)}
{zoomEnabled && (
<Button
@@ -495,7 +555,7 @@ export function SettingsPanel({
className="mt-2 w-full gap-2 bg-red-500/10 text-red-400 border border-red-500/20 hover:bg-red-500/20 hover:border-red-500/30 transition-all h-8 text-xs"
>
<Trash2 className="w-3 h-3" />
Delete Zoom
{t("zoom.deleteZoom")}
</Button>
)}
</div>
@@ -509,14 +569,14 @@ export function SettingsPanel({
className="w-full gap-2 bg-red-500/10 text-red-400 border border-red-500/20 hover:bg-red-500/20 hover:border-red-500/30 transition-all h-8 text-xs"
>
<Trash2 className="w-3 h-3" />
Delete Trim Region
{t("trim.deleteRegion")}
</Button>
</div>
)}
<div className="mb-4">
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-medium text-slate-200">Playback Speed</span>
<span className="text-sm font-medium text-slate-200">{t("speed.playbackSpeed")}</span>
{selectedSpeedId && selectedSpeedValue && (
<span className="text-[10px] uppercase tracking-wider font-medium text-[#d97706] bg-[#d97706]/10 px-2 py-0.5 rounded-full">
{SPEED_OPTIONS.find((o) => o.speed === selectedSpeedValue)?.label ??
@@ -550,9 +610,7 @@ export function SettingsPanel({
})}
</div>
{!selectedSpeedId && (
<p className="text-[10px] text-slate-500 mt-2 text-center">
Select a speed region to adjust
</p>
<p className="text-[10px] text-slate-500 mt-2 text-center">{t("speed.selectRegion")}</p>
)}
{selectedSpeedId && (
<Button
@@ -562,23 +620,154 @@ export function SettingsPanel({
className="mt-2 w-full gap-2 bg-red-500/10 text-red-400 border border-red-500/20 hover:bg-red-500/20 hover:border-red-500/30 transition-all h-8 text-xs"
>
<Trash2 className="w-3 h-3" />
Delete Speed Region
{t("speed.deleteRegion")}
</Button>
)}
</div>
<Accordion type="multiple" defaultValue={["effects", "background"]} className="space-y-1">
<Accordion
type="multiple"
defaultValue={hasWebcam ? ["layout", "effects", "background"] : ["effects", "background"]}
className="space-y-1"
>
{hasWebcam && (
<AccordionItem
value="layout"
className="border-white/5 rounded-xl bg-white/[0.02] px-3"
>
<AccordionTrigger className="py-2.5 hover:no-underline">
<div className="flex items-center gap-2">
<Sparkles className="w-4 h-4 text-[#34B27B]" />
<span className="text-xs font-medium">{t("layout.title")}</span>
</div>
</AccordionTrigger>
<AccordionContent className="pb-3">
<div className="p-2 rounded-lg bg-white/5 border border-white/5">
<div className="text-[10px] font-medium text-slate-300 mb-1.5">
{t("layout.preset")}
</div>
<Select
value={webcamLayoutPreset}
onValueChange={(value: WebcamLayoutPreset) =>
onWebcamLayoutPresetChange?.(value)
}
>
<SelectTrigger className="h-8 bg-black/20 border-white/10 text-xs">
<SelectValue placeholder={t("layout.selectPreset")} />
</SelectTrigger>
<SelectContent>
{WEBCAM_LAYOUT_PRESETS.filter(
(preset) =>
preset.value === "picture-in-picture" ||
isPortraitAspectRatio(aspectRatio),
).map((preset) => (
<SelectItem key={preset.value} value={preset.value} className="text-xs">
{preset.value === "picture-in-picture"
? t("layout.pictureInPicture")
: t("layout.verticalStack")}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{webcamLayoutPreset === "picture-in-picture" && (
<div className="mt-2 p-2 rounded-lg bg-white/5 border border-white/5">
<div className="text-[10px] font-medium text-slate-300 mb-1.5">
{t("layout.webcamShape")}
</div>
<div className="grid grid-cols-4 gap-1.5">
{(
[
{ value: "rectangle", label: "Rect" },
{ value: "circle", label: "Circle" },
{ value: "square", label: "Square" },
{ value: "rounded", label: "Rounded" },
] as Array<{ value: WebcamMaskShape; label: string }>
).map((shape) => (
<button
key={shape.value}
type="button"
onClick={() => onWebcamMaskShapeChange?.(shape.value)}
className={cn(
"h-10 rounded-lg border flex flex-col items-center justify-center gap-0.5 transition-all",
webcamMaskShape === shape.value
? "bg-[#34B27B] border-[#34B27B] text-white"
: "bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20 text-slate-400",
)}
>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
{shape.value === "rectangle" && (
<rect
x="1"
y="3"
width="14"
height="10"
rx="2"
stroke="currentColor"
strokeWidth="1.5"
/>
)}
{shape.value === "circle" && (
<circle
cx="8"
cy="8"
r="6.5"
stroke="currentColor"
strokeWidth="1.5"
/>
)}
{shape.value === "square" && (
<rect
x="2"
y="2"
width="12"
height="12"
rx="1"
stroke="currentColor"
strokeWidth="1.5"
/>
)}
{shape.value === "rounded" && (
<rect
x="1"
y="3"
width="14"
height="10"
rx="5"
stroke="currentColor"
strokeWidth="1.5"
/>
)}
</svg>
<span className="text-[8px] leading-none">{shape.label}</span>
</button>
))}
</div>
</div>
)}
</AccordionContent>
</AccordionItem>
)}
<AccordionItem value="effects" className="border-white/5 rounded-xl bg-white/[0.02] px-3">
<AccordionTrigger className="py-2.5 hover:no-underline">
<div className="flex items-center gap-2">
<Sparkles className="w-4 h-4 text-[#34B27B]" />
<span className="text-xs font-medium">Video Effects</span>
<span className="text-xs font-medium">{t("effects.title")}</span>
</div>
</AccordionTrigger>
<AccordionContent className="pb-3">
<div className="grid grid-cols-2 gap-2 mb-3">
<div className="flex items-center justify-between p-2 rounded-lg bg-white/5 border border-white/5">
<div className="text-[10px] font-medium text-slate-300">Blur BG</div>
<div className="text-[10px] font-medium text-slate-300">
{t("effects.blurBg")}
</div>
<Switch
checked={showBlur}
onCheckedChange={onBlurChange}
@@ -590,9 +779,11 @@ export function SettingsPanel({
<div className="grid grid-cols-2 gap-2">
<div className="p-2 rounded-lg bg-white/5 border border-white/5">
<div className="flex items-center justify-between mb-1">
<div className="text-[10px] font-medium text-slate-300">Motion Blur</div>
<div className="text-[10px] font-medium text-slate-300">
{t("effects.motionBlur")}
</div>
<span className="text-[10px] text-slate-500 font-mono">
{motionBlurAmount === 0 ? "off" : motionBlurAmount.toFixed(2)}
{motionBlurAmount === 0 ? t("effects.off") : motionBlurAmount.toFixed(2)}
</span>
</div>
<Slider
@@ -607,7 +798,9 @@ export function SettingsPanel({
</div>
<div className="p-2 rounded-lg bg-white/5 border border-white/5">
<div className="flex items-center justify-between mb-1">
<div className="text-[10px] font-medium text-slate-300">Shadow</div>
<div className="text-[10px] font-medium text-slate-300">
{t("effects.shadow")}
</div>
<span className="text-[10px] text-slate-500 font-mono">
{Math.round(shadowIntensity * 100)}%
</span>
@@ -624,7 +817,9 @@ export function SettingsPanel({
</div>
<div className="p-2 rounded-lg bg-white/5 border border-white/5">
<div className="flex items-center justify-between mb-1">
<div className="text-[10px] font-medium text-slate-300">Roundness</div>
<div className="text-[10px] font-medium text-slate-300">
{t("effects.roundness")}
</div>
<span className="text-[10px] text-slate-500 font-mono">{borderRadius}px</span>
</div>
<Slider
@@ -637,18 +832,25 @@ export function SettingsPanel({
className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3"
/>
</div>
<div className="p-2 rounded-lg bg-white/5 border border-white/5">
<div
className={`p-2 rounded-lg bg-white/5 border border-white/5 ${webcamLayoutPreset === "vertical-stack" ? "opacity-40 pointer-events-none" : ""}`}
>
<div className="flex items-center justify-between mb-1">
<div className="text-[10px] font-medium text-slate-300">Padding</div>
<span className="text-[10px] text-slate-500 font-mono">{padding}%</span>
<div className="text-[10px] font-medium text-slate-300">
{t("effects.padding")}
</div>
<span className="text-[10px] text-slate-500 font-mono">
{webcamLayoutPreset === "vertical-stack" ? "—" : `${padding}%`}
</span>
</div>
<Slider
value={[padding]}
value={[webcamLayoutPreset === "vertical-stack" ? 0 : padding]}
onValueChange={(values) => onPaddingChange?.(values[0])}
onValueCommit={() => onPaddingCommit?.()}
min={0}
max={100}
step={1}
disabled={webcamLayoutPreset === "vertical-stack"}
className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3"
/>
</div>
@@ -660,7 +862,7 @@ export function SettingsPanel({
className="w-full mt-2 gap-1.5 bg-white/5 text-slate-200 border-white/10 hover:bg-white/10 hover:border-white/20 hover:text-white text-[10px] h-8 transition-all"
>
<Crop className="w-3 h-3" />
Crop Video
{t("crop.cropVideo")}
</Button>
</AccordionContent>
</AccordionItem>
@@ -672,7 +874,7 @@ export function SettingsPanel({
<AccordionTrigger className="py-2.5 hover:no-underline">
<div className="flex items-center gap-2">
<Palette className="w-4 h-4 text-[#34B27B]" />
<span className="text-xs font-medium">Background</span>
<span className="text-xs font-medium">{t("background.title")}</span>
</div>
</AccordionTrigger>
<AccordionContent className="pb-3">
@@ -682,19 +884,19 @@ export function SettingsPanel({
value="image"
className="data-[state=active]:bg-[#34B27B] data-[state=active]:text-white text-slate-400 text-[10px] py-1 rounded-md transition-all"
>
Image
{t("background.image")}
</TabsTrigger>
<TabsTrigger
value="color"
className="data-[state=active]:bg-[#34B27B] data-[state=active]:text-white text-slate-400 text-[10px] py-1 rounded-md transition-all"
>
Color
{t("background.color")}
</TabsTrigger>
<TabsTrigger
value="gradient"
className="data-[state=active]:bg-[#34B27B] data-[state=active]:text-white text-slate-400 text-[10px] py-1 rounded-md transition-all"
>
Gradient
{t("background.gradient")}
</TabsTrigger>
</TabsList>
@@ -713,7 +915,7 @@ export function SettingsPanel({
className="w-full gap-2 bg-white/5 text-slate-200 border-white/10 hover:bg-[#34B27B] hover:text-white hover:border-[#34B27B] transition-all h-7 text-[10px]"
>
<Upload className="w-3 h-3" />
Upload Custom
{t("background.uploadCustom")}
</Button>
<div className="grid grid-cols-7 gap-1.5">
@@ -814,7 +1016,7 @@ export function SettingsPanel({
: "border-white/10 hover:border-[#34B27B]/40 opacity-80 hover:opacity-100 bg-white/5",
)}
style={{ background: g }}
aria-label={`Gradient ${idx + 1}`}
aria-label={t("background.gradientLabel", { index: idx + 1 })}
onClick={() => {
setGradient(g);
onWallpaperChange(g);
@@ -840,10 +1042,8 @@ export function SettingsPanel({
<div className="fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-[60] bg-[#09090b] rounded-2xl shadow-2xl border border-white/10 p-8 w-[90vw] max-w-5xl max-h-[90vh] overflow-auto animate-in zoom-in-95 duration-200">
<div className="flex items-center justify-between mb-6">
<div>
<span className="text-xl font-bold text-slate-200">Crop Video</span>
<p className="text-sm text-slate-400 mt-2">
Drag on each side to adjust the crop area
</p>
<span className="text-xl font-bold text-slate-200">{t("crop.cropVideo")}</span>
<p className="text-sm text-slate-400 mt-2">{t("crop.dragInstruction")}</p>
</div>
<Button
variant="ghost"
@@ -885,7 +1085,7 @@ export function SettingsPanel({
<div className="flex flex-col gap-1">
<label className="text-[10px] font-medium text-slate-400 uppercase tracking-wider">
Ratio
{t("crop.ratio")}
</label>
<div className="flex items-center gap-1.5">
<select
@@ -894,7 +1094,7 @@ export function SettingsPanel({
className="h-8 rounded-md border border-white/10 bg-[#1a1a1f] px-2 text-xs text-slate-200 outline-none focus:border-[#34B27B]/50 cursor-pointer"
>
<option value="" className="bg-[#1a1a1f] text-slate-200">
Free
{t("crop.free")}
</option>
<option value="16:9" className="bg-[#1a1a1f] text-slate-200">
16:9
@@ -924,7 +1124,9 @@ export function SettingsPanel({
? "border-[#34B27B]/50 bg-[#34B27B]/10 text-[#34B27B]"
: "border-white/10 bg-white/5 text-slate-400 hover:text-slate-200",
)}
title={cropAspectLocked ? "Unlock aspect ratio" : "Lock aspect ratio"}
title={
cropAspectLocked ? t("crop.unlockAspectRatio") : t("crop.lockAspectRatio")
}
>
{cropAspectLocked ? (
<Lock className="w-3.5 h-3.5" />
@@ -946,7 +1148,7 @@ export function SettingsPanel({
size="lg"
className="bg-[#34B27B] hover:bg-[#34B27B]/90 text-white"
>
Done
{t("crop.done")}
</Button>
</div>
</div>
@@ -966,7 +1168,7 @@ export function SettingsPanel({
)}
>
<Film className="w-3.5 h-3.5" />
MP4
{t("exportFormat.mp4")}
</button>
<button
data-testid={getTestId("gif-format-button")}
@@ -979,7 +1181,7 @@ export function SettingsPanel({
)}
>
<Image className="w-3.5 h-3.5" />
GIF
{t("exportFormat.gif")}
</button>
</div>
@@ -994,7 +1196,7 @@ export function SettingsPanel({
: "text-slate-400 hover:text-slate-200",
)}
>
Low
{t("exportQuality.low")}
</button>
<button
onClick={() => onExportQualityChange?.("good")}
@@ -1005,7 +1207,7 @@ export function SettingsPanel({
: "text-slate-400 hover:text-slate-200",
)}
>
Medium
{t("exportQuality.medium")}
</button>
<button
onClick={() => onExportQualityChange?.("source")}
@@ -1016,7 +1218,7 @@ export function SettingsPanel({
: "text-slate-400 hover:text-slate-200",
)}
>
High
{t("exportQuality.high")}
</button>
</div>
)}
@@ -1063,7 +1265,7 @@ export function SettingsPanel({
{gifOutputDimensions.width} × {gifOutputDimensions.height}px
</span>
<div className="flex items-center gap-2">
<span className="text-[10px] text-slate-400">Loop</span>
<span className="text-[10px] text-slate-400">{t("gifSettings.loop")}</span>
<Switch
checked={gifLoop}
onCheckedChange={onGifLoopChange}
@@ -1074,27 +1276,17 @@ export function SettingsPanel({
</div>
)}
<div className="grid grid-cols-2 gap-2 mb-2">
{unsavedExport && (
<Button
type="button"
variant="outline"
onClick={onLoadProject}
className="h-8 text-[10px] font-medium gap-1.5 bg-white/5 border-white/10 text-slate-300 hover:bg-white/10"
size="lg"
onClick={onSaveUnsavedExport}
className="w-full mb-2 py-5 text-sm font-semibold flex items-center justify-center gap-2 bg-indigo-500 text-white rounded-xl shadow-lg shadow-indigo-500/20 hover:bg-indigo-500/90 hover:scale-[1.02] active:scale-[0.98] transition-all duration-200"
>
<FolderOpen className="w-3.5 h-3.5" />
Load Project
<Download className="w-4 h-4" />
{t("export.chooseSaveLocation")}
</Button>
<Button
type="button"
variant="outline"
onClick={onSaveProject}
className="h-8 text-[10px] font-medium gap-1.5 bg-white/5 border-white/10 text-slate-300 hover:bg-white/10"
>
<Save className="w-3.5 h-3.5" />
Save Project
</Button>
</div>
)}
<Button
data-testid={getTestId("export-button")}
type="button"
@@ -1103,7 +1295,7 @@ export function SettingsPanel({
className="w-full py-5 text-sm font-semibold flex items-center justify-center gap-2 bg-[#34B27B] text-white rounded-xl shadow-lg shadow-[#34B27B]/20 hover:bg-[#34B27B]/90 hover:scale-[1.02] active:scale-[0.98] transition-all duration-200"
>
<Download className="w-4 h-4" />
Export {exportFormat === "gif" ? "GIF" : "Video"}
{exportFormat === "gif" ? t("export.gifButton") : t("export.videoButton")}
</Button>
<div className="flex gap-2 mt-3">
@@ -1117,7 +1309,7 @@ export function SettingsPanel({
className="flex-1 flex items-center justify-center gap-1.5 text-[10px] text-slate-500 hover:text-slate-300 py-1.5 transition-colors"
>
<Bug className="w-3 h-3 text-[#34B27B]" />
Report Bug
{t("links.reportBug")}
</button>
<button
type="button"
@@ -1127,7 +1319,7 @@ export function SettingsPanel({
className="flex-1 flex items-center justify-center gap-1.5 text-[10px] text-slate-500 hover:text-slate-300 py-1.5 transition-colors"
>
<Star className="w-3 h-3 text-yellow-400" />
Star on GitHub
{t("links.starOnGithub")}
</button>
</div>
</div>
@@ -9,6 +9,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useScopedT } from "@/contexts/I18nContext";
import { useShortcuts } from "@/contexts/ShortcutsContext";
import {
DEFAULT_SHORTCUTS,
@@ -16,7 +17,6 @@ import {
findConflict,
formatBinding,
SHORTCUT_ACTIONS,
SHORTCUT_LABELS,
type ShortcutAction,
type ShortcutBinding,
type ShortcutConflict,
@@ -28,6 +28,8 @@ const MODIFIER_KEYS = new Set(["Control", "Shift", "Alt", "Meta"]);
export function ShortcutsConfigDialog() {
const { shortcuts, isMac, isConfigOpen, closeConfig, setShortcuts, persistShortcuts } =
useShortcuts();
const t = useScopedT("shortcuts");
const tc = useScopedT("common");
const [draft, setDraft] = useState<ShortcutsConfig>(shortcuts);
const [captureFor, setCaptureFor] = useState<ShortcutAction | null>(null);
@@ -70,7 +72,7 @@ export function ShortcutsConfigDialog() {
setCaptureFor(null);
if (found?.type === "fixed") {
toast.error(`This shortcut is reserved for "${found.label}" and cannot be reassigned.`);
toast.error(t("reservedShortcut", { label: found.label }));
return;
}
@@ -84,7 +86,7 @@ export function ShortcutsConfigDialog() {
window.addEventListener("keydown", handleCapture, { capture: true });
return () => window.removeEventListener("keydown", handleCapture, { capture: true });
}, [captureFor, draft]);
}, [captureFor, draft, t]);
const handleSwap = useCallback(() => {
if (!conflict || conflict.conflictWith.type !== "configurable") return;
@@ -102,14 +104,14 @@ export function ShortcutsConfigDialog() {
const handleSave = useCallback(async () => {
setShortcuts(draft);
await persistShortcuts(draft);
toast.success("Keyboard shortcuts saved");
toast.success(t("savedToast"));
closeConfig();
}, [draft, setShortcuts, persistShortcuts, closeConfig]);
}, [draft, setShortcuts, persistShortcuts, closeConfig, t]);
const handleReset = useCallback(() => {
setDraft({ ...DEFAULT_SHORTCUTS });
toast.info("Reset to default shortcuts — click Save to apply");
}, []);
toast.info(t("resetToast"));
}, [t]);
const handleClose = useCallback(() => {
setCaptureFor(null);
@@ -128,13 +130,13 @@ export function ShortcutsConfigDialog() {
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-sm">
<Keyboard className="w-4 h-4 text-[#34B27B]" />
Keyboard Shortcuts
{t("title")}
</DialogTitle>
</DialogHeader>
<div className="space-y-0.5">
<p className="text-[10px] text-slate-500 mb-2 uppercase tracking-wide font-semibold">
Configurable
{t("configurable")}
</p>
{SHORTCUT_ACTIONS.map((action) => {
const isCapturing = captureFor === action;
@@ -142,14 +144,14 @@ export function ShortcutsConfigDialog() {
return (
<div key={action}>
<div className="flex items-center justify-between py-1.5 px-1 border-b border-white/5">
<span className="text-sm text-slate-300">{SHORTCUT_LABELS[action]}</span>
<span className="text-sm text-slate-300">{t(`actions.${action}`)}</span>
<button
type="button"
onClick={() => {
setConflict(null);
setCaptureFor(isCapturing ? null : action);
}}
title={isCapturing ? "Press Esc to cancel" : "Click to change"}
title={isCapturing ? t("pressEscToCancel") : t("clickToChange")}
className={[
"px-2 py-1 rounded text-xs font-mono border transition-all min-w-[90px] text-center select-none",
isCapturing
@@ -159,14 +161,14 @@ export function ShortcutsConfigDialog() {
: "bg-white/5 border-white/10 text-slate-200 hover:border-[#34B27B]/50 hover:text-[#34B27B] cursor-pointer",
].join(" ")}
>
{isCapturing ? "Press a key…" : formatBinding(draft[action], isMac)}
{isCapturing ? t("pressKey") : formatBinding(draft[action], isMac)}
</button>
</div>
{hasConflict && conflict?.conflictWith.type === "configurable" && (
<div className="flex items-center justify-between px-1 py-1.5 mb-0.5 bg-amber-500/10 border border-amber-500/20 rounded text-xs">
<span className="text-amber-400">
⚠ Already used by{" "}
<strong>{SHORTCUT_LABELS[conflict.conflictWith.action]}</strong>
⚠{" "}
{t("alreadyUsedBy", { action: t(`actions.${conflict.conflictWith.action}`) })}
</span>
<div className="flex gap-1.5">
<button
@@ -174,14 +176,14 @@ export function ShortcutsConfigDialog() {
onClick={handleSwap}
className="px-2 py-0.5 bg-amber-500/20 hover:bg-amber-500/30 border border-amber-500/40 rounded text-amber-300 font-medium transition-colors"
>
Swap
{t("swap")}
</button>
<button
type="button"
onClick={handleCancelConflict}
className="px-2 py-0.5 bg-white/5 hover:bg-white/10 border border-white/10 rounded text-slate-400 transition-colors"
>
Cancel
{tc("actions.cancel")}
</button>
</div>
</div>
@@ -193,14 +195,16 @@ export function ShortcutsConfigDialog() {
<div className="space-y-0.5 mt-2">
<p className="text-[10px] text-slate-500 mb-2 uppercase tracking-wide font-semibold">
Fixed
{t("fixed")}
</p>
{FIXED_SHORTCUTS.map(({ label, display }) => (
{FIXED_SHORTCUTS.map(({ i18nKey, label, display }) => (
<div
key={label}
key={i18nKey}
className="flex items-center justify-between py-1.5 px-1 border-b border-white/5 last:border-0"
>
<span className="text-sm text-slate-400">{label}</span>
<span className="text-sm text-slate-400">
{t(`fixedActions.${i18nKey}`, { defaultValue: label })}
</span>
<kbd className="px-2 py-1 bg-white/5 border border-white/10 rounded text-xs font-mono text-slate-400 min-w-[90px] text-center">
{display}
</kbd>
@@ -208,10 +212,7 @@ export function ShortcutsConfigDialog() {
))}
</div>
<p className="text-[10px] text-slate-500 mt-1">
Click a shortcut then press the new key combination. Press{" "}
<span className="font-mono border border-white/10 rounded px-1">Esc</span> to cancel.
</p>
<p className="text-[10px] text-slate-500 mt-1">{t("helpText")}</p>
<DialogFooter className="flex gap-2 sm:justify-between mt-2">
<Button
@@ -221,18 +222,18 @@ export function ShortcutsConfigDialog() {
onClick={handleReset}
>
<RotateCcw className="w-3 h-3" />
Reset to defaults
{t("resetToDefaults")}
</Button>
<div className="flex gap-2">
<Button variant="ghost" size="sm" onClick={handleClose}>
Cancel
{tc("actions.cancel")}
</Button>
<Button
size="sm"
className="bg-[#34B27B] hover:bg-[#2d9e6c] text-white"
onClick={handleSave}
>
Save
{tc("actions.save")}
</Button>
</div>
</DialogFooter>
+33 -25
View File
@@ -8,8 +8,10 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { useScopedT } from "@/contexts/I18nContext";
export function TutorialHelp() {
const t = useScopedT("dialogs");
return (
<Dialog>
<DialogTrigger asChild>
@@ -19,33 +21,33 @@ export function TutorialHelp() {
className="h-7 px-2 text-xs text-slate-400 hover:text-slate-200 hover:bg-white/10 transition-all gap-1.5"
>
<HelpCircle className="w-3.5 h-3.5" />
<span className="font-medium">How trimming works</span>
<span className="font-medium">{t("tutorial.triggerLabel")}</span>
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl bg-[#09090b] border-white/10 [&>button]:text-slate-400 [&>button:hover]:text-white">
<DialogHeader>
<DialogTitle className="text-xl font-semibold text-slate-200 flex items-center gap-2">
<Scissors className="w-5 h-5 text-[#ef4444]" /> How Trimming Works
<Scissors className="w-5 h-5 text-[#ef4444]" /> {t("tutorial.title")}
</DialogTitle>
<DialogDescription className="text-slate-400">
Understanding how to cut out unwanted parts of your video.
{t("tutorial.description")}
</DialogDescription>
</DialogHeader>
<div className="mt-4 space-y-8">
{/* Explanation */}
<div className="bg-white/5 rounded-lg p-4 border border-white/5">
<p className="text-slate-300 leading-relaxed">
The Trim tool works by defining the segments you want to
<span className="text-[#ef4444] font-bold"> remove</span>. Any part of the timeline
that is
<span className="text-[#ef4444] font-bold"> covered</span> by a red trim segment will
be cut out when you export.
{t("tutorial.explanationBefore")}
<span className="text-[#ef4444] font-bold"> {t("tutorial.remove")}</span>
{t("tutorial.explanationMiddle")}
<span className="text-[#ef4444] font-bold"> {t("tutorial.covered")}</span>
{t("tutorial.explanationAfter")}
</p>
</div>
{/* Visual Illustration */}
<div className="space-y-2">
<h3 className="text-sm font-medium text-slate-400 uppercase tracking-wider">
Visual Example
{t("tutorial.visualExample")}
</h3>
<div className="relative h-24 bg-[#000] rounded-lg border border-white/10 flex items-center px-4 overflow-hidden select-none">
{/* Background track (Kept parts) */}
@@ -58,7 +60,7 @@ export function TutorialHelp() {
style={{ width: "20%" }}
>
<span className="text-[10px] font-bold text-[#ef4444] bg-black/50 px-1 rounded">
REMOVED
{t("tutorial.removed")}
</span>
</div>
{/* Removed Segment 2 */}
@@ -67,13 +69,19 @@ export function TutorialHelp() {
style={{ width: "15%" }}
>
<span className="text-[10px] font-bold text-[#ef4444] bg-black/50 px-1 rounded">
REMOVED
{t("tutorial.removed")}
</span>
</div>
{/* Labels for kept parts */}
<div className="absolute left-[5%] text-[10px] text-slate-400 font-medium">Kept</div>
<div className="absolute left-[50%] text-[10px] text-slate-400 font-medium">Kept</div>
<div className="absolute left-[90%] text-[10px] text-slate-400 font-medium">Kept</div>
<div className="absolute left-[5%] text-[10px] text-slate-400 font-medium">
{t("tutorial.kept")}
</div>
<div className="absolute left-[50%] text-[10px] text-slate-400 font-medium">
{t("tutorial.kept")}
</div>
<div className="absolute left-[90%] text-[10px] text-slate-400 font-medium">
{t("tutorial.kept")}
</div>
</div>
<div className="flex justify-center mt-2">
<ArrowRight className="w-4 h-4 text-slate-600 rotate-90" />
@@ -84,38 +92,38 @@ export function TutorialHelp() {
className="h-8 bg-slate-700 rounded flex items-center justify-center opacity-80"
style={{ width: "30%" }}
>
<span className="text-[10px] text-white font-medium">Part 1</span>
<span className="text-[10px] text-white font-medium">{t("tutorial.part1")}</span>
</div>
<div
className="h-8 bg-slate-700 rounded flex items-center justify-center opacity-80"
style={{ width: "30%" }}
>
<span className="text-[10px] text-white font-medium">Part 2</span>
<span className="text-[10px] text-white font-medium">{t("tutorial.part2")}</span>
</div>
<div
className="h-8 bg-slate-700 rounded flex items-center justify-center opacity-80"
style={{ width: "30%" }}
>
<span className="text-[10px] text-white font-medium">Part 3</span>
<span className="text-[10px] text-white font-medium">{t("tutorial.part3")}</span>
</div>
<span className="absolute right-4 text-xs text-slate-400">Final Video</span>
<span className="absolute right-4 text-xs text-slate-400">
{t("tutorial.finalVideo")}
</span>
</div>
</div>
{/* Steps */}
<div className="grid grid-cols-2 gap-4">
<div className="p-3 rounded bg-white/5 border border-white/5">
<div className="text-[#ef4444] font-bold mb-1">1. Add Trim</div>
<div className="text-[#ef4444] font-bold mb-1">{t("tutorial.step1Title")}</div>
<p className="text-xs text-slate-400">
Press
{t("tutorial.step1DescriptionBefore")}
<kbd className="bg-white/10 px-1 rounded text-slate-300">T</kbd>
or click the scissors icon to mark a section for removal.
{t("tutorial.step1DescriptionAfter")}
</p>
</div>
<div className="p-3 rounded bg-white/5 border border-white/5">
<div className="text-[#ef4444] font-bold mb-1">2. Adjust</div>
<p className="text-xs text-slate-400">
Drag the edges of the red region to cover exactly what you want to cut out.
</p>
<div className="text-[#ef4444] font-bold mb-1">{t("tutorial.step2Title")}</div>
<p className="text-xs text-slate-400">{t("tutorial.step2Description")}</p>
</div>
</div>
</div>
+467 -232
View File
@@ -1,10 +1,13 @@
import type { Span } from "dnd-timeline";
import { FolderOpen, Languages, Save } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels";
import { toast } from "sonner";
import { Toaster } from "@/components/ui/sonner";
import { useI18n, useScopedT } from "@/contexts/I18nContext";
import { useShortcuts } from "@/contexts/ShortcutsContext";
import { INITIAL_EDITOR_STATE, useEditorHistory } from "@/hooks/useEditorHistory";
import { type Locale, SUPPORTED_LOCALES } from "@/i18n/config";
import { getLocaleName } from "@/i18n/loader";
import {
calculateOutputDimensions,
type ExportFormat,
@@ -17,15 +20,22 @@ import {
type GifSizePreset,
VideoExporter,
} from "@/lib/exporter";
import { computeFrameStepTime } from "@/lib/frameStep";
import type { ProjectMedia } from "@/lib/recordingSession";
import { matchesShortcut } from "@/lib/shortcuts";
import { getAspectRatioValue, getNativeAspectRatioValue } from "@/utils/aspectRatioUtils";
import {
getAspectRatioValue,
getNativeAspectRatioValue,
isPortraitAspectRatio,
} from "@/utils/aspectRatioUtils";
import { ExportDialog } from "./ExportDialog";
import PlaybackControls from "./PlaybackControls";
import {
createProjectData,
createProjectSnapshot,
deriveNextId,
fromFileUrl,
hasProjectUnsavedChanges,
normalizeProjectEditor,
resolveProjectMedia,
toFileUrl,
@@ -49,6 +59,7 @@ import {
type TrimRegion,
type ZoomDepth,
type ZoomFocus,
type ZoomFocusMode,
type ZoomRegion,
} from "./types";
import VideoPlayback, { VideoPlaybackRef } from "./VideoPlayback";
@@ -76,6 +87,9 @@ export default function VideoEditor() {
borderRadius,
padding,
aspectRatio,
webcamLayoutPreset,
webcamMaskShape,
webcamPosition,
} = editorState;
// ── Non-undoable state
@@ -89,6 +103,10 @@ export default function VideoEditor() {
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const currentTimeRef = useRef(currentTime);
currentTimeRef.current = currentTime;
const durationRef = useRef(duration);
durationRef.current = duration;
const [cursorTelemetry, setCursorTelemetry] = useState<CursorTelemetryPoint[]>([]);
const [selectedZoomId, setSelectedZoomId] = useState<string | null>(null);
const [selectedTrimId, setSelectedTrimId] = useState<string | null>(null);
@@ -105,13 +123,25 @@ export default function VideoEditor() {
const [gifSizePreset, setGifSizePreset] = useState<GifSizePreset>("medium");
const [exportedFilePath, setExportedFilePath] = useState<string | null>(null);
const [lastSavedSnapshot, setLastSavedSnapshot] = useState<string | null>(null);
const [unsavedExport, setUnsavedExport] = useState<{
arrayBuffer: ArrayBuffer;
fileName: string;
format: string;
} | null>(null);
const [isFullscreen, setIsFullscreen] = useState(false);
const playerContainerRef = useRef<HTMLDivElement>(null);
const videoPlaybackRef = useRef<VideoPlaybackRef>(null);
const nextZoomIdRef = useRef(1);
const nextTrimIdRef = useRef(1);
const nextSpeedIdRef = useRef(1);
const { shortcuts, isMac } = useShortcuts();
const t = useScopedT("editor");
const ts = useScopedT("settings");
const { locale, setLocale } = useI18n();
const nextAnnotationIdRef = useRef(1);
const nextAnnotationZIndexRef = useRef(1);
const exporterRef = useRef<VideoExporter | null>(null);
@@ -173,6 +203,9 @@ export default function VideoEditor() {
speedRegions: normalizedEditor.speedRegions,
annotationRegions: normalizedEditor.annotationRegions,
aspectRatio: normalizedEditor.aspectRatio,
webcamLayoutPreset: normalizedEditor.webcamLayoutPreset,
webcamMaskShape: normalizedEditor.webcamMaskShape,
webcamPosition: normalizedEditor.webcamPosition,
});
setExportQuality(normalizedEditor.exportQuality);
setExportFormat(normalizedEditor.exportFormat);
@@ -208,13 +241,11 @@ export default function VideoEditor() {
) + 1;
setLastSavedSnapshot(
JSON.stringify(
createProjectData(
webcamSourcePath
? { screenVideoPath: sourcePath, webcamVideoPath: webcamSourcePath }
: { screenVideoPath: sourcePath },
normalizedEditor,
),
createProjectSnapshot(
webcamSourcePath
? { screenVideoPath: sourcePath, webcamVideoPath: webcamSourcePath }
: { screenVideoPath: sourcePath },
normalizedEditor,
),
);
return true;
@@ -226,27 +257,28 @@ export default function VideoEditor() {
if (!currentProjectMedia) {
return null;
}
return JSON.stringify(
createProjectData(currentProjectMedia, {
wallpaper,
shadowIntensity,
showBlur,
motionBlurAmount,
borderRadius,
padding,
cropRegion,
zoomRegions,
trimRegions,
speedRegions,
annotationRegions,
aspectRatio,
exportQuality,
exportFormat,
gifFrameRate,
gifLoop,
gifSizePreset,
}),
);
return createProjectSnapshot(currentProjectMedia, {
wallpaper,
shadowIntensity,
showBlur,
motionBlurAmount,
borderRadius,
padding,
cropRegion,
zoomRegions,
trimRegions,
speedRegions,
annotationRegions,
aspectRatio,
webcamLayoutPreset,
webcamMaskShape,
webcamPosition,
exportQuality,
exportFormat,
gifFrameRate,
gifLoop,
gifSizePreset,
});
}, [
currentProjectMedia,
wallpaper,
@@ -261,6 +293,9 @@ export default function VideoEditor() {
speedRegions,
annotationRegions,
aspectRatio,
webcamLayoutPreset,
webcamMaskShape,
webcamPosition,
exportQuality,
exportFormat,
gifFrameRate,
@@ -268,12 +303,7 @@ export default function VideoEditor() {
gifSizePreset,
]);
const hasUnsavedChanges = Boolean(
currentProjectPath &&
currentProjectSnapshot &&
lastSavedSnapshot &&
currentProjectSnapshot !== lastSavedSnapshot,
);
const hasUnsavedChanges = hasProjectUnsavedChanges(currentProjectSnapshot, lastSavedSnapshot);
useEffect(() => {
async function loadInitialData() {
@@ -301,7 +331,14 @@ export default function VideoEditor() {
setWebcamVideoSourcePath(webcamSourcePath);
setWebcamVideoPath(webcamSourcePath ? toFileUrl(webcamSourcePath) : null);
setCurrentProjectPath(null);
setLastSavedSnapshot(null);
setLastSavedSnapshot(
createProjectSnapshot(
webcamSourcePath
? { screenVideoPath: sourcePath, webcamVideoPath: webcamSourcePath }
: { screenVideoPath: sourcePath },
INITIAL_EDITOR_STATE,
),
);
return;
}
@@ -313,7 +350,9 @@ export default function VideoEditor() {
setWebcamVideoSourcePath(null);
setWebcamVideoPath(null);
setCurrentProjectPath(null);
setLastSavedSnapshot(null);
setLastSavedSnapshot(
createProjectSnapshot({ screenVideoPath: sourcePath }, INITIAL_EDITOR_STATE),
);
} else {
setError("No video to load. Please record or select a video.");
}
@@ -330,12 +369,12 @@ export default function VideoEditor() {
const saveProject = useCallback(
async (forceSaveAs: boolean) => {
if (!videoPath) {
toast.error("No video loaded");
toast.error(t("errors.noVideoLoaded"));
return false;
}
if (!currentProjectMedia) {
toast.error("Unable to determine source video path");
toast.error(t("errors.unableToDetermineSourcePath"));
return false;
}
@@ -352,6 +391,9 @@ export default function VideoEditor() {
speedRegions,
annotationRegions,
aspectRatio,
webcamLayoutPreset,
webcamMaskShape,
webcamPosition,
exportQuality,
exportFormat,
gifFrameRate,
@@ -372,12 +414,12 @@ export default function VideoEditor() {
);
if (result.canceled) {
toast.info("Project save canceled");
toast.info(t("project.saveCanceled"));
return false;
}
if (!result.success) {
toast.error(result.message || "Failed to save project");
toast.error(result.message || t("project.failedToSave"));
return false;
}
@@ -386,7 +428,7 @@ export default function VideoEditor() {
}
setLastSavedSnapshot(projectSnapshot);
toast.success(`Project saved to ${result.path}`);
toast.success(t("project.savedTo", { path: result.path ?? "" }));
return true;
},
[
@@ -404,12 +446,16 @@ export default function VideoEditor() {
speedRegions,
annotationRegions,
aspectRatio,
webcamLayoutPreset,
webcamMaskShape,
webcamPosition,
exportQuality,
exportFormat,
gifFrameRate,
gifLoop,
gifSizePreset,
videoPath,
t,
],
);
@@ -510,6 +556,21 @@ export default function VideoEditor() {
}
}
const toggleFullscreen = useCallback(() => {
setIsFullscreen((prev) => !prev);
}, []);
useEffect(() => {
if (!isFullscreen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setIsFullscreen(false);
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [isFullscreen]);
function handleSeek(time: number) {
const video = videoPlaybackRef.current?.video;
if (!video) return;
@@ -641,6 +702,18 @@ export default function VideoEditor() {
[selectedZoomId, pushState],
);
const handleZoomFocusModeChange = useCallback(
(focusMode: ZoomFocusMode) => {
if (!selectedZoomId) return;
pushState((prev) => ({
zoomRegions: prev.zoomRegions.map((region) =>
region.id === selectedZoomId ? { ...region, focusMode } : region,
),
}));
},
[selectedZoomId, pushState],
);
const handleZoomDelete = useCallback(
(id: string) => {
pushState((prev) => ({ zoomRegions: prev.zoomRegions.filter((r) => r.id !== id) }));
@@ -879,6 +952,40 @@ export default function VideoEditor() {
return;
}
// Frame-step navigation (arrow keys, no modifiers)
if (
(e.key === "ArrowLeft" || e.key === "ArrowRight") &&
!e.ctrlKey &&
!e.metaKey &&
!e.shiftKey &&
!e.altKey
) {
const target = e.target;
if (
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target instanceof HTMLSelectElement ||
(target instanceof HTMLElement &&
(target.isContentEditable ||
target.closest('[role="separator"], [role="slider"], [role="spinbutton"]')))
) {
return;
}
e.preventDefault();
const video = videoPlaybackRef.current?.video;
if (!video) {
return;
}
const direction = e.key === "ArrowLeft" ? "backward" : "forward";
const newTime = computeFrameStepTime(
video.currentTime,
Number.isFinite(video.duration) ? video.duration : durationRef.current,
direction,
);
video.currentTime = newTime;
return;
}
const isInput =
e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement;
@@ -961,6 +1068,27 @@ export default function VideoEditor() {
[handleShowExportedFile],
);
const handleSaveUnsavedExport = useCallback(async () => {
if (!unsavedExport) return;
try {
const saveResult = await window.electronAPI.saveExportedVideo(
unsavedExport.arrayBuffer,
unsavedExport.fileName,
);
if (saveResult.canceled) {
toast.info("Export canceled");
} else if (saveResult.success && saveResult.path) {
setUnsavedExport(null);
handleExportSaved(unsavedExport.format === "gif" ? "GIF" : "Video", saveResult.path);
} else {
toast.error(saveResult.message || "Failed to save export");
}
} catch (error) {
console.error("Error saving unsaved export:", error);
toast.error("Failed to save exported video");
}
}, [unsavedExport, handleExportSaved]);
const handleExport = useCallback(
async (settings: ExportSettings) => {
if (!videoPath) {
@@ -1021,8 +1149,12 @@ export default function VideoEditor() {
videoPadding: padding,
cropRegion,
annotationRegions,
webcamLayoutPreset,
webcamMaskShape,
webcamPosition,
previewWidth,
previewHeight,
cursorTelemetry,
onProgress: (progress: ExportProgress) => {
setExportProgress(progress);
},
@@ -1039,8 +1171,10 @@ export default function VideoEditor() {
const saveResult = await window.electronAPI.saveExportedVideo(arrayBuffer, fileName);
if (saveResult.canceled) {
setUnsavedExport({ arrayBuffer, fileName, format: "gif" });
toast.info("Export canceled");
} else if (saveResult.success) {
} else if (saveResult.success && saveResult.path) {
setUnsavedExport(null);
handleExportSaved("GIF", saveResult.path);
} else {
setExportError(saveResult.message || "Failed to save GIF");
@@ -1148,8 +1282,12 @@ export default function VideoEditor() {
padding,
cropRegion,
annotationRegions,
webcamLayoutPreset,
webcamMaskShape,
webcamPosition,
previewWidth,
previewHeight,
cursorTelemetry,
onProgress: (progress: ExportProgress) => {
setExportProgress(progress);
},
@@ -1166,8 +1304,10 @@ export default function VideoEditor() {
const saveResult = await window.electronAPI.saveExportedVideo(arrayBuffer, fileName);
if (saveResult.canceled) {
setUnsavedExport({ arrayBuffer, fileName, format: "mp4" });
toast.info("Export canceled");
} else if (saveResult.success) {
} else if (saveResult.success && saveResult.path) {
setUnsavedExport(null);
handleExportSaved("Video", saveResult.path);
} else {
setExportError(saveResult.message || "Failed to save video");
@@ -1212,8 +1352,12 @@ export default function VideoEditor() {
annotationRegions,
isPlaying,
aspectRatio,
webcamLayoutPreset,
webcamMaskShape,
webcamPosition,
exportQuality,
handleExportSaved,
cursorTelemetry,
],
);
@@ -1232,11 +1376,16 @@ export default function VideoEditor() {
// Build export settings from current state
const sourceWidth = video.videoWidth || 1920;
const sourceHeight = video.videoHeight || 1080;
const aspectRatioValue =
aspectRatio === "native"
? getNativeAspectRatioValue(sourceWidth, sourceHeight, cropRegion)
: getAspectRatioValue(aspectRatio);
const gifDimensions = calculateOutputDimensions(
sourceWidth,
sourceHeight,
gifSizePreset,
GIF_SIZE_PRESETS,
aspectRatioValue,
);
const settings: ExportSettings = {
@@ -1260,7 +1409,17 @@ export default function VideoEditor() {
// Start export immediately
handleExport(settings);
}, [videoPath, exportFormat, exportQuality, gifFrameRate, gifLoop, gifSizePreset, handleExport]);
}, [
videoPath,
exportFormat,
exportQuality,
gifFrameRate,
gifLoop,
gifSizePreset,
aspectRatio,
cropRegion,
handleExport,
]);
const handleCancelExport = useCallback(() => {
if (exporterRef.current) {
@@ -1304,203 +1463,279 @@ export default function VideoEditor() {
className="h-10 flex-shrink-0 bg-[#09090b]/80 backdrop-blur-md border-b border-white/5 flex items-center justify-between px-6 z-50"
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
>
<div className="flex-1" />
<div
className="flex-1 flex items-center gap-1"
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
>
<div
className={`flex items-center gap-1 px-2 py-1 rounded-md text-white/50 hover:text-white/90 hover:bg-white/10 transition-all duration-150 ${isMac ? "ml-14" : "ml-2"}`}
>
<Languages size={14} />
<select
value={locale}
onChange={(e) => setLocale(e.target.value as Locale)}
className="bg-transparent text-[11px] font-medium outline-none cursor-pointer appearance-none pr-1"
style={{ color: "inherit" }}
>
{SUPPORTED_LOCALES.map((loc) => (
<option key={loc} value={loc} className="bg-[#09090b] text-white">
{getLocaleName(loc)}
</option>
))}
</select>
</div>
<button
type="button"
onClick={handleLoadProject}
className="flex items-center gap-1 px-2 py-1 rounded-md text-white/50 hover:text-white/90 hover:bg-white/10 transition-all duration-150 text-[11px] font-medium"
>
<FolderOpen size={14} />
{ts("project.load")}
</button>
<button
type="button"
onClick={handleSaveProject}
className="flex items-center gap-1 px-2 py-1 rounded-md text-white/50 hover:text-white/90 hover:bg-white/10 transition-all duration-150 text-[11px] font-medium"
>
<Save size={14} />
{ts("project.save")}
</button>
</div>
</div>
<div className="flex-1 p-5 gap-4 flex min-h-0 relative">
{/* Left Column - Video & Timeline */}
<PanelGroup direction="horizontal" className="gap-3">
<Panel>
<PanelGroup direction="vertical" className="gap-3">
{/* Top section: video preview and controls */}
<Panel defaultSize={70} maxSize={70} minSize={40}>
<div className="w-full h-full flex flex-col items-center justify-center bg-black/40 rounded-2xl border border-white/5 shadow-2xl overflow-hidden">
{/* Video preview */}
<div className="w-full flex justify-center items-center flex-auto mt-1.5">
<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 || 1920,
videoPlaybackRef.current?.video?.videoHeight || 1080,
cropRegion,
)
: getAspectRatioValue(aspectRatio),
}}
>
<VideoPlayback
key={`${videoPath || "no-video"}:${webcamVideoPath || "no-webcam"}`}
aspectRatio={aspectRatio}
ref={videoPlaybackRef}
videoPath={videoPath || ""}
webcamVideoPath={webcamVideoPath || undefined}
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}
trimRegions={trimRegions}
speedRegions={speedRegions}
annotationRegions={annotationRegions}
selectedAnnotationId={selectedAnnotationId}
onSelectAnnotation={handleSelectAnnotation}
onAnnotationPositionChange={handleAnnotationPositionChange}
onAnnotationSizeChange={handleAnnotationSizeChange}
/>
</div>
</div>
{/* Playback controls */}
<div className="w-full flex justify-center items-center h-12 flex-shrink-0 px-3 py-1.5 my-1.5">
<div className="w-full max-w-[700px]">
<PlaybackControls
isPlaying={isPlaying}
currentTime={currentTime}
duration={duration}
onTogglePlayPause={togglePlayPause}
onSeek={handleSeek}
/>
</div>
<div className="flex-[7] flex flex-col gap-3 min-w-0 h-full">
<PanelGroup direction="vertical" className="gap-3">
{/* Top section: video preview and controls */}
<Panel defaultSize={70} maxSize={70} minSize={40}>
<div
ref={playerContainerRef}
className={
isFullscreen
? "fixed inset-0 z-[99999] w-full h-full flex flex-col items-center justify-center bg-[#09090b]"
: "w-full h-full flex flex-col items-center justify-center bg-black/40 rounded-2xl border border-white/5 shadow-2xl overflow-hidden relative"
}
>
{/* Video preview */}
<div className="w-full flex justify-center items-center flex-auto mt-1.5">
<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 || 1920,
videoPlaybackRef.current?.video?.videoHeight || 1080,
cropRegion,
)
: getAspectRatioValue(aspectRatio),
}}
>
<VideoPlayback
key={`${videoPath || "no-video"}:${webcamVideoPath || "no-webcam"}`}
aspectRatio={aspectRatio}
ref={videoPlaybackRef}
videoPath={videoPath || ""}
webcamVideoPath={webcamVideoPath || undefined}
webcamLayoutPreset={webcamLayoutPreset}
webcamMaskShape={webcamMaskShape}
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}
trimRegions={trimRegions}
speedRegions={speedRegions}
annotationRegions={annotationRegions}
selectedAnnotationId={selectedAnnotationId}
onSelectAnnotation={handleSelectAnnotation}
onAnnotationPositionChange={handleAnnotationPositionChange}
onAnnotationSizeChange={handleAnnotationSizeChange}
cursorTelemetry={cursorTelemetry}
/>
</div>
</div>
</Panel>
<PanelResizeHandle className="bg-[#09090b]/80 hover:bg-[#09090b] transition-colors rounded-full flex items-center justify-center">
<div className="w-8 h-1 bg-white/20 rounded-full"></div>
</PanelResizeHandle>
{/* Timeline section */}
<Panel defaultSize={30} maxSize={60} minSize={30}>
<div className="h-full bg-[#09090b] rounded-2xl border border-white/5 shadow-lg 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={annotationRegions}
onAnnotationAdded={handleAnnotationAdded}
onAnnotationSpanChange={handleAnnotationSpanChange}
onAnnotationDelete={handleAnnotationDelete}
selectedAnnotationId={selectedAnnotationId}
onSelectAnnotation={handleSelectAnnotation}
aspectRatio={aspectRatio}
onAspectRatioChange={(ar) => pushState({ aspectRatio: ar })}
/>
{/* Playback controls */}
<div className="w-full flex justify-center items-center h-12 flex-shrink-0 px-3 py-1.5 my-1.5">
<div className="w-full max-w-[700px]">
<PlaybackControls
isPlaying={isPlaying}
currentTime={currentTime}
duration={duration}
isFullscreen={isFullscreen}
onToggleFullscreen={toggleFullscreen}
onTogglePlayPause={togglePlayPause}
onSeek={handleSeek}
/>
</div>
</div>
</Panel>
</PanelGroup>
</Panel>
</div>
</Panel>
<PanelResizeHandle className="h-full bg-[#09090b]/80 hover:bg-[#09090b] transition-colors rounded-full flex items-center justify-center">
<div className="w-1 h-8 bg-white/20 rounded-full"></div>
</PanelResizeHandle>
<PanelResizeHandle className="bg-[#09090b]/80 hover:bg-[#09090b] transition-colors rounded-full flex items-center justify-center">
<div className="w-8 h-1 bg-white/20 rounded-full"></div>
</PanelResizeHandle>
{/* Right section: settings panel */}
<Panel defaultSize={30} minSize={25} maxSize={50}>
<SettingsPanel
selected={wallpaper}
onWallpaperChange={(w) => pushState({ wallpaper: w })}
selectedZoomDepth={
selectedZoomId ? zoomRegions.find((z) => z.id === selectedZoomId)?.depth : null
}
onZoomDepthChange={(depth) => selectedZoomId && handleZoomDepthChange(depth)}
selectedZoomId={selectedZoomId}
onZoomDelete={handleZoomDelete}
selectedTrimId={selectedTrimId}
onTrimDelete={handleTrimDelete}
shadowIntensity={shadowIntensity}
onShadowChange={(v) => updateState({ shadowIntensity: v })}
onShadowCommit={commitState}
showBlur={showBlur}
onBlurChange={(v) => pushState({ showBlur: v })}
motionBlurAmount={motionBlurAmount}
onMotionBlurChange={(v) => updateState({ motionBlurAmount: v })}
onMotionBlurCommit={commitState}
borderRadius={borderRadius}
onBorderRadiusChange={(v) => updateState({ borderRadius: v })}
onBorderRadiusCommit={commitState}
padding={padding}
onPaddingChange={(v) => updateState({ padding: v })}
onPaddingCommit={commitState}
cropRegion={cropRegion}
onCropChange={(r) => pushState({ cropRegion: r })}
aspectRatio={aspectRatio}
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(
videoPlaybackRef.current?.video?.videoWidth || 1920,
videoPlaybackRef.current?.video?.videoHeight || 1080,
gifSizePreset,
GIF_SIZE_PRESETS,
)}
onExport={handleOpenExportDialog}
selectedAnnotationId={selectedAnnotationId}
annotationRegions={annotationRegions}
onAnnotationContentChange={handleAnnotationContentChange}
onAnnotationTypeChange={handleAnnotationTypeChange}
onAnnotationStyleChange={handleAnnotationStyleChange}
onAnnotationFigureDataChange={handleAnnotationFigureDataChange}
onAnnotationDelete={handleAnnotationDelete}
onSaveProject={handleSaveProject}
onLoadProject={handleLoadProject}
selectedSpeedId={selectedSpeedId}
selectedSpeedValue={
selectedSpeedId
? (speedRegions.find((r) => r.id === selectedSpeedId)?.speed ?? null)
: null
}
onSpeedChange={handleSpeedChange}
onSpeedDelete={handleSpeedDelete}
/>
</Panel>
</PanelGroup>
{/* Timeline section */}
<Panel defaultSize={30} maxSize={60} minSize={30}>
<div className="h-full bg-[#09090b] rounded-2xl border border-white/5 shadow-lg 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={annotationRegions}
onAnnotationAdded={handleAnnotationAdded}
onAnnotationSpanChange={handleAnnotationSpanChange}
onAnnotationDelete={handleAnnotationDelete}
selectedAnnotationId={selectedAnnotationId}
onSelectAnnotation={handleSelectAnnotation}
aspectRatio={aspectRatio}
onAspectRatioChange={(ar) =>
pushState({
aspectRatio: ar,
webcamLayoutPreset:
!isPortraitAspectRatio(ar) && webcamLayoutPreset === "vertical-stack"
? "picture-in-picture"
: webcamLayoutPreset,
})
}
/>
</div>
</Panel>
</PanelGroup>
</div>
{/* Right section: settings panel */}
<div className="flex-[3] min-w-[280px] max-w-[420px] 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)}
selectedZoomFocusMode={
selectedZoomId
? (zoomRegions.find((z) => z.id === selectedZoomId)?.focusMode ?? "manual")
: null
}
onZoomFocusModeChange={(mode) => selectedZoomId && handleZoomFocusModeChange(mode)}
hasCursorTelemetry={cursorTelemetry.length > 0}
selectedZoomId={selectedZoomId}
onZoomDelete={handleZoomDelete}
selectedTrimId={selectedTrimId}
onTrimDelete={handleTrimDelete}
shadowIntensity={shadowIntensity}
onShadowChange={(v) => updateState({ shadowIntensity: v })}
onShadowCommit={commitState}
showBlur={showBlur}
onBlurChange={(v) => pushState({ showBlur: v })}
motionBlurAmount={motionBlurAmount}
onMotionBlurChange={(v) => updateState({ motionBlurAmount: v })}
onMotionBlurCommit={commitState}
borderRadius={borderRadius}
onBorderRadiusChange={(v) => updateState({ borderRadius: v })}
onBorderRadiusCommit={commitState}
padding={padding}
onPaddingChange={(v) => updateState({ padding: v })}
onPaddingCommit={commitState}
cropRegion={cropRegion}
onCropChange={(r) => pushState({ cropRegion: r })}
aspectRatio={aspectRatio}
hasWebcam={Boolean(webcamVideoPath)}
webcamLayoutPreset={webcamLayoutPreset}
onWebcamLayoutPresetChange={(preset) =>
pushState({
webcamLayoutPreset: preset,
webcamPosition: preset === "vertical-stack" ? null : webcamPosition,
})
}
webcamMaskShape={webcamMaskShape}
onWebcamMaskShapeChange={(shape) => pushState({ webcamMaskShape: shape })}
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(
videoPlaybackRef.current?.video?.videoWidth || 1920,
videoPlaybackRef.current?.video?.videoHeight || 1080,
gifSizePreset,
GIF_SIZE_PRESETS,
aspectRatio === "native"
? getNativeAspectRatioValue(
videoPlaybackRef.current?.video?.videoWidth || 1920,
videoPlaybackRef.current?.video?.videoHeight || 1080,
cropRegion,
)
: getAspectRatioValue(aspectRatio),
)}
onExport={handleOpenExportDialog}
selectedAnnotationId={selectedAnnotationId}
annotationRegions={annotationRegions}
onAnnotationContentChange={handleAnnotationContentChange}
onAnnotationTypeChange={handleAnnotationTypeChange}
onAnnotationStyleChange={handleAnnotationStyleChange}
onAnnotationFigureDataChange={handleAnnotationFigureDataChange}
onAnnotationDelete={handleAnnotationDelete}
selectedSpeedId={selectedSpeedId}
selectedSpeedValue={
selectedSpeedId
? (speedRegions.find((r) => r.id === selectedSpeedId)?.speed ?? null)
: null
}
onSpeedChange={handleSpeedChange}
onSpeedDelete={handleSpeedDelete}
unsavedExport={unsavedExport}
onSaveUnsavedExport={handleSaveUnsavedExport}
/>
</div>
</div>
<Toaster theme="dark" className="pointer-events-auto" />
<ExportDialog
isOpen={showExportDialog}
onClose={() => setShowExportDialog(false)}
+190 -59
View File
@@ -19,7 +19,13 @@ import {
useState,
} from "react";
import { getAssetPath } from "@/lib/assetPath";
import { computeWebcamOverlayLayout, type WebcamOverlayLayout } from "@/lib/webcamOverlay";
import {
getWebcamLayoutCssBoxShadow,
type Size,
type StyledRenderRect,
type WebcamLayoutPreset,
} from "@/lib/compositeLayout";
import { getCssClipPath } from "@/lib/webcamMaskShapes";
import {
type AspectRatio,
formatAspectRatioForCSS,
@@ -36,10 +42,14 @@ import {
type ZoomRegion,
} from "./types";
import {
AUTO_FOLLOW_RAMP_DISTANCE,
AUTO_FOLLOW_SMOOTHING_FACTOR,
AUTO_FOLLOW_SMOOTHING_FACTOR_MAX,
DEFAULT_FOCUS,
ZOOM_SCALE_DEADZONE,
ZOOM_TRANSLATION_DEADZONE_PX,
} from "./videoPlayback/constants";
import { adaptiveSmoothFactor, smoothCursorFocus } from "./videoPlayback/cursorFollowUtils";
import { clampFocusToStage as clampFocusToStageUtil } from "./videoPlayback/focusUtils";
import { layoutVideoContent as layoutVideoContentUtil } from "./videoPlayback/layoutUtils";
import { clamp01 } from "./videoPlayback/mathUtils";
@@ -57,6 +67,11 @@ import {
interface VideoPlaybackProps {
videoPath: string;
webcamVideoPath?: string;
webcamLayoutPreset: WebcamLayoutPreset;
webcamMaskShape?: import("./types").WebcamMaskShape;
webcamPosition?: { cx: number; cy: number } | null;
onWebcamPositionChange?: (position: { cx: number; cy: number }) => void;
onWebcamPositionDragEnd?: () => void;
onDurationChange: (duration: number) => void;
onTimeUpdate: (time: number) => void;
currentTime: number;
@@ -84,6 +99,7 @@ interface VideoPlaybackProps {
onSelectAnnotation?: (id: string | null) => void;
onAnnotationPositionChange?: (id: string, position: { x: number; y: number }) => void;
onAnnotationSizeChange?: (id: string, size: { width: number; height: number }) => void;
cursorTelemetry?: import("./types").CursorTelemetryPoint[];
}
export interface VideoPlaybackRef {
@@ -101,6 +117,11 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
{
videoPath,
webcamVideoPath,
webcamLayoutPreset,
webcamMaskShape,
webcamPosition,
onWebcamPositionChange,
onWebcamPositionDragEnd,
onDurationChange,
onTimeUpdate,
currentTime,
@@ -128,13 +149,13 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
onSelectAnnotation,
onAnnotationPositionChange,
onAnnotationSizeChange,
cursorTelemetry = [],
},
ref,
) => {
const videoRef = useRef<HTMLVideoElement | null>(null);
const webcamVideoRef = useRef<HTMLVideoElement | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const stageRef = useRef<HTMLDivElement | null>(null);
const appRef = useRef<Application | null>(null);
const videoSpriteRef = useRef<Sprite | null>(null);
const videoContainerRef = useRef<Container | null>(null);
@@ -144,13 +165,11 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
const [videoReady, setVideoReady] = useState(false);
const overlayRef = useRef<HTMLDivElement | null>(null);
const focusIndicatorRef = useRef<HTMLDivElement | null>(null);
const [webcamLayout, setWebcamLayout] = useState<WebcamOverlayLayout | null>(null);
const [webcamDimensions, setWebcamDimensions] = useState<{
width: number;
height: number;
} | null>(null);
const [webcamLayout, setWebcamLayout] = useState<StyledRenderRect | null>(null);
const [webcamDimensions, setWebcamDimensions] = useState<Size | null>(null);
const currentTimeRef = useRef(0);
const zoomRegionsRef = useRef<ZoomRegion[]>([]);
const cursorTelemetryRef = useRef<import("./types").CursorTelemetryPoint[]>([]);
const selectedZoomIdRef = useRef<string | null>(null);
const animationStateRef = useRef({
scale: 1,
@@ -164,6 +183,8 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
const blurFilterRef = useRef<BlurFilter | null>(null);
const motionBlurFilterRef = useRef<MotionBlurFilter | null>(null);
const isDraggingFocusRef = useRef(false);
const isDraggingWebcamRef = useRef(false);
const webcamDragOffsetRef = useRef({ dx: 0, dy: 0 });
const stageSizeRef = useRef({ width: 0, height: 0 });
const videoSizeRef = useRef({ width: 0, height: 0 });
const baseScaleRef = useRef(1);
@@ -183,6 +204,8 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
const onTimeUpdateRef = useRef(onTimeUpdate);
const onPlayStateChangeRef = useRef(onPlayStateChange);
const videoReadyRafRef = useRef<number | null>(null);
const smoothedAutoFocusRef = useRef<ZoomFocus | null>(null);
const prevTargetProgressRef = useRef(0);
const clampFocusToStage = useCallback((focus: ZoomFocus, depth: ZoomDepth) => {
return clampFocusToStageUtil(focus, depth, stageSizeRef.current);
@@ -258,6 +281,10 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
lockedVideoDimensions: lockedVideoDimensionsRef.current,
borderRadius,
padding,
webcamDimensions,
webcamLayoutPreset,
webcamPosition,
webcamMaskShape,
});
if (result) {
@@ -267,6 +294,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
baseOffsetRef.current = result.baseOffset;
baseMaskRef.current = result.maskRect;
cropBoundsRef.current = result.cropBounds;
setWebcamLayout(result.webcamRect);
// Reset camera container to identity
cameraContainer.scale.set(1);
@@ -279,7 +307,16 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
updateOverlayForRegion(activeRegion);
}
}, [updateOverlayForRegion, cropRegion, borderRadius, padding]);
}, [
updateOverlayForRegion,
cropRegion,
borderRadius,
padding,
webcamDimensions,
webcamLayoutPreset,
webcamPosition,
webcamMaskShape,
]);
useEffect(() => {
layoutVideoContentRef.current = layoutVideoContent;
@@ -356,6 +393,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
if (!regionId) return;
const region = zoomRegionsRef.current.find((r) => r.id === regionId);
if (!region) return;
if (region.focusMode === "auto") return;
onSelectZoom(region.id);
event.preventDefault();
isDraggingFocusRef.current = true;
@@ -388,10 +426,61 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
endFocusDrag(event);
};
// ── Webcam PiP drag handlers ──
const handleWebcamPointerDown = (event: React.PointerEvent<HTMLVideoElement>) => {
if (isPlayingRef.current) return;
if (webcamLayoutPreset !== "picture-in-picture") return;
event.preventDefault();
event.stopPropagation();
isDraggingWebcamRef.current = true;
event.currentTarget.setPointerCapture(event.pointerId);
const webcamEl = event.currentTarget;
const webcamRect = webcamEl.getBoundingClientRect();
webcamDragOffsetRef.current = {
dx: event.clientX - (webcamRect.left + webcamRect.width / 2),
dy: event.clientY - (webcamRect.top + webcamRect.height / 2),
};
};
const handleWebcamPointerMove = (event: React.PointerEvent<HTMLVideoElement>) => {
if (!isDraggingWebcamRef.current) return;
event.preventDefault();
event.stopPropagation();
const containerEl = containerRef.current;
if (!containerEl || !onWebcamPositionChange) return;
const containerRect = containerEl.getBoundingClientRect();
const cx = clamp01(
(event.clientX - webcamDragOffsetRef.current.dx - containerRect.left) / containerRect.width,
);
const cy = clamp01(
(event.clientY - webcamDragOffsetRef.current.dy - containerRect.top) / containerRect.height,
);
onWebcamPositionChange({ cx, cy });
};
const handleWebcamPointerUp = (event: React.PointerEvent<HTMLVideoElement>) => {
if (!isDraggingWebcamRef.current) return;
isDraggingWebcamRef.current = false;
try {
event.currentTarget.releasePointerCapture(event.pointerId);
} catch {
// Pointer may already be released.
}
onWebcamPositionDragEnd?.();
};
useEffect(() => {
zoomRegionsRef.current = zoomRegions;
}, [zoomRegions]);
useEffect(() => {
cursorTelemetryRef.current = cursorTelemetry;
}, [cursorTelemetry]);
useEffect(() => {
selectedZoomIdRef.current = selectedZoomId;
}, [selectedZoomId]);
@@ -760,10 +849,16 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
};
const ticker = () => {
const bm = baseMaskRef.current;
const ss = stageSizeRef.current;
const viewportRatio =
bm.width > 0 && bm.height > 0
? { widthRatio: ss.width / bm.width, heightRatio: ss.height / bm.height }
: undefined;
const { region, strength, blendedScale, transition } = findDominantRegion(
zoomRegionsRef.current,
currentTimeRef.current,
{ connectZooms: true },
{ connectZooms: true, cursorTelemetry: cursorTelemetryRef.current, viewportRatio },
);
const defaultFocus = DEFAULT_FOCUS;
@@ -784,6 +879,47 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
targetFocus = regionFocus;
targetProgress = strength;
// Apply adaptive smoothing for auto-follow mode
if (region.focusMode === "auto" && !transition) {
const raw = targetFocus;
const isZoomingIn =
targetProgress < 0.999 && targetProgress >= prevTargetProgressRef.current;
if (targetProgress >= 0.999) {
// Full zoom: adaptive smoothing — moves faster when far, decelerates when close
const prev = smoothedAutoFocusRef.current ?? raw;
const factor = adaptiveSmoothFactor(
raw,
prev,
AUTO_FOLLOW_SMOOTHING_FACTOR,
AUTO_FOLLOW_SMOOTHING_FACTOR_MAX,
AUTO_FOLLOW_RAMP_DISTANCE,
);
const smoothed = smoothCursorFocus(raw, prev, factor);
smoothedAutoFocusRef.current = smoothed;
targetFocus = smoothed;
} else if (isZoomingIn) {
// Zoom-in: track cursor directly so zoom always aims at current cursor
// position; keep ref in sync to avoid snap when full-zoom begins
smoothedAutoFocusRef.current = raw;
} else {
// Zoom-out: keep smoothing for continuity — avoids snap at zoom-out start
const prev = smoothedAutoFocusRef.current ?? raw;
const factor = adaptiveSmoothFactor(
raw,
prev,
AUTO_FOLLOW_SMOOTHING_FACTOR,
AUTO_FOLLOW_SMOOTHING_FACTOR_MAX,
AUTO_FOLLOW_RAMP_DISTANCE,
);
const smoothed = smoothCursorFocus(raw, prev, factor);
smoothedAutoFocusRef.current = smoothed;
targetFocus = smoothed;
}
} else if (region.focusMode !== "auto") {
smoothedAutoFocusRef.current = null;
}
prevTargetProgressRef.current = targetProgress;
// Handle connected zoom transitions (pan between adjacent zoom regions)
if (transition) {
const startTransform = computeZoomTransform({
@@ -910,6 +1046,10 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
};
const [resolvedWallpaper, setResolvedWallpaper] = useState<string | null>(null);
const webcamCssBoxShadow = useMemo(
() => getWebcamLayoutCssBoxShadow(webcamLayoutPreset),
[webcamLayoutPreset],
);
useEffect(() => {
const webcamVideo = webcamVideoRef.current;
@@ -934,34 +1074,6 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
};
}, [webcamVideoPath]);
useEffect(() => {
const stage = stageRef.current;
if (!stage || !webcamDimensions) {
setWebcamLayout(null);
return;
}
const updateLayout = () => {
const layout = computeWebcamOverlayLayout({
stageWidth: stage.clientWidth,
stageHeight: stage.clientHeight,
videoWidth: webcamDimensions.width,
videoHeight: webcamDimensions.height,
});
setWebcamLayout(layout);
};
updateLayout();
if (typeof ResizeObserver === "undefined") {
return;
}
const observer = new ResizeObserver(updateLayout);
observer.observe(stage);
return () => observer.disconnect();
}, [webcamDimensions]);
useEffect(() => {
const webcamVideo = webcamVideoRef.current;
if (!webcamVideo || !webcamVideoPath) {
@@ -1075,7 +1187,6 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
return (
<div
ref={stageRef}
className="relative rounded-sm overflow-hidden"
style={{
width: "100%",
@@ -1109,27 +1220,47 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
: "none",
}}
/>
{webcamVideoPath && (
<video
ref={webcamVideoRef}
src={webcamVideoPath}
className="absolute object-cover pointer-events-none"
style={{
left: webcamLayout?.x ?? 0,
top: webcamLayout?.y ?? 0,
width: webcamLayout?.width ?? 0,
height: webcamLayout?.height ?? 0,
borderRadius: webcamLayout?.borderRadius ?? 0,
boxShadow: "0 12px 36px rgba(0,0,0,0.35), 0 4px 12px rgba(0,0,0,0.22)",
zIndex: 20,
opacity: webcamLayout ? 1 : 0,
backgroundColor: "#000",
}}
muted
preload="metadata"
playsInline
/>
)}
{webcamVideoPath &&
(() => {
const clipPath = getCssClipPath(webcamLayout?.maskShape ?? "rectangle");
const useClipPath = !!clipPath;
return (
<div
className="absolute"
style={{
left: webcamLayout?.x ?? 0,
top: webcamLayout?.y ?? 0,
width: webcamLayout?.width ?? 0,
height: webcamLayout?.height ?? 0,
zIndex: 20,
opacity: webcamLayout ? 1 : 0,
filter:
useClipPath && webcamCssBoxShadow !== "none"
? `drop-shadow(${webcamCssBoxShadow})`
: undefined,
}}
>
<video
ref={webcamVideoRef}
src={webcamVideoPath}
className={`w-full h-full object-cover ${webcamLayoutPreset === "picture-in-picture" ? "cursor-grab active:cursor-grabbing" : "pointer-events-none"}`}
style={{
borderRadius: useClipPath ? 0 : (webcamLayout?.borderRadius ?? 0),
clipPath: clipPath ?? undefined,
boxShadow: useClipPath ? "none" : webcamCssBoxShadow,
backgroundColor: "#000",
}}
onPointerDown={handleWebcamPointerDown}
onPointerMove={handleWebcamPointerMove}
onPointerUp={handleWebcamPointerUp}
onPointerLeave={handleWebcamPointerUp}
muted
preload="metadata"
playsInline
/>
</div>
);
})()}
{/* Only render overlay after PIXI and video are fully initialized */}
{pixiReady && videoReady && (
<div
@@ -1,6 +1,9 @@
import { describe, expect, it } from "vitest";
import {
createProjectData,
createProjectSnapshot,
hasProjectUnsavedChanges,
normalizeProjectEditor,
PROJECT_VERSION,
resolveProjectMedia,
validateProjectData,
@@ -39,6 +42,8 @@ describe("projectPersistence media compatibility", () => {
speedRegions: [],
annotationRegions: [],
aspectRatio: "16:9",
webcamLayoutPreset: "picture-in-picture",
webcamMaskShape: "circle",
exportQuality: "good",
exportFormat: "mp4",
gifFrameRate: 15,
@@ -54,4 +59,47 @@ describe("projectPersistence media compatibility", () => {
});
expect(validateProjectData(project)).toBe(true);
});
it("normalizes webcam mask shape values safely", () => {
expect(normalizeProjectEditor({ webcamMaskShape: "rounded" }).webcamMaskShape).toBe("rounded");
expect(
normalizeProjectEditor({ webcamMaskShape: "not-a-real-shape" as never }).webcamMaskShape,
).toBe("rectangle");
});
});
it("creates stable snapshots for identical project state", () => {
const media = {
screenVideoPath: "/tmp/screen.webm",
webcamVideoPath: "/tmp/webcam.webm",
};
const editor = normalizeProjectEditor({
wallpaper: "/wallpapers/wallpaper1.jpg",
shadowIntensity: 0,
showBlur: false,
motionBlurAmount: 0,
borderRadius: 0,
padding: 50,
cropRegion: { x: 0, y: 0, width: 1, height: 1 },
zoomRegions: [],
trimRegions: [],
speedRegions: [],
annotationRegions: [],
aspectRatio: "16:9",
webcamLayoutPreset: "picture-in-picture",
webcamMaskShape: "circle",
exportQuality: "good",
exportFormat: "mp4",
gifFrameRate: 15,
gifLoop: true,
gifSizePreset: "medium",
});
expect(createProjectSnapshot(media, editor)).toBe(createProjectSnapshot(media, editor));
});
it("detects unsaved changes from differing snapshots", () => {
expect(hasProjectUnsavedChanges(null, null)).toBe(false);
expect(hasProjectUnsavedChanges("same", "same")).toBe(false);
expect(hasProjectUnsavedChanges("current", "baseline")).toBe(true);
});
@@ -11,9 +11,15 @@ import {
DEFAULT_CROP_REGION,
DEFAULT_FIGURE_DATA,
DEFAULT_PLAYBACK_SPEED,
DEFAULT_WEBCAM_LAYOUT_PRESET,
DEFAULT_WEBCAM_MASK_SHAPE,
DEFAULT_WEBCAM_POSITION,
DEFAULT_ZOOM_DEPTH,
type SpeedRegion,
type TrimRegion,
type WebcamLayoutPreset,
type WebcamMaskShape,
type WebcamPosition,
type ZoomRegion,
} from "./types";
@@ -39,6 +45,9 @@ export interface ProjectEditorState {
speedRegions: SpeedRegion[];
annotationRegions: AnnotationRegion[];
aspectRatio: AspectRatio;
webcamLayoutPreset: WebcamLayoutPreset;
webcamMaskShape: WebcamMaskShape;
webcamPosition: WebcamPosition | null;
exportQuality: ExportQuality;
exportFormat: ExportFormat;
gifFrameRate: GifFrameRate;
@@ -183,6 +192,7 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
cx: clamp(isFiniteNumber(region.focus?.cx) ? region.focus.cx : 0.5, 0, 1),
cy: clamp(isFiniteNumber(region.focus?.cy) ? region.focus.cy : 0.5, 0, 1),
},
focusMode: region.focusMode === "auto" ? "auto" : "manual",
};
})
: [];
@@ -341,6 +351,28 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
annotationRegions: normalizedAnnotationRegions,
aspectRatio:
editor.aspectRatio && validAspectRatios.has(editor.aspectRatio) ? editor.aspectRatio : "16:9",
webcamLayoutPreset:
editor.webcamLayoutPreset === "vertical-stack" ||
editor.webcamLayoutPreset === "picture-in-picture"
? editor.webcamLayoutPreset
: DEFAULT_WEBCAM_LAYOUT_PRESET,
webcamMaskShape:
editor.webcamMaskShape === "rectangle" ||
editor.webcamMaskShape === "circle" ||
editor.webcamMaskShape === "square" ||
editor.webcamMaskShape === "rounded"
? editor.webcamMaskShape
: DEFAULT_WEBCAM_MASK_SHAPE,
webcamPosition:
editor.webcamPosition &&
typeof editor.webcamPosition === "object" &&
isFiniteNumber((editor.webcamPosition as WebcamPosition).cx) &&
isFiniteNumber((editor.webcamPosition as WebcamPosition).cy)
? {
cx: clamp((editor.webcamPosition as WebcamPosition).cx, 0, 1),
cy: clamp((editor.webcamPosition as WebcamPosition).cy, 0, 1),
}
: DEFAULT_WEBCAM_POSITION,
exportQuality:
editor.exportQuality === "medium" || editor.exportQuality === "source"
? editor.exportQuality
@@ -373,3 +405,19 @@ export function createProjectData(
editor,
};
}
export function createProjectSnapshot(
media: ProjectMedia,
editor: Partial<ProjectEditorState>,
): string {
return JSON.stringify(createProjectData(media, normalizeProjectEditor(editor)));
}
export function hasProjectUnsavedChanges(
currentSnapshot: string | null,
baselineSnapshot: string | null,
): boolean {
return Boolean(
currentSnapshot !== null && baselineSnapshot !== null && currentSnapshot !== baselineSnapshot,
);
}
@@ -20,6 +20,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useScopedT } from "@/contexts/I18nContext";
import { useShortcuts } from "@/contexts/ShortcutsContext";
import { matchesShortcut } from "@/lib/shortcuts";
import { cn } from "@/lib/utils";
@@ -169,6 +170,33 @@ function createInitialRange(totalMs: number): Range {
return { start: 0, end: FALLBACK_RANGE_MS };
}
function clampVisibleRange(candidate: Range, totalMs: number): Range {
if (totalMs <= 0) {
return candidate;
}
const span = Math.max(candidate.end - candidate.start, 1);
if (span >= totalMs) {
return { start: 0, end: totalMs };
}
const start = Math.max(0, Math.min(candidate.start, totalMs - span));
return { start, end: start + span };
}
function normalizeWheelDelta(delta: number, deltaMode: number, pageSizePx: number): number {
if (deltaMode === WheelEvent.DOM_DELTA_LINE) {
return delta * 16;
}
if (deltaMode === WheelEvent.DOM_DELTA_PAGE) {
return delta * pageSizePx;
}
return delta;
}
function formatTimeLabel(milliseconds: number, intervalMs: number) {
const totalSeconds = milliseconds / 1000;
const hours = Math.floor(totalSeconds / 3600);
@@ -204,18 +232,21 @@ function PlaybackCursor({
currentTimeMs,
videoDurationMs,
onSeek,
onRangeChange,
timelineRef,
keyframes = [],
}: {
currentTimeMs: number;
videoDurationMs: number;
onSeek?: (time: number) => void;
onRangeChange?: (updater: (previous: Range) => Range) => void;
timelineRef: React.RefObject<HTMLDivElement>;
keyframes?: { id: string; time: number }[];
}) {
const { sidebarWidth, direction, range, valueToPixels, pixelsToValue } = useTimelineContext();
const sideProperty = direction === "rtl" ? "right" : "left";
const [isDragging, setIsDragging] = useState(false);
const [dragPreviewTimeMs, setDragPreviewTimeMs] = useState<number | null>(null);
useEffect(() => {
if (!isDragging) return;
@@ -225,6 +256,7 @@ function PlaybackCursor({
const rect = timelineRef.current.getBoundingClientRect();
const clickX = e.clientX - rect.left - sidebarWidth;
const contentWidth = Math.max(rect.width - sidebarWidth, 1);
// Allow dragging outside to 0 or max, but clamp the value
const relativeMs = pixelsToValue(clickX);
@@ -243,11 +275,51 @@ function PlaybackCursor({
absoluteMs = nearbyKeyframe.time;
}
setDragPreviewTimeMs(absoluteMs);
const visibleMs = range.end - range.start;
if (onRangeChange && visibleMs > 0 && videoDurationMs > visibleMs) {
const msPerPixel = visibleMs / contentWidth;
const overflowLeftPx = Math.max(0, -clickX);
const overflowRightPx = Math.max(0, clickX - contentWidth);
if (overflowLeftPx > 0 && range.start > 0) {
const shiftMs = overflowLeftPx * msPerPixel;
onRangeChange((previous) => {
const nextRange = clampVisibleRange(
{
start: previous.start - shiftMs,
end: previous.end - shiftMs,
},
videoDurationMs,
);
return nextRange.start === previous.start && nextRange.end === previous.end
? previous
: nextRange;
});
} else if (overflowRightPx > 0 && range.end < videoDurationMs) {
const shiftMs = overflowRightPx * msPerPixel;
onRangeChange((previous) => {
const nextRange = clampVisibleRange(
{
start: previous.start + shiftMs,
end: previous.end + shiftMs,
},
videoDurationMs,
);
return nextRange.start === previous.start && nextRange.end === previous.end
? previous
: nextRange;
});
}
}
onSeek(absoluteMs / 1000);
};
const handleMouseUp = () => {
setIsDragging(false);
setDragPreviewTimeMs(null);
document.body.style.cursor = "";
};
@@ -263,6 +335,7 @@ function PlaybackCursor({
}, [
isDragging,
onSeek,
onRangeChange,
timelineRef,
sidebarWidth,
range.start,
@@ -272,11 +345,14 @@ function PlaybackCursor({
keyframes,
]);
if (videoDurationMs <= 0 || currentTimeMs < 0) {
const displayTimeMs =
isDragging && dragPreviewTimeMs !== null ? dragPreviewTimeMs : currentTimeMs;
if (videoDurationMs <= 0 || displayTimeMs < 0) {
return null;
}
const clampedTime = Math.min(currentTimeMs, videoDurationMs);
const clampedTime = Math.min(displayTimeMs, videoDurationMs);
if (clampedTime < range.start || clampedTime > range.end) {
return null;
@@ -299,6 +375,7 @@ function PlaybackCursor({
}}
onMouseDown={(e) => {
e.stopPropagation(); // Prevent timeline click
setDragPreviewTimeMs(currentTimeMs);
setIsDragging(true);
}}
>
@@ -444,6 +521,7 @@ function Timeline({
videoDurationMs,
currentTimeMs,
onSeek,
onRangeChange,
onSelectZoom,
onSelectTrim,
onSelectAnnotation,
@@ -458,6 +536,7 @@ function Timeline({
videoDurationMs: number;
currentTimeMs: number;
onSeek?: (time: number) => void;
onRangeChange?: (updater: (previous: Range) => Range) => void;
onSelectZoom?: (id: string | null) => void;
onSelectTrim?: (id: string | null) => void;
onSelectAnnotation?: (id: string | null) => void;
@@ -468,6 +547,7 @@ function Timeline({
selectedSpeedId?: string | null;
keyframes?: { id: string; time: number }[];
}) {
const t = useScopedT("timeline");
const { setTimelineRef, style, sidebarWidth, range, pixelsToValue } = useTimelineContext();
const localTimelineRef = useRef<HTMLDivElement | null>(null);
@@ -514,6 +594,46 @@ function Timeline({
],
);
const handleTimelineWheel = useCallback(
(event: React.WheelEvent<HTMLDivElement>) => {
if (!onRangeChange || event.ctrlKey || event.metaKey || videoDurationMs <= 0) {
return;
}
const visibleMs = range.end - range.start;
if (visibleMs <= 0 || videoDurationMs <= visibleMs) {
return;
}
const dominantDelta =
Math.abs(event.deltaX) > Math.abs(event.deltaY) ? event.deltaX : event.deltaY;
if (dominantDelta === 0) {
return;
}
event.preventDefault();
const pageWidthPx = Math.max(event.currentTarget.clientWidth - sidebarWidth, 1);
const normalizedDeltaPx = normalizeWheelDelta(dominantDelta, event.deltaMode, pageWidthPx);
const shiftMs = pixelsToValue(normalizedDeltaPx);
onRangeChange((previous) => {
const nextRange = clampVisibleRange(
{
start: previous.start + shiftMs,
end: previous.end + shiftMs,
},
videoDurationMs,
);
return nextRange.start === previous.start && nextRange.end === previous.end
? previous
: nextRange;
});
},
[onRangeChange, videoDurationMs, range.end, range.start, sidebarWidth, pixelsToValue],
);
const zoomItems = items.filter((item) => item.rowId === ZOOM_ROW_ID);
const trimItems = items.filter((item) => item.rowId === TRIM_ROW_ID);
const annotationItems = items.filter((item) => item.rowId === ANNOTATION_ROW_ID);
@@ -525,6 +645,7 @@ function Timeline({
style={style}
className="select-none bg-[#09090b] min-h-[140px] relative cursor-pointer group"
onClick={handleTimelineClick}
onWheel={handleTimelineWheel}
>
<div className="absolute inset-0 bg-[linear-gradient(to_right,#ffffff03_1px,transparent_1px)] bg-[length:20px_100%] pointer-events-none" />
<TimelineAxis videoDurationMs={videoDurationMs} currentTimeMs={currentTimeMs} />
@@ -532,11 +653,12 @@ function Timeline({
currentTimeMs={currentTimeMs}
videoDurationMs={videoDurationMs}
onSeek={onSeek}
onRangeChange={onRangeChange}
timelineRef={localTimelineRef}
keyframes={keyframes}
/>
<Row id={ZOOM_ROW_ID} isEmpty={zoomItems.length === 0} hint="Press Z to add zoom">
<Row id={ZOOM_ROW_ID} isEmpty={zoomItems.length === 0} hint={t("hints.pressZoom")}>
{zoomItems.map((item) => (
<Item
id={item.id}
@@ -553,7 +675,7 @@ function Timeline({
))}
</Row>
<Row id={TRIM_ROW_ID} isEmpty={trimItems.length === 0} hint="Press T to add trim">
<Row id={TRIM_ROW_ID} isEmpty={trimItems.length === 0} hint={t("hints.pressTrim")}>
{trimItems.map((item) => (
<Item
id={item.id}
@@ -572,7 +694,7 @@ function Timeline({
<Row
id={ANNOTATION_ROW_ID}
isEmpty={annotationItems.length === 0}
hint="Press A to add annotation"
hint={t("hints.pressAnnotation")}
>
{annotationItems.map((item) => (
<Item
@@ -589,7 +711,7 @@ function Timeline({
))}
</Row>
<Row id={SPEED_ROW_ID} isEmpty={speedItems.length === 0} hint="Press S to add speed">
<Row id={SPEED_ROW_ID} isEmpty={speedItems.length === 0} hint={t("hints.pressSpeed")}>
{speedItems.map((item) => (
<Item
id={item.id}
@@ -642,6 +764,7 @@ export default function TimelineEditor({
aspectRatio,
onAspectRatioChange,
}: TimelineEditorProps) {
const t = useScopedT("timeline");
const totalMs = useMemo(() => Math.max(0, Math.round(videoDuration * 1000)), [videoDuration]);
const currentTimeMs = useMemo(() => Math.round(currentTime * 1000), [currentTime]);
const timelineScale = useMemo(() => calculateTimelineScale(videoDuration), [videoDuration]);
@@ -657,17 +780,15 @@ export default function TimelineEditor({
const [keyframes, setKeyframes] = useState<{ id: string; time: number }[]>([]);
const [selectedKeyframeId, setSelectedKeyframeId] = useState<string | null>(null);
const [scrollLabels, setScrollLabels] = useState({
pan: "Shift + Ctrl + Scroll",
pan: "Scroll",
zoom: "Ctrl + Scroll",
});
const timelineContainerRef = useRef<HTMLDivElement>(null);
const { shortcuts: keyShortcuts, isMac } = useShortcuts();
useEffect(() => {
formatShortcut(["shift", "mod", "Scroll"]).then((pan) => {
formatShortcut(["mod", "Scroll"]).then((zoom) => {
setScrollLabels({ pan, zoom });
});
formatShortcut(["mod", "Scroll"]).then((zoom) => {
setScrollLabels({ pan: "Scroll", zoom });
});
}, []);
@@ -848,15 +969,15 @@ export default function TimelineEditor({
(region) => startPos >= region.startMs && startPos < region.endMs,
);
if (isOverlapping || gapToNext <= 0) {
toast.error("Cannot place zoom here", {
description: "Zoom already exists at this location or not enough space available.",
toast.error(t("errors.cannotPlaceZoom"), {
description: t("errors.zoomExistsAtLocation"),
});
return;
}
const actualDuration = Math.min(defaultRegionDurationMs, gapToNext);
onZoomAdded({ start: startPos, end: startPos + actualDuration });
}, [videoDuration, totalMs, currentTimeMs, zoomRegions, onZoomAdded, defaultRegionDurationMs]);
}, [videoDuration, totalMs, currentTimeMs, zoomRegions, onZoomAdded, defaultRegionDurationMs, t]);
const handleSuggestZooms = useCallback(() => {
if (!videoDuration || videoDuration === 0 || totalMs === 0) {
@@ -864,13 +985,13 @@ export default function TimelineEditor({
}
if (!onZoomSuggested) {
toast.error("Zoom suggestion handler unavailable");
toast.error(t("errors.zoomSuggestionUnavailable"));
return;
}
if (cursorTelemetry.length < 2) {
toast.info("No cursor telemetry available", {
description: "Record a screencast first to generate cursor-based suggestions.",
toast.info(t("errors.noCursorTelemetry"), {
description: t("errors.noCursorTelemetryDescription"),
});
return;
}
@@ -887,8 +1008,8 @@ export default function TimelineEditor({
const normalizedSamples = normalizeCursorTelemetry(cursorTelemetry, totalMs);
if (normalizedSamples.length < 2) {
toast.info("No usable cursor telemetry", {
description: "The recording does not include enough cursor movement data.",
toast.info(t("errors.noUsableTelemetry"), {
description: t("errors.noUsableTelemetryDescription"),
});
return;
}
@@ -896,8 +1017,8 @@ export default function TimelineEditor({
const dwellCandidates = detectZoomDwellCandidates(normalizedSamples);
if (dwellCandidates.length === 0) {
toast.info("No clear cursor dwell moments found", {
description: "Try a recording with slower cursor pauses on important actions.",
toast.info(t("errors.noDwellMoments"), {
description: t("errors.noDwellMomentsDescription"),
});
return;
}
@@ -934,13 +1055,17 @@ export default function TimelineEditor({
});
if (addedCount === 0) {
toast.info("No auto-zoom slots available", {
description: "Detected dwell points overlap existing zoom regions.",
toast.info(t("errors.noAutoZoomSlots"), {
description: t("errors.noAutoZoomSlotsDescription"),
});
return;
}
toast.success(`Added ${addedCount} cursor-based zoom suggestion${addedCount === 1 ? "" : "s"}`);
toast.success(
addedCount === 1
? t("success.addedZoomSuggestions", { count: String(addedCount) })
: t("success.addedZoomSuggestionsPlural", { count: String(addedCount) }),
);
}, [
videoDuration,
totalMs,
@@ -948,6 +1073,7 @@ export default function TimelineEditor({
zoomRegions,
onZoomSuggested,
cursorTelemetry,
t,
]);
const handleAddTrim = useCallback(() => {
@@ -972,15 +1098,15 @@ export default function TimelineEditor({
(region) => startPos >= region.startMs && startPos < region.endMs,
);
if (isOverlapping || gapToNext <= 0) {
toast.error("Cannot place trim here", {
description: "Trim already exists at this location or not enough space available.",
toast.error(t("errors.cannotPlaceTrim"), {
description: t("errors.trimExistsAtLocation"),
});
return;
}
const actualDuration = Math.min(defaultRegionDurationMs, gapToNext);
onTrimAdded({ start: startPos, end: startPos + actualDuration });
}, [videoDuration, totalMs, currentTimeMs, trimRegions, onTrimAdded, defaultRegionDurationMs]);
}, [videoDuration, totalMs, currentTimeMs, trimRegions, onTrimAdded, defaultRegionDurationMs, t]);
const handleAddSpeed = useCallback(() => {
if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onSpeedAdded) {
@@ -1004,15 +1130,23 @@ export default function TimelineEditor({
(region) => startPos >= region.startMs && startPos < region.endMs,
);
if (isOverlapping || gapToNext <= 0) {
toast.error("Cannot place speed here", {
description: "Speed region already exists at this location or not enough space available.",
toast.error(t("errors.cannotPlaceSpeed"), {
description: t("errors.speedExistsAtLocation"),
});
return;
}
const actualDuration = Math.min(defaultRegionDurationMs, gapToNext);
onSpeedAdded({ start: startPos, end: startPos + actualDuration });
}, [videoDuration, totalMs, currentTimeMs, speedRegions, onSpeedAdded, defaultRegionDurationMs]);
}, [
videoDuration,
totalMs,
currentTimeMs,
speedRegions,
onSpeedAdded,
defaultRegionDurationMs,
t,
]);
const handleAddAnnotation = useCallback(() => {
if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onAnnotationAdded) {
@@ -1135,7 +1269,7 @@ export default function TimelineEditor({
id: region.id,
rowId: ZOOM_ROW_ID,
span: { start: region.startMs, end: region.endMs },
label: `Zoom ${index + 1}`,
label: t("labels.zoomItem", { index: String(index + 1) }),
zoomDepth: region.depth,
variant: "zoom",
}));
@@ -1144,7 +1278,7 @@ export default function TimelineEditor({
id: region.id,
rowId: TRIM_ROW_ID,
span: { start: region.startMs, end: region.endMs },
label: `Trim ${index + 1}`,
label: t("labels.trimItem", { index: String(index + 1) }),
variant: "trim",
}));
@@ -1153,12 +1287,12 @@ export default function TimelineEditor({
if (region.type === "text") {
// Show text preview
const preview = region.content.trim() || "Empty text";
const preview = region.content.trim() || t("labels.emptyText");
label = preview.length > 20 ? `${preview.substring(0, 20)}...` : preview;
} else if (region.type === "image") {
label = "Image";
label = t("labels.imageItem");
} else {
label = "Annotation";
label = t("labels.annotationItem");
}
return {
@@ -1174,13 +1308,13 @@ export default function TimelineEditor({
id: region.id,
rowId: SPEED_ROW_ID,
span: { start: region.startMs, end: region.endMs },
label: `Speed ${index + 1}`,
label: t("labels.speedItem", { index: String(index + 1) }),
speedValue: region.speed,
variant: "speed",
}));
return [...zooms, ...trims, ...annotations, ...speeds];
}, [zoomRegions, trimRegions, annotationRegions, speedRegions]);
}, [zoomRegions, trimRegions, annotationRegions, speedRegions, t]);
// Flat list of all non-annotation region spans for neighbour-clamping during drag/resize
const allRegionSpans = useMemo(() => {
@@ -1222,8 +1356,8 @@ export default function TimelineEditor({
<Plus className="w-6 h-6 text-slate-600" />
</div>
<div className="text-center">
<p className="text-sm font-medium text-slate-300">No Video Loaded</p>
<p className="text-xs text-slate-500 mt-1">Drag and drop a video to start editing</p>
<p className="text-sm font-medium text-slate-300">{t("emptyState.noVideo")}</p>
<p className="text-xs text-slate-500 mt-1">{t("emptyState.dragAndDrop")}</p>
</div>
</div>
);
@@ -1238,7 +1372,7 @@ export default function TimelineEditor({
variant="ghost"
size="icon"
className="h-7 w-7 text-slate-400 hover:text-[#34B27B] hover:bg-[#34B27B]/10 transition-all"
title="Add Zoom (Z)"
title={t("buttons.addZoom")}
>
<ZoomIn className="w-4 h-4" />
</Button>
@@ -1247,7 +1381,7 @@ export default function TimelineEditor({
variant="ghost"
size="icon"
className="h-7 w-7 text-slate-400 hover:text-[#34B27B] hover:bg-[#34B27B]/10 transition-all"
title="Suggest Zooms from Cursor"
title={t("buttons.suggestZooms")}
>
<WandSparkles className="w-4 h-4" />
</Button>
@@ -1256,7 +1390,7 @@ export default function TimelineEditor({
variant="ghost"
size="icon"
className="h-7 w-7 text-slate-400 hover:text-[#ef4444] hover:bg-[#ef4444]/10 transition-all"
title="Add Trim (T)"
title={t("buttons.addTrim")}
>
<Scissors className="w-4 h-4" />
</Button>
@@ -1265,7 +1399,7 @@ export default function TimelineEditor({
variant="ghost"
size="icon"
className="h-7 w-7 text-slate-400 hover:text-[#B4A046] hover:bg-[#B4A046]/10 transition-all"
title="Add Annotation (A)"
title={t("buttons.addAnnotation")}
>
<MessageSquare className="w-4 h-4" />
</Button>
@@ -1274,7 +1408,7 @@ export default function TimelineEditor({
variant="ghost"
size="icon"
className="h-7 w-7 text-slate-400 hover:text-[#d97706] hover:bg-[#d97706]/10 transition-all"
title="Add Speed (S)"
title={t("buttons.addSpeed")}
>
<Gauge className="w-4 h-4" />
</Button>
@@ -1313,13 +1447,13 @@ export default function TimelineEditor({
<kbd className="px-1.5 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-sans">
{scrollLabels.pan}
</kbd>
<span>Pan</span>
<span>{t("labels.pan")}</span>
</span>
<span className="flex items-center gap-1.5">
<kbd className="px-1.5 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-sans">
{scrollLabels.zoom}
</kbd>
<span>Zoom</span>
<span>{t("labels.zoom")}</span>
</span>
</div>
</div>
@@ -1351,6 +1485,7 @@ export default function TimelineEditor({
videoDurationMs={totalMs}
currentTimeMs={currentTimeMs}
onSeek={onSeek}
onRangeChange={setRange}
onSelectZoom={onSelectZoom}
onSelectTrim={onSelectTrim}
onSelectAnnotation={onSelectAnnotation}
+18
View File
@@ -1,4 +1,21 @@
import type { WebcamLayoutPreset } from "@/lib/compositeLayout";
export type ZoomDepth = 1 | 2 | 3 | 4 | 5 | 6;
export type ZoomFocusMode = "manual" | "auto";
export type { WebcamLayoutPreset };
export const DEFAULT_WEBCAM_LAYOUT_PRESET: WebcamLayoutPreset = "picture-in-picture";
export type WebcamMaskShape = "rectangle" | "circle" | "square" | "rounded";
export const DEFAULT_WEBCAM_MASK_SHAPE: WebcamMaskShape = "rectangle";
export interface WebcamPosition {
cx: number; // normalized horizontal center (0-1)
cy: number; // normalized vertical center (0-1)
}
export const DEFAULT_WEBCAM_POSITION: WebcamPosition | null = null;
export interface ZoomFocus {
cx: number; // normalized horizontal center (0-1)
@@ -11,6 +28,7 @@ export interface ZoomRegion {
endMs: number;
depth: ZoomDepth;
focus: ZoomFocus;
focusMode?: ZoomFocusMode;
}
export interface CursorTelemetryPoint {
@@ -8,3 +8,6 @@ export const VIEWPORT_SCALE = 0.8;
export const SMOOTHING_FACTOR = 0.12;
export const ZOOM_TRANSLATION_DEADZONE_PX = 1.25;
export const ZOOM_SCALE_DEADZONE = 0.002;
export const AUTO_FOLLOW_SMOOTHING_FACTOR = 0.1;
export const AUTO_FOLLOW_SMOOTHING_FACTOR_MAX = 0.25;
export const AUTO_FOLLOW_RAMP_DISTANCE = 0.15;
@@ -0,0 +1,73 @@
import type { CursorTelemetryPoint, ZoomFocus } from "../types";
/**
* Binary-search the sorted telemetry array and linearly interpolate
* the cursor position at the given playback time.
*/
export function interpolateCursorAt(
telemetry: CursorTelemetryPoint[],
timeMs: number,
): ZoomFocus | null {
if (telemetry.length === 0) return null;
if (timeMs <= telemetry[0].timeMs) {
return { cx: telemetry[0].cx, cy: telemetry[0].cy };
}
const last = telemetry[telemetry.length - 1];
if (timeMs >= last.timeMs) {
return { cx: last.cx, cy: last.cy };
}
let lo = 0;
let hi = telemetry.length - 1;
while (lo < hi - 1) {
const mid = (lo + hi) >>> 1;
if (telemetry[mid].timeMs <= timeMs) {
lo = mid;
} else {
hi = mid;
}
}
const before = telemetry[lo];
const after = telemetry[hi];
const span = after.timeMs - before.timeMs;
const t = span > 0 ? (timeMs - before.timeMs) / span : 0;
return {
cx: before.cx + (after.cx - before.cx) * t,
cy: before.cy + (after.cy - before.cy) * t,
};
}
/**
* Exponential smoothing to reduce jitter from high-frequency cursor data.
* Lower factor = smoother / more lag, higher = more responsive.
*/
export function smoothCursorFocus(raw: ZoomFocus, prev: ZoomFocus, factor: number): ZoomFocus {
return {
cx: prev.cx + (raw.cx - prev.cx) * factor,
cy: prev.cy + (raw.cy - prev.cy) * factor,
};
}
/**
* Compute an adaptive smoothing factor that scales with distance:
* far from target → faster (maxFactor), close → slower (minFactor).
* This replaces the hard deadzone with a natural deceleration curve.
*/
export function adaptiveSmoothFactor(
raw: ZoomFocus,
prev: ZoomFocus,
minFactor: number,
maxFactor: number,
rampDistance: number,
): number {
const dx = raw.cx - prev.cx;
const dy = raw.cy - prev.cy;
const distance = Math.sqrt(dx * dx + dy * dy);
const t = Math.min(1, distance / rampDistance);
return minFactor + (maxFactor - minFactor) * t;
}
@@ -39,9 +39,16 @@ function getFocusBounds(depth: ZoomDepth) {
return getFocusBoundsForScale(zoomScale);
}
function getFocusBoundsForScale(zoomScale: number) {
const marginX = 1 / (2 * zoomScale);
const marginY = 1 / (2 * zoomScale);
interface ViewportRatio {
widthRatio: number;
heightRatio: number;
}
function getFocusBoundsForScale(zoomScale: number, viewportRatio?: ViewportRatio) {
const wr = viewportRatio?.widthRatio ?? 1;
const hr = viewportRatio?.heightRatio ?? 1;
const marginX = Math.min(0.5, wr / (2 * zoomScale));
const marginY = Math.min(0.5, hr / (2 * zoomScale));
return {
minX: marginX,
@@ -65,12 +72,16 @@ export function clampFocusToStage(
};
}
export function clampFocusToScale(focus: ZoomFocus, zoomScale: number): ZoomFocus {
export function clampFocusToScale(
focus: ZoomFocus,
zoomScale: number,
viewportRatio?: ViewportRatio,
): ZoomFocus {
const baseFocus = {
cx: clamp(focus.cx, 0, 1),
cy: clamp(focus.cy, 0, 1),
};
const bounds = getFocusBoundsForScale(zoomScale);
const bounds = getFocusBoundsForScale(zoomScale, viewportRatio);
return {
cx: clamp(baseFocus.cx, bounds.minX, bounds.maxX),
@@ -78,12 +89,16 @@ export function clampFocusToScale(focus: ZoomFocus, zoomScale: number): ZoomFocu
};
}
export function softenFocusToScale(focus: ZoomFocus, zoomScale: number): ZoomFocus {
export function softenFocusToScale(
focus: ZoomFocus,
zoomScale: number,
viewportRatio?: ViewportRatio,
): ZoomFocus {
const baseFocus = {
cx: clamp(focus.cx, 0, 1),
cy: clamp(focus.cy, 0, 1),
};
const bounds = getFocusBoundsForScale(zoomScale);
const bounds = getFocusBoundsForScale(zoomScale, viewportRatio);
const horizontalRange = bounds.maxX - bounds.minX;
const verticalRange = bounds.maxY - bounds.minY;
const horizontalSoftness = Math.min(0.12, horizontalRange * 0.35);
@@ -1,5 +1,12 @@
import { Application, Graphics, Sprite } from "pixi.js";
import type { CropRegion } from "../types";
import {
computeCompositeLayout,
type RenderRect,
type Size,
type StyledRenderRect,
type WebcamLayoutPreset,
} from "@/lib/compositeLayout";
import type { CropRegion, WebcamMaskShape } from "../types";
interface LayoutParams {
container: HTMLDivElement;
@@ -11,6 +18,10 @@ interface LayoutParams {
lockedVideoDimensions?: { width: number; height: number } | null;
borderRadius?: number;
padding?: number;
webcamDimensions?: Size | null;
webcamLayoutPreset?: WebcamLayoutPreset;
webcamPosition?: { cx: number; cy: number } | null;
webcamMaskShape?: WebcamMaskShape;
}
interface LayoutResult {
@@ -18,7 +29,8 @@ interface LayoutResult {
videoSize: { width: number; height: number };
baseScale: number;
baseOffset: { x: number; y: number };
maskRect: { x: number; y: number; width: number; height: number };
maskRect: RenderRect;
webcamRect: StyledRenderRect | null;
cropBounds: { startX: number; endX: number; startY: number; endY: number };
}
@@ -33,6 +45,10 @@ export function layoutVideoContent(params: LayoutParams): LayoutResult | null {
lockedVideoDimensions,
borderRadius = 0,
padding = 0,
webcamDimensions,
webcamLayoutPreset,
webcamPosition,
webcamMaskShape,
} = params;
const videoWidth = lockedVideoDimensions?.width || videoElement.videoWidth;
@@ -67,15 +83,35 @@ export function layoutVideoContent(params: LayoutParams): LayoutResult | null {
// Calculate scale to fit the cropped area in the viewport
// Padding is a percentage (0-100), where 50 matches the original VIEWPORT_SCALE of 0.8
const paddingScale = 1.0 - (padding / 100) * 0.4;
// Vertical stack ignores padding — it's full-bleed
const effectivePadding = webcamLayoutPreset === "vertical-stack" ? 0 : padding;
const paddingScale = 1.0 - (effectivePadding / 100) * 0.4;
const maxDisplayWidth = width * paddingScale;
const maxDisplayHeight = height * paddingScale;
const scale = Math.min(
maxDisplayWidth / croppedVideoWidth,
maxDisplayHeight / croppedVideoHeight,
1,
);
const compositeLayout = computeCompositeLayout({
canvasSize: { width, height },
maxContentSize: { width: maxDisplayWidth, height: maxDisplayHeight },
screenSize: { width: croppedVideoWidth, height: croppedVideoHeight },
webcamSize: webcamDimensions,
layoutPreset: webcamLayoutPreset,
webcamPosition,
webcamMaskShape,
});
if (!compositeLayout) {
return null;
}
const screenRect = compositeLayout.screenRect;
// Cover mode: scale to fill the rect (may crop), otherwise fit-to-width
let scale: number;
if (compositeLayout.screenCover) {
scale = Math.max(screenRect.width / croppedVideoWidth, screenRect.height / croppedVideoHeight);
} else {
scale = screenRect.width / croppedVideoWidth;
}
videoSprite.scale.set(scale);
@@ -83,31 +119,25 @@ export function layoutVideoContent(params: LayoutParams): LayoutResult | null {
const fullVideoDisplayWidth = videoWidth * scale;
const fullVideoDisplayHeight = videoHeight * scale;
// Calculate display size of just the cropped region
// Position the video so the cropped region is centered within the screenRect
const croppedDisplayWidth = croppedVideoWidth * scale;
const croppedDisplayHeight = croppedVideoHeight * scale;
// Center the cropped region in the container
const centerOffsetX = (width - croppedDisplayWidth) / 2;
const centerOffsetY = (height - croppedDisplayHeight) / 2;
// Position the full video sprite so that when we apply the mask,
// the cropped region appears centered
// The crop starts at (crop.x * videoWidth, crop.y * videoHeight) in video coordinates
// In display coordinates, that's (crop.x * fullVideoDisplayWidth, crop.y * fullVideoDisplayHeight)
// We want that point to be at centerOffsetX, centerOffsetY
const spriteX = centerOffsetX - crop.x * fullVideoDisplayWidth;
const spriteY = centerOffsetY - crop.y * fullVideoDisplayHeight;
const offsetX = screenRect.x + (screenRect.width - croppedDisplayWidth) / 2;
const offsetY = screenRect.y + (screenRect.height - croppedDisplayHeight) / 2;
const spriteX = offsetX - crop.x * fullVideoDisplayWidth;
const spriteY = offsetY - crop.y * fullVideoDisplayHeight;
videoSprite.position.set(spriteX, spriteY);
// Create a mask that only shows the cropped region (centered in container)
const maskX = centerOffsetX;
const maskY = centerOffsetY;
// Apply border radius
// Apply border radius — mask clips the video to the screenRect
maskGraphics.clear();
maskGraphics.roundRect(maskX, maskY, croppedDisplayWidth, croppedDisplayHeight, borderRadius);
maskGraphics.roundRect(
screenRect.x,
screenRect.y,
screenRect.width,
screenRect.height,
compositeLayout.screenCover ? 0 : borderRadius,
);
maskGraphics.fill({ color: 0xffffff });
return {
@@ -115,7 +145,8 @@ export function layoutVideoContent(params: LayoutParams): LayoutResult | null {
videoSize: { width: croppedVideoWidth, height: croppedVideoHeight },
baseScale: scale,
baseOffset: { x: spriteX, y: spriteY },
maskRect: { x: maskX, y: maskY, width: croppedDisplayWidth, height: croppedDisplayHeight },
maskRect: compositeLayout.screenRect,
webcamRect: compositeLayout.webcamRect,
cropBounds: { startX: cropStartX, endX: cropEndX, startY: cropStartY, endY: cropEndY },
};
}
@@ -14,7 +14,7 @@ interface OverlayUpdateParams {
export function updateOverlayIndicator(params: OverlayUpdateParams) {
const { overlayEl, indicatorEl, region, focusOverride, videoSize, baseScale, isPlaying } = params;
if (!region) {
if (!region || region.focusMode === "auto") {
indicatorEl.style.display = "none";
overlayEl.style.pointerEvents = "none";
return;
@@ -1,6 +1,7 @@
import type { ZoomFocus, ZoomRegion } from "../types";
import type { CursorTelemetryPoint, ZoomFocus, ZoomRegion } from "../types";
import { ZOOM_DEPTH_SCALES } from "../types";
import { TRANSITION_WINDOW_MS, ZOOM_IN_TRANSITION_WINDOW_MS } from "./constants";
import { interpolateCursorAt } from "./cursorFollowUtils";
import { clampFocusToScale } from "./focusUtils";
import { clamp01, cubicBezier, easeOutScreenStudio } from "./mathUtils";
@@ -10,6 +11,8 @@ const ZOOM_IN_OVERLAP_MS = 500;
type DominantRegionOptions = {
connectZooms?: boolean;
cursorTelemetry?: CursorTelemetryPoint[];
viewportRatio?: ViewportRatio;
};
type ConnectedRegionPair = {
@@ -64,8 +67,33 @@ function getLinearFocus(start: ZoomFocus, end: ZoomFocus, amount: number): ZoomF
};
}
function getResolvedFocus(region: ZoomRegion, zoomScale: number): ZoomFocus {
return clampFocusToScale(region.focus, zoomScale);
interface ViewportRatio {
widthRatio: number;
heightRatio: number;
}
function getResolvedFocus(
region: ZoomRegion,
zoomScale: number,
timeMs?: number,
cursorTelemetry?: CursorTelemetryPoint[],
viewportRatio?: ViewportRatio,
): ZoomFocus {
let focus = region.focus;
if (
region.focusMode === "auto" &&
cursorTelemetry &&
cursorTelemetry.length > 0 &&
timeMs !== undefined
) {
const cursorFocus = interpolateCursorAt(cursorTelemetry, timeMs);
if (cursorFocus) {
focus = cursorFocus;
}
}
return clampFocusToScale(focus, zoomScale, viewportRatio);
}
function getConnectedRegionPairs(regions: ZoomRegion[]) {
@@ -96,6 +124,8 @@ function getActiveRegion(
regions: ZoomRegion[],
timeMs: number,
connectedPairs: ConnectedRegionPair[],
cursorTelemetry?: CursorTelemetryPoint[],
viewportRatio?: ViewportRatio,
) {
const activeRegions = regions
.map((region) => {
@@ -130,21 +160,32 @@ function getActiveRegion(
return {
region: {
...activeRegion,
focus: getResolvedFocus(activeRegion, activeScale),
focus: getResolvedFocus(activeRegion, activeScale, timeMs, cursorTelemetry, viewportRatio),
},
strength: activeRegions[0].strength,
blendedScale: null,
};
}
function getConnectedRegionHold(timeMs: number, connectedPairs: ConnectedRegionPair[]) {
function getConnectedRegionHold(
timeMs: number,
connectedPairs: ConnectedRegionPair[],
cursorTelemetry?: CursorTelemetryPoint[],
viewportRatio?: ViewportRatio,
) {
for (const pair of connectedPairs) {
if (timeMs > pair.transitionEnd && timeMs < pair.nextRegion.startMs) {
const nextScale = ZOOM_DEPTH_SCALES[pair.nextRegion.depth];
return {
region: {
...pair.nextRegion,
focus: getResolvedFocus(pair.nextRegion, nextScale),
focus: getResolvedFocus(
pair.nextRegion,
nextScale,
timeMs,
cursorTelemetry,
viewportRatio,
),
},
strength: 1,
blendedScale: null,
@@ -155,7 +196,12 @@ function getConnectedRegionHold(timeMs: number, connectedPairs: ConnectedRegionP
return null;
}
function getConnectedRegionTransition(connectedPairs: ConnectedRegionPair[], timeMs: number) {
function getConnectedRegionTransition(
connectedPairs: ConnectedRegionPair[],
timeMs: number,
cursorTelemetry?: CursorTelemetryPoint[],
viewportRatio?: ViewportRatio,
) {
for (const pair of connectedPairs) {
const { currentRegion, nextRegion, transitionStart, transitionEnd } = pair;
@@ -169,8 +215,23 @@ function getConnectedRegionTransition(connectedPairs: ConnectedRegionPair[], tim
const currentScale = ZOOM_DEPTH_SCALES[currentRegion.depth];
const nextScale = ZOOM_DEPTH_SCALES[nextRegion.depth];
const transitionScale = lerp(currentScale, nextScale, transitionProgress);
const currentFocus = getResolvedFocus(currentRegion, currentScale);
const nextFocus = getResolvedFocus(nextRegion, nextScale);
// Both regions share the same timeMs, so interpolate cursor once and reuse.
const sharedCursorFocus =
cursorTelemetry && cursorTelemetry.length > 0
? interpolateCursorAt(cursorTelemetry, timeMs)
: null;
const currentFocus = clampFocusToScale(
currentRegion.focusMode === "auto" && sharedCursorFocus
? sharedCursorFocus
: currentRegion.focus,
currentScale,
viewportRatio,
);
const nextFocus = clampFocusToScale(
nextRegion.focusMode === "auto" && sharedCursorFocus ? sharedCursorFocus : nextRegion.focus,
nextScale,
viewportRatio,
);
const transitionFocus = getLinearFocus(currentFocus, nextFocus, transitionProgress);
return {
@@ -204,20 +265,22 @@ export function findDominantRegion(
transition: ConnectedPanTransition | null;
} {
const connectedPairs = options.connectZooms ? getConnectedRegionPairs(regions) : [];
const telemetry = options.cursorTelemetry;
const vr = options.viewportRatio;
if (options.connectZooms) {
const connectedTransition = getConnectedRegionTransition(connectedPairs, timeMs);
const connectedTransition = getConnectedRegionTransition(connectedPairs, timeMs, telemetry, vr);
if (connectedTransition) {
return connectedTransition;
}
const connectedHold = getConnectedRegionHold(timeMs, connectedPairs);
const connectedHold = getConnectedRegionHold(timeMs, connectedPairs, telemetry, vr);
if (connectedHold) {
return { ...connectedHold, transition: null };
}
}
const activeRegion = getActiveRegion(regions, timeMs, connectedPairs);
const activeRegion = getActiveRegion(regions, timeMs, connectedPairs, telemetry, vr);
return activeRegion
? { ...activeRegion, transition: null }
: { region: null, strength: 0, blendedScale: null, transition: null };
+90
View File
@@ -0,0 +1,90 @@
import {
createContext,
type ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from "react";
import {
DEFAULT_LOCALE,
type I18nNamespace,
LOCALE_STORAGE_KEY,
type Locale,
SUPPORTED_LOCALES,
} from "@/i18n/config";
import { translate } from "@/i18n/loader";
type TranslateVars = Record<string, string | number>;
interface I18nContextValue {
locale: Locale;
setLocale: (locale: Locale) => void;
t: (qualifiedKey: string, vars?: TranslateVars) => string;
}
const I18nContext = createContext<I18nContextValue | null>(null);
export function useI18n(): I18nContextValue {
const ctx = useContext(I18nContext);
if (!ctx) throw new Error("useI18n must be used within <I18nProvider>");
return ctx;
}
export function useScopedT(namespace: I18nNamespace) {
const { locale } = useI18n();
return useCallback(
(key: string, vars?: TranslateVars): string => translate(locale, namespace, key, vars),
[locale, namespace],
);
}
function isSupportedLocale(value: string): value is Locale {
return (SUPPORTED_LOCALES as readonly string[]).includes(value);
}
function getInitialLocale(): Locale {
try {
const stored = localStorage.getItem(LOCALE_STORAGE_KEY);
if (stored && isSupportedLocale(stored)) return stored;
} catch {
// localStorage may be unavailable
}
return DEFAULT_LOCALE;
}
export function I18nProvider({ children }: { children: ReactNode }) {
const [locale, setLocaleState] = useState<Locale>(getInitialLocale);
const setLocale = useCallback((newLocale: Locale) => {
setLocaleState(newLocale);
try {
localStorage.setItem(LOCALE_STORAGE_KEY, newLocale);
} catch {
// localStorage may be unavailable
}
document.documentElement.lang = newLocale;
// Notify Electron main process
window.electronAPI?.setLocale?.(newLocale);
}, []);
useEffect(() => {
document.documentElement.lang = locale;
}, [locale]);
const t = useCallback(
(qualifiedKey: string, vars?: TranslateVars): string => {
const dotIndex = qualifiedKey.indexOf(".");
if (dotIndex === -1) return qualifiedKey;
const namespace = qualifiedKey.slice(0, dotIndex) as I18nNamespace;
const key = qualifiedKey.slice(dotIndex + 1);
return translate(locale, namespace, key, vars);
},
[locale],
);
const value = useMemo<I18nContextValue>(() => ({ locale, setLocale, t }), [locale, setLocale, t]);
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
}
+113
View File
@@ -0,0 +1,113 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useCameraDevices } from "./useCameraDevices";
// Mock navigator.mediaDevices
const mockDevices = [
{ kind: "videoinput", deviceId: "cam1", label: "Camera 1", groupId: "group1" },
{ kind: "videoinput", deviceId: "cam2", label: "Camera 2", groupId: "group1" },
{ kind: "audioinput", deviceId: "mic1", label: "Mic 1", groupId: "group2" },
];
const mockGetUserMedia = vi.fn().mockResolvedValue({
getTracks: () => [{ stop: vi.fn() }],
});
const mockEnumerateDevices = vi.fn().mockResolvedValue(mockDevices);
Object.defineProperty(global.navigator, "mediaDevices", {
value: {
enumerateDevices: mockEnumerateDevices,
getUserMedia: mockGetUserMedia,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
},
configurable: true,
});
describe("useCameraDevices", () => {
beforeEach(() => {
vi.clearAllMocks();
mockEnumerateDevices.mockResolvedValue(mockDevices);
mockGetUserMedia.mockResolvedValue({
getTracks: () => [{ stop: vi.fn() }],
});
});
afterEach(() => {
vi.resetAllMocks();
});
it("should list video input devices", async () => {
const { result } = renderHook(() => useCameraDevices(true));
await waitFor(() => {
expect(result.current.devices).toHaveLength(2);
});
expect(result.current.devices[0].label).toBe("Camera 1");
expect(result.current.devices[1].deviceId).toBe("cam2");
});
it("should set first device as default", async () => {
const { result } = renderHook(() => useCameraDevices(true));
await waitFor(() => {
expect(result.current.selectedDeviceId).toBe("cam1");
});
});
it("should use device ID as fallback label when label is missing", async () => {
mockEnumerateDevices.mockResolvedValueOnce([
{ kind: "videoinput", deviceId: "cam1abc123456", label: "", groupId: "group1" },
]);
const { result } = renderHook(() => useCameraDevices(true));
await waitFor(() => {
expect(result.current.devices[0]?.label).toBe("Camera cam1abc1");
});
expect(mockGetUserMedia).not.toHaveBeenCalled();
});
it("should set error state when enumeration fails", async () => {
mockEnumerateDevices.mockRejectedValueOnce(new Error("Permission denied"));
const { result } = renderHook(() => useCameraDevices(true));
await waitFor(() => {
expect(result.current.error).toBe("Permission denied");
});
expect(result.current.devices).toHaveLength(0);
expect(result.current.isLoading).toBe(false);
});
it("should fall back to first available device when selected device is unplugged", async () => {
const { result } = renderHook(() => useCameraDevices(true));
await waitFor(() => {
expect(result.current.selectedDeviceId).toBe("cam1");
});
// Simulate cam1 being unplugged — only cam2 remains
const cam2Only = [
{ kind: "videoinput", deviceId: "cam2", label: "Camera 2", groupId: "group1" },
];
mockEnumerateDevices.mockResolvedValueOnce(cam2Only);
// Trigger devicechange event via the registered handler
const devicechangeHandler = (
navigator.mediaDevices.addEventListener as ReturnType<typeof vi.fn>
).mock.calls[0]?.[1] as (() => void) | undefined;
await act(async () => {
devicechangeHandler?.();
});
await waitFor(() => {
expect(result.current.selectedDeviceId).toBe("cam2");
});
});
});
+64
View File
@@ -0,0 +1,64 @@
import { useEffect, useRef, useState } from "react";
export interface CameraDevice {
deviceId: string;
label: string;
groupId: string;
}
export function useCameraDevices(enabled: boolean = false) {
const [devices, setDevices] = useState<CameraDevice[]>([]);
const [selectedDeviceId, setSelectedDeviceId] = useState<string>("");
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const selectedDeviceIdRef = useRef(selectedDeviceId);
selectedDeviceIdRef.current = selectedDeviceId;
useEffect(() => {
if (!enabled) return;
let mounted = true;
const loadDevices = async () => {
try {
setIsLoading(true);
setError(null);
// Enumerate without requesting a second stream — the recorder handles
// the real acquisition; unlabeled devices fall back to their device ID.
const allDevices = await navigator.mediaDevices.enumerateDevices();
const videoInputs = allDevices
.filter((device) => device.kind === "videoinput")
.map((device) => ({
deviceId: device.deviceId,
label: device.label || `Camera ${device.deviceId.slice(0, 8)}`,
groupId: device.groupId,
}));
if (mounted) {
setDevices(videoInputs);
const currentId = selectedDeviceIdRef.current;
const stillAvailable = videoInputs.some((d) => d.deviceId === currentId);
if (!currentId || !stillAvailable) {
setSelectedDeviceId(videoInputs[0]?.deviceId ?? "");
}
setIsLoading(false);
}
} catch (err) {
if (mounted) {
setError(err instanceof Error ? err.message : "Failed to load cameras");
setIsLoading(false);
}
}
};
loadDevices();
navigator.mediaDevices.addEventListener("devicechange", loadDevices);
return () => {
mounted = false;
navigator.mediaDevices.removeEventListener("devicechange", loadDevices);
};
}, [enabled]);
return { devices, selectedDeviceId, setSelectedDeviceId, isLoading, error };
}
+15 -1
View File
@@ -4,9 +4,17 @@ import type {
CropRegion,
SpeedRegion,
TrimRegion,
WebcamLayoutPreset,
WebcamMaskShape,
WebcamPosition,
ZoomRegion,
} from "@/components/video-editor/types";
import { DEFAULT_CROP_REGION } from "@/components/video-editor/types";
import {
DEFAULT_CROP_REGION,
DEFAULT_WEBCAM_LAYOUT_PRESET,
DEFAULT_WEBCAM_MASK_SHAPE,
DEFAULT_WEBCAM_POSITION,
} from "@/components/video-editor/types";
import type { AspectRatio } from "@/utils/aspectRatioUtils";
// Undoable state — selection IDs are intentionally excluded (undoing a
@@ -24,6 +32,9 @@ export interface EditorState {
borderRadius: number;
padding: number;
aspectRatio: AspectRatio;
webcamLayoutPreset: WebcamLayoutPreset;
webcamMaskShape: WebcamMaskShape;
webcamPosition: WebcamPosition | null;
}
export const INITIAL_EDITOR_STATE: EditorState = {
@@ -39,6 +50,9 @@ export const INITIAL_EDITOR_STATE: EditorState = {
borderRadius: 0,
padding: 50,
aspectRatio: "16:9",
webcamLayoutPreset: DEFAULT_WEBCAM_LAYOUT_PRESET,
webcamMaskShape: DEFAULT_WEBCAM_MASK_SHAPE,
webcamPosition: DEFAULT_WEBCAM_POSITION,
};
type StateUpdate = Partial<EditorState> | ((prev: EditorState) => Partial<EditorState>);
+166 -46
View File
@@ -1,6 +1,8 @@
import { fixWebmDuration } from "@fix-webm-duration/fix";
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { useScopedT } from "@/contexts/I18nContext";
import { requestCameraAccess } from "@/lib/requestCameraAccess";
const TARGET_FRAME_RATE = 60;
const MIN_FRAME_RATE = 30;
@@ -39,12 +41,18 @@ const WEBCAM_TARGET_FRAME_RATE = 30;
type UseScreenRecorderReturn = {
recording: boolean;
paused: boolean;
elapsedSeconds: number;
toggleRecording: () => void;
togglePaused: () => void;
restartRecording: () => void;
cancelRecording: () => void;
microphoneEnabled: boolean;
setMicrophoneEnabled: (enabled: boolean) => void;
microphoneDeviceId: string | undefined;
setMicrophoneDeviceId: (deviceId: string | undefined) => void;
webcamDeviceId: string | undefined;
setWebcamDeviceId: (deviceId: string | undefined) => void;
systemAudioEnabled: boolean;
setSystemAudioEnabled: (enabled: boolean) => void;
webcamEnabled: boolean;
@@ -79,9 +87,13 @@ function createRecorderHandle(stream: MediaStream, options: MediaRecorderOptions
}
export function useScreenRecorder(): UseScreenRecorderReturn {
const t = useScopedT("editor");
const [recording, setRecording] = useState(false);
const [paused, setPaused] = useState(false);
const [elapsedSeconds, setElapsedSeconds] = useState(0);
const [microphoneEnabled, setMicrophoneEnabled] = useState(false);
const [microphoneDeviceId, setMicrophoneDeviceId] = useState<string | undefined>(undefined);
const [webcamDeviceId, setWebcamDeviceId] = useState<string | undefined>(undefined);
const [systemAudioEnabled, setSystemAudioEnabled] = useState(false);
const [webcamEnabled, setWebcamEnabledState] = useState(false);
const screenRecorder = useRef<RecorderHandle | null>(null);
@@ -91,13 +103,20 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
const microphoneStream = useRef<MediaStream | null>(null);
const webcamStream = useRef<MediaStream | null>(null);
const mixingContext = useRef<AudioContext | null>(null);
const startTime = useRef<number>(0);
const recordingId = useRef<number>(0);
const accumulatedDurationMs = useRef(0);
const segmentStartedAt = useRef<number | null>(null);
const finalizingRecordingId = useRef<number | null>(null);
const allowAutoFinalize = useRef(false);
const discardRecordingId = useRef<number | null>(null);
const restarting = useRef(false);
const getRecordingDurationMs = useCallback(() => {
const segmentDuration =
segmentStartedAt.current === null ? 0 : Date.now() - segmentStartedAt.current;
return accumulatedDurationMs.current + segmentDuration;
}, []);
const selectMimeType = () => {
const preferred = [
"video/webm;codecs=av1",
@@ -151,37 +170,29 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
}
}, []);
const setWebcamEnabled = useCallback(async (enabled: boolean) => {
if (!enabled) {
setWebcamEnabledState(false);
return true;
}
const setWebcamEnabled = useCallback(
async (enabled: boolean) => {
if (!enabled) {
setWebcamEnabledState(false);
return true;
}
const accessResult = await window.electronAPI.requestCameraAccess();
if (!accessResult.success) {
toast.error("Failed to request camera access.");
return false;
}
const accessResult = await requestCameraAccess();
if (!accessResult.success) {
toast.error(t("recording.failedCameraAccess"));
return false;
}
if (!accessResult.granted) {
toast.error("Camera access is blocked. Enable it in system settings to use the webcam.");
return false;
}
if (!accessResult.granted) {
toast.error(t("recording.cameraBlocked"));
return false;
}
try {
const probeStream = await navigator.mediaDevices.getUserMedia({
audio: false,
video: true,
});
probeStream.getTracks().forEach((track) => track.stop());
setWebcamEnabledState(true);
return true;
} catch (error) {
console.warn("Failed to preflight webcam access:", error);
toast.error("Camera access denied. Webcam overlay will stay disabled.");
return false;
}
}, []);
},
[t],
);
const finalizeRecording = useCallback(
(
@@ -204,6 +215,10 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
teardownMedia();
setRecording(false);
setPaused(false);
setElapsedSeconds(0);
accumulatedDurationMs.current = 0;
segmentStartedAt.current = null;
window.electronAPI?.setRecordingState(false);
void (async () => {
@@ -275,7 +290,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
}
const activeWebcamRecorder = webcamRecorder.current;
const duration = Date.now() - startTime.current;
const duration = getRecordingDurationMs();
const activeRecordingId = recordingId.current;
finalizeRecording(
@@ -285,7 +300,10 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
activeRecordingId,
);
if (activeScreenRecorder.recorder.state === "recording") {
if (
activeScreenRecorder.recorder.state === "recording" ||
activeScreenRecorder.recorder.state === "paused"
) {
try {
activeScreenRecorder.recorder.stop();
} catch {
@@ -293,7 +311,10 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
}
}
if (activeWebcamRecorder) {
if (activeWebcamRecorder.recorder.state === "recording") {
if (
activeWebcamRecorder.recorder.state === "recording" ||
activeWebcamRecorder.recorder.state === "paused"
) {
try {
activeWebcamRecorder.recorder.stop();
} catch {
@@ -318,14 +339,20 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
restarting.current = false;
discardRecordingId.current = null;
if (screenRecorder.current?.recorder.state === "recording") {
if (
screenRecorder.current?.recorder.state === "recording" ||
screenRecorder.current?.recorder.state === "paused"
) {
try {
screenRecorder.current.recorder.stop();
} catch {
// Ignore recorder teardown errors during cleanup.
}
}
if (webcamRecorder.current?.recorder.state === "recording") {
if (
webcamRecorder.current?.recorder.state === "recording" ||
webcamRecorder.current?.recorder.state === "paused"
) {
try {
webcamRecorder.current.recorder.stop();
} catch {
@@ -342,7 +369,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
try {
const selectedSource = await window.electronAPI.getSelectedSource();
if (!selectedSource) {
alert("Please select a source to record");
alert(t("recording.selectSource"));
return;
}
@@ -372,7 +399,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
} as unknown as MediaStreamConstraints);
} catch (audioErr) {
console.warn("System audio capture failed, falling back to video-only:", audioErr);
toast.error("System audio not available. Recording without system audio.");
toast.error(t("recording.systemAudioUnavailable"));
screenMediaStream = await navigator.mediaDevices.getUserMedia({
audio: false,
video: videoConstraints,
@@ -405,7 +432,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
});
} catch (audioError) {
console.warn("Failed to get microphone access:", audioError);
toast.error("Microphone access denied. Recording will continue without audio.");
toast.error(t("recording.microphoneDenied"));
setMicrophoneEnabled(false);
}
}
@@ -414,11 +441,18 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
try {
webcamStream.current = await navigator.mediaDevices.getUserMedia({
audio: false,
video: {
width: { ideal: WEBCAM_TARGET_WIDTH },
height: { ideal: WEBCAM_TARGET_HEIGHT },
frameRate: { ideal: WEBCAM_TARGET_FRAME_RATE, max: WEBCAM_TARGET_FRAME_RATE },
},
video: webcamDeviceId
? {
deviceId: { exact: webcamDeviceId },
width: { ideal: WEBCAM_TARGET_WIDTH },
height: { ideal: WEBCAM_TARGET_HEIGHT },
frameRate: { ideal: WEBCAM_TARGET_FRAME_RATE, max: WEBCAM_TARGET_FRAME_RATE },
}
: {
width: { ideal: WEBCAM_TARGET_WIDTH },
height: { ideal: WEBCAM_TARGET_HEIGHT },
frameRate: { ideal: WEBCAM_TARGET_FRAME_RATE, max: WEBCAM_TARGET_FRAME_RATE },
},
});
} catch (cameraError) {
console.warn("Failed to get webcam access:", cameraError);
@@ -427,7 +461,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
webcamStream.current = null;
}
setWebcamEnabledState(false);
toast.error("Camera access denied. Recording will continue without webcam.");
toast.error(t("recording.cameraDenied"));
}
}
@@ -513,9 +547,12 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
}
recordingId.current = Date.now();
startTime.current = recordingId.current;
accumulatedDurationMs.current = 0;
segmentStartedAt.current = Date.now();
allowAutoFinalize.current = true;
setRecording(true);
setPaused(false);
setElapsedSeconds(0);
window.electronAPI?.setRecordingState(true);
const activeScreenRecorder = screenRecorder.current;
@@ -531,7 +568,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
finalizeRecording(
activeScreenRecorder,
activeWebcamRecorder ?? null,
Math.max(0, Date.now() - startTime.current),
Math.max(0, getRecordingDurationMs()),
activeRecordingId,
);
},
@@ -542,17 +579,61 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
console.error("Failed to start recording:", error);
const errorMsg = error instanceof Error ? error.message : "Failed to start recording";
if (errorMsg.includes("Permission denied") || errorMsg.includes("NotAllowedError")) {
toast.error("Recording permission denied. Please allow screen recording.");
toast.error(t("recording.permissionDenied"));
} else {
toast.error(errorMsg);
}
setRecording(false);
setPaused(false);
setElapsedSeconds(0);
accumulatedDurationMs.current = 0;
segmentStartedAt.current = null;
screenRecorder.current = null;
webcamRecorder.current = null;
teardownMedia();
}
};
const togglePaused = () => {
const activeScreenRecorder = screenRecorder.current?.recorder;
if (!activeScreenRecorder || activeScreenRecorder.state === "inactive") {
return;
}
const activeWebcamRecorder = webcamRecorder.current?.recorder;
if (activeScreenRecorder.state === "paused") {
try {
activeScreenRecorder.resume();
if (activeWebcamRecorder?.state === "paused") {
activeWebcamRecorder.resume();
}
segmentStartedAt.current = Date.now();
setPaused(false);
} catch (error) {
console.error("Failed to resume recording:", error);
}
return;
}
if (activeScreenRecorder.state !== "recording") {
return;
}
try {
accumulatedDurationMs.current = getRecordingDurationMs();
segmentStartedAt.current = null;
setElapsedSeconds(Math.floor(accumulatedDurationMs.current / 1000));
activeScreenRecorder.pause();
if (activeWebcamRecorder?.state === "recording") {
activeWebcamRecorder.pause();
}
setPaused(true);
} catch (error) {
console.error("Failed to pause recording:", error);
}
};
const toggleRecording = () => {
recording ? stopRecording.current() : startRecording();
};
@@ -561,13 +642,14 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
if (restarting.current) return;
const activeScreenRecorder = screenRecorder.current;
if (!activeScreenRecorder || activeScreenRecorder.recorder.state !== "recording") return;
if (!activeScreenRecorder || activeScreenRecorder.recorder.state === "inactive") return;
const activeWebcamRecorder = webcamRecorder.current;
const activeRecordingId = recordingId.current;
restarting.current = true;
discardRecordingId.current = activeRecordingId;
allowAutoFinalize.current = false;
const stopPromises = [
new Promise<void>((resolve) => {
@@ -575,7 +657,10 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
}),
];
if (activeWebcamRecorder?.recorder.state === "recording") {
if (
activeWebcamRecorder?.recorder.state === "recording" ||
activeWebcamRecorder?.recorder.state === "paused"
) {
stopPromises.push(
new Promise<void>((resolve) => {
activeWebcamRecorder.recorder.addEventListener("stop", () => resolve(), {
@@ -595,14 +680,49 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
}
};
useEffect(() => {
if (!recording) {
setElapsedSeconds(0);
return;
}
setElapsedSeconds(Math.floor(getRecordingDurationMs() / 1000));
if (paused) {
return;
}
const interval = window.setInterval(() => {
setElapsedSeconds(Math.floor(getRecordingDurationMs() / 1000));
}, 250);
return () => window.clearInterval(interval);
}, [getRecordingDurationMs, paused, recording]);
const cancelRecording = () => {
const activeScreenRecorder = screenRecorder.current;
if (!activeScreenRecorder || activeScreenRecorder.recorder.state !== "recording") return;
const activeRecordingId = recordingId.current;
discardRecordingId.current = activeRecordingId;
allowAutoFinalize.current = false;
stopRecording.current();
};
return {
recording,
paused,
elapsedSeconds,
toggleRecording,
togglePaused,
restartRecording,
cancelRecording,
microphoneEnabled,
setMicrophoneEnabled,
microphoneDeviceId,
setMicrophoneDeviceId,
webcamDeviceId,
setWebcamDeviceId,
systemAudioEnabled,
setSystemAudioEnabled,
webcamEnabled,
+16
View File
@@ -0,0 +1,16 @@
export const DEFAULT_LOCALE = "en" as const;
export const SUPPORTED_LOCALES = ["en", "zh-CN", "es"] as const;
export const I18N_NAMESPACES = [
"common",
"dialogs",
"editor",
"launch",
"settings",
"shortcuts",
"timeline",
] as const;
export type Locale = (typeof SUPPORTED_LOCALES)[number];
export type I18nNamespace = (typeof I18N_NAMESPACES)[number];
export const LOCALE_STORAGE_KEY = "openscreen-locale";
+60
View File
@@ -0,0 +1,60 @@
import { DEFAULT_LOCALE, type I18nNamespace, type Locale } from "./config";
type MessageMap = Record<string, unknown>;
const modules = import.meta.glob("./locales/**/*.json", { eager: true }) as Record<
string,
{ default: MessageMap }
>;
const messages: Record<string, Record<string, MessageMap>> = {};
for (const [path, mod] of Object.entries(modules)) {
// path looks like "./locales/en/common.json"
const parts = path.replace("./locales/", "").replace(".json", "").split("/");
const locale = parts[0];
const namespace = parts[1];
if (!messages[locale]) messages[locale] = {};
messages[locale][namespace] = mod.default;
}
function getMessageValue(obj: unknown, dotPath: string): string | undefined {
const keys = dotPath.split(".");
let current: unknown = obj;
for (const key of keys) {
if (current == null || typeof current !== "object") return undefined;
current = (current as Record<string, unknown>)[key];
}
return typeof current === "string" ? current : undefined;
}
function interpolate(str: string, vars?: Record<string, string | number>): string {
if (!vars) return str;
return str.replace(/\{\{(\w+)\}\}/g, (_, key: string) => String(vars[key] ?? `{{${key}}}`));
}
export function getMessages(locale: Locale, namespace: I18nNamespace): MessageMap {
return messages[locale]?.[namespace] ?? {};
}
export function getLocaleName(locale: Locale): string {
return getMessageValue(messages[locale]?.common, "locale.name") ?? locale;
}
export function getLocaleShort(locale: Locale): string {
return getMessageValue(messages[locale]?.common, "locale.short") ?? locale;
}
export function translate(
locale: Locale,
namespace: I18nNamespace,
key: string,
vars?: Record<string, string | number>,
): string {
const value =
getMessageValue(messages[locale]?.[namespace], key) ??
getMessageValue(messages[DEFAULT_LOCALE]?.[namespace], key);
if (value == null) return `${namespace}.${key}`;
return interpolate(value, vars);
}
+29
View File
@@ -0,0 +1,29 @@
{
"actions": {
"cancel": "Cancel",
"save": "Save",
"delete": "Delete",
"close": "Close",
"share": "Share",
"done": "Done",
"open": "Open",
"upload": "Upload",
"export": "Export",
"file": "File",
"edit": "Edit",
"view": "View",
"window": "Window",
"quit": "Quit",
"stopRecording": "Stop Recording"
},
"playback": {
"play": "Play",
"pause": "Pause",
"fullscreen": "Fullscreen",
"exitFullscreen": "Exit Fullscreen"
},
"locale": {
"name": "English",
"short": "EN"
}
}
+68
View File
@@ -0,0 +1,68 @@
{
"export": {
"complete": "Export Complete",
"yourFormatReady": "Your {{format}} is ready",
"showInFolder": "Show in Folder",
"finalizingVideo": "Finalizing video export...",
"compilingGifProgress": "Compiling GIF... {{progress}}%",
"compilingGifWait": "Compiling GIF... This may take a while",
"takeMoment": "This may take a moment...",
"failed": "Export Failed",
"tryAgain": "Please try again",
"finalizingVideoTitle": "Finalizing Video",
"compilingGif": "Compiling GIF",
"exportingFormat": "Exporting {{format}}",
"compiling": "Compiling",
"renderingFrames": "Rendering Frames",
"processing": "Processing...",
"finalizing": "Finalizing...",
"compilingStatus": "Compiling...",
"status": "Status",
"format": "Format",
"frames": "Frames",
"cancelExport": "Cancel Export",
"savedSuccessfully": "{{format}} saved successfully!"
},
"tutorial": {
"triggerLabel": "How trimming works",
"title": "How Trimming Works",
"description": "Understanding how to cut out unwanted parts of your video.",
"explanation": "The Trim tool works by defining the segments you want to",
"explanationRemove": "remove",
"explanationCovered": "covered",
"explanationEnd": "by a red trim segment will be cut out when you export.",
"visualExample": "Visual Example",
"removed": "REMOVED",
"kept": "Kept",
"part1": "Part 1",
"part2": "Part 2",
"part3": "Part 3",
"finalVideo": "Final Video",
"step1Title": "1. Add Trim",
"step1Description": "Press T or click the scissors icon to mark a section for removal.",
"step2Title": "2. Adjust",
"step2Description": "Drag the edges of the red region to cover exactly what you want to cut out."
},
"unsavedChanges": {
"title": "Unsaved Changes",
"message": "You have unsaved changes.",
"detail": "Do you want to save your project before closing?",
"saveAndClose": "Save & Close",
"discardAndClose": "Discard & Close",
"loadProject": "Load Project…",
"saveProject": "Save Project…",
"saveProjectAs": "Save Project As…"
},
"fileDialogs": {
"saveGif": "Save Exported GIF",
"saveVideo": "Save Exported Video",
"selectVideo": "Select Video File",
"saveProject": "Save OpenScreen Project",
"openProject": "Open OpenScreen Project",
"gifImage": "GIF Image",
"mp4Video": "MP4 Video",
"videoFiles": "Video Files",
"openscreenProject": "OpenScreen Project",
"allFiles": "All Files"
}
}
+35
View File
@@ -0,0 +1,35 @@
{
"errors": {
"noVideoLoaded": "No video loaded",
"videoNotReady": "Video not ready",
"unableToDetermineSourcePath": "Unable to determine source video path",
"failedToSaveGif": "Failed to save GIF",
"gifExportFailed": "GIF export failed",
"failedToSaveVideo": "Failed to save video",
"exportFailed": "Export failed",
"exportFailedWithError": "Export failed: {{error}}",
"failedToSaveExport": "Failed to save export",
"failedToSaveExportedVideo": "Failed to save exported video",
"failedToRevealInFolder": "Error revealing in folder: {{error}}"
},
"export": {
"canceled": "Export canceled",
"exportedSuccessfully": "{{format}} exported successfully"
},
"project": {
"saveCanceled": "Project save canceled",
"failedToSave": "Failed to save project",
"savedTo": "Project saved to {{path}}",
"failedToLoad": "Failed to load project",
"invalidFormat": "Invalid project file format",
"loadedFrom": "Project loaded from {{path}}"
},
"recording": {
"failedCameraAccess": "Failed to request camera access.",
"cameraBlocked": "Camera access is blocked. Enable it in system settings to use the webcam.",
"systemAudioUnavailable": "System audio not available. Recording without system audio.",
"microphoneDenied": "Microphone access denied. Recording will continue without audio.",
"cameraDenied": "Camera access denied. Recording will continue without webcam.",
"permissionDenied": "Recording permission denied. Please allow screen recording."
}
}
+37
View File
@@ -0,0 +1,37 @@
{
"tooltips": {
"hideHUD": "Hide HUD",
"closeApp": "Close App",
"restartRecording": "Restart recording",
"cancelRecording": "Cancel recording",
"pauseRecording": "Pause recording",
"resumeRecording": "Resume recording",
"openVideoFile": "Open video file",
"openProject": "Open project"
},
"audio": {
"enableSystemAudio": "Enable system audio",
"disableSystemAudio": "Disable system audio",
"enableMicrophone": "Enable microphone",
"disableMicrophone": "Disable microphone",
"defaultMicrophone": "Default Microphone"
},
"webcam": {
"enableWebcam": "Enable webcam",
"disableWebcam": "Disable webcam",
"defaultCamera": "Default Camera",
"searching": "Searching...",
"noneFound": "No camera found",
"unavailable": "Camera unavailable"
},
"sourceSelector": {
"loading": "Loading sources...",
"screens": "Screens ({{count}})",
"windows": "Windows ({{count}})",
"defaultSourceName": "Screen"
},
"recording": {
"selectSource": "Please select a source to record"
},
"language": "Language"
}
+159
View File
@@ -0,0 +1,159 @@
{
"zoom": {
"level": "Zoom Level",
"selectRegion": "Select a zoom region to adjust",
"deleteZoom": "Delete Zoom",
"focusMode": {
"title": "Focus Mode",
"manual": "Manual",
"auto": "Auto",
"autoDescription": "Camera follows the recorded cursor position"
}
},
"speed": {
"playbackSpeed": "Playback Speed",
"selectRegion": "Select a speed region to adjust",
"deleteRegion": "Delete Speed Region"
},
"trim": {
"deleteRegion": "Delete Trim Region"
},
"layout": {
"title": "Layout",
"preset": "Preset",
"selectPreset": "Select preset",
"pictureInPicture": "Picture in Picture",
"verticalStack": "Vertical Stack",
"webcamShape": "Camera Shape"
},
"effects": {
"title": "Video Effects",
"blurBg": "Blur BG",
"motionBlur": "Motion Blur",
"off": "off",
"shadow": "Shadow",
"roundness": "Roundness",
"padding": "Padding"
},
"background": {
"title": "Background",
"image": "Image",
"color": "Color",
"gradient": "Gradient",
"uploadCustom": "Upload Custom",
"gradientLabel": "Gradient {{index}}"
},
"crop": {
"title": "Crop",
"cropVideo": "Crop Video",
"dragInstruction": "Drag on each side to adjust the crop area",
"ratio": "Ratio",
"free": "Free",
"done": "Done",
"lockAspectRatio": "Lock aspect ratio",
"unlockAspectRatio": "Unlock aspect ratio"
},
"exportFormat": {
"mp4": "MP4",
"gif": "GIF",
"mp4Video": "MP4 Video",
"mp4Description": "High quality video file",
"gifAnimation": "GIF Animation",
"gifDescription": "Animated image for sharing"
},
"exportQuality": {
"title": "Export Quality",
"low": "Low",
"medium": "Medium",
"high": "High"
},
"gifSettings": {
"frameRate": "GIF Frame Rate",
"size": "GIF Size",
"loop": "Loop GIF"
},
"project": {
"save": "Save Project",
"load": "Load Project"
},
"export": {
"videoButton": "Export Video",
"gifButton": "Export GIF",
"chooseSaveLocation": "Choose Save Location"
},
"links": {
"reportBug": "Report Bug",
"starOnGithub": "Star on GitHub"
},
"imageUpload": {
"invalidFileType": "Invalid file type",
"jpgOnly": "Please upload a JPG or JPEG image file.",
"uploadSuccess": "Custom image uploaded successfully!",
"failedToUpload": "Failed to upload image",
"errorReading": "There was an error reading the file."
},
"annotation": {
"title": "Annotation Settings",
"active": "Active",
"typeText": "Text",
"typeImage": "Image",
"typeArrow": "Arrow",
"textContent": "Text Content",
"textPlaceholder": "Enter your text...",
"fontStyle": "Font Style",
"selectStyle": "Select style",
"size": "Size",
"customFonts": "Custom Fonts",
"textColor": "Text Color",
"background": "Background",
"none": "None",
"color": "Color",
"clearBackground": "Clear Background",
"uploadImage": "Upload Image",
"supportedFormats": "Supported formats: JPG, PNG, GIF, WebP",
"arrowDirection": "Arrow Direction",
"strokeWidth": "Stroke Width: {{width}}px",
"arrowColor": "Arrow Color",
"deleteAnnotation": "Delete Annotation",
"shortcutsAndTips": "Shortcuts & Tips",
"tipMovePlayhead": "Move playhead to overlapping annotation section and select an item.",
"tipTabCycle": "Use Tab to cycle through overlapping items.",
"tipShiftTabCycle": "Use Shift+Tab to cycle backwards.",
"invalidImageType": "Invalid file type",
"imageFormatsOnly": "Please upload a JPG, PNG, GIF, or WebP image file.",
"imageUploadSuccess": "Image uploaded successfully!",
"failedImageUpload": "Failed to upload image"
},
"fontStyles": {
"classic": "Classic",
"editor": "Editor",
"strong": "Strong",
"typewriter": "Typewriter",
"deco": "Deco",
"simple": "Simple",
"modern": "Modern",
"clean": "Clean"
},
"customFont": {
"dialogTitle": "Add Google Font",
"urlLabel": "Google Fonts Import URL",
"urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
"urlHelp": "Get this from Google Fonts: Select a font → Click \"Get font\" → Copy the @import URL",
"nameLabel": "Display Name",
"namePlaceholder": "My Custom Font",
"nameHelp": "This is how the font will appear in the font selector",
"addButton": "Add Font",
"addingButton": "Adding...",
"errorEmptyUrl": "Please enter a Google Fonts import URL",
"errorInvalidUrl": "Please enter a valid Google Fonts URL",
"errorEmptyName": "Please enter a font name",
"errorExtractFailed": "Could not extract font family from URL",
"successMessage": "Font \"{{fontName}}\" added successfully",
"failedToAdd": "Failed to add font",
"errorTimeout": "Font took too long to load. Please check the URL and try again.",
"errorLoadFailed": "The font could not be loaded. Please verify the Google Fonts URL is correct."
},
"language": {
"title": "Language"
}
}
+36
View File
@@ -0,0 +1,36 @@
{
"title": "Keyboard Shortcuts",
"customize": "Customize",
"configurable": "Configurable",
"fixed": "Fixed",
"pressKey": "Press a key…",
"clickToChange": "Click to change",
"pressEscToCancel": "Press Esc to cancel",
"helpText": "Click a shortcut then press the new key combination. Press Esc to cancel.",
"resetToDefaults": "Reset to defaults",
"alreadyUsedBy": "Already used by {{action}}",
"swap": "Swap",
"reservedShortcut": "This shortcut is reserved for \"{{label}}\" and cannot be reassigned.",
"savedToast": "Keyboard shortcuts saved",
"resetToast": "Reset to default shortcuts — click Save to apply",
"actions": {
"addZoom": "Add Zoom",
"addTrim": "Add Trim",
"addSpeed": "Add Speed",
"addAnnotation": "Add Annotation",
"addKeyframe": "Add Keyframe",
"deleteSelected": "Delete Selected",
"playPause": "Play / Pause"
},
"fixedActions": {
"undo": "Undo",
"redo": "Redo",
"cycleAnnotationsForward": "Cycle Annotations Forward",
"cycleAnnotationsBackward": "Cycle Annotations Backward",
"deleteSelectedAlt": "Delete Selected (alt)",
"panTimeline": "Pan Timeline",
"zoomTimeline": "Zoom Timeline",
"frameBack": "Frame Back",
"frameForward": "Frame Forward"
}
}
+50
View File
@@ -0,0 +1,50 @@
{
"buttons": {
"addZoom": "Add Zoom (Z)",
"suggestZooms": "Suggest Zooms from Cursor",
"addTrim": "Add Trim (T)",
"addAnnotation": "Add Annotation (A)",
"addSpeed": "Add Speed (S)"
},
"hints": {
"pressZoom": "Press Z to add zoom",
"pressTrim": "Press T to add trim",
"pressAnnotation": "Press A to add annotation",
"pressSpeed": "Press S to add speed"
},
"labels": {
"pan": "Pan",
"zoom": "Zoom",
"zoomItem": "Zoom {{index}}",
"trimItem": "Trim {{index}}",
"speedItem": "Speed {{index}}",
"annotationItem": "Annotation",
"imageItem": "Image",
"emptyText": "Empty text"
},
"emptyState": {
"noVideo": "No Video Loaded",
"dragAndDrop": "Drag and drop a video to start editing"
},
"errors": {
"cannotPlaceZoom": "Cannot place zoom here",
"zoomExistsAtLocation": "Zoom already exists at this location or not enough space available.",
"zoomSuggestionUnavailable": "Zoom suggestion handler unavailable",
"noCursorTelemetry": "No cursor telemetry available",
"noCursorTelemetryDescription": "Record a screencast first to generate cursor-based suggestions.",
"noUsableTelemetry": "No usable cursor telemetry",
"noUsableTelemetryDescription": "The recording does not include enough cursor movement data.",
"noDwellMoments": "No clear cursor dwell moments found",
"noDwellMomentsDescription": "Try a recording with slower cursor pauses on important actions.",
"noAutoZoomSlots": "No auto-zoom slots available",
"noAutoZoomSlotsDescription": "Detected dwell points overlap existing zoom regions.",
"cannotPlaceTrim": "Cannot place trim here",
"trimExistsAtLocation": "Trim already exists at this location or not enough space available.",
"cannotPlaceSpeed": "Cannot place speed here",
"speedExistsAtLocation": "Speed region already exists at this location or not enough space available."
},
"success": {
"addedZoomSuggestions": "Added {{count}} cursor-based zoom suggestion",
"addedZoomSuggestionsPlural": "Added {{count}} cursor-based zoom suggestions"
}
}
+29
View File
@@ -0,0 +1,29 @@
{
"actions": {
"cancel": "Cancelar",
"save": "Guardar",
"delete": "Eliminar",
"close": "Cerrar",
"share": "Compartir",
"done": "Listo",
"open": "Abrir",
"upload": "Subir",
"export": "Exportar",
"file": "Archivo",
"edit": "Editar",
"view": "Vista",
"window": "Ventana",
"quit": "Salir",
"stopRecording": "Detener grabación"
},
"playback": {
"play": "Reproducir",
"pause": "Pausar",
"fullscreen": "Pantalla completa",
"exitFullscreen": "Salir de pantalla completa"
},
"locale": {
"name": "Español",
"short": "ES"
}
}
+68
View File
@@ -0,0 +1,68 @@
{
"export": {
"complete": "Exportación completada",
"yourFormatReady": "Tu {{format}} está listo",
"showInFolder": "Mostrar en carpeta",
"finalizingVideo": "Finalizando exportación de video...",
"compilingGifProgress": "Compilando GIF... {{progress}}%",
"compilingGifWait": "Compilando GIF... Esto puede tardar un rato",
"takeMoment": "Esto puede tardar un momento...",
"failed": "La exportación falló",
"tryAgain": "Por favor intenta de nuevo",
"finalizingVideoTitle": "Finalizando video",
"compilingGif": "Compilando GIF",
"exportingFormat": "Exportando {{format}}",
"compiling": "Compilando",
"renderingFrames": "Renderizando cuadros",
"processing": "Procesando...",
"finalizing": "Finalizando...",
"compilingStatus": "Compilando...",
"status": "Estado",
"format": "Formato",
"frames": "Cuadros",
"cancelExport": "Cancelar exportación",
"savedSuccessfully": "¡{{format}} guardado exitosamente!"
},
"tutorial": {
"triggerLabel": "Cómo funciona el recorte",
"title": "Cómo funciona el recorte",
"description": "Aprende a eliminar las partes no deseadas de tu video.",
"explanation": "La herramienta de recorte funciona definiendo los segmentos que deseas",
"explanationRemove": "eliminar",
"explanationCovered": "cubierto",
"explanationEnd": "por un segmento rojo de recorte será eliminado al exportar.",
"visualExample": "Ejemplo visual",
"removed": "ELIMINADO",
"kept": "Conservado",
"part1": "Parte 1",
"part2": "Parte 2",
"part3": "Parte 3",
"finalVideo": "Video final",
"step1Title": "1. Agregar recorte",
"step1Description": "Presiona T o haz clic en el ícono de tijeras para marcar una sección a eliminar.",
"step2Title": "2. Ajustar",
"step2Description": "Arrastra los bordes de la región roja para cubrir exactamente lo que deseas eliminar."
},
"unsavedChanges": {
"title": "Cambios sin guardar",
"message": "Tienes cambios sin guardar.",
"detail": "¿Deseas guardar tu proyecto antes de cerrar?",
"saveAndClose": "Guardar y cerrar",
"discardAndClose": "Descartar y cerrar",
"loadProject": "Cargar proyecto…",
"saveProject": "Guardar proyecto…",
"saveProjectAs": "Guardar proyecto como…"
},
"fileDialogs": {
"saveGif": "Guardar GIF exportado",
"saveVideo": "Guardar video exportado",
"selectVideo": "Seleccionar archivo de video",
"saveProject": "Guardar proyecto OpenScreen",
"openProject": "Abrir proyecto OpenScreen",
"gifImage": "Imagen GIF",
"mp4Video": "Video MP4",
"videoFiles": "Archivos de video",
"openscreenProject": "Proyecto OpenScreen",
"allFiles": "Todos los archivos"
}
}
+35
View File
@@ -0,0 +1,35 @@
{
"errors": {
"noVideoLoaded": "No hay video cargado",
"videoNotReady": "El video no está listo",
"unableToDetermineSourcePath": "No se pudo determinar la ruta del video de origen",
"failedToSaveGif": "Error al guardar el GIF",
"gifExportFailed": "La exportación de GIF falló",
"failedToSaveVideo": "Error al guardar el video",
"exportFailed": "La exportación falló",
"exportFailedWithError": "La exportación falló: {{error}}",
"failedToSaveExport": "Error al guardar la exportación",
"failedToSaveExportedVideo": "Error al guardar el video exportado",
"failedToRevealInFolder": "Error al mostrar en la carpeta: {{error}}"
},
"export": {
"canceled": "Exportación cancelada",
"exportedSuccessfully": "{{format}} exportado exitosamente"
},
"project": {
"saveCanceled": "Guardado de proyecto cancelado",
"failedToSave": "Error al guardar el proyecto",
"savedTo": "Proyecto guardado en {{path}}",
"failedToLoad": "Error al cargar el proyecto",
"invalidFormat": "Formato de archivo de proyecto no válido",
"loadedFrom": "Proyecto cargado desde {{path}}"
},
"recording": {
"failedCameraAccess": "Error al solicitar acceso a la cámara.",
"cameraBlocked": "El acceso a la cámara está bloqueado. Actívalo en la configuración del sistema para usar la cámara web.",
"systemAudioUnavailable": "Audio del sistema no disponible. Grabando sin audio del sistema.",
"microphoneDenied": "Acceso al micrófono denegado. La grabación continuará sin audio.",
"cameraDenied": "Acceso a la cámara denegado. La grabación continuará sin cámara web.",
"permissionDenied": "Permiso de grabación denegado. Por favor permite la grabación de pantalla."
}
}
+37
View File
@@ -0,0 +1,37 @@
{
"tooltips": {
"hideHUD": "Ocultar HUD",
"closeApp": "Cerrar aplicación",
"restartRecording": "Reiniciar grabación",
"cancelRecording": "Cancelar grabación",
"pauseRecording": "Pausar grabación",
"resumeRecording": "Reanudar grabación",
"openVideoFile": "Abrir archivo de video",
"openProject": "Abrir proyecto"
},
"audio": {
"enableSystemAudio": "Activar audio del sistema",
"disableSystemAudio": "Desactivar audio del sistema",
"enableMicrophone": "Activar micrófono",
"disableMicrophone": "Desactivar micrófono",
"defaultMicrophone": "Micrófono predeterminado"
},
"webcam": {
"enableWebcam": "Activar cámara web",
"disableWebcam": "Desactivar cámara web",
"defaultCamera": "Cámara predeterminada",
"searching": "Buscando...",
"noneFound": "No se encontró cámara",
"unavailable": "Cámara no disponible"
},
"sourceSelector": {
"loading": "Cargando fuentes...",
"screens": "Pantallas ({{count}})",
"windows": "Ventanas ({{count}})",
"defaultSourceName": "Pantalla"
},
"recording": {
"selectSource": "Por favor selecciona una fuente para grabar"
},
"language": "Idioma"
}
+159
View File
@@ -0,0 +1,159 @@
{
"zoom": {
"level": "Nivel de zoom",
"selectRegion": "Selecciona una región de zoom para ajustar",
"deleteZoom": "Eliminar zoom",
"focusMode": {
"title": "Modo de enfoque",
"manual": "Manual",
"auto": "Auto",
"autoDescription": "La cámara sigue la posición del cursor grabado"
}
},
"speed": {
"playbackSpeed": "Velocidad de reproducción",
"selectRegion": "Selecciona una región de velocidad para ajustar",
"deleteRegion": "Eliminar región de velocidad"
},
"trim": {
"deleteRegion": "Eliminar región de recorte"
},
"layout": {
"title": "Diseño",
"preset": "Predefinido",
"selectPreset": "Seleccionar predefinido",
"pictureInPicture": "Imagen en imagen",
"verticalStack": "Apilado vertical",
"webcamShape": "Forma de cámara"
},
"effects": {
"title": "Efectos de video",
"blurBg": "Desenfocar fondo",
"motionBlur": "Desenfoque de movimiento",
"off": "desactivado",
"shadow": "Sombra",
"roundness": "Redondez",
"padding": "Relleno"
},
"background": {
"title": "Fondo",
"image": "Imagen",
"color": "Color",
"gradient": "Degradado",
"uploadCustom": "Subir personalizado",
"gradientLabel": "Degradado {{index}}"
},
"crop": {
"title": "Recortar",
"cropVideo": "Recortar video",
"dragInstruction": "Arrastra cada lado para ajustar el área de recorte",
"ratio": "Proporción",
"free": "Libre",
"done": "Listo",
"lockAspectRatio": "Bloquear relación de aspecto",
"unlockAspectRatio": "Desbloquear relación de aspecto"
},
"exportFormat": {
"mp4": "MP4",
"gif": "GIF",
"mp4Video": "Video MP4",
"mp4Description": "Archivo de video de alta calidad",
"gifAnimation": "Animación GIF",
"gifDescription": "Imagen animada para compartir"
},
"exportQuality": {
"title": "Calidad de exportación",
"low": "Baja",
"medium": "Media",
"high": "Alta"
},
"gifSettings": {
"frameRate": "Velocidad de cuadros del GIF",
"size": "Tamaño del GIF",
"loop": "Repetir GIF"
},
"project": {
"save": "Guardar proyecto",
"load": "Cargar proyecto"
},
"export": {
"videoButton": "Exportar video",
"gifButton": "Exportar GIF",
"chooseSaveLocation": "Elegir ubicación de guardado"
},
"links": {
"reportBug": "Reportar error",
"starOnGithub": "Dar estrella en GitHub"
},
"imageUpload": {
"invalidFileType": "Tipo de archivo no válido",
"jpgOnly": "Por favor sube un archivo de imagen JPG o JPEG.",
"uploadSuccess": "¡Imagen personalizada subida exitosamente!",
"failedToUpload": "Error al subir la imagen",
"errorReading": "Hubo un error al leer el archivo."
},
"annotation": {
"title": "Configuración de anotaciones",
"active": "Activo",
"typeText": "Texto",
"typeImage": "Imagen",
"typeArrow": "Flecha",
"textContent": "Contenido de texto",
"textPlaceholder": "Escribe tu texto...",
"fontStyle": "Estilo de fuente",
"selectStyle": "Seleccionar estilo",
"size": "Tamaño",
"customFonts": "Fuentes personalizadas",
"textColor": "Color de texto",
"background": "Fondo",
"none": "Ninguno",
"color": "Color",
"clearBackground": "Quitar fondo",
"uploadImage": "Subir imagen",
"supportedFormats": "Formatos compatibles: JPG, PNG, GIF, WebP",
"arrowDirection": "Dirección de la flecha",
"strokeWidth": "Grosor del trazo: {{width}}px",
"arrowColor": "Color de la flecha",
"deleteAnnotation": "Eliminar anotación",
"shortcutsAndTips": "Atajos y consejos",
"tipMovePlayhead": "Mueve el cabezal de reproducción a la sección de anotación superpuesta y selecciona un elemento.",
"tipTabCycle": "Usa Tab para recorrer los elementos superpuestos.",
"tipShiftTabCycle": "Usa Shift+Tab para recorrer hacia atrás.",
"invalidImageType": "Tipo de archivo no válido",
"imageFormatsOnly": "Por favor sube un archivo de imagen JPG, PNG, GIF o WebP.",
"imageUploadSuccess": "¡Imagen subida exitosamente!",
"failedImageUpload": "Error al subir la imagen"
},
"fontStyles": {
"classic": "Clásico",
"editor": "Editor",
"strong": "Fuerte",
"typewriter": "Máquina de escribir",
"deco": "Deco",
"simple": "Simple",
"modern": "Moderno",
"clean": "Limpio"
},
"customFont": {
"dialogTitle": "Agregar fuente de Google",
"urlLabel": "URL de importación de Google Fonts",
"urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
"urlHelp": "Obtén esto de Google Fonts: Selecciona una fuente → Haz clic en \"Get font\" → Copia la URL de @import",
"nameLabel": "Nombre para mostrar",
"namePlaceholder": "Mi fuente personalizada",
"nameHelp": "Así aparecerá la fuente en el selector de fuentes",
"addButton": "Agregar fuente",
"addingButton": "Agregando...",
"errorEmptyUrl": "Por favor ingresa una URL de importación de Google Fonts",
"errorInvalidUrl": "Por favor ingresa una URL válida de Google Fonts",
"errorEmptyName": "Por favor ingresa un nombre de fuente",
"errorExtractFailed": "No se pudo extraer la familia de fuentes de la URL",
"successMessage": "Fuente \"{{fontName}}\" agregada exitosamente",
"failedToAdd": "Error al agregar la fuente",
"errorTimeout": "La fuente tardó demasiado en cargarse. Por favor verifica la URL e intenta de nuevo.",
"errorLoadFailed": "No se pudo cargar la fuente. Por favor verifica que la URL de Google Fonts sea correcta."
},
"language": {
"title": "Idioma"
}
}
+36
View File
@@ -0,0 +1,36 @@
{
"title": "Atajos de teclado",
"customize": "Personalizar",
"configurable": "Configurables",
"fixed": "Fijos",
"pressKey": "Presiona una tecla…",
"clickToChange": "Haz clic para cambiar",
"pressEscToCancel": "Presiona Esc para cancelar",
"helpText": "Haz clic en un atajo y luego presiona la nueva combinación de teclas. Presiona Esc para cancelar.",
"resetToDefaults": "Restablecer valores predeterminados",
"alreadyUsedBy": "Ya está en uso por {{action}}",
"swap": "Intercambiar",
"reservedShortcut": "Este atajo está reservado para \"{{label}}\" y no se puede reasignar.",
"savedToast": "Atajos de teclado guardados",
"resetToast": "Restablecido a los atajos predeterminados — haz clic en Guardar para aplicar",
"actions": {
"addZoom": "Agregar zoom",
"addTrim": "Agregar recorte",
"addSpeed": "Agregar velocidad",
"addAnnotation": "Agregar anotación",
"addKeyframe": "Agregar fotograma clave",
"deleteSelected": "Eliminar seleccionado",
"playPause": "Reproducir / Pausar"
},
"fixedActions": {
"undo": "Deshacer",
"redo": "Rehacer",
"cycleAnnotationsForward": "Recorrer anotaciones hacia adelante",
"cycleAnnotationsBackward": "Recorrer anotaciones hacia atrás",
"deleteSelectedAlt": "Eliminar seleccionado (alt)",
"panTimeline": "Desplazar línea de tiempo",
"zoomTimeline": "Zoom en línea de tiempo",
"frameBack": "Fotograma anterior",
"frameForward": "Fotograma siguiente"
}
}
+50
View File
@@ -0,0 +1,50 @@
{
"buttons": {
"addZoom": "Agregar zoom (Z)",
"suggestZooms": "Sugerir zooms desde el cursor",
"addTrim": "Agregar recorte (T)",
"addAnnotation": "Agregar anotación (A)",
"addSpeed": "Agregar velocidad (S)"
},
"hints": {
"pressZoom": "Presiona Z para agregar zoom",
"pressTrim": "Presiona T para agregar recorte",
"pressAnnotation": "Presiona A para agregar anotación",
"pressSpeed": "Presiona S para agregar velocidad"
},
"labels": {
"pan": "Desplazar",
"zoom": "Zoom",
"zoomItem": "Zoom {{index}}",
"trimItem": "Recorte {{index}}",
"speedItem": "Velocidad {{index}}",
"annotationItem": "Anotación",
"imageItem": "Imagen",
"emptyText": "Texto vacío"
},
"emptyState": {
"noVideo": "No hay video cargado",
"dragAndDrop": "Arrastra y suelta un video para comenzar a editar"
},
"errors": {
"cannotPlaceZoom": "No se puede colocar el zoom aquí",
"zoomExistsAtLocation": "Ya existe un zoom en esta ubicación o no hay suficiente espacio disponible.",
"zoomSuggestionUnavailable": "El controlador de sugerencias de zoom no está disponible",
"noCursorTelemetry": "No hay telemetría de cursor disponible",
"noCursorTelemetryDescription": "Graba una captura de pantalla primero para generar sugerencias basadas en el cursor.",
"noUsableTelemetry": "No hay telemetría de cursor utilizable",
"noUsableTelemetryDescription": "La grabación no incluye suficientes datos de movimiento del cursor.",
"noDwellMoments": "No se encontraron momentos claros de pausa del cursor",
"noDwellMomentsDescription": "Intenta una grabación con pausas más lentas del cursor en acciones importantes.",
"noAutoZoomSlots": "No hay espacios de auto-zoom disponibles",
"noAutoZoomSlotsDescription": "Los puntos de pausa detectados se superponen con regiones de zoom existentes.",
"cannotPlaceTrim": "No se puede colocar el recorte aquí",
"trimExistsAtLocation": "Ya existe un recorte en esta ubicación o no hay suficiente espacio disponible.",
"cannotPlaceSpeed": "No se puede colocar la velocidad aquí",
"speedExistsAtLocation": "Ya existe una región de velocidad en esta ubicación o no hay suficiente espacio disponible."
},
"success": {
"addedZoomSuggestions": "Se agregó {{count}} sugerencia de zoom basada en el cursor",
"addedZoomSuggestionsPlural": "Se agregaron {{count}} sugerencias de zoom basadas en el cursor"
}
}
+29
View File
@@ -0,0 +1,29 @@
{
"actions": {
"cancel": "取消",
"save": "保存",
"delete": "删除",
"close": "关闭",
"share": "分享",
"done": "完成",
"open": "打开",
"upload": "上传",
"export": "导出",
"file": "文件",
"edit": "编辑",
"view": "视图",
"window": "窗口",
"quit": "退出",
"stopRecording": "停止录制"
},
"playback": {
"play": "播放",
"pause": "暂停",
"fullscreen": "全屏",
"exitFullscreen": "退出全屏"
},
"locale": {
"name": "中文",
"short": "中文"
}
}
+68
View File
@@ -0,0 +1,68 @@
{
"export": {
"complete": "导出完成",
"yourFormatReady": "您的 {{format}} 已准备就绪",
"showInFolder": "在文件夹中显示",
"finalizingVideo": "正在完成视频导出...",
"compilingGifProgress": "正在编译 GIF... {{progress}}%",
"compilingGifWait": "正在编译 GIF... 这可能需要一些时间",
"takeMoment": "这可能需要一点时间...",
"failed": "导出失败",
"tryAgain": "请重试",
"finalizingVideoTitle": "正在完成视频",
"compilingGif": "正在编译 GIF",
"exportingFormat": "正在导出 {{format}}",
"compiling": "编译中",
"renderingFrames": "渲染帧",
"processing": "处理中...",
"finalizing": "正在完成...",
"compilingStatus": "编译中...",
"status": "状态",
"format": "格式",
"frames": "帧",
"cancelExport": "取消导出",
"savedSuccessfully": "{{format}} 保存成功!"
},
"tutorial": {
"triggerLabel": "剪辑功能说明",
"title": "剪辑功能说明",
"description": "了解如何剪掉视频中不需要的部分。",
"explanation": "剪辑工具通过定义您要",
"explanationRemove": "移除",
"explanationCovered": "覆盖",
"explanationEnd": "的片段来工作。被红色剪辑区域覆盖的部分将在导出时被剪掉。",
"visualExample": "示例演示",
"removed": "已移除",
"kept": "保留",
"part1": "第 1 部分",
"part2": "第 2 部分",
"part3": "第 3 部分",
"finalVideo": "最终视频",
"step1Title": "1. 添加剪辑",
"step1Description": "按 T 或点击剪刀图标来标记要移除的片段。",
"step2Title": "2. 调整",
"step2Description": "拖动红色区域的边缘,精确覆盖您要剪掉的部分。"
},
"unsavedChanges": {
"title": "未保存的更改",
"message": "您有未保存的更改。",
"detail": "是否在关闭前保存项目?",
"saveAndClose": "保存并关闭",
"discardAndClose": "放弃并关闭",
"loadProject": "加载项目…",
"saveProject": "保存项目…",
"saveProjectAs": "项目另存为…"
},
"fileDialogs": {
"saveGif": "保存导出的 GIF",
"saveVideo": "保存导出的视频",
"selectVideo": "选择视频文件",
"saveProject": "保存 OpenScreen 项目",
"openProject": "打开 OpenScreen 项目",
"gifImage": "GIF 图片",
"mp4Video": "MP4 视频",
"videoFiles": "视频文件",
"openscreenProject": "OpenScreen 项目",
"allFiles": "所有文件"
}
}
+35
View File
@@ -0,0 +1,35 @@
{
"errors": {
"noVideoLoaded": "未加载视频",
"videoNotReady": "视频未就绪",
"unableToDetermineSourcePath": "无法确定源视频路径",
"failedToSaveGif": "保存 GIF 失败",
"gifExportFailed": "GIF 导出失败",
"failedToSaveVideo": "保存视频失败",
"exportFailed": "导出失败",
"exportFailedWithError": "导出失败:{{error}}",
"failedToSaveExport": "保存导出文件失败",
"failedToSaveExportedVideo": "保存导出的视频失败",
"failedToRevealInFolder": "在文件夹中显示时出错:{{error}}"
},
"export": {
"canceled": "导出已取消",
"exportedSuccessfully": "{{format}} 导出成功"
},
"project": {
"saveCanceled": "项目保存已取消",
"failedToSave": "保存项目失败",
"savedTo": "项目已保存至 {{path}}",
"failedToLoad": "加载项目失败",
"invalidFormat": "无效的项目文件格式",
"loadedFrom": "项目已从 {{path}} 加载"
},
"recording": {
"failedCameraAccess": "请求摄像头权限失败。",
"cameraBlocked": "摄像头权限已被阻止。请在系统设置中启用以使用摄像头。",
"systemAudioUnavailable": "系统音频不可用。将在无系统音频的情况下录制。",
"microphoneDenied": "麦克风权限被拒绝。录制将继续,但不包含音频。",
"cameraDenied": "摄像头权限被拒绝。录制将继续,但不包含摄像头画面。",
"permissionDenied": "录屏权限被拒绝。请允许屏幕录制。"
}
}
+37
View File
@@ -0,0 +1,37 @@
{
"tooltips": {
"hideHUD": "隐藏控制面板",
"closeApp": "关闭应用",
"restartRecording": "重新开始录制",
"cancelRecording": "取消录制",
"pauseRecording": "暂停录制",
"resumeRecording": "继续录制",
"openVideoFile": "打开视频文件",
"openProject": "打开项目"
},
"audio": {
"enableSystemAudio": "启用系统音频",
"disableSystemAudio": "禁用系统音频",
"enableMicrophone": "启用麦克风",
"disableMicrophone": "禁用麦克风",
"defaultMicrophone": "默认麦克风"
},
"webcam": {
"enableWebcam": "启用摄像头",
"disableWebcam": "禁用摄像头",
"defaultCamera": "默认摄像头",
"searching": "正在搜索...",
"noneFound": "未找到摄像头",
"unavailable": "摄像头不可用"
},
"sourceSelector": {
"loading": "正在加载源...",
"screens": "屏幕 ({{count}})",
"windows": "窗口 ({{count}})",
"defaultSourceName": "屏幕"
},
"recording": {
"selectSource": "请选择要录制的源"
},
"language": "语言"
}
+159
View File
@@ -0,0 +1,159 @@
{
"zoom": {
"level": "缩放级别",
"selectRegion": "选择要调整的缩放区域",
"deleteZoom": "删除缩放",
"focusMode": {
"title": "对焦模式",
"manual": "手动",
"auto": "自动",
"autoDescription": "摄像头跟随录制时的光标位置"
}
},
"speed": {
"playbackSpeed": "播放速度",
"selectRegion": "选择要调整的速度区域",
"deleteRegion": "删除速度区域"
},
"trim": {
"deleteRegion": "删除剪辑区域"
},
"layout": {
"title": "布局",
"preset": "预设",
"selectPreset": "选择预设",
"pictureInPicture": "画中画",
"verticalStack": "垂直堆叠",
"webcamShape": "摄像头形状"
},
"effects": {
"title": "视频效果",
"blurBg": "模糊背景",
"motionBlur": "运动模糊",
"off": "关",
"shadow": "阴影",
"roundness": "圆角",
"padding": "内边距"
},
"background": {
"title": "背景",
"image": "图片",
"color": "颜色",
"gradient": "渐变",
"uploadCustom": "上传自定义",
"gradientLabel": "渐变 {{index}}"
},
"crop": {
"title": "裁剪",
"cropVideo": "裁剪视频",
"dragInstruction": "拖动每一侧来调整裁剪区域",
"ratio": "比例",
"free": "自由",
"done": "完成",
"lockAspectRatio": "锁定宽高比",
"unlockAspectRatio": "解锁宽高比"
},
"exportFormat": {
"mp4": "MP4",
"gif": "GIF",
"mp4Video": "MP4 视频",
"mp4Description": "高质量视频文件",
"gifAnimation": "GIF 动画",
"gifDescription": "可分享的动态图片"
},
"exportQuality": {
"title": "导出质量",
"low": "低",
"medium": "中",
"high": "高"
},
"gifSettings": {
"frameRate": "GIF 帧率",
"size": "GIF 尺寸",
"loop": "循环 GIF"
},
"project": {
"save": "保存项目",
"load": "加载项目"
},
"export": {
"videoButton": "导出视频",
"gifButton": "导出 GIF",
"chooseSaveLocation": "选择保存位置"
},
"links": {
"reportBug": "报告错误",
"starOnGithub": "在 GitHub 上加星"
},
"imageUpload": {
"invalidFileType": "无效的文件类型",
"jpgOnly": "请上传 JPG 或 JPEG 格式的图片文件。",
"uploadSuccess": "自定义图片上传成功!",
"failedToUpload": "上传图片失败",
"errorReading": "读取文件时出错。"
},
"annotation": {
"title": "标注设置",
"active": "活动",
"typeText": "文本",
"typeImage": "图片",
"typeArrow": "箭头",
"textContent": "文本内容",
"textPlaceholder": "输入您的文本...",
"fontStyle": "字体样式",
"selectStyle": "选择样式",
"size": "大小",
"customFonts": "自定义字体",
"textColor": "文本颜色",
"background": "背景",
"none": "无",
"color": "颜色",
"clearBackground": "清除背景",
"uploadImage": "上传图片",
"supportedFormats": "支持的格式:JPG、PNG、GIF、WebP",
"arrowDirection": "箭头方向",
"strokeWidth": "描边宽度:{{width}}px",
"arrowColor": "箭头颜色",
"deleteAnnotation": "删除标注",
"shortcutsAndTips": "快捷键与提示",
"tipMovePlayhead": "将播放头移动到重叠的标注区域并选择一个项目。",
"tipTabCycle": "使用 Tab 键在重叠项目之间循环切换。",
"tipShiftTabCycle": "使用 Shift+Tab 反向循环切换。",
"invalidImageType": "无效的文件类型",
"imageFormatsOnly": "请上传 JPG、PNG、GIF 或 WebP 格式的图片文件。",
"imageUploadSuccess": "图片上传成功!",
"failedImageUpload": "上传图片失败"
},
"fontStyles": {
"classic": "经典",
"editor": "编辑器",
"strong": "粗体",
"typewriter": "打字机",
"deco": "装饰",
"simple": "简约",
"modern": "现代",
"clean": "简洁"
},
"customFont": {
"dialogTitle": "添加 Google 字体",
"urlLabel": "Google Fonts 导入 URL",
"urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
"urlHelp": "从 Google Fonts 获取:选择字体 → 点击 \"Get font\" → 复制 @import URL",
"nameLabel": "显示名称",
"namePlaceholder": "我的自定义字体",
"nameHelp": "这是字体在字体选择器中显示的名称",
"addButton": "添加字体",
"addingButton": "添加中...",
"errorEmptyUrl": "请输入 Google Fonts 导入 URL",
"errorInvalidUrl": "请输入有效的 Google Fonts URL",
"errorEmptyName": "请输入字体名称",
"errorExtractFailed": "无法从 URL 中提取字体系列",
"successMessage": "字体 \"{{fontName}}\" 添加成功",
"failedToAdd": "添加字体失败",
"errorTimeout": "字体加载时间过长。请检查 URL 并重试。",
"errorLoadFailed": "无法加载该字体。请确认 Google Fonts URL 是否正确。"
},
"language": {
"title": "语言"
}
}
+36
View File
@@ -0,0 +1,36 @@
{
"title": "键盘快捷键",
"customize": "自定义",
"configurable": "可配置",
"fixed": "固定",
"pressKey": "请按下按键…",
"clickToChange": "点击以更改",
"pressEscToCancel": "按 Esc 取消",
"helpText": "点击一个快捷键,然后按下新的组合键。按 Esc 取消。",
"resetToDefaults": "恢复默认设置",
"alreadyUsedBy": "已被 \"{{action}}\" 使用",
"swap": "交换",
"reservedShortcut": "此快捷键已保留给 \"{{label}}\",无法重新分配。",
"savedToast": "键盘快捷键已保存",
"resetToast": "已恢复默认快捷键 — 点击保存以应用",
"actions": {
"addZoom": "添加缩放",
"addTrim": "添加剪辑",
"addSpeed": "添加速度",
"addAnnotation": "添加标注",
"addKeyframe": "添加关键帧",
"deleteSelected": "删除所选",
"playPause": "播放 / 暂停"
},
"fixedActions": {
"undo": "撤销",
"redo": "重做",
"cycleAnnotationsForward": "向前切换标注",
"cycleAnnotationsBackward": "向后切换标注",
"deleteSelectedAlt": "删除所选(替代)",
"panTimeline": "平移时间轴",
"zoomTimeline": "缩放时间轴",
"frameBack": "上一帧",
"frameForward": "下一帧"
}
}
+50
View File
@@ -0,0 +1,50 @@
{
"buttons": {
"addZoom": "添加缩放 (Z)",
"suggestZooms": "根据光标建议缩放",
"addTrim": "添加剪辑 (T)",
"addAnnotation": "添加标注 (A)",
"addSpeed": "添加速度 (S)"
},
"hints": {
"pressZoom": "按 Z 添加缩放",
"pressTrim": "按 T 添加剪辑",
"pressAnnotation": "按 A 添加标注",
"pressSpeed": "按 S 添加速度"
},
"labels": {
"pan": "平移",
"zoom": "缩放",
"zoomItem": "缩放 {{index}}",
"trimItem": "剪辑 {{index}}",
"speedItem": "速度 {{index}}",
"annotationItem": "标注",
"imageItem": "图片",
"emptyText": "空文本"
},
"emptyState": {
"noVideo": "未加载视频",
"dragAndDrop": "拖放视频以开始编辑"
},
"errors": {
"cannotPlaceZoom": "无法在此处放置缩放",
"zoomExistsAtLocation": "此位置已存在缩放或没有足够的空间。",
"zoomSuggestionUnavailable": "缩放建议处理器不可用",
"noCursorTelemetry": "无可用的光标遥测数据",
"noCursorTelemetryDescription": "请先录制一段屏幕录像以生成基于光标的建议。",
"noUsableTelemetry": "无可用的光标遥测数据",
"noUsableTelemetryDescription": "录制内容没有包含足够的光标移动数据。",
"noDwellMoments": "未找到明确的光标停留时刻",
"noDwellMomentsDescription": "请尝试在重要操作上进行较慢光标停留的录制。",
"noAutoZoomSlots": "无可用的自动缩放位置",
"noAutoZoomSlotsDescription": "检测到的停留点与现有缩放区域重叠。",
"cannotPlaceTrim": "无法在此处放置剪辑",
"trimExistsAtLocation": "此位置已存在剪辑或没有足够的空间。",
"cannotPlaceSpeed": "无法在此处放置速度",
"speedExistsAtLocation": "此位置已存在速度区域或没有足够的空间。"
},
"success": {
"addedZoomSuggestions": "已添加 {{count}} 个基于光标的缩放建议",
"addedZoomSuggestionsPlural": "已添加 {{count}} 个基于光标的缩放建议"
}
}
@@ -0,0 +1,42 @@
import { describe, expect, it } from "vitest";
import { computeFrameStepTime, FRAME_DURATION_SEC } from "@/lib/frameStep";
describe("computeFrameStepTime", () => {
const duration = 10;
it("moves forward by one frame from the middle", () => {
const result = computeFrameStepTime(5, duration, "forward");
expect(result).toBeCloseTo(5 + FRAME_DURATION_SEC, 10);
});
it("moves backward by one frame from the middle", () => {
const result = computeFrameStepTime(5, duration, "backward");
expect(result).toBeCloseTo(5 - FRAME_DURATION_SEC, 10);
});
it("clamps to 0 when stepping backward at the beginning", () => {
const result = computeFrameStepTime(0, duration, "backward");
expect(result).toBe(0);
});
it("clamps to 0 when stepping backward near the beginning", () => {
const result = computeFrameStepTime(FRAME_DURATION_SEC / 2, duration, "backward");
expect(result).toBe(0);
});
it("clamps to duration when stepping forward at the end", () => {
const result = computeFrameStepTime(duration, duration, "forward");
expect(result).toBe(duration);
});
it("clamps to duration when stepping forward near the end", () => {
const result = computeFrameStepTime(duration - FRAME_DURATION_SEC / 2, duration, "forward");
expect(result).toBe(duration);
});
it("handles duration of 0 gracefully", () => {
expect(computeFrameStepTime(0, 0, "forward")).toBe(0);
expect(computeFrameStepTime(0, 0, "backward")).toBe(0);
});
});
+124
View File
@@ -0,0 +1,124 @@
import { describe, expect, it } from "vitest";
import { computeCompositeLayout } from "./compositeLayout";
describe("computeCompositeLayout", () => {
it("anchors the overlay in the lower-right corner", () => {
const layout = computeCompositeLayout({
canvasSize: { width: 1920, height: 1080 },
screenSize: { width: 1920, height: 1080 },
webcamSize: { width: 1280, height: 720 },
});
expect(layout).not.toBeNull();
expect(layout!.webcamRect).not.toBeNull();
expect(layout!.webcamRect!.x + layout!.webcamRect!.width).toBeLessThanOrEqual(1920);
expect(layout!.webcamRect!.y + layout!.webcamRect!.height).toBeLessThanOrEqual(1080);
expect(layout!.webcamRect!.x).toBeGreaterThan(1920 / 2);
expect(layout!.webcamRect!.y).toBeGreaterThan(1080 / 2);
});
it("keeps the overlay within the configured stage fraction while preserving aspect ratio", () => {
const layout = computeCompositeLayout({
canvasSize: { width: 1280, height: 720 },
screenSize: { width: 1280, height: 720 },
webcamSize: { width: 1920, height: 1080 },
});
expect(layout).not.toBeNull();
expect(layout!.webcamRect).not.toBeNull();
expect(layout!.webcamRect!.width).toBeLessThanOrEqual(Math.round(1280 * 0.18) + 1);
expect(layout!.webcamRect!.height).toBeLessThanOrEqual(Math.round(720 * 0.18) + 1);
expect(
Math.abs(layout!.webcamRect!.width * 1080 - layout!.webcamRect!.height * 1920),
).toBeLessThanOrEqual(1920);
});
it("uses cover-style full-width stacking in vertical stack mode", () => {
const layout = computeCompositeLayout({
canvasSize: { width: 1920, height: 1080 },
maxContentSize: { width: 1536, height: 864 },
screenSize: { width: 1920, height: 1080 },
webcamSize: { width: 1280, height: 720 },
layoutPreset: "vertical-stack",
});
expect(layout).not.toBeNull();
expect(layout?.screenRect).toEqual({
x: 0,
y: 0,
width: 1920,
height: 0,
});
expect(layout?.webcamRect).toEqual({
x: 0,
y: 0,
width: 1920,
height: 1080,
borderRadius: 0,
});
expect(layout?.screenCover).toBe(true);
});
it("fills the canvas with the screen when vertical stack has no webcam", () => {
const layout = computeCompositeLayout({
canvasSize: { width: 1920, height: 1080 },
maxContentSize: { width: 1536, height: 864 },
screenSize: { width: 1920, height: 1080 },
layoutPreset: "vertical-stack",
});
expect(layout).not.toBeNull();
expect(layout?.screenRect).toEqual({
x: 0,
y: 0,
width: 1920,
height: 1080,
});
expect(layout?.webcamRect).toBeNull();
expect(layout?.screenCover).toBe(true);
});
it("forces circular and square masks to use square dimensions", () => {
const circularLayout = computeCompositeLayout({
canvasSize: { width: 1920, height: 1080 },
screenSize: { width: 1920, height: 1080 },
webcamSize: { width: 1280, height: 720 },
webcamMaskShape: "circle",
});
const squareLayout = computeCompositeLayout({
canvasSize: { width: 1920, height: 1080 },
screenSize: { width: 1920, height: 1080 },
webcamSize: { width: 1280, height: 720 },
webcamMaskShape: "square",
});
expect(circularLayout?.webcamRect).not.toBeNull();
expect(squareLayout?.webcamRect).not.toBeNull();
expect(circularLayout?.webcamRect?.width).toBe(circularLayout?.webcamRect?.height);
expect(squareLayout?.webcamRect?.width).toBe(squareLayout?.webcamRect?.height);
expect(circularLayout?.webcamRect?.maskShape).toBe("circle");
expect(squareLayout?.webcamRect?.maskShape).toBe("square");
});
it("applies larger rounding for the rounded webcam mask", () => {
const roundedLayout = computeCompositeLayout({
canvasSize: { width: 1920, height: 1080 },
screenSize: { width: 1920, height: 1080 },
webcamSize: { width: 1280, height: 720 },
webcamMaskShape: "rounded",
});
const rectangleLayout = computeCompositeLayout({
canvasSize: { width: 1920, height: 1080 },
screenSize: { width: 1920, height: 1080 },
webcamSize: { width: 1280, height: 720 },
webcamMaskShape: "rectangle",
});
expect(roundedLayout?.webcamRect).not.toBeNull();
expect(rectangleLayout?.webcamRect).not.toBeNull();
expect(roundedLayout?.webcamRect?.borderRadius).toBeGreaterThan(
rectangleLayout?.webcamRect?.borderRadius ?? 0,
);
expect(roundedLayout?.webcamRect?.maskShape).toBe("rounded");
});
});
+274
View File
@@ -0,0 +1,274 @@
export interface RenderRect {
x: number;
y: number;
width: number;
height: number;
}
export interface StyledRenderRect extends RenderRect {
borderRadius: number;
maskShape?: import("@/components/video-editor/types").WebcamMaskShape;
}
export interface Size {
width: number;
height: number;
}
export type WebcamLayoutPreset = "picture-in-picture" | "vertical-stack";
export interface WebcamLayoutShadow {
color: string;
blur: number;
offsetX: number;
offsetY: number;
}
interface BorderRadiusRule {
max: number;
min: number;
fraction: number;
}
interface OverlayTransform {
type: "overlay";
maxStageFraction: number;
marginFraction: number;
minMargin: number;
minSize: number;
}
interface StackTransform {
type: "stack";
gap: number;
}
export interface WebcamLayoutPresetDefinition {
label: string;
transform: OverlayTransform | StackTransform;
borderRadius: BorderRadiusRule;
shadow: WebcamLayoutShadow | null;
}
export interface WebcamCompositeLayout {
screenRect: RenderRect;
webcamRect: StyledRenderRect | null;
/** When true, the video should be scaled to cover screenRect (cropping overflow). */
screenCover?: boolean;
}
const MAX_STAGE_FRACTION = 0.18;
const MARGIN_FRACTION = 0.02;
const MAX_BORDER_RADIUS = 24;
const WEBCAM_LAYOUT_PRESET_MAP: Record<WebcamLayoutPreset, WebcamLayoutPresetDefinition> = {
"picture-in-picture": {
label: "Picture in Picture",
transform: {
type: "overlay",
maxStageFraction: MAX_STAGE_FRACTION,
marginFraction: MARGIN_FRACTION,
minMargin: 0,
minSize: 0,
},
borderRadius: {
max: MAX_BORDER_RADIUS,
min: 12,
fraction: 0.12,
},
shadow: {
color: "rgba(0,0,0,0.35)",
blur: 24,
offsetX: 0,
offsetY: 10,
},
},
"vertical-stack": {
label: "Vertical Stack",
transform: {
type: "stack",
gap: 0,
},
borderRadius: {
max: 0,
min: 0,
fraction: 0,
},
shadow: null,
},
};
export const WEBCAM_LAYOUT_PRESETS = Object.entries(WEBCAM_LAYOUT_PRESET_MAP).map(
([value, preset]) => ({
value: value as WebcamLayoutPreset,
label: preset.label,
}),
);
export function getWebcamLayoutPresetDefinition(
preset: WebcamLayoutPreset = "picture-in-picture",
): WebcamLayoutPresetDefinition {
return WEBCAM_LAYOUT_PRESET_MAP[preset];
}
export function getWebcamLayoutCssBoxShadow(
preset: WebcamLayoutPreset = "picture-in-picture",
): string {
const shadow = getWebcamLayoutPresetDefinition(preset).shadow;
return shadow
? `${shadow.offsetX}px ${shadow.offsetY}px ${shadow.blur}px ${shadow.color}`
: "none";
}
export function computeCompositeLayout(params: {
canvasSize: Size;
maxContentSize?: Size;
screenSize: Size;
webcamSize?: Size | null;
layoutPreset?: WebcamLayoutPreset;
webcamPosition?: { cx: number; cy: number } | null;
webcamMaskShape?: import("@/components/video-editor/types").WebcamMaskShape;
}): WebcamCompositeLayout | null {
const {
canvasSize,
maxContentSize = canvasSize,
screenSize,
webcamSize,
layoutPreset = "picture-in-picture",
webcamPosition,
webcamMaskShape = "rectangle",
} = params;
const { width: canvasWidth, height: canvasHeight } = canvasSize;
const { width: screenWidth, height: screenHeight } = screenSize;
const webcamWidth = webcamSize?.width;
const webcamHeight = webcamSize?.height;
const preset = getWebcamLayoutPresetDefinition(layoutPreset);
if (canvasWidth <= 0 || canvasHeight <= 0 || screenWidth <= 0 || screenHeight <= 0) {
return null;
}
if (preset.transform.type === "stack") {
if (!webcamWidth || !webcamHeight || webcamWidth <= 0 || webcamHeight <= 0) {
// No webcam — screen fills the entire canvas (cover mode)
return {
screenRect: { x: 0, y: 0, width: canvasWidth, height: canvasHeight },
webcamRect: null,
screenCover: true,
};
}
// Webcam: full width at the bottom, maintaining its aspect ratio
const webcamAspect = webcamWidth / webcamHeight;
const resolvedWebcamWidth = canvasWidth;
const resolvedWebcamHeight = Math.round(canvasWidth / webcamAspect);
// Screen: fills remaining space at the top (cover mode — may crop sides)
const screenRectHeight = canvasHeight - resolvedWebcamHeight;
return {
screenRect: {
x: 0,
y: 0,
width: canvasWidth,
height: Math.max(0, screenRectHeight),
},
webcamRect: {
x: 0,
y: Math.max(0, screenRectHeight),
width: resolvedWebcamWidth,
height: resolvedWebcamHeight,
borderRadius: 0,
},
screenCover: true,
};
}
const transform = preset.transform;
const screenRect = centerRect({
canvasSize,
size: screenSize,
maxSize: maxContentSize,
});
if (!webcamWidth || !webcamHeight || webcamWidth <= 0 || webcamHeight <= 0) {
return { screenRect, webcamRect: null };
}
const margin = Math.max(
transform.minMargin,
Math.round(Math.min(canvasWidth, canvasHeight) * transform.marginFraction),
);
const maxWidth = Math.max(transform.minSize, canvasWidth * transform.maxStageFraction);
const maxHeight = Math.max(transform.minSize, canvasHeight * transform.maxStageFraction);
const scale = Math.min(maxWidth / webcamWidth, maxHeight / webcamHeight);
let width = Math.round(webcamWidth * scale);
let height = Math.round(webcamHeight * scale);
// Shape-specific dimension adjustments
if (webcamMaskShape === "circle" || webcamMaskShape === "square") {
const side = Math.min(width, height);
width = side;
height = side;
}
let webcamX: number;
let webcamY: number;
if (webcamPosition) {
// Custom position: cx/cy represent the center of the webcam as a fraction of the canvas
webcamX = Math.round(webcamPosition.cx * canvasWidth - width / 2);
webcamY = Math.round(webcamPosition.cy * canvasHeight - height / 2);
// Clamp to stay within canvas bounds
webcamX = Math.max(0, Math.min(canvasWidth - width, webcamX));
webcamY = Math.max(0, Math.min(canvasHeight - height, webcamY));
} else {
// Default: bottom-right with margin
webcamX = Math.max(0, Math.round(canvasWidth - margin - width));
webcamY = Math.max(0, Math.round(canvasHeight - margin - height));
}
// Shape-specific border radius
let borderRadius: number;
if (webcamMaskShape === "rounded") {
borderRadius = Math.round(Math.min(width, height) * 0.3);
} else if (webcamMaskShape === "circle") {
borderRadius = Math.round(Math.min(width, height) / 2);
} else {
borderRadius = Math.min(
preset.borderRadius.max,
Math.max(
preset.borderRadius.min,
Math.round(Math.min(width, height) * preset.borderRadius.fraction),
),
);
}
return {
screenRect,
webcamRect: {
x: webcamX,
y: webcamY,
width,
height,
borderRadius,
maskShape: webcamMaskShape,
},
};
}
function centerRect(params: { canvasSize: Size; size: Size; maxSize: Size }): RenderRect {
const { canvasSize, size, maxSize } = params;
const { width: canvasWidth, height: canvasHeight } = canvasSize;
const { width, height } = size;
const { width: maxWidth, height: maxHeight } = maxSize;
const scale = Math.min(maxWidth / width, maxHeight / height, 1);
const resolvedWidth = Math.round(width * scale);
const resolvedHeight = Math.round(height * scale);
return {
x: Math.max(0, Math.floor((canvasWidth - resolvedWidth) / 2)),
y: Math.max(0, Math.floor((canvasHeight - resolvedHeight) / 2)),
width: resolvedWidth,
height: resolvedHeight,
};
}
+199 -89
View File
@@ -12,15 +12,23 @@ import type {
AnnotationRegion,
CropRegion,
SpeedRegion,
WebcamLayoutPreset,
ZoomDepth,
ZoomRegion,
} from "@/components/video-editor/types";
import { ZOOM_DEPTH_SCALES } from "@/components/video-editor/types";
import {
AUTO_FOLLOW_RAMP_DISTANCE,
AUTO_FOLLOW_SMOOTHING_FACTOR,
AUTO_FOLLOW_SMOOTHING_FACTOR_MAX,
DEFAULT_FOCUS,
ZOOM_SCALE_DEADZONE,
ZOOM_TRANSLATION_DEADZONE_PX,
} from "@/components/video-editor/videoPlayback/constants";
import {
adaptiveSmoothFactor,
smoothCursorFocus,
} from "@/components/video-editor/videoPlayback/cursorFollowUtils";
import { clampFocusToStage as clampFocusToStageUtil } from "@/components/video-editor/videoPlayback/focusUtils";
import { findDominantRegion } from "@/components/video-editor/videoPlayback/zoomRegionUtils";
import {
@@ -30,8 +38,20 @@ import {
createMotionBlurState,
type MotionBlurState,
} from "@/components/video-editor/videoPlayback/zoomTransform";
import { computeWebcamOverlayLayout } from "@/lib/webcamOverlay";
import {
computeCompositeLayout,
getWebcamLayoutPresetDefinition,
type Size,
type StyledRenderRect,
} from "@/lib/compositeLayout";
import { drawCanvasClipPath } from "@/lib/webcamMaskShapes";
import { renderAnnotations } from "./annotationRenderer";
import {
getLinearGradientPoints,
getRadialGradientShape,
parseCssGradient,
resolveLinearGradientAngle,
} from "./gradientParser";
interface FrameRenderConfig {
width: number;
@@ -47,12 +67,15 @@ interface FrameRenderConfig {
cropRegion: CropRegion;
videoWidth: number;
videoHeight: number;
webcamWidth?: number;
webcamHeight?: number;
webcamSize?: Size | null;
webcamLayoutPreset?: WebcamLayoutPreset;
webcamMaskShape?: import("@/components/video-editor/types").WebcamMaskShape;
webcamPosition?: { cx: number; cy: number } | null;
annotationRegions?: AnnotationRegion[];
speedRegions?: SpeedRegion[];
previewWidth?: number;
previewHeight?: number;
cursorTelemetry?: import("@/components/video-editor/types").CursorTelemetryPoint[];
}
interface AnimationState {
@@ -71,6 +94,7 @@ interface LayoutCache {
baseScale: number;
baseOffset: { x: number; y: number };
maskRect: { x: number; y: number; width: number; height: number };
webcamRect: StyledRenderRect | null;
}
// Renders video frames with all effects (background, zoom, crop, blur, shadow) to an offscreen canvas for export.
@@ -93,6 +117,9 @@ export class FrameRenderer {
private layoutCache: LayoutCache | null = null;
private currentVideoTime = 0;
private motionBlurState: MotionBlurState = createMotionBlurState();
private smoothedAutoFocus: { cx: number; cy: number } | null = null;
private prevAnimationTimeMs: number | null = null;
private prevTargetProgress = 0;
constructor(config: FrameRenderConfig) {
this.config = config;
@@ -156,7 +183,9 @@ export class FrameRenderer {
this.compositeCanvas = document.createElement("canvas");
this.compositeCanvas.width = this.config.width;
this.compositeCanvas.height = this.config.height;
this.compositeCtx = this.compositeCanvas.getContext("2d", { willReadFrequently: false });
this.compositeCtx = this.compositeCanvas.getContext("2d", {
willReadFrequently: false,
});
if (!this.compositeCtx) {
throw new Error("Failed to get 2D context for composite canvas");
@@ -167,7 +196,9 @@ export class FrameRenderer {
this.shadowCanvas = document.createElement("canvas");
this.shadowCanvas.width = this.config.width;
this.shadowCanvas.height = this.config.height;
this.shadowCtx = this.shadowCanvas.getContext("2d", { willReadFrequently: false });
this.shadowCtx = this.shadowCanvas.getContext("2d", {
willReadFrequently: false,
});
if (!this.shadowCtx) {
throw new Error("Failed to get 2D context for shadow canvas");
@@ -248,40 +279,39 @@ export class FrameRenderer {
wallpaper.startsWith("linear-gradient") ||
wallpaper.startsWith("radial-gradient")
) {
const gradientMatch = wallpaper.match(/(linear|radial)-gradient\((.+)\)/);
if (gradientMatch) {
const [, type, params] = gradientMatch;
const parts = params.split(",").map((s) => s.trim());
const parsedGradient = parseCssGradient(wallpaper);
if (parsedGradient) {
const gradient =
parsedGradient.type === "linear"
? (() => {
const points = getLinearGradientPoints(
resolveLinearGradientAngle(parsedGradient.descriptor),
this.config.width,
this.config.height,
);
let gradient: CanvasGradient;
return bgCtx.createLinearGradient(points.x0, points.y0, points.x1, points.y1);
})()
: (() => {
const shape = getRadialGradientShape(
parsedGradient.descriptor,
this.config.width,
this.config.height,
);
if (type === "linear") {
gradient = bgCtx.createLinearGradient(0, 0, 0, this.config.height);
parts.forEach((part, index) => {
if (part.startsWith("to ") || part.includes("deg")) return;
return bgCtx.createRadialGradient(
shape.cx,
shape.cy,
0,
shape.cx,
shape.cy,
shape.radius,
);
})();
const colorMatch = part.match(/^(#[0-9a-fA-F]{3,8}|rgba?\([^)]+\)|[a-z]+)/);
if (colorMatch) {
const color = colorMatch[1];
const position = index / (parts.length - 1);
gradient.addColorStop(position, color);
}
});
} else {
const cx = this.config.width / 2;
const cy = this.config.height / 2;
const radius = Math.max(this.config.width, this.config.height) / 2;
gradient = bgCtx.createRadialGradient(cx, cy, 0, cx, cy, radius);
parts.forEach((part, index) => {
const colorMatch = part.match(/^(#[0-9a-fA-F]{3,8}|rgba?\([^)]+\)|[a-z]+)/);
if (colorMatch) {
const color = colorMatch[1];
const position = index / (parts.length - 1);
gradient.addColorStop(position, color);
}
});
}
parsedGradient.stops.forEach((stop) => {
gradient.addColorStop(stop.offset, stop.color);
});
bgCtx.fillStyle = gradient;
bgCtx.fillRect(0, 0, this.config.width, this.config.height);
@@ -329,7 +359,7 @@ export class FrameRenderer {
}
// Apply layout
this.updateLayout();
this.updateLayout(webcamFrame);
const timeMs = this.currentVideoTime * 1000;
const TICKS_PER_FRAME = 1;
@@ -393,7 +423,7 @@ export class FrameRenderer {
}
}
private updateLayout(): void {
private updateLayout(webcamFrame?: VideoFrame | null): void {
if (!this.app || !this.videoSprite || !this.maskGraphics || !this.videoContainer) return;
const { width, height } = this.config;
@@ -412,42 +442,62 @@ export class FrameRenderer {
// Calculate scale to fit in viewport
// Padding is a percentage (0-100), where 50% ~ 0.8 scale
const paddingScale = 1.0 - (padding / 100) * 0.4;
// Vertical stack ignores padding — it's full-bleed
const effectivePadding = this.config.webcamLayoutPreset === "vertical-stack" ? 0 : padding;
const paddingScale = 1.0 - (effectivePadding / 100) * 0.4;
const viewportWidth = width * paddingScale;
const viewportHeight = height * paddingScale;
const scale = Math.min(viewportWidth / croppedVideoWidth, viewportHeight / croppedVideoHeight);
const compositeLayout = computeCompositeLayout({
canvasSize: { width, height },
maxContentSize: { width: viewportWidth, height: viewportHeight },
screenSize: { width: croppedVideoWidth, height: croppedVideoHeight },
webcamSize: webcamFrame ? this.config.webcamSize : null,
layoutPreset: this.config.webcamLayoutPreset,
webcamPosition: this.config.webcamPosition,
webcamMaskShape: this.config.webcamMaskShape,
});
if (!compositeLayout) return;
const screenRect = compositeLayout.screenRect;
// Cover mode: scale to fill the rect (may crop), otherwise fit-to-width
let scale: number;
if (compositeLayout.screenCover) {
scale = Math.max(
screenRect.width / croppedVideoWidth,
screenRect.height / croppedVideoHeight,
);
} else {
scale = screenRect.width / croppedVideoWidth;
}
// Position video sprite
this.videoSprite.width = videoWidth * scale;
this.videoSprite.height = videoHeight * scale;
const cropPixelX = cropStartX * videoWidth * scale;
const cropPixelY = cropStartY * videoHeight * scale;
this.videoSprite.x = -cropPixelX;
this.videoSprite.y = -cropPixelY;
// Position video container
// Center the cropped region within the screenRect
const croppedDisplayWidth = croppedVideoWidth * scale;
const croppedDisplayHeight = croppedVideoHeight * scale;
const centerOffsetX = (width - croppedDisplayWidth) / 2;
const centerOffsetY = (height - croppedDisplayHeight) / 2;
this.videoContainer.x = centerOffsetX;
this.videoContainer.y = centerOffsetY;
const coverOffsetX = (screenRect.width - croppedDisplayWidth) / 2;
const coverOffsetY = (screenRect.height - croppedDisplayHeight) / 2;
const cropPixelX = cropStartX * videoWidth * scale;
const cropPixelY = cropStartY * videoHeight * scale;
this.videoSprite.x = -cropPixelX + coverOffsetX;
this.videoSprite.y = -cropPixelY + coverOffsetY;
// Position video container
this.videoContainer.x = screenRect.x;
this.videoContainer.y = screenRect.y;
// scale border radius by export/preview canvas ratio
const previewWidth = this.config.previewWidth || 1920;
const previewHeight = this.config.previewHeight || 1080;
const canvasScaleFactor = Math.min(width / previewWidth, height / previewHeight);
const scaledBorderRadius = borderRadius * canvasScaleFactor;
const scaledBorderRadius = compositeLayout.screenCover ? 0 : borderRadius * canvasScaleFactor;
this.maskGraphics.clear();
this.maskGraphics.roundRect(
0,
0,
croppedDisplayWidth,
croppedDisplayHeight,
scaledBorderRadius,
);
this.maskGraphics.roundRect(0, 0, screenRect.width, screenRect.height, scaledBorderRadius);
this.maskGraphics.fill({ color: 0xffffff });
// Cache layout info
@@ -455,8 +505,9 @@ export class FrameRenderer {
stageSize: { width, height },
videoSize: { width: croppedVideoWidth, height: croppedVideoHeight },
baseScale: scale,
baseOffset: { x: centerOffsetX, y: centerOffsetY },
maskRect: { x: 0, y: 0, width: croppedDisplayWidth, height: croppedDisplayHeight },
baseOffset: { x: compositeLayout.screenRect.x, y: compositeLayout.screenRect.y },
maskRect: compositeLayout.screenRect,
webcamRect: compositeLayout.webcamRect,
};
}
@@ -471,10 +522,16 @@ export class FrameRenderer {
private updateAnimationState(timeMs: number): number {
if (!this.cameraContainer || !this.layoutCache) return 0;
const bmEx = this.layoutCache.maskRect;
const ssEx = this.layoutCache.stageSize;
const viewportRatio =
bmEx.width > 0 && bmEx.height > 0
? { widthRatio: ssEx.width / bmEx.width, heightRatio: ssEx.height / bmEx.height }
: undefined;
const { region, strength, blendedScale, transition } = findDominantRegion(
this.config.zoomRegions,
timeMs,
{ connectZooms: true },
{ connectZooms: true, cursorTelemetry: this.config.cursorTelemetry, viewportRatio },
);
const defaultFocus = DEFAULT_FOCUS;
@@ -490,6 +547,50 @@ export class FrameRenderer {
targetFocus = regionFocus;
targetProgress = strength;
// Apply adaptive smoothing for auto-follow mode
if (region.focusMode === "auto" && !transition) {
const raw = targetFocus;
const dtMs = this.prevAnimationTimeMs != null ? timeMs - this.prevAnimationTimeMs : 0;
const framesElapsed = dtMs > 0 ? dtMs / (1000 / 60) : 1;
const isZoomingIn = targetProgress < 0.999 && targetProgress >= this.prevTargetProgress;
if (targetProgress >= 0.999) {
// Full zoom: adaptive smoothing — moves faster when far, decelerates when close
const prev = this.smoothedAutoFocus ?? raw;
const baseFactor = adaptiveSmoothFactor(
raw,
prev,
AUTO_FOLLOW_SMOOTHING_FACTOR,
AUTO_FOLLOW_SMOOTHING_FACTOR_MAX,
AUTO_FOLLOW_RAMP_DISTANCE,
);
const factor = 1 - Math.pow(1 - baseFactor, Math.max(1, framesElapsed));
const smoothed = smoothCursorFocus(raw, prev, factor);
this.smoothedAutoFocus = smoothed;
targetFocus = smoothed;
} else if (isZoomingIn) {
// Zoom-in: track cursor directly so zoom always aims at current cursor
// position; keep ref in sync to avoid snap when full-zoom begins
this.smoothedAutoFocus = raw;
} else {
// Zoom-out: keep smoothing for continuity — avoids snap at zoom-out start
const prev = this.smoothedAutoFocus ?? raw;
const baseFactor = adaptiveSmoothFactor(
raw,
prev,
AUTO_FOLLOW_SMOOTHING_FACTOR,
AUTO_FOLLOW_SMOOTHING_FACTOR_MAX,
AUTO_FOLLOW_RAMP_DISTANCE,
);
const factor = 1 - Math.pow(1 - baseFactor, Math.max(1, framesElapsed));
const smoothed = smoothCursorFocus(raw, prev, factor);
this.smoothedAutoFocus = smoothed;
targetFocus = smoothed;
}
} else if (region.focusMode !== "auto") {
this.smoothedAutoFocus = null;
}
this.prevTargetProgress = targetProgress;
if (transition) {
const startTransform = computeZoomTransform({
stageSize: this.layoutCache.stageSize,
@@ -565,6 +666,8 @@ export class FrameRenderer {
state.y = appliedY;
state.appliedScale = appliedScale;
this.prevAnimationTimeMs = timeMs;
return Math.max(
Math.abs(appliedScale - prevScale),
Math.abs(appliedX - prevX) / Math.max(1, this.layoutCache.stageSize.width),
@@ -628,34 +731,37 @@ export class FrameRenderer {
ctx.drawImage(videoCanvas, 0, 0, w, h);
}
if (webcamFrame && this.config.webcamWidth && this.config.webcamHeight) {
const layout = computeWebcamOverlayLayout({
stageWidth: w,
stageHeight: h,
videoWidth: this.config.webcamWidth,
videoHeight: this.config.webcamHeight,
});
if (layout) {
ctx.save();
ctx.beginPath();
ctx.roundRect(layout.x, layout.y, layout.width, layout.height, layout.borderRadius);
ctx.closePath();
ctx.shadowColor = "rgba(0,0,0,0.35)";
ctx.shadowBlur = 24;
ctx.shadowOffsetY = 10;
ctx.fillStyle = "#000000";
ctx.fill();
ctx.clip();
ctx.drawImage(
webcamFrame as unknown as CanvasImageSource,
layout.x,
layout.y,
layout.width,
layout.height,
);
ctx.restore();
const webcamRect = this.layoutCache?.webcamRect ?? null;
if (webcamFrame && webcamRect) {
const preset = getWebcamLayoutPresetDefinition(this.config.webcamLayoutPreset);
const shape = webcamRect.maskShape ?? this.config.webcamMaskShape ?? "rectangle";
ctx.save();
drawCanvasClipPath(
ctx,
webcamRect.x,
webcamRect.y,
webcamRect.width,
webcamRect.height,
shape,
webcamRect.borderRadius,
);
if (preset.shadow) {
ctx.shadowColor = preset.shadow.color;
ctx.shadowBlur = preset.shadow.blur;
ctx.shadowOffsetX = preset.shadow.offsetX;
ctx.shadowOffsetY = preset.shadow.offsetY;
}
ctx.fillStyle = "#000000";
ctx.fill();
ctx.clip();
ctx.drawImage(
webcamFrame as unknown as CanvasImageSource,
webcamRect.x,
webcamRect.y,
webcamRect.width,
webcamRect.height,
);
ctx.restore();
}
}
@@ -673,7 +779,11 @@ export class FrameRenderer {
}
this.backgroundSprite = null;
if (this.app) {
this.app.destroy(true, { children: true, texture: true, textureSource: true });
this.app.destroy(true, {
children: true,
texture: true,
textureSource: true,
});
this.app = null;
}
this.cameraContainer = null;
+19
View File
@@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { calculateOutputDimensions } from "./gifExporter";
import { GIF_SIZE_PRESETS } from "./types";
describe("calculateOutputDimensions", () => {
it("uses the selected aspect ratio for scaled GIF exports", () => {
expect(calculateOutputDimensions(1080, 1920, "medium", GIF_SIZE_PRESETS, 16 / 9)).toEqual({
width: 1280,
height: 720,
});
});
it("fits original-size GIF exports within the source bounds at the selected aspect ratio", () => {
expect(calculateOutputDimensions(1080, 1920, "original", GIF_SIZE_PRESETS, 16 / 9)).toEqual({
width: 1080,
height: 606,
});
});
});
+35 -12
View File
@@ -4,6 +4,7 @@ import type {
CropRegion,
SpeedRegion,
TrimRegion,
WebcamLayoutPreset,
ZoomRegion,
} from "@/components/video-editor/types";
import { AsyncVideoFrameQueue } from "./asyncVideoFrameQueue";
@@ -39,9 +40,13 @@ interface GifExporterConfig {
padding?: number;
videoPadding?: number;
cropRegion: CropRegion;
webcamLayoutPreset?: WebcamLayoutPreset;
webcamMaskShape?: import("@/components/video-editor/types").WebcamMaskShape;
webcamPosition?: { cx: number; cy: number } | null;
annotationRegions?: AnnotationRegion[];
previewWidth?: number;
previewHeight?: number;
cursorTelemetry?: import("@/components/video-editor/types").CursorTelemetryPoint[];
onProgress?: (progress: ExportProgress) => void;
}
@@ -58,24 +63,39 @@ export function calculateOutputDimensions(
sourceHeight: number,
sizePreset: GifSizePreset,
sizePresets: typeof GIF_SIZE_PRESETS,
targetAspectRatio = sourceWidth / sourceHeight,
): { width: number; height: number } {
const preset = sizePresets[sizePreset];
const maxHeight = preset.maxHeight;
const aspectRatio =
Number.isFinite(targetAspectRatio) && targetAspectRatio > 0
? targetAspectRatio
: sourceWidth / sourceHeight;
// If original is smaller than max height or preset is 'original', use source dimensions
if (sourceHeight <= maxHeight || sizePreset === "original") {
return { width: sourceWidth, height: sourceHeight };
const toEven = (value: number) => {
const evenValue = Math.max(2, Math.floor(value / 2) * 2);
return evenValue;
};
if (sizePreset === "original") {
const sourceAspect = sourceWidth / sourceHeight;
if (aspectRatio >= sourceAspect) {
const width = toEven(sourceWidth);
const height = toEven(width / aspectRatio);
return { width, height };
}
const height = toEven(sourceHeight);
const width = toEven(height * aspectRatio);
return { width, height };
}
// Calculate scaled dimensions preserving aspect ratio
const aspectRatio = sourceWidth / sourceHeight;
const newHeight = maxHeight;
const newWidth = Math.round(newHeight * aspectRatio);
const targetHeight = maxHeight;
const targetWidth = Math.round(targetHeight * aspectRatio);
// Ensure dimensions are even (required for some encoders)
return {
width: newWidth % 2 === 0 ? newWidth : newWidth + 1,
height: newHeight % 2 === 0 ? newHeight : newHeight + 1,
width: toEven(targetWidth),
height: toEven(targetHeight),
};
}
@@ -121,12 +141,15 @@ export class GifExporter {
cropRegion: this.config.cropRegion,
videoWidth: videoInfo.width,
videoHeight: videoInfo.height,
webcamWidth: webcamInfo?.width,
webcamHeight: webcamInfo?.height,
webcamSize: webcamInfo ? { width: webcamInfo.width, height: webcamInfo.height } : null,
webcamLayoutPreset: this.config.webcamLayoutPreset,
webcamMaskShape: this.config.webcamMaskShape,
webcamPosition: this.config.webcamPosition,
annotationRegions: this.config.annotationRegions,
speedRegions: this.config.speedRegions,
previewWidth: this.config.previewWidth,
previewHeight: this.config.previewHeight,
cursorTelemetry: this.config.cursorTelemetry,
});
await this.renderer.initialize();
+58
View File
@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import {
getLinearGradientPoints,
getRadialGradientShape,
parseCssGradient,
resolveLinearGradientAngle,
} from "./gradientParser";
describe("parseCssGradient", () => {
it("parses rgba-based gradient presets without splitting inside color functions", () => {
const parsed = parseCssGradient(
"linear-gradient( 111.6deg, rgba(114,167,232,1) 9.4%, rgba(253,129,82,1) 43.9%, rgba(253,129,82,1) 54.8%, rgba(249,202,86,1) 86.3% )",
);
expect(parsed?.type).toBe("linear");
expect(parsed?.descriptor).toBe("111.6deg");
expect(parsed?.stops).toHaveLength(4);
expect(parsed?.stops.map((stop) => stop.color)).toEqual([
"rgba(114,167,232,1)",
"rgba(253,129,82,1)",
"rgba(253,129,82,1)",
"rgba(249,202,86,1)",
]);
expect(parsed?.stops[0]?.offset).toBeCloseTo(0.094);
expect(parsed?.stops[1]?.offset).toBeCloseTo(0.439);
expect(parsed?.stops[2]?.offset).toBeCloseTo(0.548);
expect(parsed?.stops[3]?.offset).toBeCloseTo(0.863);
});
it("fills missing stop positions for simple hex gradients", () => {
const parsed = parseCssGradient("linear-gradient(135deg, #FBC8B4, #2447B1)");
expect(parsed?.stops).toEqual([
{ color: "#FBC8B4", offset: 0 },
{ color: "#2447B1", offset: 1 },
]);
});
});
describe("gradient geometry", () => {
it("maps linear directions to canvas endpoints", () => {
const angle = resolveLinearGradientAngle("to right");
const points = getLinearGradientPoints(angle, 1920, 1080);
expect(points.x0).toBeCloseTo(0);
expect(points.y0).toBeCloseTo(540);
expect(points.x1).toBeCloseTo(1920);
expect(points.y1).toBeCloseTo(540);
});
it("uses radial positions from the descriptor", () => {
const shape = getRadialGradientShape("circle farthest-corner at 10% 20%", 1000, 500);
expect(shape.cx).toBe(100);
expect(shape.cy).toBe(100);
expect(shape.radius).toBeCloseTo(Math.hypot(900, 400));
});
});
+236
View File
@@ -0,0 +1,236 @@
export interface ParsedGradientStop {
color: string;
offset: number;
}
export interface ParsedGradient {
type: "linear" | "radial";
descriptor: string | null;
stops: ParsedGradientStop[];
}
const COLOR_TOKEN_RE = /^(#[0-9a-fA-F]{3,8}|(?:rgba?|hsla?)\([^)]*\)|[a-zA-Z-]+)/;
export function parseCssGradient(input: string): ParsedGradient | null {
const gradientMatch = input.match(/^(linear|radial)-gradient\((.*)\)$/i);
if (!gradientMatch) {
return null;
}
const type = gradientMatch[1].toLowerCase() as ParsedGradient["type"];
const rawArgs = splitGradientArgs(gradientMatch[2]);
if (rawArgs.length === 0) {
return null;
}
let descriptor: string | null = null;
let stopArgs = rawArgs;
if (isGradientDescriptor(type, rawArgs[0])) {
descriptor = rawArgs[0];
stopArgs = rawArgs.slice(1);
}
const parsedStops = stopArgs
.map((part) => parseColorStop(part))
.filter((stop): stop is { color: string; offset: number | null } => stop !== null);
if (parsedStops.length === 0) {
return null;
}
return {
type,
descriptor,
stops: normalizeStopOffsets(parsedStops),
};
}
export function getLinearGradientPoints(angleDeg: number, width: number, height: number) {
const radians = (angleDeg * Math.PI) / 180;
const vx = Math.sin(radians);
const vy = -Math.cos(radians);
const halfSpan = (Math.abs(vx) * width + Math.abs(vy) * height) / 2;
const cx = width / 2;
const cy = height / 2;
return {
x0: cx - vx * halfSpan,
y0: cy - vy * halfSpan,
x1: cx + vx * halfSpan,
y1: cy + vy * halfSpan,
};
}
export function resolveLinearGradientAngle(descriptor: string | null): number {
if (!descriptor) {
return 180;
}
const angleMatch = descriptor.match(/(-?\d*\.?\d+)deg/i);
if (angleMatch) {
return Number.parseFloat(angleMatch[1]);
}
const normalized = descriptor.trim().toLowerCase().replace(/\s+/g, " ");
const directionMap: Record<string, number> = {
"to top": 0,
"to top right": 45,
"to right": 90,
"to bottom right": 135,
"to bottom": 180,
"to bottom left": 225,
"to left": 270,
"to top left": 315,
};
return directionMap[normalized] ?? 180;
}
export function getRadialGradientShape(descriptor: string | null, width: number, height: number) {
const atMatch = descriptor?.match(/at\s+(-?\d*\.?\d+)%\s+(-?\d*\.?\d+)%/i);
const cx = atMatch ? (Number.parseFloat(atMatch[1]) / 100) * width : width / 2;
const cy = atMatch ? (Number.parseFloat(atMatch[2]) / 100) * height : height / 2;
const distances = [
Math.hypot(cx, cy),
Math.hypot(width - cx, cy),
Math.hypot(cx, height - cy),
Math.hypot(width - cx, height - cy),
];
return {
cx,
cy,
radius: Math.max(...distances),
};
}
function splitGradientArgs(input: string): string[] {
const parts: string[] = [];
let current = "";
let depth = 0;
for (const char of input) {
if (char === "(") {
depth += 1;
current += char;
continue;
}
if (char === ")") {
depth = Math.max(0, depth - 1);
current += char;
continue;
}
if (char === "," && depth === 0) {
const trimmed = current.trim();
if (trimmed) {
parts.push(trimmed);
}
current = "";
continue;
}
current += char;
}
const trimmed = current.trim();
if (trimmed) {
parts.push(trimmed);
}
return parts;
}
function isGradientDescriptor(type: ParsedGradient["type"], part: string) {
if (type === "linear") {
return /^\s*to\s+/i.test(part) || /-?\d*\.?\d+deg/i.test(part);
}
return /\b(circle|ellipse|closest|farthest)\b/i.test(part) || /\bat\b/i.test(part);
}
function parseColorStop(part: string): { color: string; offset: number | null } | null {
const match = part.trim().match(COLOR_TOKEN_RE);
if (!match) {
return null;
}
const color = match[1];
const rest = part.slice(match[0].length);
const percentMatch = rest.match(/(-?\d*\.?\d+)%/);
const offset = percentMatch ? clamp(Number.parseFloat(percentMatch[1]) / 100, 0, 1) : null;
return { color, offset };
}
function normalizeStopOffsets(
stops: Array<{ color: string; offset: number | null }>,
): ParsedGradientStop[] {
const explicitCount = stops.filter((stop) => stop.offset !== null).length;
if (explicitCount === 0) {
if (stops.length === 1) {
return [{ color: stops[0].color, offset: 0 }];
}
return stops.map((stop, index) => ({
color: stop.color,
offset: index / (stops.length - 1),
}));
}
const resolved = stops.map((stop) => stop.offset);
const firstExplicit = resolved.findIndex((offset) => offset !== null);
const lastExplicit = findLastDefinedIndex(resolved);
for (let index = 0; index < firstExplicit; index += 1) {
const end = resolved[firstExplicit] ?? 0;
resolved[index] = firstExplicit === 0 ? end : (end * index) / firstExplicit;
}
for (let index = lastExplicit + 1; index < resolved.length; index += 1) {
const start = resolved[lastExplicit] ?? 1;
const denominator = resolved.length - 1 - lastExplicit;
resolved[index] =
denominator <= 0 ? start : start + ((1 - start) * (index - lastExplicit)) / denominator;
}
let runStart = firstExplicit;
while (runStart < lastExplicit) {
const nextExplicit = resolved.findIndex((offset, index) => index > runStart && offset !== null);
if (nextExplicit === -1) {
break;
}
const start = resolved[runStart] ?? 0;
const end = resolved[nextExplicit] ?? start;
const gap = nextExplicit - runStart;
for (let index = runStart + 1; index < nextExplicit; index += 1) {
resolved[index] = start + ((end - start) * (index - runStart)) / gap;
}
runStart = nextExplicit;
}
return stops.map((stop, index) => ({
color: stop.color,
offset: clamp(resolved[index] ?? 0, 0, 1),
}));
}
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
function findLastDefinedIndex(values: Array<number | null>) {
for (let index = values.length - 1; index >= 0; index -= 1) {
if (values[index] !== null) {
return index;
}
}
return -1;
}
+47
View File
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import { shouldFailDecodeEndedEarly } from "./streamingDecoder";
describe("shouldFailDecodeEndedEarly", () => {
it("does not fail once every segment has been satisfied", () => {
expect(
shouldFailDecodeEndedEarly({
cancelled: false,
lastDecodedFrameSec: 5.33,
requiredEndSec: 6.498,
streamDurationSec: 5.33,
}),
).toBe(false);
});
it("fails when decode stops far before the required end", () => {
expect(
shouldFailDecodeEndedEarly({
cancelled: false,
lastDecodedFrameSec: 5.33,
requiredEndSec: 10,
streamDurationSec: 5.33,
}),
).toBe(true);
});
it("fails when no frame could be decoded for a non-empty timeline", () => {
expect(
shouldFailDecodeEndedEarly({
cancelled: false,
lastDecodedFrameSec: null,
requiredEndSec: 1,
}),
).toBe(true);
});
it("fails when the decoder has not reached the reported stream end", () => {
expect(
shouldFailDecodeEndedEarly({
cancelled: false,
lastDecodedFrameSec: 4.9,
requiredEndSec: 6.498,
streamDurationSec: 5.33,
}),
).toBe(true);
});
});
+101 -9
View File
@@ -1,6 +1,8 @@
import { WebDemuxer } from "web-demuxer";
import type { SpeedRegion, TrimRegion } from "@/components/video-editor/types";
const SOURCE_LOAD_TIMEOUT_MS = 60_000;
export interface DecodedVideoInfo {
width: number;
height: number;
@@ -12,6 +14,55 @@ export interface DecodedVideoInfo {
audioCodec?: string;
}
type EarlyDecodeEndCheck = {
cancelled: boolean;
lastDecodedFrameSec: number | null;
requiredEndSec: number;
streamDurationSec?: number;
};
const EARLY_DECODE_END_THRESHOLD_SEC = 1;
const METADATA_TAIL_TOLERANCE_SEC = 1.5;
const STREAM_DURATION_MATCH_TOLERANCE_SEC = 0.25;
export function shouldFailDecodeEndedEarly({
cancelled,
lastDecodedFrameSec,
requiredEndSec,
streamDurationSec,
}: EarlyDecodeEndCheck): boolean {
if (cancelled || requiredEndSec <= 0) {
return false;
}
if (lastDecodedFrameSec === null) {
return true;
}
const decodeGapSec = requiredEndSec - lastDecodedFrameSec;
if (decodeGapSec <= EARLY_DECODE_END_THRESHOLD_SEC) {
return false;
}
if (typeof streamDurationSec !== "number" || !Number.isFinite(streamDurationSec)) {
return true;
}
const metadataTailSec = requiredEndSec - streamDurationSec;
const decodedNearStreamEnd =
Math.abs(lastDecodedFrameSec - streamDurationSec) <= STREAM_DURATION_MATCH_TOLERANCE_SEC;
if (
decodedNearStreamEnd &&
metadataTailSec > 0 &&
metadataTailSec <= METADATA_TAIL_TOLERANCE_SEC
) {
return false;
}
return true;
}
/** Caller must close the VideoFrame after use. */
type OnFrameCallback = (
frame: VideoFrame,
@@ -36,7 +87,11 @@ export class StreamingVideoDecoder {
const isRemoteUrl = /^(https?:|blob:|data:)/i.test(videoUrl);
if (!isRemoteUrl && window.electronAPI?.readBinaryFile) {
const result = await window.electronAPI.readBinaryFile(videoUrl);
const result = await this.withTimeout(
window.electronAPI.readBinaryFile(videoUrl),
SOURCE_LOAD_TIMEOUT_MS,
"Timed out while loading the source video.",
);
if (!result.success || !result.data) {
throw new Error(result.message || result.error || "Failed to read source video");
}
@@ -49,11 +104,19 @@ export class StreamingVideoDecoder {
};
}
const response = await fetch(videoUrl);
const response = await this.withTimeout(
fetch(videoUrl),
SOURCE_LOAD_TIMEOUT_MS,
"Timed out while loading the source video.",
);
if (!response.ok) {
throw new Error(`Failed to fetch source video: ${response.status} ${response.statusText}`);
}
const blob = await response.blob();
const blob = await this.withTimeout(
response.blob(),
SOURCE_LOAD_TIMEOUT_MS,
"Timed out while reading the source video.",
);
const filename = videoUrl.split("/").pop() || "video";
return {
blob,
@@ -67,9 +130,17 @@ export class StreamingVideoDecoder {
// Relative URL so it resolves correctly in both dev (http) and packaged (file://) builds
const wasmUrl = new URL("./wasm/web-demuxer.wasm", window.location.href).href;
this.demuxer = new WebDemuxer({ wasmFilePath: wasmUrl });
await this.demuxer.load(file);
await this.withTimeout(
this.demuxer.load(file),
SOURCE_LOAD_TIMEOUT_MS,
"Timed out while parsing the source video.",
);
const mediaInfo = await this.demuxer.getMediaInfo();
const mediaInfo = await this.withTimeout(
this.demuxer.getMediaInfo(),
SOURCE_LOAD_TIMEOUT_MS,
"Timed out while reading video metadata.",
);
const videoStream = mediaInfo.streams.find((s) => s.codec_type_string === "video");
let frameRate = 60;
@@ -366,12 +437,17 @@ export class StreamingVideoDecoder {
const requiredEndSec = segments.length > 0 ? segments[segments.length - 1].endSec : 0;
if (
!this.cancelled &&
lastDecodedFrameSec !== null &&
requiredEndSec - lastDecodedFrameSec > 1
shouldFailDecodeEndedEarly({
cancelled: this.cancelled,
lastDecodedFrameSec,
requiredEndSec,
streamDurationSec: this.metadata.streamDuration,
})
) {
const decodedAtLabel =
lastDecodedFrameSec === null ? "no decoded frame" : `${lastDecodedFrameSec.toFixed(3)}s`;
throw new Error(
`Video decode ended early at ${lastDecodedFrameSec.toFixed(3)}s (needed ${requiredEndSec.toFixed(3)}s).`,
`Video decode ended early at ${decodedAtLabel} (needed ${requiredEndSec.toFixed(3)}s).`,
);
}
}
@@ -472,4 +548,20 @@ export class StreamingVideoDecoder {
this.demuxer = null;
}
}
private withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = window.setTimeout(() => reject(new Error(message)), timeoutMs);
promise.then(
(value) => {
window.clearTimeout(timer);
resolve(value);
},
(error) => {
window.clearTimeout(timer);
reject(error);
},
);
});
}
}
+201 -107
View File
@@ -3,6 +3,7 @@ import type {
CropRegion,
SpeedRegion,
TrimRegion,
WebcamLayoutPreset,
ZoomRegion,
} from "@/components/video-editor/types";
import { AsyncVideoFrameQueue } from "./asyncVideoFrameQueue";
@@ -12,6 +13,9 @@ import { VideoMuxer } from "./muxer";
import { StreamingVideoDecoder } from "./streamingDecoder";
import type { ExportConfig, ExportProgress, ExportResult } from "./types";
const ENCODER_STALL_TIMEOUT_MS = 15_000;
const ENCODER_FLUSH_TIMEOUT_MS = 20_000;
interface VideoExporterConfig extends ExportConfig {
videoUrl: string;
webcamVideoUrl?: string;
@@ -27,9 +31,13 @@ interface VideoExporterConfig extends ExportConfig {
padding?: number;
videoPadding?: number;
cropRegion: CropRegion;
webcamLayoutPreset?: WebcamLayoutPreset;
webcamMaskShape?: import("@/components/video-editor/types").WebcamMaskShape;
webcamPosition?: { cx: number; cy: number } | null;
annotationRegions?: AnnotationRegion[];
previewWidth?: number;
previewHeight?: number;
cursorTelemetry?: import("@/components/video-editor/types").CursorTelemetryPoint[];
onProgress?: (progress: ExportProgress) => void;
}
@@ -43,35 +51,76 @@ export class VideoExporter {
private webcamDecoder: StreamingVideoDecoder | null = null;
private cancelled = false;
private encodeQueue = 0;
// Increased queue size for better throughput with hardware encoding
// Keep a smaller queue for software encoding so Windows does not balloon memory.
private readonly MAX_ENCODE_QUEUE = 120;
private videoDescription: Uint8Array | undefined;
private videoColorSpace: VideoColorSpaceInit | undefined;
// Track muxing promises for parallel processing
private muxingPromises: Promise<void>[] = [];
private chunkCount = 0;
private lastEncoderOutputAt = 0;
private fatalEncoderError: Error | null = null;
constructor(config: VideoExporterConfig) {
this.config = config;
}
async export(): Promise<ExportResult> {
let webcamFrameQueue: AsyncVideoFrameQueue | null = null;
try {
this.cleanup();
this.cancelled = false;
const encoderPreferences = this.getEncoderPreferences();
let lastError: Error | null = null;
// Initialize streaming decoder and load video metadata
this.streamingDecoder = new StreamingVideoDecoder();
const videoInfo = await this.streamingDecoder.loadMetadata(this.config.videoUrl);
for (const encoderPreference of encoderPreferences) {
try {
return await this.exportWithEncoderPreference(encoderPreference);
} catch (error) {
const normalizedError = error instanceof Error ? error : new Error(String(error));
lastError = normalizedError;
if (this.cancelled) {
return { success: false, error: "Export cancelled" };
}
if (encoderPreferences.length > 1) {
console.warn(
`[VideoExporter] ${encoderPreference} export attempt failed:`,
normalizedError,
);
}
} finally {
this.cleanup();
}
}
return {
success: false,
error: lastError?.message || "Export failed",
};
}
private async exportWithEncoderPreference(
encoderPreference: HardwareAcceleration,
): Promise<ExportResult> {
let webcamFrameQueue: AsyncVideoFrameQueue | null = null;
let stopWebcamDecode = false;
let webcamDecodeError: Error | null = null;
let webcamDecodePromise: Promise<void> | null = null;
let webcamDecoder: StreamingVideoDecoder | null = null;
this.cleanup();
this.cancelled = false;
this.fatalEncoderError = null;
try {
const streamingDecoder = new StreamingVideoDecoder();
this.streamingDecoder = streamingDecoder;
const videoInfo = await streamingDecoder.loadMetadata(this.config.videoUrl);
let webcamInfo: Awaited<ReturnType<StreamingVideoDecoder["loadMetadata"]>> | null = null;
if (this.config.webcamVideoUrl) {
this.webcamDecoder = new StreamingVideoDecoder();
webcamInfo = await this.webcamDecoder.loadMetadata(this.config.webcamVideoUrl);
webcamDecoder = new StreamingVideoDecoder();
this.webcamDecoder = webcamDecoder;
webcamInfo = await webcamDecoder.loadMetadata(this.config.webcamVideoUrl);
}
// Initialize frame renderer
this.renderer = new FrameRenderer({
const renderer = new FrameRenderer({
width: this.config.width,
height: this.config.height,
wallpaper: this.config.wallpaper,
@@ -85,25 +134,27 @@ export class VideoExporter {
cropRegion: this.config.cropRegion,
videoWidth: videoInfo.width,
videoHeight: videoInfo.height,
webcamWidth: webcamInfo?.width,
webcamHeight: webcamInfo?.height,
webcamSize: webcamInfo ? { width: webcamInfo.width, height: webcamInfo.height } : null,
webcamLayoutPreset: this.config.webcamLayoutPreset,
webcamMaskShape: this.config.webcamMaskShape,
webcamPosition: this.config.webcamPosition,
annotationRegions: this.config.annotationRegions,
speedRegions: this.config.speedRegions,
previewWidth: this.config.previewWidth,
previewHeight: this.config.previewHeight,
cursorTelemetry: this.config.cursorTelemetry,
});
await this.renderer.initialize();
this.renderer = renderer;
await renderer.initialize();
// Initialize video encoder
await this.initializeEncoder();
await this.initializeEncoder(encoderPreference);
// Initialize muxer (with audio if source has an audio track)
const hasAudio = videoInfo.hasAudio;
this.muxer = new VideoMuxer(this.config, hasAudio);
await this.muxer.initialize();
const muxer = new VideoMuxer(this.config, hasAudio);
this.muxer = muxer;
await muxer.initialize();
// Calculate effective duration and frame count (excluding trim regions)
const effectiveDuration = this.streamingDecoder.getEffectiveDuration(
const effectiveDuration = streamingDecoder.getEffectiveDuration(
this.config.trimRegions,
this.config.speedRegions,
);
@@ -115,16 +166,19 @@ export class VideoExporter {
console.log("[VideoExporter] Total frames to export:", totalFrames);
console.log("[VideoExporter] Using streaming decode (web-demuxer + VideoDecoder)");
const frameDuration = 1_000_000 / this.config.frameRate; // in microseconds
const frameDuration = 1_000_000 / this.config.frameRate;
let frameIndex = 0;
const maxEncodeQueue =
encoderPreference === "prefer-software"
? Math.min(this.MAX_ENCODE_QUEUE, 32)
: this.MAX_ENCODE_QUEUE;
webcamFrameQueue = this.config.webcamVideoUrl ? new AsyncVideoFrameQueue() : null;
let stopWebcamDecode = false;
let webcamDecodeError: Error | null = null;
const webcamDecodePromise =
this.webcamDecoder && webcamFrameQueue
webcamDecodePromise =
webcamDecoder && webcamFrameQueue
? (() => {
const queue = webcamFrameQueue;
return this.webcamDecoder
return webcamDecoder
.decodeAll(
this.config.frameRate,
this.config.trimRegions,
@@ -142,7 +196,7 @@ export class VideoExporter {
)
.catch((error) => {
webcamDecodeError = error instanceof Error ? error : new Error(String(error));
throw error;
throw webcamDecodeError;
})
.finally(() => {
if (webcamDecodeError) {
@@ -154,8 +208,7 @@ export class VideoExporter {
})()
: null;
// Stream decode and process frames — no seeking!
await this.streamingDecoder.decodeAll(
await streamingDecoder.decodeAll(
this.config.frameRate,
this.config.trimRegions,
this.config.speedRegions,
@@ -166,22 +219,31 @@ export class VideoExporter {
return;
}
if (this.fatalEncoderError) {
throw this.fatalEncoderError;
}
const timestamp = frameIndex * frameDuration;
webcamFrame = webcamFrameQueue ? await webcamFrameQueue.dequeue() : null;
const renderer = this.renderer;
if (this.cancelled || !renderer) {
if (this.cancelled) {
return;
}
// Render the frame with all effects using source timestamp
const sourceTimestampUs = sourceTimestampMs * 1000; // Convert to microseconds
const sourceTimestampUs = sourceTimestampMs * 1000;
await renderer.renderFrame(videoFrame, sourceTimestampUs, webcamFrame);
const canvas = renderer.getCanvas();
// Create VideoFrame from canvas on GPU without reading pixels
// @ts-expect-error - colorSpace not in TypeScript definitions but works at runtime
const exportFrame = new VideoFrame(canvas, {
// Read raw pixels from the canvas instead of passing
// the canvas directly to VideoFrame. On some Linux
// systems the GPU shared-image path (EGL/Ozone) fails
// silently, producing empty frames.
const canvasCtx = canvas.getContext("2d")!;
const imageData = canvasCtx.getImageData(0, 0, canvas.width, canvas.height);
const exportFrame = new VideoFrame(imageData.data.buffer, {
format: "RGBA",
codedWidth: canvas.width,
codedHeight: canvas.height,
timestamp,
duration: frameDuration,
colorSpace: {
@@ -192,12 +254,19 @@ export class VideoExporter {
},
});
// Check encoder queue before encoding to keep it full
while (
this.encoder &&
this.encoder.encodeQueueSize >= this.MAX_ENCODE_QUEUE &&
this.encoder.encodeQueueSize >= maxEncodeQueue &&
!this.cancelled
) {
if (Date.now() - this.lastEncoderOutputAt > ENCODER_STALL_TIMEOUT_MS) {
exportFrame.close();
throw new Error(
encoderPreference === "prefer-hardware"
? "The hardware video encoder stopped responding. Retrying with a safer encoder."
: "The video encoder stopped responding during export.",
);
}
await new Promise((resolve) => setTimeout(resolve, 5));
}
@@ -211,18 +280,14 @@ export class VideoExporter {
}
exportFrame.close();
frameIndex++;
// Update progress
if (this.config.onProgress) {
this.config.onProgress({
currentFrame: frameIndex,
totalFrames,
percentage: (frameIndex / totalFrames) * 100,
estimatedTimeRemaining: 0,
});
}
this.reportProgress({
currentFrame: frameIndex,
totalFrames,
percentage: (frameIndex / totalFrames) * 100,
estimatedTimeRemaining: 0,
});
} finally {
videoFrame.close();
webcamFrame?.close();
@@ -234,38 +299,47 @@ export class VideoExporter {
return { success: false, error: "Export cancelled" };
}
if (this.fatalEncoderError) {
throw this.fatalEncoderError;
}
stopWebcamDecode = true;
webcamFrameQueue?.destroy();
this.webcamDecoder?.cancel();
webcamDecoder?.cancel();
await webcamDecodePromise;
// Finalize encoding
if (this.encoder && this.encoder.state === "configured") {
await this.encoder.flush();
await this.withTimeout(
this.encoder.flush(),
ENCODER_FLUSH_TIMEOUT_MS,
encoderPreference === "prefer-hardware"
? "The hardware video encoder stopped responding while finalizing the export."
: "The video encoder stopped responding while finalizing the export.",
);
}
if (this.fatalEncoderError) {
throw this.fatalEncoderError;
}
// Wait for all video muxing operations to complete
await Promise.all(this.muxingPromises);
if (this.config.onProgress) {
this.config.onProgress({
currentFrame: totalFrames,
totalFrames,
percentage: 100,
estimatedTimeRemaining: 0,
phase: "finalizing",
});
}
this.reportProgress({
currentFrame: totalFrames,
totalFrames,
percentage: 100,
estimatedTimeRemaining: 0,
phase: "finalizing",
});
// Process audio track if present
if (hasAudio && !this.cancelled) {
const demuxer = this.streamingDecoder!.getDemuxer();
const demuxer = streamingDecoder.getDemuxer();
if (demuxer) {
console.log("[VideoExporter] Processing audio track...");
this.audioProcessor = new AudioProcessor();
await this.audioProcessor.process(
demuxer,
this.muxer!,
muxer,
this.config.videoUrl,
this.config.trimRegions,
this.config.speedRegions,
@@ -274,31 +348,30 @@ export class VideoExporter {
}
}
// Finalize muxer and get output blob
const blob = await this.muxer!.finalize();
const blob = await muxer.finalize();
return { success: true, blob };
} catch (error) {
console.error("Export error:", error);
return {
success: false,
error: error instanceof Error ? error.message : String(error),
};
} finally {
stopWebcamDecode = true;
webcamFrameQueue?.destroy();
this.cleanup();
webcamDecoder?.cancel();
if (webcamDecodePromise) {
await webcamDecodePromise.catch(() => undefined);
}
}
}
private async initializeEncoder(): Promise<void> {
private async initializeEncoder(hardwareAcceleration: HardwareAcceleration): Promise<void> {
this.encodeQueue = 0;
this.muxingPromises = [];
this.chunkCount = 0;
this.lastEncoderOutputAt = Date.now();
this.fatalEncoderError = null;
let videoDescription: Uint8Array | undefined;
this.encoder = new VideoEncoder({
output: (chunk, meta) => {
// Capture decoder config metadata from encoder output
this.lastEncoderOutputAt = Date.now();
if (meta?.decoderConfig?.description && !videoDescription) {
const desc = meta.decoderConfig.description;
if (desc instanceof ArrayBuffer || desc instanceof SharedArrayBuffer) {
@@ -308,19 +381,17 @@ export class VideoExporter {
}
this.videoDescription = videoDescription;
}
// Capture colorSpace from encoder metadata if provided
if (meta?.decoderConfig?.colorSpace && !this.videoColorSpace) {
this.videoColorSpace = meta.decoderConfig.colorSpace;
}
// Stream chunk to muxer immediately (parallel processing)
const isFirstChunk = this.chunkCount === 0;
this.chunkCount++;
const muxingPromise = (async () => {
try {
if (isFirstChunk && this.videoDescription) {
// Add decoder config for the first chunk
const colorSpace = this.videoColorSpace || {
primaries: "bt709",
transfer: "iec61966-2-1",
@@ -352,43 +423,37 @@ export class VideoExporter {
},
error: (error) => {
console.error("[VideoExporter] Encoder error:", error);
// Stop export encoding failed
this.cancelled = true;
this.fatalEncoderError =
error instanceof Error ? error : new Error(`Video encoder error: ${String(error)}`);
this.streamingDecoder?.cancel();
this.webcamDecoder?.cancel();
},
});
const codec = this.config.codec || "avc1.640033";
const encoderConfig: VideoEncoderConfig = {
codec,
codec: this.config.codec || "avc1.640033",
width: this.config.width,
height: this.config.height,
bitrate: this.config.bitrate,
framerate: this.config.frameRate,
latencyMode: "quality", // Changed from 'realtime' to 'quality' for better throughput
latencyMode: "quality",
bitrateMode: "variable",
hardwareAcceleration: "prefer-hardware",
hardwareAcceleration,
};
// Check hardware support first
const hardwareSupport = await VideoEncoder.isConfigSupported(encoderConfig);
if (hardwareSupport.supported) {
// Use hardware encoding
console.log("[VideoExporter] Using hardware acceleration");
this.encoder.configure(encoderConfig);
} else {
// Fall back to software encoding
console.log("[VideoExporter] Hardware not supported, using software encoding");
encoderConfig.hardwareAcceleration = "prefer-software";
const softwareSupport = await VideoEncoder.isConfigSupported(encoderConfig);
if (!softwareSupport.supported) {
throw new Error("Video encoding not supported on this system");
}
this.encoder.configure(encoderConfig);
const support = await VideoEncoder.isConfigSupported(encoderConfig);
if (!support.supported) {
throw new Error(
hardwareAcceleration === "prefer-hardware"
? "Hardware video encoding is not supported on this system."
: "Software video encoding is not supported on this system.",
);
}
console.log(
`[VideoExporter] Using ${hardwareAcceleration === "prefer-hardware" ? "hardware" : "software"} acceleration`,
);
this.encoder.configure(encoderConfig);
}
cancel(): void {
@@ -451,5 +516,34 @@ export class VideoExporter {
this.chunkCount = 0;
this.videoDescription = undefined;
this.videoColorSpace = undefined;
this.lastEncoderOutputAt = 0;
this.fatalEncoderError = null;
}
private getEncoderPreferences(): HardwareAcceleration[] {
if (typeof navigator !== "undefined" && /\bWindows\b/i.test(navigator.userAgent)) {
return ["prefer-software", "prefer-hardware"];
}
return ["prefer-hardware", "prefer-software"];
}
private reportProgress(progress: ExportProgress): void {
this.config.onProgress?.(progress);
}
private withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = window.setTimeout(() => reject(new Error(message)), timeoutMs);
promise.then(
(value) => {
window.clearTimeout(timer);
resolve(value);
},
(error) => {
window.clearTimeout(timer);
reject(error);
},
);
});
}
}
+15
View File
@@ -0,0 +1,15 @@
/** Duration of a single frame in seconds at 60 FPS (~16.67ms). */
export const FRAME_DURATION_SEC = 1 / 60;
/**
* Compute the new playhead time after stepping one frame forward or backward.
* The result is clamped to the range [0, duration].
*/
export function computeFrameStepTime(
currentTime: number,
duration: number,
direction: "forward" | "backward",
): number {
const delta = direction === "forward" ? FRAME_DURATION_SEC : -FRAME_DURATION_SEC;
return Math.min(duration, Math.max(0, currentTime + delta));
}
+57
View File
@@ -0,0 +1,57 @@
export type CameraAccessResult = {
success: boolean;
granted: boolean;
status: string;
error?: string;
};
function getDeniedStatus(error: unknown) {
if (error instanceof DOMException) {
return error.name;
}
return "unknown";
}
export async function requestCameraAccess(): Promise<CameraAccessResult> {
if (window.electronAPI?.requestCameraAccess) {
try {
const electronResult = await window.electronAPI.requestCameraAccess();
if (!electronResult.success || !electronResult.granted) {
return electronResult;
}
} catch (error) {
return {
success: false,
granted: false,
status: "error",
error: String(error),
};
}
}
if (!navigator.mediaDevices?.getUserMedia) {
return {
success: false,
granted: false,
status: "unsupported",
error: "Camera access is not supported in this runtime.",
};
}
try {
const stream = await navigator.mediaDevices.getUserMedia({
audio: false,
video: true,
});
stream.getTracks().forEach((track) => track.stop());
return { success: true, granted: true, status: "granted" };
} catch (error) {
return {
success: true,
granted: false,
status: getDeniedStatus(error),
error: String(error),
};
}
}
+25 -4
View File
@@ -21,14 +21,16 @@ export interface ShortcutBinding {
export type ShortcutsConfig = Record<ShortcutAction, ShortcutBinding>;
export interface FixedShortcut {
i18nKey: string;
label: string;
display: string;
bindings: ShortcutBinding[];
}
export const FIXED_SHORTCUTS: FixedShortcut[] = [
{ label: "Undo", display: "Ctrl + Z", bindings: [{ key: "z", ctrl: true }] },
{ i18nKey: "undo", label: "Undo", display: "Ctrl + Z", bindings: [{ key: "z", ctrl: true }] },
{
i18nKey: "redo",
label: "Redo",
display: "Ctrl + Shift + Z / Ctrl + Y",
bindings: [
@@ -36,19 +38,38 @@ export const FIXED_SHORTCUTS: FixedShortcut[] = [
{ key: "y", ctrl: true },
],
},
{ label: "Cycle Annotations Forward", display: "Tab", bindings: [{ key: "tab" }] },
{
i18nKey: "cycleAnnotationsForward",
label: "Cycle Annotations Forward",
display: "Tab",
bindings: [{ key: "tab" }],
},
{
i18nKey: "cycleAnnotationsBackward",
label: "Cycle Annotations Backward",
display: "Shift + Tab",
bindings: [{ key: "tab", shift: true }],
},
{
i18nKey: "deleteSelectedAlt",
label: "Delete Selected (alt)",
display: "Del / ⌫",
bindings: [{ key: "delete" }, { key: "backspace" }],
},
{ label: "Pan Timeline", display: "Shift + Ctrl + Scroll", bindings: [] },
{ label: "Zoom Timeline", display: "Ctrl + Scroll", bindings: [] },
{
i18nKey: "panTimeline",
label: "Pan Timeline",
display: "Shift + Ctrl + Scroll",
bindings: [],
},
{ i18nKey: "zoomTimeline", label: "Zoom Timeline", display: "Ctrl + Scroll", bindings: [] },
{ i18nKey: "frameBack", label: "Frame Back", display: "←", bindings: [{ key: "arrowleft" }] },
{
i18nKey: "frameForward",
label: "Frame Forward",
display: "→",
bindings: [{ key: "arrowright" }],
},
];
export type ShortcutConflict =
+48
View File
@@ -0,0 +1,48 @@
import type { WebcamMaskShape } from "@/components/video-editor/types";
/**
* Returns a CSS clip-path value for the given shape, or null if borderRadius alone suffices.
*/
export function getCssClipPath(shape: WebcamMaskShape): string | null {
switch (shape) {
case "circle":
return "circle(50% at 50% 50%)";
case "rectangle":
case "rounded":
case "square":
default:
return null;
}
}
/**
* Draws a Canvas 2D clip path for the given webcam mask shape.
* Call ctx.beginPath() is handled internally; caller should call ctx.clip() after.
*/
export function drawCanvasClipPath(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
w: number,
h: number,
shape: WebcamMaskShape,
borderRadius: number,
): void {
ctx.beginPath();
switch (shape) {
case "circle": {
const cx = x + w / 2;
const cy = y + h / 2;
const r = Math.min(w, h) / 2;
ctx.arc(cx, cy, r, 0, Math.PI * 2);
break;
}
case "rectangle":
case "rounded":
case "square":
default:
ctx.roundRect(x, y, w, h, borderRadius);
break;
}
ctx.closePath();
}
-33
View File
@@ -1,33 +0,0 @@
import { describe, expect, it } from "vitest";
import { computeWebcamOverlayLayout } from "./webcamOverlay";
describe("computeWebcamOverlayLayout", () => {
it("anchors the overlay in the lower-right corner", () => {
const layout = computeWebcamOverlayLayout({
stageWidth: 1920,
stageHeight: 1080,
videoWidth: 1280,
videoHeight: 720,
});
expect(layout).not.toBeNull();
expect(layout!.x + layout!.width).toBeLessThanOrEqual(1920);
expect(layout!.y + layout!.height).toBeLessThanOrEqual(1080);
expect(layout!.x).toBeGreaterThan(1920 / 2);
expect(layout!.y).toBeGreaterThan(1080 / 2);
});
it("keeps the overlay within the configured stage fraction while preserving aspect ratio", () => {
const layout = computeWebcamOverlayLayout({
stageWidth: 1280,
stageHeight: 720,
videoWidth: 1920,
videoHeight: 1080,
});
expect(layout).not.toBeNull();
expect(layout!.width).toBeLessThanOrEqual(Math.round(1280 * 0.18) + 1);
expect(layout!.height).toBeLessThanOrEqual(Math.round(720 * 0.18) + 1);
expect(Math.abs(layout!.width * 1080 - layout!.height * 1920)).toBeLessThanOrEqual(1920);
});
});
-45
View File
@@ -1,45 +0,0 @@
export interface WebcamOverlayLayout {
x: number;
y: number;
width: number;
height: number;
margin: number;
borderRadius: number;
}
const MAX_STAGE_FRACTION = 0.18;
const MARGIN_FRACTION = 0.02;
const MIN_SIZE = 96;
const MAX_BORDER_RADIUS = 24;
export function computeWebcamOverlayLayout(params: {
stageWidth: number;
stageHeight: number;
videoWidth: number;
videoHeight: number;
}): WebcamOverlayLayout | null {
const { stageWidth, stageHeight, videoWidth, videoHeight } = params;
if (stageWidth <= 0 || stageHeight <= 0 || videoWidth <= 0 || videoHeight <= 0) {
return null;
}
const margin = Math.max(12, Math.round(Math.min(stageWidth, stageHeight) * MARGIN_FRACTION));
const maxWidth = Math.max(MIN_SIZE, stageWidth * MAX_STAGE_FRACTION);
const maxHeight = Math.max(MIN_SIZE, stageHeight * MAX_STAGE_FRACTION);
const scale = Math.min(maxWidth / videoWidth, maxHeight / videoHeight);
const width = Math.round(videoWidth * scale);
const height = Math.round(videoHeight * scale);
return {
x: Math.max(0, Math.round(stageWidth - margin - width)),
y: Math.max(0, Math.round(stageHeight - margin - height)),
width,
height,
margin,
borderRadius: Math.min(
MAX_BORDER_RADIUS,
Math.max(12, Math.round(Math.min(width, height) * 0.12)),
),
};
}
+4 -1
View File
@@ -1,10 +1,13 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App.tsx";
import { I18nProvider } from "./contexts/I18nContext";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
<I18nProvider>
<App />
</I18nProvider>
</React.StrictMode>,
);
+4
View File
@@ -67,6 +67,10 @@ export function getAspectRatioLabel(aspectRatio: AspectRatio): string {
return aspectRatio;
}
export function isPortraitAspectRatio(aspectRatio: AspectRatio): boolean {
return getAspectRatioValue(aspectRatio) < 1;
}
export function formatAspectRatioForCSS(aspectRatio: AspectRatio, nativeRatio?: number): string {
if (aspectRatio === "native") return String(nativeRatio ?? 16 / 9);
return aspectRatio.replace(":", "/");
+1
View File
@@ -119,5 +119,6 @@ interface Window {
setMicrophoneExpanded: (expanded: boolean) => void;
setHasUnsavedChanges: (hasChanges: boolean) => void;
onRequestSaveBeforeClose: (callback: () => Promise<boolean> | boolean) => () => void;
setLocale: (locale: string) => Promise<void>;
};
}
+17 -8
View File
@@ -58,14 +58,23 @@ test("exports a GIF from a loaded video", async () => {
);
});
await hudWindow.evaluate((videoPath: string) => {
window.electronAPI.setCurrentVideoPath(videoPath);
try {
try {
await hudWindow.evaluate(async (videoPath: string) => {
await window.electronAPI.setCurrentVideoPath(videoPath);
window.electronAPI.switchToEditor();
} catch {
// Expected: HUD window closes during this call, killing the context.
}, TEST_VIDEO);
} catch (error) {
// Expected: switchToEditor() closes the HUD window, which terminates
// the Playwright page context before evaluate() can resolve.
if (
!(
error instanceof Error &&
error.message.includes("Target page, context or browser has been closed")
)
) {
throw error;
}
}, TEST_VIDEO);
}
// ── 3. Switch to the editor window. This closes the HUD and opens
// a new BrowserWindow with ?windowType=editor.
@@ -87,8 +96,8 @@ test("exports a GIF from a loaded video", async () => {
await editorWindow.getByTestId("testId-gif-format-button").click();
await editorWindow.getByTestId("testId-export-button").click();
// ── 6. Wait for the toast to say exported successfully
await expect(editorWindow.getByText(`GIF exported successfully to pending`)).toBeVisible({
// ── 6. Wait for the success toast.
await expect(editorWindow.getByText("GIF exported successfully")).toBeVisible({
timeout: 90_000,
});
+1 -1
View File
@@ -4,7 +4,7 @@ import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
environment: "node",
environment: "jsdom",
include: ["src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"],
},
resolve: {