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
77 changed files with 4890 additions and 943 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
+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,
+764 -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",
@@ -79,6 +83,13 @@
"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",
@@ -91,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",
@@ -565,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",
@@ -1825,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",
@@ -4244,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",
@@ -4261,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",
@@ -5342,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",
@@ -5529,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",
@@ -6389,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",
@@ -6421,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",
@@ -6449,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",
@@ -6579,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",
@@ -6756,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",
@@ -7185,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",
@@ -8276,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",
@@ -8671,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",
@@ -8842,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",
@@ -9549,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",
@@ -9666,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",
@@ -9790,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",
@@ -10567,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",
@@ -11174,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",
@@ -11724,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",
@@ -11820,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",
@@ -12085,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",
@@ -12603,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",
@@ -12868,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",
@@ -13217,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",
@@ -13281,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",
@@ -13344,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",
@@ -14281,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",
@@ -14300,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",
@@ -14405,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",
@@ -14446,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",
+6 -1
View File
@@ -1,7 +1,7 @@
{
"name": "openscreen",
"private": true,
"version": "1.2.0",
"version": "1.3.0",
"type": "module",
"packageManager": "npm@10.9.4",
"engines": {
@@ -14,6 +14,7 @@
"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",
@@ -69,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",
@@ -81,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.`,
);
}
+352 -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,7 +17,12 @@ 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";
@@ -36,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 },
@@ -62,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,
@@ -74,42 +95,62 @@ 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) {
@@ -187,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={async () => {
await 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>
);
}
+206 -84
View File
@@ -4,11 +4,9 @@ import {
Crop,
Download,
Film,
FolderOpen,
Image,
Lock,
Palette,
Save,
Sparkles,
Star,
Trash2,
@@ -35,12 +33,13 @@ import {
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";
@@ -52,7 +51,9 @@ import type {
FigureData,
PlaybackSpeed,
WebcamLayoutPreset,
WebcamMaskShape,
ZoomDepth,
ZoomFocusMode,
} from "./types";
import { SPEED_OPTIONS } from "./types";
@@ -93,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;
@@ -127,8 +131,6 @@ 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;
@@ -146,6 +148,8 @@ interface SettingsPanelProps {
hasWebcam?: boolean;
webcamLayoutPreset?: WebcamLayoutPreset;
onWebcamLayoutPresetChange?: (preset: WebcamLayoutPreset) => void;
webcamMaskShape?: import("./types").WebcamMaskShape;
onWebcamMaskShapeChange?: (shape: import("./types").WebcamMaskShape) => void;
}
export default SettingsPanel;
@@ -164,6 +168,9 @@ export function SettingsPanel({
onWallpaperChange,
selectedZoomDepth,
onZoomDepthChange,
selectedZoomFocusMode,
onZoomFocusModeChange,
hasCursorTelemetry = false,
selectedZoomId,
onZoomDelete,
selectedTrimId,
@@ -197,8 +204,6 @@ export function SettingsPanel({
gifSizePreset = "medium",
onGifSizePresetChange,
gifOutputDimensions = { width: 1280, height: 720 },
onSaveProject,
onLoadProject,
onExport,
unsavedExport,
onSaveUnsavedExport,
@@ -216,7 +221,10 @@ export function SettingsPanel({
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);
@@ -382,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;
@@ -396,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"),
});
};
@@ -468,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">
@@ -502,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
@@ -514,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>
@@ -528,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 ??
@@ -569,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
@@ -581,7 +620,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 Speed Region
{t("speed.deleteRegion")}
</Button>
)}
</div>
@@ -599,12 +638,14 @@ export function SettingsPanel({
<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">Layout</span>
<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">Preset</div>
<div className="text-[10px] font-medium text-slate-300 mb-1.5">
{t("layout.preset")}
</div>
<Select
value={webcamLayoutPreset}
onValueChange={(value: WebcamLayoutPreset) =>
@@ -612,17 +653,104 @@ export function SettingsPanel({
}
>
<SelectTrigger className="h-8 bg-black/20 border-white/10 text-xs">
<SelectValue placeholder="Select preset" />
<SelectValue placeholder={t("layout.selectPreset")} />
</SelectTrigger>
<SelectContent>
{WEBCAM_LAYOUT_PRESETS.map((preset) => (
{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.label}
{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>
)}
@@ -631,13 +759,15 @@ export function SettingsPanel({
<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}
@@ -649,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
@@ -666,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>
@@ -683,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
@@ -696,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>
@@ -719,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>
@@ -731,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">
@@ -741,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>
@@ -772,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">
@@ -873,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);
@@ -899,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"
@@ -944,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
@@ -953,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
@@ -983,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" />
@@ -1005,7 +1148,7 @@ export function SettingsPanel({
size="lg"
className="bg-[#34B27B] hover:bg-[#34B27B]/90 text-white"
>
Done
{t("crop.done")}
</Button>
</div>
</div>
@@ -1025,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")}
@@ -1038,7 +1181,7 @@ export function SettingsPanel({
)}
>
<Image className="w-3.5 h-3.5" />
GIF
{t("exportFormat.gif")}
</button>
</div>
@@ -1053,7 +1196,7 @@ export function SettingsPanel({
: "text-slate-400 hover:text-slate-200",
)}
>
Low
{t("exportQuality.low")}
</button>
<button
onClick={() => onExportQualityChange?.("good")}
@@ -1064,7 +1207,7 @@ export function SettingsPanel({
: "text-slate-400 hover:text-slate-200",
)}
>
Medium
{t("exportQuality.medium")}
</button>
<button
onClick={() => onExportQualityChange?.("source")}
@@ -1075,7 +1218,7 @@ export function SettingsPanel({
: "text-slate-400 hover:text-slate-200",
)}
>
High
{t("exportQuality.high")}
</button>
</div>
)}
@@ -1122,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}
@@ -1133,27 +1276,6 @@ export function SettingsPanel({
</div>
)}
<div className="grid grid-cols-2 gap-2 mb-2">
<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"
>
<FolderOpen className="w-3.5 h-3.5" />
Load Project
</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>
{unsavedExport && (
<Button
type="button"
@@ -1162,7 +1284,7 @@ export function SettingsPanel({
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"
>
<Download className="w-4 h-4" />
Choose Save Location
{t("export.chooseSaveLocation")}
</Button>
)}
<Button
@@ -1173,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">
@@ -1187,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"
@@ -1197,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>
+411 -240
View File
@@ -1,9 +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 { 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,
@@ -16,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,
@@ -48,6 +59,7 @@ import {
type TrimRegion,
type ZoomDepth,
type ZoomFocus,
type ZoomFocusMode,
type ZoomRegion,
} from "./types";
import VideoPlayback, { VideoPlaybackRef } from "./VideoPlayback";
@@ -76,6 +88,8 @@ export default function VideoEditor() {
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);
@@ -110,13 +128,20 @@ export default function VideoEditor() {
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);
@@ -179,6 +204,8 @@ export default function VideoEditor() {
annotationRegions: normalizedEditor.annotationRegions,
aspectRatio: normalizedEditor.aspectRatio,
webcamLayoutPreset: normalizedEditor.webcamLayoutPreset,
webcamMaskShape: normalizedEditor.webcamMaskShape,
webcamPosition: normalizedEditor.webcamPosition,
});
setExportQuality(normalizedEditor.exportQuality);
setExportFormat(normalizedEditor.exportFormat);
@@ -214,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;
@@ -232,28 +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,
webcamLayoutPreset,
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,
@@ -269,6 +294,8 @@ export default function VideoEditor() {
annotationRegions,
aspectRatio,
webcamLayoutPreset,
webcamMaskShape,
webcamPosition,
exportQuality,
exportFormat,
gifFrameRate,
@@ -276,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() {
@@ -309,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;
}
@@ -321,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.");
}
@@ -338,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;
}
@@ -361,6 +392,8 @@ export default function VideoEditor() {
annotationRegions,
aspectRatio,
webcamLayoutPreset,
webcamMaskShape,
webcamPosition,
exportQuality,
exportFormat,
gifFrameRate,
@@ -381,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;
}
@@ -395,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;
},
[
@@ -414,12 +447,15 @@ export default function VideoEditor() {
annotationRegions,
aspectRatio,
webcamLayoutPreset,
webcamMaskShape,
webcamPosition,
exportQuality,
exportFormat,
gifFrameRate,
gifLoop,
gifSizePreset,
videoPath,
t,
],
);
@@ -520,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;
@@ -651,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) }));
@@ -889,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;
@@ -1053,8 +1150,11 @@ export default function VideoEditor() {
cropRegion,
annotationRegions,
webcamLayoutPreset,
webcamMaskShape,
webcamPosition,
previewWidth,
previewHeight,
cursorTelemetry,
onProgress: (progress: ExportProgress) => {
setExportProgress(progress);
},
@@ -1183,8 +1283,11 @@ export default function VideoEditor() {
cropRegion,
annotationRegions,
webcamLayoutPreset,
webcamMaskShape,
webcamPosition,
previewWidth,
previewHeight,
cursorTelemetry,
onProgress: (progress: ExportProgress) => {
setExportProgress(progress);
},
@@ -1250,8 +1353,11 @@ export default function VideoEditor() {
isPlaying,
aspectRatio,
webcamLayoutPreset,
webcamMaskShape,
webcamPosition,
exportQuality,
handleExportSaved,
cursorTelemetry,
],
);
@@ -1357,212 +1463,277 @@ 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}
webcamLayoutPreset={webcamLayoutPreset}
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}
hasWebcam={Boolean(webcamVideoPath)}
webcamLayoutPreset={webcamLayoutPreset}
onWebcamLayoutPresetChange={(preset) => pushState({ webcamLayoutPreset: preset })}
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}
onSaveProject={handleSaveProject}
onLoadProject={handleLoadProject}
selectedSpeedId={selectedSpeedId}
selectedSpeedValue={
selectedSpeedId
? (speedRegions.find((r) => r.id === selectedSpeedId)?.speed ?? null)
: null
}
onSpeedChange={handleSpeedChange}
onSpeedDelete={handleSpeedDelete}
unsavedExport={unsavedExport}
onSaveUnsavedExport={handleSaveUnsavedExport}
/>
</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>
<ExportDialog
+165 -22
View File
@@ -25,6 +25,7 @@ import {
type StyledRenderRect,
type WebcamLayoutPreset,
} from "@/lib/compositeLayout";
import { getCssClipPath } from "@/lib/webcamMaskShapes";
import {
type AspectRatio,
formatAspectRatioForCSS,
@@ -41,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";
@@ -63,6 +68,10 @@ 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;
@@ -90,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 {
@@ -108,6 +118,10 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
videoPath,
webcamVideoPath,
webcamLayoutPreset,
webcamMaskShape,
webcamPosition,
onWebcamPositionChange,
onWebcamPositionDragEnd,
onDurationChange,
onTimeUpdate,
currentTime,
@@ -135,6 +149,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
onSelectAnnotation,
onAnnotationPositionChange,
onAnnotationSizeChange,
cursorTelemetry = [],
},
ref,
) => {
@@ -154,6 +169,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
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,
@@ -167,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);
@@ -186,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);
@@ -263,6 +283,8 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
padding,
webcamDimensions,
webcamLayoutPreset,
webcamPosition,
webcamMaskShape,
});
if (result) {
@@ -292,6 +314,8 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
padding,
webcamDimensions,
webcamLayoutPreset,
webcamPosition,
webcamMaskShape,
]);
useEffect(() => {
@@ -369,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;
@@ -401,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]);
@@ -773,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;
@@ -797,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({
@@ -1097,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: webcamCssBoxShadow,
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,
@@ -40,6 +43,7 @@ describe("projectPersistence media compatibility", () => {
annotationRegions: [],
aspectRatio: "16:9",
webcamLayoutPreset: "picture-in-picture",
webcamMaskShape: "circle",
exportQuality: "good",
exportFormat: "mp4",
gifFrameRate: 15,
@@ -55,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);
});
@@ -12,10 +12,14 @@ import {
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";
@@ -42,6 +46,8 @@ export interface ProjectEditorState {
annotationRegions: AnnotationRegion[];
aspectRatio: AspectRatio;
webcamLayoutPreset: WebcamLayoutPreset;
webcamMaskShape: WebcamMaskShape;
webcamPosition: WebcamPosition | null;
exportQuality: ExportQuality;
exportFormat: ExportFormat;
gifFrameRate: GifFrameRate;
@@ -186,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",
};
})
: [];
@@ -349,6 +356,23 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
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
@@ -381,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";
@@ -546,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);
@@ -656,7 +658,7 @@ function Timeline({
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}
@@ -673,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}
@@ -692,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
@@ -709,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}
@@ -762,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]);
@@ -966,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) {
@@ -982,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;
}
@@ -1005,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;
}
@@ -1014,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;
}
@@ -1052,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,
@@ -1066,6 +1073,7 @@ export default function TimelineEditor({
zoomRegions,
onZoomSuggested,
cursorTelemetry,
t,
]);
const handleAddTrim = useCallback(() => {
@@ -1090,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) {
@@ -1122,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) {
@@ -1253,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",
}));
@@ -1262,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",
}));
@@ -1271,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 {
@@ -1292,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(() => {
@@ -1340,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>
);
@@ -1356,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>
@@ -1365,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>
@@ -1374,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>
@@ -1383,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>
@@ -1392,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>
@@ -1431,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>
+13
View File
@@ -1,10 +1,22 @@
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)
cy: number; // normalized vertical center (0-1)
@@ -16,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);
@@ -6,7 +6,7 @@ import {
type StyledRenderRect,
type WebcamLayoutPreset,
} from "@/lib/compositeLayout";
import type { CropRegion } from "../types";
import type { CropRegion, WebcamMaskShape } from "../types";
interface LayoutParams {
container: HTMLDivElement;
@@ -20,6 +20,8 @@ interface LayoutParams {
padding?: number;
webcamDimensions?: Size | null;
webcamLayoutPreset?: WebcamLayoutPreset;
webcamPosition?: { cx: number; cy: number } | null;
webcamMaskShape?: WebcamMaskShape;
}
interface LayoutResult {
@@ -45,6 +47,8 @@ export function layoutVideoContent(params: LayoutParams): LayoutResult | null {
padding = 0,
webcamDimensions,
webcamLayoutPreset,
webcamPosition,
webcamMaskShape,
} = params;
const videoWidth = lockedVideoDimensions?.width || videoElement.videoWidth;
@@ -79,7 +83,9 @@ 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;
@@ -89,13 +95,23 @@ export function layoutVideoContent(params: LayoutParams): LayoutResult | null {
screenSize: { width: croppedVideoWidth, height: croppedVideoHeight },
webcamSize: webcamDimensions,
layoutPreset: webcamLayoutPreset,
webcamPosition,
webcamMaskShape,
});
if (!compositeLayout) {
return null;
}
const scale = compositeLayout.screenRect.width / croppedVideoWidth;
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);
@@ -103,25 +119,24 @@ 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 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 screenRect.x, screenRect.y
const spriteX = compositeLayout.screenRect.x - crop.x * fullVideoDisplayWidth;
const spriteY = compositeLayout.screenRect.y - crop.y * fullVideoDisplayHeight;
// Position the video so the cropped region is centered within the screenRect
const croppedDisplayWidth = croppedVideoWidth * scale;
const croppedDisplayHeight = croppedVideoHeight * scale;
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);
// Apply border radius
// Apply border radius — mask clips the video to the screenRect
maskGraphics.clear();
maskGraphics.roundRect(
compositeLayout.screenRect.x,
compositeLayout.screenRect.y,
compositeLayout.screenRect.width,
compositeLayout.screenRect.height,
borderRadius,
screenRect.x,
screenRect.y,
screenRect.width,
screenRect.height,
compositeLayout.screenCover ? 0 : borderRadius,
);
maskGraphics.fill({ color: 0xffffff });
@@ -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 };
}
+12 -1
View File
@@ -5,9 +5,16 @@ import type {
SpeedRegion,
TrimRegion,
WebcamLayoutPreset,
WebcamMaskShape,
WebcamPosition,
ZoomRegion,
} from "@/components/video-editor/types";
import { DEFAULT_CROP_REGION, DEFAULT_WEBCAM_LAYOUT_PRESET } 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
@@ -26,6 +33,8 @@ export interface EditorState {
padding: number;
aspectRatio: AspectRatio;
webcamLayoutPreset: WebcamLayoutPreset;
webcamMaskShape: WebcamMaskShape;
webcamPosition: WebcamPosition | null;
}
export const INITIAL_EDITOR_STATE: EditorState = {
@@ -42,6 +51,8 @@ export const INITIAL_EDITOR_STATE: EditorState = {
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>);
+169 -39
View File
@@ -1,6 +1,7 @@
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;
@@ -40,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;
@@ -80,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);
@@ -92,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",
@@ -152,26 +170,29 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
}
}, []);
const setWebcamEnabled = useCallback(async (enabled: boolean) => {
if (!enabled) {
setWebcamEnabledState(false);
const setWebcamEnabled = useCallback(
async (enabled: boolean) => {
if (!enabled) {
setWebcamEnabledState(false);
return true;
}
const accessResult = await requestCameraAccess();
if (!accessResult.success) {
toast.error(t("recording.failedCameraAccess"));
return false;
}
if (!accessResult.granted) {
toast.error(t("recording.cameraBlocked"));
return false;
}
setWebcamEnabledState(true);
return true;
}
const accessResult = await requestCameraAccess();
if (!accessResult.success) {
toast.error("Failed to request camera access.");
return false;
}
if (!accessResult.granted) {
toast.error("Camera access is blocked. Enable it in system settings to use the webcam.");
return false;
}
setWebcamEnabledState(true);
return true;
}, []);
},
[t],
);
const finalizeRecording = useCallback(
(
@@ -194,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 () => {
@@ -265,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(
@@ -275,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 {
@@ -283,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 {
@@ -308,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 {
@@ -332,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;
}
@@ -362,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,
@@ -395,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);
}
}
@@ -404,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);
@@ -417,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"));
}
}
@@ -503,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;
@@ -521,7 +568,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
finalizeRecording(
activeScreenRecorder,
activeWebcamRecorder ?? null,
Math.max(0, Date.now() - startTime.current),
Math.max(0, getRecordingDurationMs()),
activeRecordingId,
);
},
@@ -532,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();
};
@@ -551,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) => {
@@ -565,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(), {
@@ -585,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);
});
});
+60 -14
View File
@@ -33,7 +33,7 @@ describe("computeCompositeLayout", () => {
).toBeLessThanOrEqual(1920);
});
it("centers the combined screen and webcam stack in vertical stack mode", () => {
it("uses cover-style full-width stacking in vertical stack mode", () => {
const layout = computeCompositeLayout({
canvasSize: { width: 1920, height: 1080 },
maxContentSize: { width: 1536, height: 864 },
@@ -44,21 +44,22 @@ describe("computeCompositeLayout", () => {
expect(layout).not.toBeNull();
expect(layout?.screenRect).toEqual({
x: 576,
y: 108,
width: 768,
height: 432,
x: 0,
y: 0,
width: 1920,
height: 0,
});
expect(layout?.webcamRect).toEqual({
x: 576,
y: 540,
width: 768,
height: 432,
x: 0,
y: 0,
width: 1920,
height: 1080,
borderRadius: 0,
});
expect(layout?.screenCover).toBe(true);
});
it("keeps the screen centered and omits the webcam when dimensions are unavailable", () => {
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 },
@@ -68,11 +69,56 @@ describe("computeCompositeLayout", () => {
expect(layout).not.toBeNull();
expect(layout?.screenRect).toEqual({
x: 192,
y: 108,
width: 1536,
height: 864,
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");
});
});
+77 -53
View File
@@ -7,6 +7,7 @@ export interface RenderRect {
export interface StyledRenderRect extends RenderRect {
borderRadius: number;
maskShape?: import("@/components/video-editor/types").WebcamMaskShape;
}
export interface Size {
@@ -52,11 +53,12 @@ export interface WebcamLayoutPresetDefinition {
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 MIN_SIZE = 96;
const MAX_BORDER_RADIUS = 24;
const WEBCAM_LAYOUT_PRESET_MAP: Record<WebcamLayoutPreset, WebcamLayoutPresetDefinition> = {
"picture-in-picture": {
@@ -65,8 +67,8 @@ const WEBCAM_LAYOUT_PRESET_MAP: Record<WebcamLayoutPreset, WebcamLayoutPresetDef
type: "overlay",
maxStageFraction: MAX_STAGE_FRACTION,
marginFraction: MARGIN_FRACTION,
minMargin: 12,
minSize: MIN_SIZE,
minMargin: 0,
minSize: 0,
},
borderRadius: {
max: MAX_BORDER_RADIUS,
@@ -123,6 +125,8 @@ export function computeCompositeLayout(params: {
screenSize: Size;
webcamSize?: Size | null;
layoutPreset?: WebcamLayoutPreset;
webcamPosition?: { cx: number; cy: number } | null;
webcamMaskShape?: import("@/components/video-editor/types").WebcamMaskShape;
}): WebcamCompositeLayout | null {
const {
canvasSize,
@@ -130,9 +134,10 @@ export function computeCompositeLayout(params: {
screenSize,
webcamSize,
layoutPreset = "picture-in-picture",
webcamPosition,
webcamMaskShape = "rectangle",
} = params;
const { width: canvasWidth, height: canvasHeight } = canvasSize;
const { width: maxContentWidth, height: maxContentHeight } = maxContentSize;
const { width: screenWidth, height: screenHeight } = screenSize;
const webcamWidth = webcamSize?.width;
const webcamHeight = webcamSize?.height;
@@ -144,52 +149,37 @@ export function computeCompositeLayout(params: {
if (preset.transform.type === "stack") {
if (!webcamWidth || !webcamHeight || webcamWidth <= 0 || webcamHeight <= 0) {
// No webcam — screen fills the entire canvas (cover mode)
return {
screenRect: centerRect({
canvasSize,
size: screenSize,
maxSize: maxContentSize,
}),
screenRect: { x: 0, y: 0, width: canvasWidth, height: canvasHeight },
webcamRect: null,
screenCover: true,
};
}
const gap = preset.transform.gap;
const normalizedWebcamHeight = webcamHeight * (screenWidth / webcamWidth);
const combinedHeight = screenHeight + gap + normalizedWebcamHeight;
const scale = Math.min(maxContentWidth / screenWidth, maxContentHeight / combinedHeight, 1);
const clampedScale = Number.isFinite(scale) && scale > 0 ? scale : 1;
const resolvedScreenHeight = Math.round(screenHeight * clampedScale);
const resolvedScreenWidth = Math.round(screenWidth * clampedScale);
const resolvedWebcamHeight = Math.round(normalizedWebcamHeight * clampedScale);
const resolvedGap = Math.round(gap * clampedScale);
const totalHeight = resolvedScreenHeight + resolvedGap + resolvedWebcamHeight;
const top = Math.max(0, Math.floor((canvasHeight - totalHeight) / 2));
const left = Math.max(0, Math.floor((canvasWidth - resolvedScreenWidth) / 2));
const screenRect = {
x: left,
y: top,
width: resolvedScreenWidth,
height: resolvedScreenHeight,
};
// 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,
webcamRect: {
x: left,
y: top + resolvedScreenHeight + resolvedGap,
width: resolvedScreenWidth,
height: resolvedWebcamHeight,
borderRadius: Math.min(
preset.borderRadius.max,
Math.max(
preset.borderRadius.min,
Math.round(
Math.min(resolvedScreenWidth, resolvedWebcamHeight) * preset.borderRadius.fraction,
),
),
),
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,
};
}
@@ -211,23 +201,57 @@ export function computeCompositeLayout(params: {
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);
const width = Math.round(webcamWidth * scale);
const height = Math.round(webcamHeight * scale);
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: Math.max(0, Math.round(canvasWidth - margin - width)),
y: Math.max(0, Math.round(canvasHeight - margin - height)),
x: webcamX,
y: webcamY,
width,
height,
borderRadius: Math.min(
preset.borderRadius.max,
Math.max(
preset.borderRadius.min,
Math.round(Math.min(width, height) * preset.borderRadius.fraction),
),
),
borderRadius,
maskShape: webcamMaskShape,
},
};
}
+100 -20
View File
@@ -18,10 +18,17 @@ import type {
} 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 {
@@ -37,6 +44,7 @@ import {
type Size,
type StyledRenderRect,
} from "@/lib/compositeLayout";
import { drawCanvasClipPath } from "@/lib/webcamMaskShapes";
import { renderAnnotations } from "./annotationRenderer";
import {
getLinearGradientPoints,
@@ -61,10 +69,13 @@ interface FrameRenderConfig {
videoHeight: 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 {
@@ -106,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;
@@ -428,7 +442,9 @@ 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 compositeLayout = computeCompositeLayout({
@@ -437,40 +453,51 @@ export class FrameRenderer {
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 scale = compositeLayout.screenRect.width / croppedVideoWidth;
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;
// Center the cropped region within the screenRect
const croppedDisplayWidth = croppedVideoWidth * scale;
const croppedDisplayHeight = croppedVideoHeight * scale;
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;
this.videoSprite.y = -cropPixelY;
this.videoSprite.x = -cropPixelX + coverOffsetX;
this.videoSprite.y = -cropPixelY + coverOffsetY;
// Position video container
const croppedDisplayWidth = compositeLayout.screenRect.width;
const croppedDisplayHeight = compositeLayout.screenRect.height;
this.videoContainer.x = compositeLayout.screenRect.x;
this.videoContainer.y = compositeLayout.screenRect.y;
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
@@ -495,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;
@@ -514,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,
@@ -589,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),
@@ -655,16 +734,17 @@ export class FrameRenderer {
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();
ctx.beginPath();
ctx.roundRect(
drawCanvasClipPath(
ctx,
webcamRect.x,
webcamRect.y,
webcamRect.width,
webcamRect.height,
shape,
webcamRect.borderRadius,
);
ctx.closePath();
if (preset.shadow) {
ctx.shadowColor = preset.shadow.color;
ctx.shadowBlur = preset.shadow.blur;
+6
View File
@@ -41,9 +41,12 @@ interface GifExporterConfig {
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;
}
@@ -140,10 +143,13 @@ export class GifExporter {
videoHeight: videoInfo.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();
+16 -2
View File
@@ -32,9 +32,12 @@ interface VideoExporterConfig extends ExportConfig {
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;
}
@@ -133,10 +136,13 @@ export class VideoExporter {
videoHeight: videoInfo.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,
});
this.renderer = renderer;
await renderer.initialize();
@@ -228,8 +234,16 @@ export class VideoExporter {
const canvas = renderer.getCanvas();
// @ts-expect-error - colorSpace is available at runtime even if TS does not know it.
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: {
+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));
}
+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();
}
+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>;
};
}
+15 -6
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.
+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: {