Compare commits

...
Author SHA1 Message Date
Siddharth 7169e583c7 revert: undo local merge of PR #347 2026-04-11 09:58:15 -07:00
Siddharth de7518549c feat: add dual frame webcam layout preset (#347) 2026-04-11 09:54:30 -07:00
Shreyas b1a1f45e93 refactor: simplify dual frame preset normalization 2026-04-11 09:30:01 -07:00
Shreyas bce1957505 fix: clear webcam position for non-pip layouts 2026-04-11 09:30:00 -07:00
Shreyas 24b4b4254a fix: normalize dual frame preset for portrait projects 2026-04-11 09:26:15 -07:00
Shreyas 16cba73cb2 fix: avoid double-scaling dual frame export radius 2026-04-11 09:26:15 -07:00
Shreyas c55f462f1c feat: add dual frame webcam layout preset 2026-04-11 09:20:34 -07:00
Sid 68295b21ec Merge pull request #394 from LorenzoLancia/feature/blur-selection
feat: add blur selection (rectangle, oval)
2026-04-10 07:10:29 -07:00
LorenzoLancia 3232918197 Add the Shortcut Blur 2026-04-09 21:51:27 +02:00
LorenzoLancia 38d72217c2 fix little things blur 2026-04-08 22:43:30 +02:00
LorenzoLancia f6b7c463f0 Fix last issues 2026-04-08 22:21:19 +02:00
LorenzoLancia f8232d9c76 Fix some little issues 2026-04-08 21:36:53 +02:00
LorenzoLancia 5a9c85c345 Fix formatting and locale config 2026-04-08 20:26:16 +02:00
Lorenzo Lancia a4f1c6a2ee feat: add blur selection (rectangle, oval) 2026-04-08 16:42:12 +02:00
Sid e7d5f51740 Merge pull request #345 from GarryLaly/feature/webcam-resize-slider
feat: Add webcam size with slider
2026-04-07 22:40:15 -07:00
Sid 9e6b05815f Merge pull request #375 from mehmetnadir/feat/turkish-locale
feat(i18n): add Turkish (tr) locale support
2026-04-07 22:30:30 -07:00
Sid 7bd993a97b Merge branch 'main' into feat/turkish-locale 2026-04-07 22:30:16 -07:00
Sid 558379702a Merge pull request #330 from maxbailey/main
fix: resolve green MP4 exports on CachyOS/Arch Linux (Wayland)
2026-04-07 22:28:00 -07:00
Sid 09b99563f5 Merge pull request #380 from FabLrc/french-traduction
feat(i18n): add French translations
2026-04-07 22:21:34 -07:00
Sid b34961f6af Merge pull request #365 from AmitwalaH/fix-tutorial-translations
fix(i18n): add missing tutorial dialog translation keys
2026-04-07 22:21:02 -07:00
Sid 5a36179454 Merge pull request #383 from marcgabe15/exportTesting
feat: Add unit tests for exporting videos
2026-04-07 22:02:17 -07:00
Marc Diaz 3482be9864 refactor: remove extraneous comments 2026-04-07 13:50:26 -04:00
Marc Diaz b8fe1a1ec8 fix(playwright): use one version 2026-04-07 13:32:49 -04:00
Marc Diaz 33609432e1 fix: use npm for install 2026-04-07 13:05:27 -04:00
Marc Diaz b65c68d139 fix: use headless 2026-04-07 13:02:11 -04:00
Marc Diaz 6bff2a2a2c feat: use export testing 2026-04-07 12:58:33 -04:00
FabLrc 1f56bb42c3 fix(i18n): update French translations for cycle annotations shortcuts 2026-04-07 12:17:53 +02:00
FabLrc 7a8fb807e6 feat(i18n): add French translations for common and dialogs namespaces 2026-04-07 12:17:10 +02:00
Garry Priambudi 0e1a69a7b2 Merge branch 'main' into feature/webcam-resize-slider 2026-04-07 17:13:38 +07:00
FabLrc e739653b3f feat(i18n): add French translations for various application components 2026-04-07 12:05:36 +02:00
Sid 9024eaae61 Merge pull request #307 from Ayush765-spec/main
Added the new recording button so that user does not exit the entire application
2026-04-06 23:00:24 -07:00
Sid c5882b06b1 Merge pull request #334 from matthew-hre/matthew-hre/jj-przmrvurqkow
fix: handle av1 VideoDecoder errors
2026-04-06 22:47:50 -07:00
Sid 306b61a902 Merge pull request #291 from 1shanpanta/feat/extended-speed-options
feat: extend speed options with higher presets and custom speed input
2026-04-06 22:14:08 -07:00
Nadir A.andClaude Opus 4.6 c36349d950 feat(i18n): add Turkish (tr) locale support
Add complete Turkish translation across all 7 i18n namespaces:
- common: actions, playback controls, locale metadata
- launch: HUD tooltips, audio/webcam controls, source selector
- editor: error messages, export, project, recording permissions
- dialogs: export progress, trim tutorial, unsaved changes, file dialogs
- settings: all panels (zoom, speed, trim, layout, effects, background,
  crop, export, annotations, custom fonts, language, audio)
- shortcuts: keyboard shortcuts panel and all actions
- timeline: toolbar buttons, hints, labels, errors, success messages

Also adds "tr" to SUPPORTED_LOCALES config and i18n validation script.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 03:05:21 +03:00
AmitwalaH 4e2a53b200 fix: spacing issues in tutorial translations 2026-04-06 15:19:24 +05:30
AmitwalaH 90ba713323 fix(i18n): update tutorial dialog translation keys for all locales 2026-04-06 15:08:49 +05:30
Sid 24928164ca Merge pull request #355 from getSono/main
Adding automatic debian builds
2026-04-05 22:56:56 -07:00
Garry Priambudi 5320f76aae Merge branch 'main' into feature/webcam-resize-slider 2026-04-06 07:56:28 +07:00
Sid e571ecbf8d Merge pull request #352 from siddharthvaddem/sid/fix-read-handler-security
fix(security): prevent path traversal in IPC file read handlers
2026-04-05 17:04:27 -07:00
Julian Wolf ec946f0807 Merge branch 'siddharthvaddem:main' into main 2026-04-06 01:26:58 +02:00
Julian Wolf 37e1a82d05 Update ci.yml 2026-04-06 01:26:43 +02:00
Sid 2ce4dc49b3 Merge pull request #354 from notrudyyy/patch-1
Improve grammar and mobile images in README
2026-04-05 16:23:53 -07:00
Anirudh Vempati da79dab756 Update preview image sizes to be dynamic 2026-04-06 04:37:49 +05:30
Anirudh Vempati b6803eb6e3 Update README.md 2026-04-06 04:28:15 +05:30
Julian Wolf 3e28e5860d Fix JSON formatting in package.json 2026-04-05 22:06:06 +02:00
Julian Wolf 925a7e532d Add author information to package.json 2026-04-05 22:04:16 +02:00
Julian Wolf c9861dbef8 Refactor CI workflow for E2E tests
Updated CI workflow to include E2E tests conditionally.
2026-04-05 21:54:03 +02:00
Julian Wolf 1591edbeca Add .deb files to build artifacts 2026-04-05 21:35:33 +02:00
Julian Wolf a3c2ed8ed1 Update Linux build command to include AppImage and deb 2026-04-05 21:34:26 +02:00
Ayush765-spec 013312be1f Refactor: update 'New Recording' dialog and atomize confirm workflow (plus lint fixes) 2026-04-05 22:27:32 +05:30
Ayush Mukherjee 735dd2a191 Merge branch 'siddharthvaddem:main' into main 2026-04-05 22:14:49 +05:30
Garry Laly 2ee7ccd89c fix: feedback coderabbit 2026-04-05 20:19:31 +07:00
Garry Laly 79201569c5 feat: Add webcam size presets with slider 2026-04-05 20:00:44 +07:00
Garry Laly ca962ff16b feat: Add webcam size presets (small/medium/large) 2026-04-05 19:45:50 +07:00
Matthew Hrehirchuk 2712d8a41b fix: use view-aware byte extraction for BufferSource inputs 2026-04-04 21:00:16 -06:00
Matthew Hrehirchuk 21361d9bf8 fix: handle av1 VideoDecoder errors 2026-04-04 20:33:39 -06:00
Max Bailey 3b5ad5064e fix: resolve green MP4 exports on CachyOS/Arch Linux (Wayland)
On Linux/Wayland the implicit GPU-to-2D texture-sharing path used by
drawImage(webglCanvas) fails silently (EGL/Ozone), producing green
frames. Use explicit gl.readPixels to copy from GPU to CPU memory,
bypassing that path.
2026-04-04 19:12:15 -05:00
Ayush765-spec b451bdc03d Merge branch 'main' of https://github.com/Ayush765-spec/openscreen 2026-04-04 11:51:49 +05:30
Ayush765-spec 43ec6ee9cd fix(editor): localize new recording dialog and fix session clear behavior 2026-04-04 11:51:05 +05:30
Ayush Mukherjee 98da431da0 Merge branch 'siddharthvaddem:main' into main 2026-04-04 11:38:01 +05:30
Ayush Mukherjee 5259ae5d87 Merge branch 'siddharthvaddem:main' into main 2026-04-03 18:58:00 +05:30
Ayush765-spec 14cd045e65 [Feature]: Ability to start a new recording from the editor 2026-04-03 18:57:05 +05:30
Ishan Panta 3895ca985f [add] extend speed options with higher presets and custom speed input
add 3x, 4x, 5x speed presets and a custom playback speed input field
that accepts any integer value up to 16x. change PlaybackSpeed type
from a fixed union to number with min/max constants and clamp utility.
update project persistence to validate any speed in range instead of
exact value matching. add i18n keys for en, es, zh-CN.

closes #252
2026-04-03 08:37:16 +05:45
64 changed files with 4250 additions and 348 deletions
+1
View File
@@ -97,4 +97,5 @@ jobs:
path: |
release/**/*.AppImage
release/**/*.zsync
release/**/*.deb
retention-days: 30
+13
View File
@@ -31,6 +31,19 @@ jobs:
- run: npm ci
- run: npx tsc --noEmit
test:
name: Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run test:browser:install
- run: npm run test:browser
build:
name: Build
runs-on: ubuntu-latest
+4 -1
View File
@@ -29,4 +29,7 @@ release/**
# Playwright
test-results
playwright-report/
playwright-report/
# Vitest browser mode screenshots
__screenshots__/
+8 -9
View File
@@ -25,21 +25,20 @@ Screen Studio is an awesome product and this is definitely not a 1:1 clone. Open
OpenScreen is 100% free for personal and commercial use. Use it, modify it, distribute it. (Just be cool 😁 and give a shoutout if you feel like it !)
<p align="center">
<img src="public/preview3.png" alt="OpenScreen App Preview 3" style="height: 320px; margin-right: 12px;" />
<img src="public/preview4.png" alt="OpenScreen App Preview 4" style="height: 320px; margin-right: 12px;" />
<img src="public/preview3.png" alt="OpenScreen App Preview 3" style="height: 0.2467; margin-right: 12px;" />
<img src="public/preview4.png" alt="OpenScreen App Preview 4" style="height: 0.1678; margin-right: 12px;" />
</p>
## Core Features
- Record your whole screen or specific windows.
- Add Automatic zooms or manual zooms (customizable depth levels).
- Record microphone audio and system audio capture.
- Customize the duration and position of zooms however you please.
- Record specific windows or your whole screen.
- Add automatic or manual zooms (adjustable depth levels) and customize their durarion and position.
- Record microphone and system audio.
- Crop video recordings to hide parts.
- Choose between wallpapers, solid colors, gradients or a custom background.
- Motion blur for smoother pan and zoom effects.
- Add annotations (text, arrows, images).
- Trim sections of the clip.
- Customize speed at different segments.
- Customize the speed of different segments.
- Export in different aspect ratios and resolutions.
## Installation
@@ -78,9 +77,9 @@ You may need to grant screen recording permissions depending on your desktop env
System audio capture relies on Electron's [desktopCapturer](https://www.electronjs.org/docs/latest/api/desktop-capturer) and has some platform-specific quirks:
- **macOS**: Requires macOS 13+. On macOS 14.2+ you'll be prompted to grant audio capture permission. macOS 12 and below does not support system audio (mic still work).
- **macOS**: Requires macOS 13+. On macOS 14.2+ you'll be prompted to grant audio capture permission. macOS 12 and below does not support system audio (mic still works).
- **Windows**: Works out of the box.
- **Linux**: Needs PipeWire (default on Ubuntu 22.04+, Fedora 34+). Older PulseAudio-only setups may not support system audio (mic should still works).
- **Linux**: Needs PipeWire (default on Ubuntu 22.04+, Fedora 34+). Older PulseAudio-only setups may not support system audio (mic should still work).
## Built with
- Electron
+2
View File
@@ -26,6 +26,8 @@ interface Window {
electronAPI: {
getSources: (opts: Electron.SourcesOptions) => Promise<ProcessedDesktopSource[]>;
switchToEditor: () => Promise<void>;
switchToHud: () => Promise<void>;
startNewRecording: () => Promise<{ success: boolean; error?: string }>;
openSourceSelector: () => Promise<void>;
selectSource: (source: ProcessedDesktopSource) => Promise<ProcessedDesktopSource | null>;
getSelectedSource: () => Promise<ProcessedDesktopSource | null>;
+5 -2
View File
@@ -5,10 +5,12 @@ 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 commonFr from "../src/i18n/locales/fr/common.json";
import dialogsFr from "../src/i18n/locales/fr/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 Locale = "en" | "zh-CN" | "es" | "fr";
type Namespace = "common" | "dialogs";
type MessageMap = Record<string, unknown>;
@@ -16,12 +18,13 @@ const messages: Record<Locale, Record<Namespace, MessageMap>> = {
en: { common: commonEn, dialogs: dialogsEn },
"zh-CN": { common: commonZh, dialogs: dialogsZh },
es: { common: commonEs, dialogs: dialogsEs },
fr: { common: commonFr, dialogs: dialogsFr },
};
let currentLocale: Locale = "en";
export function setMainLocale(locale: string) {
if (locale === "en" || locale === "zh-CN" || locale === "es") {
if (locale === "en" || locale === "zh-CN" || locale === "es" || locale === "fr") {
currentLocale = locale;
}
}
+17
View File
@@ -355,7 +355,24 @@ export function registerIpcHandlers(
getMainWindow: () => BrowserWindow | null,
getSourceSelectorWindow: () => BrowserWindow | null,
onRecordingStateChange?: (recording: boolean, sourceName: string) => void,
switchToHud?: () => void,
) {
ipcMain.handle("switch-to-hud", () => {
if (switchToHud) switchToHud();
});
ipcMain.handle("start-new-recording", async () => {
try {
setCurrentRecordingSessionState(null);
if (switchToHud) {
switchToHud();
}
return { success: true };
} catch (error) {
console.error("Failed to start new recording:", error);
return { success: false, error: String(error) };
}
});
ipcMain.handle("get-sources", async (_, opts) => {
const sources = await desktopCapturer.getSources(opts);
return sources.map((source) => ({
+11
View File
@@ -371,6 +371,16 @@ app.whenReady().then(async () => {
// Ensure recordings directory exists
await ensureRecordingsDir();
function switchToHudWrapper() {
if (mainWindow) {
isForceClosing = true;
mainWindow.close();
isForceClosing = false;
mainWindow = null;
}
showMainWindow();
}
registerIpcHandlers(
createEditorWindowWrapper,
createSourceSelectorWindowWrapper,
@@ -384,6 +394,7 @@ app.whenReady().then(async () => {
showMainWindow();
}
},
switchToHudWrapper,
);
createWindow();
});
+6
View File
@@ -18,6 +18,12 @@ contextBridge.exposeInMainWorld("electronAPI", {
switchToEditor: () => {
return ipcRenderer.invoke("switch-to-editor");
},
switchToHud: () => {
return ipcRenderer.invoke("switch-to-hud");
},
startNewRecording: () => {
return ipcRenderer.invoke("start-new-recording");
},
openSourceSelector: () => {
return ipcRenderer.invoke("open-source-selector");
},
+1579 -201
View File
File diff suppressed because it is too large Load Diff
+10 -2
View File
@@ -8,6 +8,10 @@
"node": "22.22.1",
"npm": "10.9.4"
},
"author": {
"name": "Sid",
"email": "svaddem@asu.edu"
},
"scripts": {
"dev": "vite",
"build": "tsc && vite build && electron-builder",
@@ -18,10 +22,12 @@
"preview": "vite preview",
"build:mac": "tsc && vite build && electron-builder --mac",
"build:win": "tsc && vite build && electron-builder --win",
"build:linux": "tsc && vite build && electron-builder --linux",
"build:linux": "tsc && vite build && electron-builder --linux AppImage deb",
"test": "vitest --run",
"test:watch": "vitest",
"build-vite": "tsc && vite build",
"test:browser": "vitest --config vitest.browser.config.ts --run",
"test:browser:install": "playwright install --with-deps chromium-headless-shell",
"test:e2e": "playwright test",
"prepare": "husky"
},
@@ -69,7 +75,7 @@
},
"devDependencies": {
"@biomejs/biome": "^2.3.13",
"@playwright/test": "^1.58.2",
"@playwright/test": "^1.59.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
@@ -78,6 +84,8 @@
"@types/react-dom": "^18.2.21",
"@types/uuid": "^10.0.0",
"@vitejs/plugin-react": "^4.2.1",
"@vitest/browser": "^4.0.16",
"@vitest/browser-playwright": "^4.0.16",
"autoprefixer": "^10.4.21",
"electron": "^39.2.7",
"electron-builder": "^26.7.0",
+1 -1
View File
@@ -11,7 +11,7 @@ import path from "node:path";
const LOCALES_DIR = path.resolve("src/i18n/locales");
const BASE_LOCALE = "en";
const COMPARE_LOCALES = ["zh-CN", "es"];
const COMPARE_LOCALES = ["zh-CN", "es", "tr"];
function getKeys(obj, prefix = "") {
const keys = [];
+248 -12
View File
@@ -1,8 +1,28 @@
import { useRef } from "react";
import { type CSSProperties, type PointerEvent, useRef, useState } from "react";
import { Rnd } from "react-rnd";
import { cn } from "@/lib/utils";
import { getArrowComponent } from "./ArrowSvgs";
import type { AnnotationRegion } from "./types";
import {
type AnnotationRegion,
type BlurData,
DEFAULT_BLUR_DATA,
DEFAULT_BLUR_INTENSITY,
} from "./types";
const FREEHAND_POINT_THRESHOLD = 1;
function buildBlurPolygonClipPath(points: Array<{ x: number; y: number }>) {
if (points.length < 3) return undefined;
const polygon = points.map((point) => `${point.x}% ${point.y}%`).join(", ");
return `polygon(${polygon})`;
}
function buildBlurFreehandPath(points: Array<{ x: number; y: number }>, closed = true) {
if (closed ? points.length < 3 : points.length < 2) return null;
const [firstPoint, ...rest] = points;
const path = `M ${firstPoint.x} ${firstPoint.y} ${rest.map((point) => `L ${point.x} ${point.y}`).join(" ")}`;
return closed ? `${path} Z` : path;
}
interface AnnotationOverlayProps {
annotation: AnnotationRegion;
@@ -11,6 +31,8 @@ interface AnnotationOverlayProps {
containerHeight: number;
onPositionChange: (id: string, position: { x: number; y: number }) => void;
onSizeChange: (id: string, size: { width: number; height: number }) => void;
onBlurDataChange?: (id: string, blurData: BlurData) => void;
onBlurDataCommit?: () => void;
onClick: (id: string) => void;
zIndex: number;
isSelectedBoost: boolean; // Boost z-index when selected for easy editing
@@ -23,6 +45,8 @@ export function AnnotationOverlay({
containerHeight,
onPositionChange,
onSizeChange,
onBlurDataChange,
onBlurDataCommit,
onClick,
zIndex,
isSelectedBoost,
@@ -31,8 +55,16 @@ export function AnnotationOverlay({
const y = (annotation.position.y / 100) * containerHeight;
const width = (annotation.size.width / 100) * containerWidth;
const height = (annotation.size.height / 100) * containerHeight;
const blurShape = annotation.type === "blur" ? (annotation.blurData?.shape ?? "rectangle") : null;
const isSelectedFreehandBlur = isSelected && blurShape === "freehand";
const isDraggingRef = useRef(false);
const isDrawingFreehandRef = useRef(false);
const freehandPointsRef = useRef<Array<{ x: number; y: number }>>([]);
const [isFreehandDrawing, setIsFreehandDrawing] = useState(false);
const [draftFreehandPoints, setDraftFreehandPoints] = useState<Array<{ x: number; y: number }>>(
[],
);
const [livePointerPoint, setLivePointerPoint] = useState<{ x: number; y: number } | null>(null);
const renderArrow = () => {
const direction = annotation.figureData?.arrowDirection || "right";
@@ -43,6 +75,95 @@ export function AnnotationOverlay({
return <ArrowComponent color={color} strokeWidth={strokeWidth} />;
};
const normalizePoint = (event: PointerEvent<HTMLDivElement>) => {
const rect = event.currentTarget.getBoundingClientRect();
const x = ((event.clientX - rect.left) / rect.width) * 100;
const y = ((event.clientY - rect.top) / rect.height) * 100;
return {
x: Math.max(0, Math.min(100, x)),
y: Math.max(0, Math.min(100, y)),
};
};
const appendFreehandPoint = (point: { x: number; y: number }) => {
const points = freehandPointsRef.current;
const lastPoint = points[points.length - 1];
if (!lastPoint) {
points.push(point);
return;
}
const dx = point.x - lastPoint.x;
const dy = point.y - lastPoint.y;
// Sample freehand points in annotation-space percent units to avoid overly dense paths.
if (Math.hypot(dx, dy) >= FREEHAND_POINT_THRESHOLD) {
points.push(point);
}
};
const handleFreehandPointerDown = (event: PointerEvent<HTMLDivElement>) => {
if (
!isSelected ||
annotation.type !== "blur" ||
annotation.blurData?.shape !== "freehand" ||
!onBlurDataChange
) {
return;
}
event.preventDefault();
event.stopPropagation();
event.currentTarget.setPointerCapture(event.pointerId);
isDrawingFreehandRef.current = true;
setIsFreehandDrawing(true);
const point = normalizePoint(event);
freehandPointsRef.current = [point];
setDraftFreehandPoints([point]);
setLivePointerPoint(point);
};
const handleFreehandPointerMove = (event: PointerEvent<HTMLDivElement>) => {
if (!isDrawingFreehandRef.current) return;
event.preventDefault();
event.stopPropagation();
const point = normalizePoint(event);
setLivePointerPoint(point);
appendFreehandPoint(point);
setDraftFreehandPoints([...freehandPointsRef.current]);
};
const finishFreehandPointer = (event: PointerEvent<HTMLDivElement>) => {
if (!isDrawingFreehandRef.current || !onBlurDataChange) return;
isDrawingFreehandRef.current = false;
setIsFreehandDrawing(false);
try {
event.currentTarget.releasePointerCapture(event.pointerId);
} catch {
// no-op if already released
}
const points = [...freehandPointsRef.current];
if (livePointerPoint) {
const last = points[points.length - 1];
if (!last || Math.hypot(last.x - livePointerPoint.x, last.y - livePointerPoint.y) > 0.001) {
points.push(livePointerPoint);
}
}
if (points.length >= 3) {
const closedPoints = [...points];
const first = closedPoints[0];
const last = closedPoints[closedPoints.length - 1];
if (Math.hypot(last.x - first.x, last.y - first.y) > 0.001) {
closedPoints.push({ ...first });
}
onBlurDataChange(annotation.id, {
...(annotation.blurData || { ...DEFAULT_BLUR_DATA, shape: "freehand" }),
shape: "freehand",
freehandPoints: closedPoints,
});
setDraftFreehandPoints(closedPoints);
onBlurDataCommit?.();
}
setLivePointerPoint(null);
};
const renderContent = () => {
switch (annotation.type) {
case "text":
@@ -113,6 +234,114 @@ export function AnnotationOverlay({
<div className="w-full h-full flex items-center justify-center p-2">{renderArrow()}</div>
);
case "blur": {
const shape = annotation.blurData?.shape ?? "rectangle";
const blurIntensity = Math.max(
1,
Math.round(annotation.blurData?.intensity ?? DEFAULT_BLUR_INTENSITY),
);
const activeFreehandPoints =
shape === "freehand"
? isFreehandDrawing
? draftFreehandPoints
: (annotation.blurData?.freehandPoints ?? [])
: [];
const drawingPoints =
isFreehandDrawing && livePointerPoint
? (() => {
const last = activeFreehandPoints[activeFreehandPoints.length - 1];
if (!last) return [livePointerPoint];
const dx = livePointerPoint.x - last.x;
const dy = livePointerPoint.y - last.y;
return Math.hypot(dx, dy) > 0.01
? [...activeFreehandPoints, livePointerPoint]
: activeFreehandPoints;
})()
: activeFreehandPoints;
const clipPath =
shape === "freehand" ? buildBlurPolygonClipPath(activeFreehandPoints) : undefined;
const freehandPath =
shape === "freehand"
? buildBlurFreehandPath(
isFreehandDrawing ? drawingPoints : activeFreehandPoints,
!isFreehandDrawing,
)
: null;
const currentPointerPoint = isFreehandDrawing
? livePointerPoint || drawingPoints[drawingPoints.length - 1] || null
: null;
const shapeBorderRadius = shape === "oval" ? "50%" : shape === "rectangle" ? "8px" : "0";
const shouldShowFreehandBlurFill =
shape !== "freehand" || (!!clipPath && !isFreehandDrawing);
const shapeMaskStyle: CSSProperties = {
borderRadius: shapeBorderRadius,
clipPath: isFreehandDrawing ? undefined : clipPath,
WebkitClipPath: isFreehandDrawing ? undefined : clipPath,
};
const isFreehandSelected = isSelectedFreehandBlur;
return (
<div className="w-full h-full relative">
<div
className="absolute inset-0 overflow-hidden"
style={{
...shapeMaskStyle,
isolation: "isolate",
}}
>
<div
className="absolute inset-0"
style={{
...shapeMaskStyle,
backdropFilter: `blur(${blurIntensity}px)`,
WebkitBackdropFilter: `blur(${blurIntensity}px)`,
backgroundColor: "rgba(255, 255, 255, 0.02)",
opacity: shouldShowFreehandBlurFill ? 1 : 0,
}}
/>
{isSelected && shape !== "freehand" && (
<div
className="absolute inset-0 pointer-events-none border-2 border-[#34B27B]/80"
style={{ borderRadius: shapeBorderRadius }}
/>
)}
</div>
{isSelected && shape === "freehand" && freehandPath && (
<svg
viewBox="0 0 100 100"
preserveAspectRatio="none"
className="absolute inset-0 pointer-events-none"
>
<path
d={freehandPath}
fill="none"
stroke="#34B27B"
strokeWidth="0.55"
strokeLinecap="round"
strokeLinejoin="round"
/>
{currentPointerPoint && (
<circle
cx={currentPointerPoint.x}
cy={currentPointerPoint.y}
r="0.6"
fill="#34B27B"
/>
)}
</svg>
)}
{isFreehandSelected && (
<div
className="absolute inset-0 cursor-crosshair"
onPointerDown={handleFreehandPointerDown}
onPointerMove={handleFreehandPointerMove}
onPointerUp={finishFreehandPointer}
onPointerCancel={finishFreehandPointer}
/>
)}
</div>
);
}
default:
return null;
}
@@ -149,18 +378,23 @@ export function AnnotationOverlay({
}}
bounds="parent"
className={cn(
"cursor-move transition-all",
isSelected && "ring-2 ring-[#34B27B] ring-offset-2 ring-offset-transparent",
"cursor-move",
isSelected &&
annotation.type !== "blur" &&
"ring-2 ring-[#34B27B] ring-offset-2 ring-offset-transparent",
)}
style={{
zIndex: isSelectedBoost ? zIndex + 1000 : zIndex, // Boost selected annotation to ensure it's on top
pointerEvents: isSelected ? "auto" : "none",
border: isSelected ? "2px solid rgba(52, 178, 123, 0.8)" : "none",
backgroundColor: isSelected ? "rgba(52, 178, 123, 0.1)" : "transparent",
boxShadow: isSelected ? "0 0 0 1px rgba(52, 178, 123, 0.35)" : "none",
border:
isSelected && annotation.type !== "blur" ? "2px solid rgba(52, 178, 123, 0.8)" : "none",
backgroundColor:
isSelected && annotation.type !== "blur" ? "rgba(52, 178, 123, 0.1)" : "transparent",
boxShadow:
isSelected && annotation.type !== "blur" ? "0 0 0 1px rgba(52, 178, 123, 0.35)" : "none",
}}
enableResizing={isSelected}
disableDragging={!isSelected}
enableResizing={isSelected && !isSelectedFreehandBlur}
disableDragging={!isSelected || isSelectedFreehandBlur}
resizeHandleStyles={{
topLeft: {
width: "12px",
@@ -206,11 +440,13 @@ export function AnnotationOverlay({
>
<div
className={cn(
"w-full h-full rounded-lg",
"w-full h-full",
annotation.type !== "blur" && "rounded-lg",
annotation.type === "text" && "bg-transparent",
annotation.type === "image" && "bg-transparent",
annotation.type === "figure" && "bg-transparent",
isSelected && "shadow-lg",
annotation.type === "blur" && "bg-transparent",
isSelected && annotation.type !== "blur" && "shadow-lg",
)}
>
{renderContent()}
@@ -32,7 +32,12 @@ import { type CustomFont, getCustomFonts } from "@/lib/customFonts";
import { cn } from "@/lib/utils";
import { AddCustomFontDialog } from "./AddCustomFontDialog";
import { getArrowComponent } from "./ArrowSvgs";
import type { AnnotationRegion, AnnotationType, ArrowDirection, FigureData } from "./types";
import {
type AnnotationRegion,
type AnnotationType,
type ArrowDirection,
type FigureData,
} from "./types";
interface AnnotationSettingsPanelProps {
annotation: AnnotationRegion;
@@ -0,0 +1,142 @@
import { Info, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Slider } from "@/components/ui/slider";
import { useScopedT } from "@/contexts/I18nContext";
import { cn } from "@/lib/utils";
import {
type AnnotationRegion,
type BlurData,
type BlurShape,
DEFAULT_BLUR_DATA,
MAX_BLUR_INTENSITY,
MIN_BLUR_INTENSITY,
} from "./types";
interface BlurSettingsPanelProps {
blurRegion: AnnotationRegion;
onBlurDataChange: (blurData: BlurData) => void;
onBlurDataCommit?: () => void;
onDelete: () => void;
}
export function BlurSettingsPanel({
blurRegion,
onBlurDataChange,
onBlurDataCommit,
onDelete,
}: BlurSettingsPanelProps) {
const t = useScopedT("settings");
const blurShapeOptions: Array<{ value: BlurShape; labelKey: string }> = [
{ value: "rectangle", labelKey: "blurShapeRectangle" },
{ value: "oval", labelKey: "blurShapeOval" },
];
return (
<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">{t("annotation.blurShape")}</span>
<span className="text-[10px] uppercase tracking-wider font-medium text-[#34B27B] bg-[#34B27B]/10 px-2 py-1 rounded-full">
{t("annotation.active")}
</span>
</div>
<div className="grid grid-cols-2 gap-2">
{blurShapeOptions.map((shape) => {
const activeShape = blurRegion.blurData?.shape || DEFAULT_BLUR_DATA.shape;
const isActive = activeShape === shape.value;
return (
<button
key={shape.value}
onClick={() => {
const nextBlurData: BlurData = {
...DEFAULT_BLUR_DATA,
...blurRegion.blurData,
shape: shape.value,
};
onBlurDataChange(nextBlurData);
requestAnimationFrame(() => {
onBlurDataCommit?.();
});
}}
className={cn(
"h-16 rounded-lg border flex flex-col items-center justify-center transition-all p-2 gap-1",
isActive
? "bg-[#34B27B] border-[#34B27B]"
: "bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20",
)}
>
{shape.value === "rectangle" && (
<div
className={cn(
"w-8 h-5 border-2 rounded-sm",
isActive ? "border-white" : "border-slate-400",
)}
/>
)}
{shape.value === "oval" && (
<div
className={cn(
"w-8 h-5 border-2 rounded-full",
isActive ? "border-white" : "border-slate-400",
)}
/>
)}
<span className="text-[10px] leading-none">
{t(`annotation.${shape.labelKey}`)}
</span>
</button>
);
})}
</div>
<div className="mt-4 p-3 rounded-lg bg-white/5 border border-white/10">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-medium text-slate-300">
{t("annotation.blurIntensity")}
</span>
<span className="text-[10px] text-slate-400 font-mono">
{Math.round(blurRegion.blurData?.intensity ?? DEFAULT_BLUR_DATA.intensity)}px
</span>
</div>
<Slider
value={[blurRegion.blurData?.intensity ?? DEFAULT_BLUR_DATA.intensity]}
onValueChange={(values) => {
onBlurDataChange({
...DEFAULT_BLUR_DATA,
...blurRegion.blurData,
intensity: values[0],
});
}}
onValueCommit={() => onBlurDataCommit?.()}
min={MIN_BLUR_INTENSITY}
max={MAX_BLUR_INTENSITY}
step={1}
className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3"
/>
</div>
<Button
onClick={onDelete}
variant="destructive"
size="sm"
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" />
{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">{t("annotation.shortcutsAndTips")}</span>
</div>
<ul className="text-[10px] text-slate-400 space-y-1.5 list-disc pl-3 leading-relaxed">
<li>{t("annotation.tipMovePlayhead")}</li>
</ul>
</div>
</div>
</div>
);
}
+150 -4
View File
@@ -42,20 +42,86 @@ import { cn } from "@/lib/utils";
import { type AspectRatio, isPortraitAspectRatio } from "@/utils/aspectRatioUtils";
import { getTestId } from "@/utils/getTestId";
import { AnnotationSettingsPanel } from "./AnnotationSettingsPanel";
import { BlurSettingsPanel } from "./BlurSettingsPanel";
import { CropControl } from "./CropControl";
import { KeyboardShortcutsHelp } from "./KeyboardShortcutsHelp";
import type {
AnnotationRegion,
AnnotationType,
BlurData,
CropRegion,
FigureData,
PlaybackSpeed,
WebcamLayoutPreset,
WebcamMaskShape,
WebcamSizePreset,
ZoomDepth,
ZoomFocusMode,
} from "./types";
import { SPEED_OPTIONS } from "./types";
import { DEFAULT_WEBCAM_SIZE_PRESET, MAX_PLAYBACK_SPEED, SPEED_OPTIONS } from "./types";
function CustomSpeedInput({
value,
onChange,
onError,
}: {
value: number;
onChange: (val: number) => void;
onError: () => void;
}) {
const isPreset = SPEED_OPTIONS.some((o) => o.speed === value);
const [draft, setDraft] = useState(isPreset ? "" : String(Math.round(value)));
const [isFocused, setIsFocused] = useState(false);
const prevValue = useRef(value);
if (!isFocused && prevValue.current !== value) {
prevValue.current = value;
setDraft(isPreset ? "" : String(Math.round(value)));
}
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const digits = e.target.value.replace(/\D/g, "");
if (digits === "") {
setDraft("");
return;
}
const num = Number(digits);
if (num > MAX_PLAYBACK_SPEED) {
onError();
return;
}
setDraft(digits);
if (num >= 1) onChange(num);
},
[onChange, onError],
);
const handleBlur = useCallback(() => {
setIsFocused(false);
if (!draft || Number(draft) < 1) {
setDraft(isPreset ? "" : String(Math.round(value)));
}
}, [draft, isPreset, value]);
return (
<div className="flex items-center gap-1">
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
placeholder="--"
value={draft}
onFocus={() => setIsFocused(true)}
onChange={handleChange}
onBlur={handleBlur}
onKeyDown={(e) => e.key === "Enter" && (e.target as HTMLInputElement).blur()}
className="w-12 bg-white/5 border border-white/10 rounded-md px-1 py-0.5 text-[11px] font-semibold text-[#d97706] text-center focus:outline-none focus:border-[#d97706]/40"
/>
<span className="text-[11px] font-semibold text-slate-500">×</span>
</div>
);
}
const WALLPAPER_COUNT = 18;
const WALLPAPER_RELATIVE = Array.from(
@@ -132,7 +198,11 @@ interface SettingsPanelProps {
onGifSizePresetChange?: (preset: GifSizePreset) => void;
gifOutputDimensions?: { width: number; height: number };
onExport?: () => void;
unsavedExport?: { arrayBuffer: ArrayBuffer; fileName: string; format: string } | null;
unsavedExport?: {
arrayBuffer: ArrayBuffer;
fileName: string;
format: string;
} | null;
onSaveUnsavedExport?: () => void;
selectedAnnotationId?: string | null;
annotationRegions?: AnnotationRegion[];
@@ -141,6 +211,11 @@ interface SettingsPanelProps {
onAnnotationStyleChange?: (id: string, style: Partial<AnnotationRegion["style"]>) => void;
onAnnotationFigureDataChange?: (id: string, figureData: FigureData) => void;
onAnnotationDelete?: (id: string) => void;
selectedBlurId?: string | null;
blurRegions?: AnnotationRegion[];
onBlurDataChange?: (id: string, blurData: BlurData) => void;
onBlurDataCommit?: () => void;
onBlurDelete?: (id: string) => void;
selectedSpeedId?: string | null;
selectedSpeedValue?: PlaybackSpeed | null;
onSpeedChange?: (speed: PlaybackSpeed) => void;
@@ -150,6 +225,9 @@ interface SettingsPanelProps {
onWebcamLayoutPresetChange?: (preset: WebcamLayoutPreset) => void;
webcamMaskShape?: import("./types").WebcamMaskShape;
onWebcamMaskShapeChange?: (shape: import("./types").WebcamMaskShape) => void;
webcamSizePreset?: WebcamSizePreset;
onWebcamSizePresetChange?: (size: WebcamSizePreset) => void;
onWebcamSizePresetCommit?: () => void;
}
export default SettingsPanel;
@@ -214,6 +292,11 @@ export function SettingsPanel({
onAnnotationStyleChange,
onAnnotationFigureDataChange,
onAnnotationDelete,
selectedBlurId,
blurRegions = [],
onBlurDataChange,
onBlurDataCommit,
onBlurDelete,
selectedSpeedId,
selectedSpeedValue,
onSpeedChange,
@@ -223,6 +306,9 @@ export function SettingsPanel({
onWebcamLayoutPresetChange,
webcamMaskShape = "rectangle",
onWebcamMaskShapeChange,
webcamSizePreset = DEFAULT_WEBCAM_SIZE_PRESET,
onWebcamSizePresetChange,
onWebcamSizePresetCommit,
}: SettingsPanelProps) {
const t = useScopedT("settings");
const [wallpaperPaths, setWallpaperPaths] = useState<string[]>([]);
@@ -446,6 +532,9 @@ export function SettingsPanel({
const selectedAnnotation = selectedAnnotationId
? annotationRegions.find((a) => a.id === selectedAnnotationId)
: null;
const selectedBlur = selectedBlurId
? blurRegions.find((region) => region.id === selectedBlurId)
: null;
// If an annotation is selected, show annotation settings instead
if (
@@ -471,6 +560,17 @@ export function SettingsPanel({
);
}
if (selectedBlur && onBlurDataChange && onBlurDelete) {
return (
<BlurSettingsPanel
blurRegion={selectedBlur}
onBlurDataChange={(blurData) => onBlurDataChange(selectedBlur.id, blurData)}
onBlurDataCommit={onBlurDataCommit}
onDelete={() => onBlurDelete(selectedBlur.id)}
/>
);
}
return (
<div className="flex-[2] min-w-0 bg-[#09090b] border border-white/5 rounded-2xl flex flex-col shadow-xl h-full overflow-hidden">
<div className="flex-1 overflow-y-auto custom-scrollbar p-4 pb-0">
@@ -584,7 +684,7 @@ export function SettingsPanel({
</span>
)}
</div>
<div className="grid grid-cols-7 gap-1.5">
<div className="grid grid-cols-5 gap-1.5">
{SPEED_OPTIONS.map((option) => {
const isActive = selectedSpeedValue === option.speed;
return (
@@ -609,6 +709,29 @@ export function SettingsPanel({
);
})}
</div>
<div className="mt-3">
<div className="flex items-center justify-between">
<span
className={cn("text-[11px]", selectedSpeedId ? "text-slate-500" : "text-slate-600")}
>
{t("speed.customPlaybackSpeed")}
</span>
{selectedSpeedId ? (
<CustomSpeedInput
value={selectedSpeedValue ?? 1}
onChange={(val) => onSpeedChange?.(val)}
onError={() => toast.error(t("speed.maxSpeedError"))}
/>
) : (
<div className="flex items-center gap-1 opacity-40">
<div className="w-12 bg-white/5 border border-white/10 rounded-md px-1 py-0.5 text-[11px] font-semibold text-slate-600 text-center">
--
</div>
<span className="text-[11px] font-semibold text-slate-600">×</span>
</div>
)}
</div>
</div>
{!selectedSpeedId && (
<p className="text-[10px] text-slate-500 mt-2 text-center">{t("speed.selectRegion")}</p>
)}
@@ -751,6 +874,27 @@ export function SettingsPanel({
</div>
</div>
)}
{webcamLayoutPreset === "picture-in-picture" && (
<div className="p-2 rounded-lg bg-white/5 border border-white/5 mt-2">
<div className="flex items-center justify-between mb-1.5">
<div className="text-[10px] font-medium text-slate-300">
{t("layout.webcamSize")}
</div>
<div className="text-[10px] font-medium text-slate-400">
{webcamSizePreset}%
</div>
</div>
<Slider
value={[webcamSizePreset]}
onValueChange={(values) => onWebcamSizePresetChange?.(values[0])}
onValueCommit={() => onWebcamSizePresetCommit?.()}
min={10}
max={50}
step={1}
className="w-full"
/>
</div>
)}
</AccordionContent>
</AccordionItem>
)}
@@ -1016,7 +1160,9 @@ export function SettingsPanel({
: "border-white/10 hover:border-[#34B27B]/40 opacity-80 hover:opacity-100 bg-white/5",
)}
style={{ background: g }}
aria-label={t("background.gradientLabel", { index: idx + 1 })}
aria-label={t("background.gradientLabel", {
index: idx + 1,
})}
onClick={() => {
setGradient(g);
onWallpaperChange(g);
+254 -17
View File
@@ -1,8 +1,16 @@
import type { Span } from "dnd-timeline";
import { FolderOpen, Languages, Save } from "lucide-react";
import { FolderOpen, Languages, Save, Video } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels";
import { toast } from "sonner";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useI18n, useScopedT } from "@/contexts/I18nContext";
import { useShortcuts } from "@/contexts/ShortcutsContext";
import { INITIAL_EDITOR_STATE, useEditorHistory } from "@/hooks/useEditorHistory";
@@ -46,11 +54,13 @@ import { SettingsPanel } from "./SettingsPanel";
import TimelineEditor from "./timeline/TimelineEditor";
import {
type AnnotationRegion,
type BlurData,
type CursorTelemetryPoint,
clampFocusToDepth,
DEFAULT_ANNOTATION_POSITION,
DEFAULT_ANNOTATION_SIZE,
DEFAULT_ANNOTATION_STYLE,
DEFAULT_BLUR_DATA,
DEFAULT_FIGURE_DATA,
DEFAULT_PLAYBACK_SPEED,
DEFAULT_ZOOM_DEPTH,
@@ -90,6 +100,7 @@ export default function VideoEditor() {
aspectRatio,
webcamLayoutPreset,
webcamMaskShape,
webcamSizePreset,
webcamPosition,
} = editorState;
@@ -113,10 +124,12 @@ export default function VideoEditor() {
const [selectedTrimId, setSelectedTrimId] = useState<string | null>(null);
const [selectedSpeedId, setSelectedSpeedId] = useState<string | null>(null);
const [selectedAnnotationId, setSelectedAnnotationId] = useState<string | null>(null);
const [selectedBlurId, setSelectedBlurId] = useState<string | null>(null);
const [isExporting, setIsExporting] = useState(false);
const [exportProgress, setExportProgress] = useState<ExportProgress | null>(null);
const [exportError, setExportError] = useState<string | null>(null);
const [showExportDialog, setShowExportDialog] = useState(false);
const [showNewRecordingDialog, setShowNewRecordingDialog] = useState(false);
const [exportQuality, setExportQuality] = useState<ExportQuality>("good");
const [exportFormat, setExportFormat] = useState<ExportFormat>("mp4");
const [gifFrameRate, setGifFrameRate] = useState<GifFrameRate>(15);
@@ -147,6 +160,15 @@ export default function VideoEditor() {
const nextAnnotationZIndexRef = useRef(1);
const exporterRef = useRef<VideoExporter | null>(null);
const annotationOnlyRegions = useMemo(
() => annotationRegions.filter((region) => region.type !== "blur"),
[annotationRegions],
);
const blurRegions = useMemo(
() => annotationRegions.filter((region) => region.type === "blur"),
[annotationRegions],
);
const currentProjectMedia = useMemo<ProjectMedia | null>(() => {
const screenVideoPath = videoSourcePath ?? (videoPath ? fromFileUrl(videoPath) : null);
if (!screenVideoPath) {
@@ -206,6 +228,7 @@ export default function VideoEditor() {
aspectRatio: normalizedEditor.aspectRatio,
webcamLayoutPreset: normalizedEditor.webcamLayoutPreset,
webcamMaskShape: normalizedEditor.webcamMaskShape,
webcamSizePreset: normalizedEditor.webcamSizePreset,
webcamPosition: normalizedEditor.webcamPosition,
});
setExportQuality(normalizedEditor.exportQuality);
@@ -218,6 +241,7 @@ export default function VideoEditor() {
setSelectedTrimId(null);
setSelectedSpeedId(null);
setSelectedAnnotationId(null);
setSelectedBlurId(null);
nextZoomIdRef.current = deriveNextId(
"zoom",
@@ -296,6 +320,7 @@ export default function VideoEditor() {
aspectRatio,
webcamLayoutPreset,
webcamMaskShape,
webcamSizePreset,
webcamPosition,
exportQuality,
exportFormat,
@@ -416,6 +441,7 @@ export default function VideoEditor() {
aspectRatio,
webcamLayoutPreset,
webcamMaskShape,
webcamSizePreset,
webcamPosition,
exportQuality,
exportFormat,
@@ -471,6 +497,7 @@ export default function VideoEditor() {
aspectRatio,
webcamLayoutPreset,
webcamMaskShape,
webcamSizePreset,
webcamPosition,
exportQuality,
exportFormat,
@@ -501,6 +528,16 @@ export default function VideoEditor() {
await saveProject(true);
}, [saveProject]);
const handleNewRecordingConfirm = useCallback(async () => {
const result = await window.electronAPI.startNewRecording();
if (result.success) {
setShowNewRecordingDialog(false);
} else {
console.error("Failed to start new recording:", result.error);
setError("Failed to start new recording: " + (result.error || "Unknown error"));
}
}, []);
const handleLoadProject = useCallback(async () => {
const result = await window.electronAPI.loadProjectFile();
@@ -602,7 +639,11 @@ export default function VideoEditor() {
const handleSelectZoom = useCallback((id: string | null) => {
setSelectedZoomId(id);
if (id) setSelectedTrimId(null);
if (id) {
setSelectedTrimId(null);
setSelectedAnnotationId(null);
setSelectedBlurId(null);
}
}, []);
const handleSelectTrim = useCallback((id: string | null) => {
@@ -610,6 +651,7 @@ export default function VideoEditor() {
if (id) {
setSelectedZoomId(null);
setSelectedAnnotationId(null);
setSelectedBlurId(null);
}
}, []);
@@ -618,6 +660,17 @@ export default function VideoEditor() {
if (id) {
setSelectedZoomId(null);
setSelectedTrimId(null);
setSelectedBlurId(null);
}
}, []);
const handleSelectBlur = useCallback((id: string | null) => {
setSelectedBlurId(id);
if (id) {
setSelectedZoomId(null);
setSelectedTrimId(null);
setSelectedAnnotationId(null);
setSelectedSpeedId(null);
}
}, []);
@@ -635,6 +688,7 @@ export default function VideoEditor() {
setSelectedZoomId(id);
setSelectedTrimId(null);
setSelectedAnnotationId(null);
setSelectedBlurId(null);
},
[pushState],
);
@@ -653,6 +707,7 @@ export default function VideoEditor() {
setSelectedZoomId(id);
setSelectedTrimId(null);
setSelectedAnnotationId(null);
setSelectedBlurId(null);
},
[pushState],
);
@@ -669,6 +724,7 @@ export default function VideoEditor() {
setSelectedTrimId(id);
setSelectedZoomId(null);
setSelectedAnnotationId(null);
setSelectedBlurId(null);
},
[pushState],
);
@@ -678,7 +734,11 @@ export default function VideoEditor() {
pushState((prev) => ({
zoomRegions: prev.zoomRegions.map((region) =>
region.id === id
? { ...region, startMs: Math.round(span.start), endMs: Math.round(span.end) }
? {
...region,
startMs: Math.round(span.start),
endMs: Math.round(span.end),
}
: region,
),
}));
@@ -691,7 +751,11 @@ export default function VideoEditor() {
pushState((prev) => ({
trimRegions: prev.trimRegions.map((region) =>
region.id === id
? { ...region, startMs: Math.round(span.start), endMs: Math.round(span.end) }
? {
...region,
startMs: Math.round(span.start),
endMs: Math.round(span.end),
}
: region,
),
}));
@@ -717,7 +781,11 @@ export default function VideoEditor() {
pushState((prev) => ({
zoomRegions: prev.zoomRegions.map((region) =>
region.id === selectedZoomId
? { ...region, depth, focus: clampFocusToDepth(region.focus, depth) }
? {
...region,
depth,
focus: clampFocusToDepth(region.focus, depth),
}
: region,
),
}));
@@ -739,7 +807,9 @@ export default function VideoEditor() {
const handleZoomDelete = useCallback(
(id: string) => {
pushState((prev) => ({ zoomRegions: prev.zoomRegions.filter((r) => r.id !== id) }));
pushState((prev) => ({
zoomRegions: prev.zoomRegions.filter((r) => r.id !== id),
}));
if (selectedZoomId === id) {
setSelectedZoomId(null);
}
@@ -749,7 +819,9 @@ export default function VideoEditor() {
const handleTrimDelete = useCallback(
(id: string) => {
pushState((prev) => ({ trimRegions: prev.trimRegions.filter((r) => r.id !== id) }));
pushState((prev) => ({
trimRegions: prev.trimRegions.filter((r) => r.id !== id),
}));
if (selectedTrimId === id) {
setSelectedTrimId(null);
}
@@ -763,6 +835,7 @@ export default function VideoEditor() {
setSelectedZoomId(null);
setSelectedTrimId(null);
setSelectedAnnotationId(null);
setSelectedBlurId(null);
}
}, []);
@@ -775,11 +848,14 @@ export default function VideoEditor() {
endMs: Math.round(span.end),
speed: DEFAULT_PLAYBACK_SPEED,
};
pushState((prev) => ({ speedRegions: [...prev.speedRegions, newRegion] }));
pushState((prev) => ({
speedRegions: [...prev.speedRegions, newRegion],
}));
setSelectedSpeedId(id);
setSelectedZoomId(null);
setSelectedTrimId(null);
setSelectedAnnotationId(null);
setSelectedBlurId(null);
},
[pushState],
);
@@ -840,10 +916,41 @@ export default function VideoEditor() {
style: { ...DEFAULT_ANNOTATION_STYLE },
zIndex,
};
pushState((prev) => ({ annotationRegions: [...prev.annotationRegions, newRegion] }));
pushState((prev) => ({
annotationRegions: [...prev.annotationRegions, newRegion],
}));
setSelectedAnnotationId(id);
setSelectedZoomId(null);
setSelectedTrimId(null);
setSelectedBlurId(null);
},
[pushState],
);
const handleBlurAdded = useCallback(
(span: Span) => {
const id = `annotation-${nextAnnotationIdRef.current++}`;
const zIndex = nextAnnotationZIndexRef.current++;
const newRegion: AnnotationRegion = {
id,
startMs: Math.round(span.start),
endMs: Math.round(span.end),
type: "blur",
content: "",
position: { ...DEFAULT_ANNOTATION_POSITION },
size: { ...DEFAULT_ANNOTATION_SIZE },
style: { ...DEFAULT_ANNOTATION_STYLE },
zIndex,
blurData: { ...DEFAULT_BLUR_DATA },
};
pushState((prev) => ({
annotationRegions: [...prev.annotationRegions, newRegion],
}));
setSelectedBlurId(id);
setSelectedAnnotationId(null);
setSelectedZoomId(null);
setSelectedTrimId(null);
setSelectedSpeedId(null);
},
[pushState],
);
@@ -853,7 +960,11 @@ export default function VideoEditor() {
pushState((prev) => ({
annotationRegions: prev.annotationRegions.map((region) =>
region.id === id
? { ...region, startMs: Math.round(span.start), endMs: Math.round(span.end) }
? {
...region,
startMs: Math.round(span.start),
endMs: Math.round(span.end),
}
: region,
),
}));
@@ -869,8 +980,11 @@ export default function VideoEditor() {
if (selectedAnnotationId === id) {
setSelectedAnnotationId(null);
}
if (selectedBlurId === id) {
setSelectedBlurId(null);
}
},
[selectedAnnotationId, pushState],
[selectedAnnotationId, selectedBlurId, pushState],
);
const handleAnnotationContentChange = useCallback(
@@ -905,12 +1019,26 @@ export default function VideoEditor() {
if (!region.figureData) {
updatedRegion.figureData = { ...DEFAULT_FIGURE_DATA };
}
} else if (type === "blur") {
updatedRegion.content = "";
if (!region.blurData) {
updatedRegion.blurData = { ...DEFAULT_BLUR_DATA };
}
}
return updatedRegion;
}),
}));
if (type === "blur" && selectedAnnotationId === id) {
setSelectedAnnotationId(null);
setSelectedBlurId(id);
setSelectedSpeedId(null);
} else if (type !== "blur" && selectedBlurId === id) {
setSelectedBlurId(null);
setSelectedAnnotationId(id);
}
},
[pushState],
[pushState, selectedAnnotationId, selectedBlurId],
);
const handleAnnotationStyleChange = useCallback(
@@ -935,6 +1063,51 @@ export default function VideoEditor() {
[pushState],
);
const handleBlurDataPreviewChange = useCallback(
(id: string, blurData: BlurData) => {
updateState((prev) => ({
annotationRegions: prev.annotationRegions.map((region) =>
region.id === id
? {
...region,
blurData,
// Freehand drawing area is the full video surface.
...(blurData.shape === "freehand"
? {
position: { x: 0, y: 0 },
size: { width: 100, height: 100 },
}
: {}),
}
: region,
),
}));
},
[updateState],
);
const handleBlurDataPanelChange = useCallback(
(id: string, blurData: BlurData) => {
pushState((prev) => ({
annotationRegions: prev.annotationRegions.map((region) =>
region.id === id
? {
...region,
blurData,
...(blurData.shape === "freehand"
? {
position: { x: 0, y: 0 },
size: { width: 100, height: 100 },
}
: {}),
}
: region,
),
}));
},
[pushState],
);
const handleAnnotationPositionChange = useCallback(
(id: string, position: { x: number; y: number }) => {
pushState((prev) => ({
@@ -1048,11 +1221,14 @@ export default function VideoEditor() {
useEffect(() => {
if (
selectedAnnotationId &&
!annotationRegions.some((region) => region.id === selectedAnnotationId)
!annotationOnlyRegions.some((region) => region.id === selectedAnnotationId)
) {
setSelectedAnnotationId(null);
}
}, [selectedAnnotationId, annotationRegions]);
if (selectedBlurId && !blurRegions.some((region) => region.id === selectedBlurId)) {
setSelectedBlurId(null);
}
}, [selectedAnnotationId, selectedBlurId, annotationOnlyRegions, blurRegions]);
useEffect(() => {
if (selectedSpeedId && !speedRegions.some((region) => region.id === selectedSpeedId)) {
@@ -1174,6 +1350,7 @@ export default function VideoEditor() {
annotationRegions,
webcamLayoutPreset,
webcamMaskShape,
webcamSizePreset,
webcamPosition,
previewWidth,
previewHeight,
@@ -1307,6 +1484,7 @@ export default function VideoEditor() {
annotationRegions,
webcamLayoutPreset,
webcamMaskShape,
webcamSizePreset,
webcamPosition,
previewWidth,
previewHeight,
@@ -1377,6 +1555,7 @@ export default function VideoEditor() {
aspectRatio,
webcamLayoutPreset,
webcamMaskShape,
webcamSizePreset,
webcamPosition,
exportQuality,
handleExportSaved,
@@ -1482,6 +1661,34 @@ export default function VideoEditor() {
return (
<div className="flex flex-col h-screen bg-[#09090b] text-slate-200 overflow-hidden selection:bg-[#34B27B]/30">
<Dialog open={showNewRecordingDialog} onOpenChange={setShowNewRecordingDialog}>
<DialogContent
className="sm:max-w-[425px]"
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
>
<DialogHeader>
<DialogTitle>{t("newRecording.title")}</DialogTitle>
<DialogDescription>{t("newRecording.description")}</DialogDescription>
</DialogHeader>
<DialogFooter>
<button
type="button"
onClick={() => setShowNewRecordingDialog(false)}
className="px-4 py-2 rounded-md bg-white/10 text-white hover:bg-white/20 text-sm font-medium transition-colors"
>
{t("newRecording.cancel")}
</button>
<button
type="button"
onClick={handleNewRecordingConfirm}
className="px-4 py-2 rounded-md bg-[#34B27B] text-white hover:bg-[#34B27B]/90 text-sm font-medium transition-colors"
>
{t("newRecording.confirm")}
</button>
</DialogFooter>
</DialogContent>
</Dialog>
<div
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}
@@ -1507,6 +1714,14 @@ export default function VideoEditor() {
))}
</select>
</div>
<button
type="button"
onClick={() => setShowNewRecordingDialog(true)}
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"
>
<Video size={14} />
{t("newRecording.title")}
</button>
<button
type="button"
onClick={handleLoadProject}
@@ -1563,6 +1778,7 @@ export default function VideoEditor() {
webcamVideoPath={webcamVideoPath || undefined}
webcamLayoutPreset={webcamLayoutPreset}
webcamMaskShape={webcamMaskShape}
webcamSizePreset={webcamSizePreset}
webcamPosition={webcamPosition}
onWebcamPositionChange={(pos) => updateState({ webcamPosition: pos })}
onWebcamPositionDragEnd={commitState}
@@ -1587,11 +1803,18 @@ export default function VideoEditor() {
cropRegion={cropRegion}
trimRegions={trimRegions}
speedRegions={speedRegions}
annotationRegions={annotationRegions}
annotationRegions={annotationOnlyRegions}
selectedAnnotationId={selectedAnnotationId}
onSelectAnnotation={handleSelectAnnotation}
onAnnotationPositionChange={handleAnnotationPositionChange}
onAnnotationSizeChange={handleAnnotationSizeChange}
blurRegions={blurRegions}
selectedBlurId={selectedBlurId}
onSelectBlur={handleSelectBlur}
onBlurPositionChange={handleAnnotationPositionChange}
onBlurSizeChange={handleAnnotationSizeChange}
onBlurDataChange={handleBlurDataPreviewChange}
onBlurDataCommit={commitState}
cursorTelemetry={cursorTelemetry}
/>
</div>
@@ -1644,12 +1867,18 @@ export default function VideoEditor() {
onSpeedDelete={handleSpeedDelete}
selectedSpeedId={selectedSpeedId}
onSelectSpeed={handleSelectSpeed}
annotationRegions={annotationRegions}
annotationRegions={annotationOnlyRegions}
onAnnotationAdded={handleAnnotationAdded}
onAnnotationSpanChange={handleAnnotationSpanChange}
onAnnotationDelete={handleAnnotationDelete}
selectedAnnotationId={selectedAnnotationId}
onSelectAnnotation={handleSelectAnnotation}
blurRegions={blurRegions}
onBlurAdded={handleBlurAdded}
onBlurSpanChange={handleAnnotationSpanChange}
onBlurDelete={handleAnnotationDelete}
selectedBlurId={selectedBlurId}
onSelectBlur={handleSelectBlur}
aspectRatio={aspectRatio}
onAspectRatioChange={(ar) =>
pushState({
@@ -1713,6 +1942,9 @@ export default function VideoEditor() {
}
webcamMaskShape={webcamMaskShape}
onWebcamMaskShapeChange={(shape) => pushState({ webcamMaskShape: shape })}
webcamSizePreset={webcamSizePreset}
onWebcamSizePresetChange={(v) => updateState({ webcamSizePreset: v })}
onWebcamSizePresetCommit={commitState}
videoElement={videoPlaybackRef.current?.video || null}
exportQuality={exportQuality}
onExportQualityChange={setExportQuality}
@@ -1739,12 +1971,17 @@ export default function VideoEditor() {
)}
onExport={handleOpenExportDialog}
selectedAnnotationId={selectedAnnotationId}
annotationRegions={annotationRegions}
annotationRegions={annotationOnlyRegions}
onAnnotationContentChange={handleAnnotationContentChange}
onAnnotationTypeChange={handleAnnotationTypeChange}
onAnnotationStyleChange={handleAnnotationStyleChange}
onAnnotationFigureDataChange={handleAnnotationFigureDataChange}
onAnnotationDelete={handleAnnotationDelete}
selectedBlurId={selectedBlurId}
blurRegions={blurRegions}
onBlurDataChange={handleBlurDataPanelChange}
onBlurDataCommit={commitState}
onBlurDelete={handleAnnotationDelete}
selectedSpeedId={selectedSpeedId}
selectedSpeedValue={
selectedSpeedId
+155 -26
View File
@@ -24,6 +24,7 @@ import {
type Size,
type StyledRenderRect,
type WebcamLayoutPreset,
type WebcamSizePreset,
} from "@/lib/compositeLayout";
import { getCssClipPath } from "@/lib/webcamMaskShapes";
import {
@@ -34,6 +35,7 @@ import {
import { AnnotationOverlay } from "./AnnotationOverlay";
import {
type AnnotationRegion,
type BlurData,
type SpeedRegion,
type TrimRegion,
ZOOM_DEPTH_SCALES,
@@ -69,6 +71,7 @@ interface VideoPlaybackProps {
webcamVideoPath?: string;
webcamLayoutPreset: WebcamLayoutPreset;
webcamMaskShape?: import("./types").WebcamMaskShape;
webcamSizePreset?: WebcamSizePreset;
webcamPosition?: { cx: number; cy: number } | null;
onWebcamPositionChange?: (position: { cx: number; cy: number }) => void;
onWebcamPositionDragEnd?: () => void;
@@ -99,6 +102,13 @@ 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;
blurRegions?: AnnotationRegion[];
selectedBlurId?: string | null;
onSelectBlur?: (id: string | null) => void;
onBlurPositionChange?: (id: string, position: { x: number; y: number }) => void;
onBlurSizeChange?: (id: string, size: { width: number; height: number }) => void;
onBlurDataChange?: (id: string, blurData: BlurData) => void;
onBlurDataCommit?: () => void;
cursorTelemetry?: import("./types").CursorTelemetryPoint[];
}
@@ -119,6 +129,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
webcamVideoPath,
webcamLayoutPreset,
webcamMaskShape,
webcamSizePreset,
webcamPosition,
onWebcamPositionChange,
onWebcamPositionDragEnd,
@@ -149,6 +160,13 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
onSelectAnnotation,
onAnnotationPositionChange,
onAnnotationSizeChange,
blurRegions = [],
selectedBlurId,
onSelectBlur,
onBlurPositionChange,
onBlurSizeChange,
onBlurDataChange,
onBlurDataCommit,
cursorTelemetry = [],
},
ref,
@@ -163,6 +181,8 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
const timeUpdateAnimationRef = useRef<number | null>(null);
const [pixiReady, setPixiReady] = useState(false);
const [videoReady, setVideoReady] = useState(false);
const [overlaySize, setOverlaySize] = useState({ width: 800, height: 600 });
const [overlayElement, setOverlayElement] = useState<HTMLDivElement | null>(null);
const overlayRef = useRef<HTMLDivElement | null>(null);
const focusIndicatorRef = useRef<HTMLDivElement | null>(null);
const [webcamLayout, setWebcamLayout] = useState<StyledRenderRect | null>(null);
@@ -195,7 +215,10 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
const isPlayingRef = useRef(isPlaying);
const isSeekingRef = useRef(false);
const allowPlaybackRef = useRef(false);
const lockedVideoDimensionsRef = useRef<{ width: number; height: number } | null>(null);
const lockedVideoDimensionsRef = useRef<{
width: number;
height: number;
} | null>(null);
const layoutVideoContentRef = useRef<(() => void) | null>(null);
const trimRegionsRef = useRef<TrimRegion[]>([]);
const speedRegionsRef = useRef<SpeedRegion[]>([]);
@@ -283,6 +306,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
padding,
webcamDimensions,
webcamLayoutPreset,
webcamSizePreset,
webcamPosition,
webcamMaskShape,
});
@@ -314,6 +338,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
padding,
webcamDimensions,
webcamLayoutPreset,
webcamSizePreset,
webcamPosition,
webcamMaskShape,
]);
@@ -322,6 +347,11 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
layoutVideoContentRef.current = layoutVideoContent;
}, [layoutVideoContent]);
const setOverlayRefs = useCallback((node: HTMLDivElement | null) => {
overlayRef.current = node;
setOverlayElement(node);
}, []);
const selectedZoom = useMemo(() => {
if (!selectedZoomId) return null;
return zoomRegions.find((region) => region.id === selectedZoomId) ?? null;
@@ -615,7 +645,8 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
}, [selectedZoom, pixiReady, videoReady, updateOverlayForRegion]);
useEffect(() => {
const overlayEl = overlayRef.current;
if (!pixiReady || !videoReady) return;
const overlayEl = overlayElement;
if (!overlayEl) return;
if (!selectedZoom) {
overlayEl.style.cursor = "default";
@@ -624,7 +655,34 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
}
overlayEl.style.cursor = isPlaying ? "not-allowed" : "grab";
overlayEl.style.pointerEvents = isPlaying ? "none" : "auto";
}, [selectedZoom, isPlaying]);
}, [selectedZoom, isPlaying, pixiReady, videoReady, overlayElement]);
useEffect(() => {
const overlayEl = overlayElement;
if (!overlayEl) return;
const updateOverlaySize = () => {
const width = overlayEl.clientWidth || 800;
const height = overlayEl.clientHeight || 600;
setOverlaySize((prev) => {
if (prev.width === width && prev.height === height) return prev;
return { width, height };
});
};
updateOverlaySize();
if (typeof ResizeObserver !== "undefined") {
const observer = new ResizeObserver(() => {
updateOverlaySize();
});
observer.observe(overlayEl);
return () => observer.disconnect();
}
window.addEventListener("resize", updateOverlaySize);
return () => window.removeEventListener("resize", updateOverlaySize);
}, [overlayElement]);
useEffect(() => {
const container = containerRef.current;
@@ -648,7 +706,11 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
app.ticker.maxFPS = 60;
if (!mounted) {
app.destroy(true, { children: true, texture: true, textureSource: true });
app.destroy(true, {
children: true,
texture: true,
textureSource: true,
});
return;
}
@@ -672,7 +734,11 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
mounted = false;
setPixiReady(false);
if (app && app.renderer) {
app.destroy(true, { children: true, texture: true, textureSource: true });
app.destroy(true, {
children: true,
texture: true,
textureSource: true,
});
}
appRef.current = null;
cameraContainerRef.current = null;
@@ -853,12 +919,19 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
const ss = stageSizeRef.current;
const viewportRatio =
bm.width > 0 && bm.height > 0
? { widthRatio: ss.width / bm.width, heightRatio: ss.height / bm.height }
? {
widthRatio: ss.width / bm.width,
heightRatio: ss.height / bm.height,
}
: undefined;
const { region, strength, blendedScale, transition } = findDominantRegion(
zoomRegionsRef.current,
currentTimeRef.current,
{ connectZooms: true, cursorTelemetry: cursorTelemetryRef.current, viewportRatio },
{
connectZooms: true,
cursorTelemetry: cursorTelemetryRef.current,
viewportRatio,
},
);
const defaultFocus = DEFAULT_FOCUS;
@@ -1264,7 +1337,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
{/* Only render overlay after PIXI and video are fully initialized */}
{pixiReady && videoReady && (
<div
ref={overlayRef}
ref={setOverlayRefs}
className="absolute inset-0 select-none"
style={{ pointerEvents: "none", zIndex: 30 }}
onPointerDown={handleOverlayPointerDown}
@@ -1278,7 +1351,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
style={{ display: "none", pointerEvents: "none" }}
/>
{(() => {
const filtered = (annotationRegions || []).filter((annotation) => {
const filteredAnnotations = (annotationRegions || []).filter((annotation) => {
if (typeof annotation.startMs !== "number" || typeof annotation.endMs !== "number")
return false;
@@ -1288,37 +1361,93 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
return timeMs >= annotation.startMs && timeMs <= annotation.endMs;
});
// Sort by z-index (lowest to highest) so higher z-index renders on top
const sorted = [...filtered].sort((a, b) => a.zIndex - b.zIndex);
const filteredBlurRegions = (blurRegions || []).filter((blurRegion) => {
if (typeof blurRegion.startMs !== "number" || typeof blurRegion.endMs !== "number")
return false;
if (blurRegion.id === selectedBlurId) return true;
const timeMs = Math.round(currentTime * 1000);
return timeMs >= blurRegion.startMs && timeMs <= blurRegion.endMs;
});
const sorted = [
...filteredAnnotations.map((annotation) => ({
kind: "annotation" as const,
region: annotation,
})),
...filteredBlurRegions.map((blurRegion) => ({
kind: "blur" as const,
region: blurRegion,
})),
].sort((a, b) => a.region.zIndex - b.region.zIndex);
// Handle click-through cycling: when clicking same annotation, cycle to next
const handleAnnotationClick = (clickedId: string) => {
if (!onSelectAnnotation) return;
// If clicking on already selected annotation and there are multiple overlapping
if (clickedId === selectedAnnotationId && sorted.length > 1) {
if (clickedId === selectedAnnotationId && filteredAnnotations.length > 1) {
// Find current index and cycle to next
const currentIndex = sorted.findIndex((a) => a.id === clickedId);
const nextIndex = (currentIndex + 1) % sorted.length;
onSelectAnnotation(sorted[nextIndex].id);
const currentIndex = filteredAnnotations.findIndex((a) => a.id === clickedId);
const nextIndex = (currentIndex + 1) % filteredAnnotations.length;
onSelectAnnotation(filteredAnnotations[nextIndex].id);
} else {
// First click or clicking different annotation
onSelectAnnotation(clickedId);
}
};
return sorted.map((annotation) => (
const handleBlurClick = (clickedId: string) => {
if (!onSelectBlur) return;
if (clickedId === selectedBlurId && filteredBlurRegions.length > 1) {
const currentIndex = filteredBlurRegions.findIndex((a) => a.id === clickedId);
const nextIndex = (currentIndex + 1) % filteredBlurRegions.length;
onSelectBlur(filteredBlurRegions[nextIndex].id);
} else {
onSelectBlur(clickedId);
}
};
return sorted.map((item) => (
<AnnotationOverlay
key={annotation.id}
annotation={annotation}
isSelected={annotation.id === selectedAnnotationId}
containerWidth={overlayRef.current?.clientWidth || 800}
containerHeight={overlayRef.current?.clientHeight || 600}
onPositionChange={(id, position) => onAnnotationPositionChange?.(id, position)}
onSizeChange={(id, size) => onAnnotationSizeChange?.(id, size)}
onClick={handleAnnotationClick}
zIndex={annotation.zIndex}
isSelectedBoost={annotation.id === selectedAnnotationId}
key={
item.kind === "blur"
? `${item.region.id}-${overlaySize.width}-${overlaySize.height}-${item.region.blurData?.shape ?? "rectangle"}-${(item.region.blurData?.freehandPoints ?? []).map((p) => `${Math.round(p.x)}_${Math.round(p.y)}`).join("-")}`
: `${item.region.id}-${overlaySize.width}-${overlaySize.height}`
}
annotation={item.region}
isSelected={
item.kind === "blur"
? item.region.id === selectedBlurId
: item.region.id === selectedAnnotationId
}
containerWidth={overlaySize.width}
containerHeight={overlaySize.height}
onPositionChange={(id, position) =>
item.kind === "blur"
? onBlurPositionChange?.(id, position)
: onAnnotationPositionChange?.(id, position)
}
onSizeChange={(id, size) =>
item.kind === "blur"
? onBlurSizeChange?.(id, size)
: onAnnotationSizeChange?.(id, size)
}
onBlurDataChange={
item.kind === "blur"
? (id, blurData) => onBlurDataChange?.(id, blurData)
: undefined
}
onBlurDataCommit={item.kind === "blur" ? onBlurDataCommit : undefined}
onClick={item.kind === "blur" ? handleBlurClick : handleAnnotationClick}
zIndex={item.region.zIndex}
isSelectedBoost={
item.kind === "blur"
? item.region.id === selectedBlurId
: item.region.id === selectedAnnotationId
}
/>
));
})()}
@@ -5,25 +5,36 @@ import { ASPECT_RATIOS, type AspectRatio } from "@/utils/aspectRatioUtils";
import {
type AnnotationRegion,
type CropRegion,
clampPlaybackSpeed,
DEFAULT_ANNOTATION_POSITION,
DEFAULT_ANNOTATION_SIZE,
DEFAULT_ANNOTATION_STYLE,
DEFAULT_BLUR_DATA,
DEFAULT_BLUR_FREEHAND_POINTS,
DEFAULT_BLUR_INTENSITY,
DEFAULT_CROP_REGION,
DEFAULT_FIGURE_DATA,
DEFAULT_PLAYBACK_SPEED,
DEFAULT_WEBCAM_LAYOUT_PRESET,
DEFAULT_WEBCAM_MASK_SHAPE,
DEFAULT_WEBCAM_POSITION,
DEFAULT_WEBCAM_SIZE_PRESET,
DEFAULT_ZOOM_DEPTH,
MAX_BLUR_INTENSITY,
MAX_PLAYBACK_SPEED,
MIN_BLUR_INTENSITY,
MIN_PLAYBACK_SPEED,
type SpeedRegion,
type TrimRegion,
type WebcamLayoutPreset,
type WebcamMaskShape,
type WebcamPosition,
type WebcamSizePreset,
type ZoomRegion,
} from "./types";
const WALLPAPER_COUNT = 18;
const VALID_BLUR_SHAPES = new Set(["rectangle", "oval", "freehand"] as const);
export const WALLPAPER_PATHS = Array.from(
{ length: WALLPAPER_COUNT },
@@ -47,6 +58,7 @@ export interface ProjectEditorState {
aspectRatio: AspectRatio;
webcamLayoutPreset: WebcamLayoutPreset;
webcamMaskShape: WebcamMaskShape;
webcamSizePreset: WebcamSizePreset;
webcamPosition: WebcamPosition | null;
exportQuality: ExportQuality;
exportFormat: ExportFormat;
@@ -223,14 +235,10 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
const endMs = Math.max(startMs + 1, rawEnd);
const speed =
region.speed === 0.25 ||
region.speed === 0.5 ||
region.speed === 0.75 ||
region.speed === 1.25 ||
region.speed === 1.5 ||
region.speed === 1.75 ||
region.speed === 2
? region.speed
isFiniteNumber(region.speed) &&
region.speed >= MIN_PLAYBACK_SPEED &&
region.speed <= MAX_PLAYBACK_SPEED
? clampPlaybackSpeed(region.speed)
: DEFAULT_PLAYBACK_SPEED;
return {
@@ -252,12 +260,20 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
const rawEnd = isFiniteNumber(region.endMs) ? Math.round(region.endMs) : rawStart + 1000;
const startMs = Math.max(0, Math.min(rawStart, rawEnd));
const endMs = Math.max(startMs + 1, rawEnd);
const blurShape =
typeof region.blurData?.shape === "string" &&
VALID_BLUR_SHAPES.has(region.blurData.shape)
? region.blurData.shape
: DEFAULT_BLUR_DATA.shape;
return {
id: region.id,
startMs,
endMs,
type: region.type === "image" || region.type === "figure" ? region.type : "text",
type:
region.type === "image" || region.type === "figure" || region.type === "blur"
? region.type
: "text",
content: typeof region.content === "string" ? region.content : "",
textContent: typeof region.textContent === "string" ? region.textContent : undefined,
imageContent: typeof region.imageContent === "string" ? region.imageContent : undefined,
@@ -304,6 +320,37 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
...region.figureData,
}
: undefined,
blurData:
region.blurData && typeof region.blurData === "object"
? {
...DEFAULT_BLUR_DATA,
...region.blurData,
shape: blurShape,
intensity: isFiniteNumber(region.blurData.intensity)
? clamp(region.blurData.intensity, MIN_BLUR_INTENSITY, MAX_BLUR_INTENSITY)
: DEFAULT_BLUR_INTENSITY,
freehandPoints: Array.isArray(region.blurData.freehandPoints)
? region.blurData.freehandPoints
.filter(
(
point,
): point is {
x: number;
y: number;
} =>
Boolean(
point &&
isFiniteNumber((point as { x?: unknown }).x) &&
isFiniteNumber((point as { y?: unknown }).y),
),
)
.map((point) => ({
x: clamp(point.x, 0, 100),
y: clamp(point.y, 0, 100),
}))
: DEFAULT_BLUR_FREEHAND_POINTS,
}
: undefined,
};
})
: [];
@@ -363,6 +410,10 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
editor.webcamMaskShape === "rounded"
? editor.webcamMaskShape
: DEFAULT_WEBCAM_MASK_SHAPE,
webcamSizePreset:
typeof editor.webcamSizePreset === "number" && isFiniteNumber(editor.webcamSizePreset)
? Math.max(10, Math.min(50, editor.webcamSizePreset))
: DEFAULT_WEBCAM_SIZE_PRESET,
webcamPosition:
editor.webcamPosition &&
typeof editor.webcamPosition === "object" &&
@@ -14,7 +14,7 @@ interface ItemProps {
onSelect?: () => void;
zoomDepth?: number;
speedValue?: number;
variant?: "zoom" | "trim" | "annotation" | "speed";
variant?: "zoom" | "trim" | "annotation" | "speed" | "blur";
}
// Map zoom depth to multiplier labels
@@ -44,6 +44,7 @@ import { detectZoomDwellCandidates, normalizeCursorTelemetry } from "./zoomSugge
const ZOOM_ROW_ID = "row-zoom";
const TRIM_ROW_ID = "row-trim";
const ANNOTATION_ROW_ID = "row-annotation";
const BLUR_ROW_ID = "row-blur";
const SPEED_ROW_ID = "row-speed";
const FALLBACK_RANGE_MS = 1000;
const TARGET_MARKER_COUNT = 12;
@@ -73,6 +74,12 @@ interface TimelineEditorProps {
onAnnotationDelete?: (id: string) => void;
selectedAnnotationId?: string | null;
onSelectAnnotation?: (id: string | null) => void;
blurRegions?: AnnotationRegion[];
onBlurAdded?: (span: Span) => void;
onBlurSpanChange?: (id: string, span: Span) => void;
onBlurDelete?: (id: string) => void;
selectedBlurId?: string | null;
onSelectBlur?: (id: string | null) => void;
speedRegions?: SpeedRegion[];
onSpeedAdded?: (span: Span) => void;
onSpeedSpanChange?: (id: string, span: Span) => void;
@@ -96,7 +103,7 @@ interface TimelineRenderItem {
label: string;
zoomDepth?: number;
speedValue?: number;
variant: "zoom" | "trim" | "annotation" | "speed";
variant: "zoom" | "trim" | "annotation" | "speed" | "blur";
}
const SCALE_CANDIDATES = [
@@ -525,10 +532,12 @@ function Timeline({
onSelectZoom,
onSelectTrim,
onSelectAnnotation,
onSelectBlur,
onSelectSpeed,
selectedZoomId,
selectedTrimId,
selectedAnnotationId,
selectedBlurId,
selectedSpeedId,
keyframes = [],
}: {
@@ -540,10 +549,12 @@ function Timeline({
onSelectZoom?: (id: string | null) => void;
onSelectTrim?: (id: string | null) => void;
onSelectAnnotation?: (id: string | null) => void;
onSelectBlur?: (id: string | null) => void;
onSelectSpeed?: (id: string | null) => void;
selectedZoomId: string | null;
selectedTrimId?: string | null;
selectedAnnotationId?: string | null;
selectedBlurId?: string | null;
selectedSpeedId?: string | null;
keyframes?: { id: string; time: number }[];
}) {
@@ -568,6 +579,7 @@ function Timeline({
onSelectZoom?.(null);
onSelectTrim?.(null);
onSelectAnnotation?.(null);
onSelectBlur?.(null);
onSelectSpeed?.(null);
const rect = e.currentTarget.getBoundingClientRect();
@@ -586,6 +598,7 @@ function Timeline({
onSelectZoom,
onSelectTrim,
onSelectAnnotation,
onSelectBlur,
onSelectSpeed,
videoDurationMs,
sidebarWidth,
@@ -637,6 +650,7 @@ function Timeline({
const zoomItems = items.filter((item) => item.rowId === ZOOM_ROW_ID);
const trimItems = items.filter((item) => item.rowId === TRIM_ROW_ID);
const annotationItems = items.filter((item) => item.rowId === ANNOTATION_ROW_ID);
const blurItems = items.filter((item) => item.rowId === BLUR_ROW_ID);
const speedItems = items.filter((item) => item.rowId === SPEED_ROW_ID);
return (
@@ -711,6 +725,22 @@ function Timeline({
))}
</Row>
<Row id={BLUR_ROW_ID} isEmpty={blurItems.length === 0} hint={t("hints.pressBlur")}>
{blurItems.map((item) => (
<Item
id={item.id}
key={item.id}
rowId={item.rowId}
span={item.span}
isSelected={item.id === selectedBlurId}
onSelect={() => onSelectBlur?.(item.id)}
variant={item.variant}
>
{item.label}
</Item>
))}
</Row>
<Row id={SPEED_ROW_ID} isEmpty={speedItems.length === 0} hint={t("hints.pressSpeed")}>
{speedItems.map((item) => (
<Item
@@ -755,6 +785,12 @@ export default function TimelineEditor({
onAnnotationDelete,
selectedAnnotationId,
onSelectAnnotation,
blurRegions = [],
onBlurAdded,
onBlurSpanChange,
onBlurDelete,
selectedBlurId,
onSelectBlur,
speedRegions = [],
onSpeedAdded,
onSpeedSpanChange,
@@ -839,6 +875,12 @@ export default function TimelineEditor({
onSelectAnnotation(null);
}, [selectedAnnotationId, onAnnotationDelete, onSelectAnnotation]);
const deleteSelectedBlur = useCallback(() => {
if (!selectedBlurId || !onBlurDelete || !onSelectBlur) return;
onBlurDelete(selectedBlurId);
onSelectBlur(null);
}, [selectedBlurId, onBlurDelete, onSelectBlur]);
const deleteSelectedSpeed = useCallback(() => {
if (!selectedSpeedId || !onSpeedDelete || !onSelectSpeed) return;
onSpeedDelete(selectedSpeedId);
@@ -908,9 +950,10 @@ export default function TimelineEditor({
const isZoomItem = zoomRegions.some((r) => r.id === excludeId);
const isTrimItem = trimRegions.some((r) => r.id === excludeId);
const isAnnotationItem = annotationRegions.some((r) => r.id === excludeId);
const isBlurItem = blurRegions.some((r) => r.id === excludeId);
const isSpeedItem = speedRegions.some((r) => r.id === excludeId);
if (isAnnotationItem) {
if (isAnnotationItem || isBlurItem) {
return false;
}
@@ -937,7 +980,7 @@ export default function TimelineEditor({
return false;
},
[zoomRegions, trimRegions, annotationRegions, speedRegions],
[zoomRegions, trimRegions, annotationRegions, blurRegions, speedRegions],
);
// At least 5% of the timeline or 1000ms, whichever is larger, so the region
@@ -1165,6 +1208,21 @@ export default function TimelineEditor({
onAnnotationAdded({ start: startPos, end: endPos });
}, [videoDuration, totalMs, currentTimeMs, onAnnotationAdded, defaultRegionDurationMs]);
const handleAddBlur = useCallback(() => {
if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onBlurAdded) {
return;
}
const defaultDuration = Math.min(defaultRegionDurationMs, totalMs);
if (defaultDuration <= 0) {
return;
}
const startPos = Math.max(0, Math.min(currentTimeMs, totalMs));
const endPos = Math.min(startPos + defaultDuration, totalMs);
onBlurAdded({ start: startPos, end: endPos });
}, [videoDuration, totalMs, currentTimeMs, onBlurAdded, defaultRegionDurationMs]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
@@ -1183,6 +1241,9 @@ export default function TimelineEditor({
if (matchesShortcut(e, keyShortcuts.addAnnotation, isMac)) {
handleAddAnnotation();
}
if (matchesShortcut(e, keyShortcuts.addBlur, isMac)) {
handleAddBlur();
}
if (matchesShortcut(e, keyShortcuts.addSpeed, isMac)) {
handleAddSpeed();
}
@@ -1223,6 +1284,8 @@ export default function TimelineEditor({
deleteSelectedTrim();
} else if (selectedAnnotationId) {
deleteSelectedAnnotation();
} else if (selectedBlurId) {
deleteSelectedBlur();
} else if (selectedSpeedId) {
deleteSelectedSpeed();
}
@@ -1235,18 +1298,22 @@ export default function TimelineEditor({
handleAddZoom,
handleAddTrim,
handleAddAnnotation,
handleAddBlur,
handleAddSpeed,
deleteSelectedKeyframe,
deleteSelectedZoom,
deleteSelectedTrim,
deleteSelectedAnnotation,
deleteSelectedBlur,
deleteSelectedSpeed,
selectedKeyframeId,
selectedZoomId,
selectedTrimId,
selectedAnnotationId,
selectedBlurId,
selectedSpeedId,
annotationRegions,
blurRegions,
currentTime,
onSelectAnnotation,
keyShortcuts,
@@ -1304,6 +1371,14 @@ export default function TimelineEditor({
};
});
const blurs: TimelineRenderItem[] = blurRegions.map((region, index) => ({
id: region.id,
rowId: BLUR_ROW_ID,
span: { start: region.startMs, end: region.endMs },
label: t("labels.blurItem", { index: String(index + 1) }),
variant: "blur",
}));
const speeds: TimelineRenderItem[] = speedRegions.map((region, index) => ({
id: region.id,
rowId: SPEED_ROW_ID,
@@ -1313,8 +1388,8 @@ export default function TimelineEditor({
variant: "speed",
}));
return [...zooms, ...trims, ...annotations, ...speeds];
}, [zoomRegions, trimRegions, annotationRegions, speedRegions, t]);
return [...zooms, ...trims, ...annotations, ...blurs, ...speeds];
}, [zoomRegions, trimRegions, annotationRegions, blurRegions, speedRegions, t]);
// Flat list of all non-annotation region spans for neighbour-clamping during drag/resize
const allRegionSpans = useMemo(() => {
@@ -1335,6 +1410,8 @@ export default function TimelineEditor({
onSpeedSpanChange?.(id, span);
} else if (annotationRegions.some((r) => r.id === id)) {
onAnnotationSpanChange?.(id, span);
} else if (blurRegions.some((r) => r.id === id)) {
onBlurSpanChange?.(id, span);
}
},
[
@@ -1342,10 +1419,12 @@ export default function TimelineEditor({
trimRegions,
speedRegions,
annotationRegions,
blurRegions,
onZoomSpanChange,
onTrimSpanChange,
onSpeedSpanChange,
onAnnotationSpanChange,
onBlurSpanChange,
],
);
@@ -1403,6 +1482,25 @@ export default function TimelineEditor({
>
<MessageSquare className="w-4 h-4" />
</Button>
<Button
onClick={handleAddBlur}
variant="ghost"
size="icon"
className="h-7 w-7 text-slate-400 hover:text-[#7dd3fc] hover:bg-[#7dd3fc]/10 transition-all"
title={t("buttons.addBlur")}
>
<svg
className="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<circle cx="8" cy="12" r="3" />
<circle cx="16" cy="12" r="3" />
<path d="M6 6h12M6 18h12" />
</svg>
</Button>
<Button
onClick={handleAddSpeed}
variant="ghost"
@@ -1489,10 +1587,12 @@ export default function TimelineEditor({
onSelectZoom={onSelectZoom}
onSelectTrim={onSelectTrim}
onSelectAnnotation={onSelectAnnotation}
onSelectBlur={onSelectBlur}
onSelectSpeed={onSelectSpeed}
selectedZoomId={selectedZoomId}
selectedTrimId={selectedTrimId}
selectedAnnotationId={selectedAnnotationId}
selectedBlurId={selectedBlurId}
selectedSpeedId={selectedSpeedId}
keyframes={keyframes}
/>
+50 -2
View File
@@ -3,6 +3,10 @@ import type { WebcamLayoutPreset } from "@/lib/compositeLayout";
export type ZoomDepth = 1 | 2 | 3 | 4 | 5 | 6;
export type ZoomFocusMode = "manual" | "auto";
export type { WebcamLayoutPreset };
/** Webcam size as a percentage of the canvas reference dimension (10–50). */
export type WebcamSizePreset = number;
export const DEFAULT_WEBCAM_SIZE_PRESET: WebcamSizePreset = 25;
export const DEFAULT_WEBCAM_LAYOUT_PRESET: WebcamLayoutPreset = "picture-in-picture";
@@ -43,7 +47,7 @@ export interface TrimRegion {
endMs: number;
}
export type AnnotationType = "text" | "image" | "figure";
export type AnnotationType = "text" | "image" | "figure" | "blur";
export type ArrowDirection =
| "up"
@@ -61,6 +65,19 @@ export interface FigureData {
strokeWidth: number;
}
export type BlurShape = "rectangle" | "oval" | "freehand";
export const MIN_BLUR_INTENSITY = 2;
export const MAX_BLUR_INTENSITY = 40;
export const DEFAULT_BLUR_INTENSITY = 12;
export interface BlurData {
shape: BlurShape;
intensity: number;
// Points are normalized (0-100) within the annotation bounds.
freehandPoints?: Array<{ x: number; y: number }>;
}
export interface AnnotationPosition {
x: number;
y: number;
@@ -95,6 +112,7 @@ export interface AnnotationRegion {
style: AnnotationTextStyle;
zIndex: number;
figureData?: FigureData;
blurData?: BlurData;
}
export const DEFAULT_ANNOTATION_POSITION: AnnotationPosition = {
@@ -124,6 +142,24 @@ export const DEFAULT_FIGURE_DATA: FigureData = {
strokeWidth: 4,
};
export const DEFAULT_BLUR_FREEHAND_POINTS: Array<{ x: number; y: number }> = [
{ x: 10, y: 30 },
{ x: 25, y: 10 },
{ x: 55, y: 8 },
{ x: 82, y: 20 },
{ x: 90, y: 45 },
{ x: 78, y: 72 },
{ x: 52, y: 90 },
{ x: 22, y: 84 },
{ x: 8, y: 58 },
];
export const DEFAULT_BLUR_DATA: BlurData = {
shape: "rectangle",
intensity: DEFAULT_BLUR_INTENSITY,
freehandPoints: DEFAULT_BLUR_FREEHAND_POINTS,
};
export interface CropRegion {
x: number;
y: number;
@@ -138,7 +174,16 @@ export const DEFAULT_CROP_REGION: CropRegion = {
height: 1,
};
export type PlaybackSpeed = 0.25 | 0.5 | 0.75 | 1.25 | 1.5 | 1.75 | 2;
export type PlaybackSpeed = number;
export const MIN_PLAYBACK_SPEED = 0.1;
// Anything above 16x causes the playhead to stall during preview
// due to the video decoder not being able to keep up.
export const MAX_PLAYBACK_SPEED = 16;
export function clampPlaybackSpeed(speed: number): PlaybackSpeed {
return Math.round(Math.min(MAX_PLAYBACK_SPEED, Math.max(MIN_PLAYBACK_SPEED, speed)) * 100) / 100;
}
export interface SpeedRegion {
id: string;
@@ -155,6 +200,9 @@ export const SPEED_OPTIONS: Array<{ speed: PlaybackSpeed; label: string }> = [
{ speed: 1.5, label: "1.5×" },
{ speed: 1.75, label: "1.75×" },
{ speed: 2, label: "2×" },
{ speed: 3, label: "3×" },
{ speed: 4, label: "4×" },
{ speed: 5, label: "5×" },
];
export const DEFAULT_PLAYBACK_SPEED: PlaybackSpeed = 1.5;
@@ -5,6 +5,7 @@ import {
type Size,
type StyledRenderRect,
type WebcamLayoutPreset,
type WebcamSizePreset,
} from "@/lib/compositeLayout";
import type { CropRegion, WebcamMaskShape } from "../types";
@@ -20,6 +21,7 @@ interface LayoutParams {
padding?: number;
webcamDimensions?: Size | null;
webcamLayoutPreset?: WebcamLayoutPreset;
webcamSizePreset?: WebcamSizePreset;
webcamPosition?: { cx: number; cy: number } | null;
webcamMaskShape?: WebcamMaskShape;
}
@@ -47,6 +49,7 @@ export function layoutVideoContent(params: LayoutParams): LayoutResult | null {
padding = 0,
webcamDimensions,
webcamLayoutPreset,
webcamSizePreset,
webcamPosition,
webcamMaskShape,
} = params;
@@ -95,6 +98,7 @@ export function layoutVideoContent(params: LayoutParams): LayoutResult | null {
screenSize: { width: croppedVideoWidth, height: croppedVideoHeight },
webcamSize: webcamDimensions,
layoutPreset: webcamLayoutPreset,
webcamSizePreset,
webcamPosition,
webcamMaskShape,
});
+4
View File
@@ -7,6 +7,7 @@ import type {
WebcamLayoutPreset,
WebcamMaskShape,
WebcamPosition,
WebcamSizePreset,
ZoomRegion,
} from "@/components/video-editor/types";
import {
@@ -14,6 +15,7 @@ import {
DEFAULT_WEBCAM_LAYOUT_PRESET,
DEFAULT_WEBCAM_MASK_SHAPE,
DEFAULT_WEBCAM_POSITION,
DEFAULT_WEBCAM_SIZE_PRESET,
} from "@/components/video-editor/types";
import type { AspectRatio } from "@/utils/aspectRatioUtils";
@@ -34,6 +36,7 @@ export interface EditorState {
aspectRatio: AspectRatio;
webcamLayoutPreset: WebcamLayoutPreset;
webcamMaskShape: WebcamMaskShape;
webcamSizePreset: WebcamSizePreset;
webcamPosition: WebcamPosition | null;
}
@@ -52,6 +55,7 @@ export const INITIAL_EDITOR_STATE: EditorState = {
aspectRatio: "16:9",
webcamLayoutPreset: DEFAULT_WEBCAM_LAYOUT_PRESET,
webcamMaskShape: DEFAULT_WEBCAM_MASK_SHAPE,
webcamSizePreset: DEFAULT_WEBCAM_SIZE_PRESET,
webcamPosition: DEFAULT_WEBCAM_POSITION,
};
+1 -1
View File
@@ -1,5 +1,5 @@
export const DEFAULT_LOCALE = "en" as const;
export const SUPPORTED_LOCALES = ["en", "zh-CN", "es"] as const;
export const SUPPORTED_LOCALES = ["en", "zh-CN", "es", "fr", "tr"] as const;
export const I18N_NAMESPACES = [
"common",
"dialogs",
+8 -5
View File
@@ -27,10 +27,11 @@
"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.",
"explanationBefore": "The Trim tool works by defining the segments you want to",
"remove": "remove",
"explanationMiddle": " — anything",
"covered": "covered",
"explanationAfter": "by a red trim segment will be cut out when you export.",
"visualExample": "Visual Example",
"removed": "REMOVED",
"kept": "Kept",
@@ -39,7 +40,9 @@
"part3": "Part 3",
"finalVideo": "Final Video",
"step1Title": "1. Add Trim",
"step1Description": "Press T or click the scissors icon to mark a section for removal.",
"step1DescriptionBefore": "Press ",
"step1DescriptionAfter": " 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."
},
+6
View File
@@ -1,4 +1,10 @@
{
"newRecording": {
"title": "Return to Recorder",
"description": "Your current session has been saved.",
"cancel": "Cancel",
"confirm": "Confirm"
},
"errors": {
"noVideoLoaded": "No video loaded",
"videoNotReady": "Video not ready",
+11 -2
View File
@@ -13,7 +13,9 @@
"speed": {
"playbackSpeed": "Playback Speed",
"selectRegion": "Select a speed region to adjust",
"deleteRegion": "Delete Speed Region"
"deleteRegion": "Delete Speed Region",
"customPlaybackSpeed": "Custom Playback Speed",
"maxSpeedError": "Speed can't go higher than 16×"
},
"trim": {
"deleteRegion": "Delete Trim Region"
@@ -24,7 +26,8 @@
"selectPreset": "Select preset",
"pictureInPicture": "Picture in Picture",
"verticalStack": "Vertical Stack",
"webcamShape": "Camera Shape"
"webcamShape": "Camera Shape",
"webcamSize": "Webcam Size"
},
"effects": {
"title": "Video Effects",
@@ -98,6 +101,7 @@
"typeText": "Text",
"typeImage": "Image",
"typeArrow": "Arrow",
"typeBlur": "Blur",
"textContent": "Text Content",
"textPlaceholder": "Enter your text...",
"fontStyle": "Font Style",
@@ -114,6 +118,11 @@
"arrowDirection": "Arrow Direction",
"strokeWidth": "Stroke Width: {{width}}px",
"arrowColor": "Arrow Color",
"blurShape": "Blur Shape",
"blurIntensity": "Blur Intensity",
"blurShapeRectangle": "Rectangle",
"blurShapeOval": "Oval",
"blurShapeFreehand": "Freehand",
"deleteAnnotation": "Delete Annotation",
"shortcutsAndTips": "Shortcuts & Tips",
"tipMovePlayhead": "Move playhead to overlapping annotation section and select an item.",
+1
View File
@@ -18,6 +18,7 @@
"addTrim": "Add Trim",
"addSpeed": "Add Speed",
"addAnnotation": "Add Annotation",
"addBlur": "Add Blur",
"addKeyframe": "Add Keyframe",
"deleteSelected": "Delete Selected",
"playPause": "Play / Pause"
+3
View File
@@ -4,12 +4,14 @@
"suggestZooms": "Suggest Zooms from Cursor",
"addTrim": "Add Trim (T)",
"addAnnotation": "Add Annotation (A)",
"addBlur": "Add Blur (B)",
"addSpeed": "Add Speed (S)"
},
"hints": {
"pressZoom": "Press Z to add zoom",
"pressTrim": "Press T to add trim",
"pressAnnotation": "Press A to add annotation",
"pressBlur": "Press B to add blur region",
"pressSpeed": "Press S to add speed"
},
"labels": {
@@ -19,6 +21,7 @@
"trimItem": "Trim {{index}}",
"speedItem": "Speed {{index}}",
"annotationItem": "Annotation",
"blurItem": "Blur {{index}}",
"imageItem": "Image",
"emptyText": "Empty text"
},
+7 -5
View File
@@ -27,10 +27,11 @@
"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.",
"explanationBefore": "La herramienta de recorte funciona definiendo los segmentos que deseas",
"remove": "eliminar",
"explanationMiddle": " — cualquier parte",
"covered": "cubierta",
"explanationAfter": "por un segmento rojo será eliminada al exportar.",
"visualExample": "Ejemplo visual",
"removed": "ELIMINADO",
"kept": "Conservado",
@@ -39,7 +40,8 @@
"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.",
"step1DescriptionBefore": "Presiona ",
"step1DescriptionAfter": " 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."
},
+11 -2
View File
@@ -13,7 +13,9 @@
"speed": {
"playbackSpeed": "Velocidad de reproducción",
"selectRegion": "Selecciona una región de velocidad para ajustar",
"deleteRegion": "Eliminar región de velocidad"
"deleteRegion": "Eliminar región de velocidad",
"customPlaybackSpeed": "Velocidad personalizada",
"maxSpeedError": "La velocidad no puede superar 16×"
},
"trim": {
"deleteRegion": "Eliminar región de recorte"
@@ -24,7 +26,8 @@
"selectPreset": "Seleccionar predefinido",
"pictureInPicture": "Imagen en imagen",
"verticalStack": "Apilado vertical",
"webcamShape": "Forma de cámara"
"webcamShape": "Forma de cámara",
"webcamSize": "Tamaño de cámara"
},
"effects": {
"title": "Efectos de video",
@@ -98,6 +101,7 @@
"typeText": "Texto",
"typeImage": "Imagen",
"typeArrow": "Flecha",
"typeBlur": "Desenfoque",
"textContent": "Contenido de texto",
"textPlaceholder": "Escribe tu texto...",
"fontStyle": "Estilo de fuente",
@@ -114,6 +118,11 @@
"arrowDirection": "Dirección de la flecha",
"strokeWidth": "Grosor del trazo: {{width}}px",
"arrowColor": "Color de la flecha",
"blurShape": "Forma del desenfoque",
"blurIntensity": "Intensidad del desenfoque",
"blurShapeRectangle": "Rectángulo",
"blurShapeOval": "Óvalo",
"blurShapeFreehand": "Mano alzada",
"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.",
+1
View File
@@ -18,6 +18,7 @@
"addTrim": "Agregar recorte",
"addSpeed": "Agregar velocidad",
"addAnnotation": "Agregar anotación",
"addBlur": "Agregar desenfoque",
"addKeyframe": "Agregar fotograma clave",
"deleteSelected": "Eliminar seleccionado",
"playPause": "Reproducir / Pausar"
+6 -3
View File
@@ -4,13 +4,15 @@
"suggestZooms": "Sugerir zooms desde el cursor",
"addTrim": "Agregar recorte (T)",
"addAnnotation": "Agregar anotación (A)",
"addSpeed": "Agregar velocidad (S)"
"addSpeed": "Agregar velocidad (S)",
"addBlur": "Agregar desenfoque (B)"
},
"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"
"pressSpeed": "Presiona S para agregar velocidad",
"pressBlur": "Presiona B para agregar una región de desenfoque"
},
"labels": {
"pan": "Desplazar",
@@ -20,7 +22,8 @@
"speedItem": "Velocidad {{index}}",
"annotationItem": "Anotación",
"imageItem": "Imagen",
"emptyText": "Texto vacío"
"emptyText": "Texto vacío",
"blurItem": "Desenfoque {{index}}"
},
"emptyState": {
"noVideo": "No hay video cargado",
+29
View File
@@ -0,0 +1,29 @@
{
"actions": {
"cancel": "Annuler",
"save": "Enregistrer",
"delete": "Supprimer",
"close": "Fermer",
"share": "Partager",
"done": "Terminer",
"open": "Ouvrir",
"upload": "Téléverser",
"export": "Exporter",
"file": "Fichier",
"edit": "Éditer",
"view": "Affichage",
"window": "Fenêtre",
"quit": "Quitter",
"stopRecording": "Arrêter l'enregistrement"
},
"playback": {
"play": "Lecture",
"pause": "Pause",
"fullscreen": "Plein écran",
"exitFullscreen": "Quitter le plein écran"
},
"locale": {
"name": "Français",
"short": "FR"
}
}
+68
View File
@@ -0,0 +1,68 @@
{
"export": {
"complete": "Export terminé",
"yourFormatReady": "Votre {{format}} est prêt",
"showInFolder": "Afficher dans le dossier",
"finalizingVideo": "Finalisation de l'export vidéo...",
"compilingGifProgress": "Compilation du GIF... {{progress}}%",
"compilingGifWait": "Compilation du GIF... Cela peut prendre un moment",
"takeMoment": "Cela peut prendre un moment...",
"failed": "Export échoué",
"tryAgain": "Veuillez réessayer",
"finalizingVideoTitle": "Finalisation de la vidéo",
"compilingGif": "Compilation du GIF",
"exportingFormat": "Export de {{format}}",
"compiling": "Compilation en cours",
"renderingFrames": "Rendu des images",
"processing": "Traitement en cours...",
"finalizing": "Finalisation...",
"compilingStatus": "Compilation...",
"status": "Statut",
"format": "Format",
"frames": "Images",
"cancelExport": "Annuler l'export",
"savedSuccessfully": "{{format}} enregistré avec succès !"
},
"tutorial": {
"triggerLabel": "Comment fonctionne la coupe",
"title": "Comment fonctionne la coupe",
"description": "Comprendre comment supprimer les parties indésirables de votre vidéo.",
"explanation": "L'outil Coupe fonctionne en définissant les segments que vous souhaitez",
"explanationRemove": "supprimer",
"explanationCovered": "couvert",
"explanationEnd": "par un segment de coupe rouge sera coupé lors de l'export.",
"visualExample": "Exemple visuel",
"removed": "SUPPRIMÉ",
"kept": "Conservé",
"part1": "Partie 1",
"part2": "Partie 2",
"part3": "Partie 3",
"finalVideo": "Vidéo finale",
"step1Title": "1. Ajouter une coupe",
"step1Description": "Appuyez sur T ou cliquez sur l'icône ciseaux pour marquer une section à supprimer.",
"step2Title": "2. Ajuster",
"step2Description": "Faites glisser les bords de la région rouge pour couvrir exactement ce que vous souhaitez couper."
},
"unsavedChanges": {
"title": "Modifications non enregistrées",
"message": "Vous avez des modifications non enregistrées.",
"detail": "Voulez-vous enregistrer votre projet avant de fermer ?",
"saveAndClose": "Enregistrer et fermer",
"discardAndClose": "Ignorer et fermer",
"loadProject": "Charger un projet…",
"saveProject": "Enregistrer le projet…",
"saveProjectAs": "Enregistrer le projet sous…"
},
"fileDialogs": {
"saveGif": "Enregistrer le GIF exporté",
"saveVideo": "Enregistrer la vidéo exportée",
"selectVideo": "Sélectionner un fichier vidéo",
"saveProject": "Enregistrer le projet OpenScreen",
"openProject": "Ouvrir un projet OpenScreen",
"gifImage": "Image GIF",
"mp4Video": "Vidéo MP4",
"videoFiles": "Fichiers vidéo",
"openscreenProject": "Projet OpenScreen",
"allFiles": "Tous les fichiers"
}
}
+35
View File
@@ -0,0 +1,35 @@
{
"errors": {
"noVideoLoaded": "Aucune vidéo chargée",
"videoNotReady": "Vidéo non prête",
"unableToDetermineSourcePath": "Impossible de déterminer le chemin de la vidéo source",
"failedToSaveGif": "Échec de l'enregistrement du GIF",
"gifExportFailed": "L'export du GIF a échoué",
"failedToSaveVideo": "Échec de l'enregistrement de la vidéo",
"exportFailed": "L'export a échoué",
"exportFailedWithError": "L'export a échoué : {{error}}",
"failedToSaveExport": "Échec de l'enregistrement de l'export",
"failedToSaveExportedVideo": "Échec de l'enregistrement de la vidéo exportée",
"failedToRevealInFolder": "Erreur lors de l'affichage dans le dossier : {{error}}"
},
"export": {
"canceled": "Export annulé",
"exportedSuccessfully": "{{format}} exporté avec succès"
},
"project": {
"saveCanceled": "Enregistrement du projet annulé",
"failedToSave": "Échec de l'enregistrement du projet",
"savedTo": "Projet enregistré dans {{path}}",
"failedToLoad": "Échec du chargement du projet",
"invalidFormat": "Format de fichier projet invalide",
"loadedFrom": "Projet chargé depuis {{path}}"
},
"recording": {
"failedCameraAccess": "Échec de la demande d'accès à la caméra.",
"cameraBlocked": "L'accès à la caméra est bloqué. Activez-le dans les paramètres système pour utiliser la webcam.",
"systemAudioUnavailable": "Audio système non disponible. Enregistrement sans audio système.",
"microphoneDenied": "Accès au microphone refusé. L'enregistrement continuera sans audio.",
"cameraDenied": "Accès à la caméra refusé. L'enregistrement continuera sans webcam.",
"permissionDenied": "Permission d'enregistrement refusée. Veuillez autoriser l'enregistrement d'écran."
}
}
+37
View File
@@ -0,0 +1,37 @@
{
"tooltips": {
"hideHUD": "Masquer le HUD",
"closeApp": "Fermer l'application",
"restartRecording": "Redémarrer l'enregistrement",
"cancelRecording": "Annuler l'enregistrement",
"pauseRecording": "Mettre en pause l'enregistrement",
"resumeRecording": "Reprendre l'enregistrement",
"openVideoFile": "Ouvrir un fichier vidéo",
"openProject": "Ouvrir un projet"
},
"audio": {
"enableSystemAudio": "Activer l'audio système",
"disableSystemAudio": "Désactiver l'audio système",
"enableMicrophone": "Activer le microphone",
"disableMicrophone": "Désactiver le microphone",
"defaultMicrophone": "Microphone par défaut"
},
"webcam": {
"enableWebcam": "Activer la webcam",
"disableWebcam": "Désactiver la webcam",
"defaultCamera": "Caméra par défaut",
"searching": "Recherche en cours...",
"noneFound": "Aucune caméra trouvée",
"unavailable": "Caméra non disponible"
},
"sourceSelector": {
"loading": "Chargement des sources...",
"screens": "Écrans ({{count}})",
"windows": "Fenêtres ({{count}})",
"defaultSourceName": "Écran"
},
"recording": {
"selectSource": "Veuillez sélectionner une source à enregistrer"
},
"language": "Langue"
}
+165
View File
@@ -0,0 +1,165 @@
{
"zoom": {
"level": "Niveau de zoom",
"selectRegion": "Sélectionnez une région de zoom à ajuster",
"deleteZoom": "Supprimer le zoom",
"focusMode": {
"title": "Mode focus",
"manual": "Manuel",
"auto": "Auto",
"autoDescription": "La caméra suit la position du curseur enregistré"
}
},
"speed": {
"playbackSpeed": "Vitesse de lecture",
"selectRegion": "Sélectionnez une région de vitesse à ajuster",
"deleteRegion": "Supprimer la région de vitesse"
},
"trim": {
"deleteRegion": "Supprimer la région de coupe"
},
"layout": {
"title": "Mise en page",
"preset": "Préréglage",
"selectPreset": "Choisir un préréglage",
"pictureInPicture": "Incrustation d'image",
"verticalStack": "Empilement vertical",
"webcamShape": "Forme de la caméra"
},
"effects": {
"title": "Effets vidéo",
"blurBg": "Flou arrière-plan",
"motionBlur": "Flou de mouvement",
"off": "désactivé",
"shadow": "Ombre",
"roundness": "Arrondi",
"padding": "Marge"
},
"background": {
"title": "Arrière-plan",
"image": "Image",
"color": "Couleur",
"gradient": "Dégradé",
"uploadCustom": "Téléverser une image",
"gradientLabel": "Dégradé {{index}}"
},
"crop": {
"title": "Recadrage",
"cropVideo": "Recadrer la vidéo",
"dragInstruction": "Faites glisser chaque côté pour ajuster la zone de recadrage",
"ratio": "Ratio",
"free": "Libre",
"done": "Terminer",
"lockAspectRatio": "Verrouiller le ratio",
"unlockAspectRatio": "Déverrouiller le ratio"
},
"exportFormat": {
"mp4": "MP4",
"gif": "GIF",
"mp4Video": "Vidéo MP4",
"mp4Description": "Fichier vidéo haute qualité",
"gifAnimation": "Animation GIF",
"gifDescription": "Image animée pour le partage"
},
"exportQuality": {
"title": "Qualité d'export",
"low": "Faible",
"medium": "Moyenne",
"high": "Haute"
},
"gifSettings": {
"frameRate": "Fréquence d'images GIF",
"size": "Taille du GIF",
"loop": "GIF en boucle"
},
"project": {
"save": "Enregistrer le projet",
"load": "Charger un projet"
},
"export": {
"videoButton": "Exporter la vidéo",
"gifButton": "Exporter le GIF",
"chooseSaveLocation": "Choisir l'emplacement d'enregistrement"
},
"links": {
"reportBug": "Signaler un bug",
"starOnGithub": "Étoile sur GitHub"
},
"imageUpload": {
"invalidFileType": "Type de fichier invalide",
"jpgOnly": "Veuillez téléverser un fichier image JPG ou JPEG.",
"uploadSuccess": "Image personnalisée téléversée avec succès !",
"failedToUpload": "Échec du téléversement de l'image",
"errorReading": "Une erreur s'est produite lors de la lecture du fichier."
},
"annotation": {
"title": "Paramètres d'annotation",
"active": "Actif",
"typeText": "Texte",
"typeImage": "Image",
"typeArrow": "Flèche",
"typeBlur": "Flou",
"textContent": "Contenu du texte",
"textPlaceholder": "Saisissez votre texte...",
"fontStyle": "Style de police",
"selectStyle": "Choisir un style",
"size": "Taille",
"customFonts": "Polices personnalisées",
"textColor": "Couleur du texte",
"background": "Arrière-plan",
"none": "Aucun",
"color": "Couleur",
"clearBackground": "Supprimer l'arrière-plan",
"uploadImage": "Téléverser une image",
"supportedFormats": "Formats supportés : JPG, PNG, GIF, WebP",
"arrowDirection": "Direction de la flèche",
"strokeWidth": "Épaisseur du trait : {{width}}px",
"arrowColor": "Couleur de la flèche",
"blurShape": "Forme du flou",
"blurIntensity": "Intensité du flou",
"blurShapeRectangle": "Rectangle",
"blurShapeOval": "Ovale",
"blurShapeFreehand": "Main levée",
"deleteAnnotation": "Supprimer l'annotation",
"shortcutsAndTips": "Raccourcis & Astuces",
"tipMovePlayhead": "Déplacez la tête de lecture sur la section d'annotation et sélectionnez un élément.",
"tipTabCycle": "Utilisez Tab pour cycler entre les éléments superposés.",
"tipShiftTabCycle": "Utilisez Shift+Tab pour cycler en sens inverse.",
"invalidImageType": "Type de fichier invalide",
"imageFormatsOnly": "Veuillez téléverser un fichier image JPG, PNG, GIF ou WebP.",
"imageUploadSuccess": "Image téléversée avec succès !",
"failedImageUpload": "Échec du téléversement de l'image"
},
"fontStyles": {
"classic": "Classique",
"editor": "Éditeur",
"strong": "Gras",
"typewriter": "Machine à écrire",
"deco": "Déco",
"simple": "Simple",
"modern": "Moderne",
"clean": "Épuré"
},
"customFont": {
"dialogTitle": "Ajouter une police Google",
"urlLabel": "URL d'import Google Fonts",
"urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
"urlHelp": "Obtenez-la depuis Google Fonts : Sélectionnez une police → Cliquez sur « Obtenir la police » → Copiez l'URL @import",
"nameLabel": "Nom d'affichage",
"namePlaceholder": "Ma police personnalisée",
"nameHelp": "C'est ainsi que la police apparaîtra dans le sélecteur de polices",
"addButton": "Ajouter la police",
"addingButton": "Ajout en cours...",
"errorEmptyUrl": "Veuillez saisir une URL d'import Google Fonts",
"errorInvalidUrl": "Veuillez saisir une URL Google Fonts valide",
"errorEmptyName": "Veuillez saisir un nom de police",
"errorExtractFailed": "Impossible d'extraire la famille de polices depuis l'URL",
"successMessage": "Police « {{fontName}} » ajoutée avec succès",
"failedToAdd": "Échec de l'ajout de la police",
"errorTimeout": "La police a mis trop de temps à charger. Vérifiez l'URL et réessayez.",
"errorLoadFailed": "La police n'a pas pu être chargée. Vérifiez que l'URL Google Fonts est correcte."
},
"language": {
"title": "Langue"
}
}
+37
View File
@@ -0,0 +1,37 @@
{
"title": "Raccourcis clavier",
"customize": "Personnaliser",
"configurable": "Configurable",
"fixed": "Fixe",
"pressKey": "Appuyez sur une touche…",
"clickToChange": "Cliquez pour modifier",
"pressEscToCancel": "Appuyez sur Échap pour annuler",
"helpText": "Cliquez sur un raccourci puis appuyez sur la nouvelle combinaison de touches. Appuyez sur Échap pour annuler.",
"resetToDefaults": "Réinitialiser les valeurs par défaut",
"alreadyUsedBy": "Déjà utilisé par {{action}}",
"swap": "Échanger",
"reservedShortcut": "Ce raccourci est réservé pour « {{label}} » et ne peut pas être réassigné.",
"savedToast": "Raccourcis clavier enregistrés",
"resetToast": "Réinitialisé aux raccourcis par défaut — cliquez sur Enregistrer pour appliquer",
"actions": {
"addZoom": "Ajouter un zoom",
"addTrim": "Ajouter une coupe",
"addSpeed": "Ajouter une vitesse",
"addAnnotation": "Ajouter une annotation",
"addBlur": "Ajouter un flou",
"addKeyframe": "Ajouter une image-clé",
"deleteSelected": "Supprimer la sélection",
"playPause": "Lecture / Pause"
},
"fixedActions": {
"undo": "Annuler",
"redo": "Rétablir",
"cycleAnnotationsForward": "Parcourir les annotations en avant",
"cycleAnnotationsBackward": "Parcourir les annotations en arrière",
"deleteSelectedAlt": "Supprimer la sélection (alt)",
"panTimeline": "Panoramique de la timeline",
"zoomTimeline": "Zoom de la timeline",
"frameBack": "Image précédente",
"frameForward": "Image suivante"
}
}
+53
View File
@@ -0,0 +1,53 @@
{
"buttons": {
"addZoom": "Ajouter un zoom (Z)",
"suggestZooms": "Suggérer des zooms depuis le curseur",
"addTrim": "Ajouter une coupe (T)",
"addAnnotation": "Ajouter une annotation (A)",
"addSpeed": "Ajouter une vitesse (S)",
"addBlur": "Ajouter un flou (B)"
},
"hints": {
"pressZoom": "Appuyez sur Z pour ajouter un zoom",
"pressTrim": "Appuyez sur T pour ajouter une coupe",
"pressAnnotation": "Appuyez sur A pour ajouter une annotation",
"pressSpeed": "Appuyez sur S pour ajouter une vitesse",
"pressBlur": "Appuyez sur B pour ajouter une zone de flou"
},
"labels": {
"pan": "Panoramique",
"zoom": "Zoom",
"zoomItem": "Zoom {{index}}",
"trimItem": "Coupe {{index}}",
"speedItem": "Vitesse {{index}}",
"annotationItem": "Annotation",
"imageItem": "Image",
"emptyText": "Texte vide",
"blurItem": "Flou {{index}}"
},
"emptyState": {
"noVideo": "Aucune vidéo chargée",
"dragAndDrop": "Glissez-déposez une vidéo pour commencer à éditer"
},
"errors": {
"cannotPlaceZoom": "Impossible de placer le zoom ici",
"zoomExistsAtLocation": "Un zoom existe déjà à cet emplacement ou l\u0027espace disponible est insuffisant.",
"zoomSuggestionUnavailable": "Gestionnaire de suggestions de zoom non disponible",
"noCursorTelemetry": "Aucune télémétrie de curseur disponible",
"noCursorTelemetryDescription": "Enregistrez d\u0027abord un screencast pour générer des suggestions basées sur le curseur.",
"noUsableTelemetry": "Aucune télémétrie de curseur utilisable",
"noUsableTelemetryDescription": "L\u0027enregistrement ne contient pas suffisamment de données de mouvement du curseur.",
"noDwellMoments": "Aucun moment de pause du curseur trouvé",
"noDwellMomentsDescription": "Essayez un enregistrement avec des pauses plus lentes du curseur sur les actions importantes.",
"noAutoZoomSlots": "Aucun emplacement de zoom automatique disponible",
"noAutoZoomSlotsDescription": "Les points de pause détectés chevauchent des régions de zoom existantes.",
"cannotPlaceTrim": "Impossible de placer la coupe ici",
"trimExistsAtLocation": "Une coupe existe déjà à cet emplacement ou l\u0027espace disponible est insuffisant.",
"cannotPlaceSpeed": "Impossible de placer la vitesse ici",
"speedExistsAtLocation": "Une région de vitesse existe déjà à cet emplacement ou l\u0027espace disponible est insuffisant."
},
"success": {
"addedZoomSuggestions": "{{count}} suggestion de zoom basée sur le curseur ajoutée",
"addedZoomSuggestionsPlural": "{{count}} suggestions de zoom basées sur le curseur ajoutées"
}
}
+29
View File
@@ -0,0 +1,29 @@
{
"actions": {
"cancel": "İptal",
"save": "Kaydet",
"delete": "Sil",
"close": "Kapat",
"share": "Paylaş",
"done": "Tamam",
"open": "Aç",
"upload": "Yükle",
"export": "Dışa Aktar",
"file": "Dosya",
"edit": "Düzenle",
"view": "Görünüm",
"window": "Pencere",
"quit": "Çıkış",
"stopRecording": "Kaydı Durdur"
},
"playback": {
"play": "Oynat",
"pause": "Duraklat",
"fullscreen": "Tam Ekran",
"exitFullscreen": "Tam Ekrandan Çık"
},
"locale": {
"name": "Türkçe",
"short": "TR"
}
}
+68
View File
@@ -0,0 +1,68 @@
{
"export": {
"complete": "Dışa Aktarım Tamamlandı",
"yourFormatReady": "{{format}} dosyanız hazır",
"showInFolder": "Klasörde Göster",
"finalizingVideo": "Video dışa aktarımı sonlandırılıyor...",
"compilingGifProgress": "GIF derleniyor... %{{progress}}",
"compilingGifWait": "GIF derleniyor... Bu biraz zaman alabilir",
"takeMoment": "Bu biraz zaman alabilir...",
"failed": "Dışa Aktarım Başarısız",
"tryAgain": "Lütfen tekrar deneyin",
"finalizingVideoTitle": "Video Sonlandırılıyor",
"compilingGif": "GIF Derleniyor",
"exportingFormat": "{{format}} Dışa Aktarılıyor",
"compiling": "Derleniyor",
"renderingFrames": "Kareler İşleniyor",
"processing": "İşleniyor...",
"finalizing": "Sonlandırılıyor...",
"compilingStatus": "Derleniyor...",
"status": "Durum",
"format": "Biçim",
"frames": "Kareler",
"cancelExport": "Dışa Aktarımı İptal Et",
"savedSuccessfully": "{{format}} başarıyla kaydedildi!"
},
"tutorial": {
"triggerLabel": "Kırpma nasıl çalışır",
"title": "Kırpma Nasıl Çalışır",
"description": "Videonuzun istenmeyen bölümlerini nasıl keseceğinizi anlayın.",
"explanation": "Kırpma aracı, kaldırmak istediğiniz bölümleri tanımlayarak çalışır.",
"explanationRemove": "kaldırmak",
"explanationCovered": "kaplanan",
"explanationEnd": "kırmızı kırpma bölgesi ile işaretlenen kısımlar dışa aktarımda kesilecektir.",
"visualExample": "Görsel Örnek",
"removed": "KALDIRILDI",
"kept": "Korundu",
"part1": "Bölüm 1",
"part2": "Bölüm 2",
"part3": "Bölüm 3",
"finalVideo": "Son Video",
"step1Title": "1. Kırpma Ekle",
"step1Description": "Kaldırılacak bölümü işaretlemek için T tuşuna basın veya makas simgesine tıklayın.",
"step2Title": "2. Ayarla",
"step2Description": "Kesmek istediğiniz kısmı tam olarak kaplamak için kırmızı bölgenin kenarlarını sürükleyin."
},
"unsavedChanges": {
"title": "Kaydedilmemiş Değişiklikler",
"message": "Kaydedilmemiş değişiklikleriniz var.",
"detail": "Kapatmadan önce projenizi kaydetmek ister misiniz?",
"saveAndClose": "Kaydet ve Kapat",
"discardAndClose": "Kaydetmeden Kapat",
"loadProject": "Proje Yükle…",
"saveProject": "Proje Kaydet…",
"saveProjectAs": "Farklı Kaydet…"
},
"fileDialogs": {
"saveGif": "Dışa Aktarılan GIF'i Kaydet",
"saveVideo": "Dışa Aktarılan Videoyu Kaydet",
"selectVideo": "Video Dosyası Seç",
"saveProject": "OpenScreen Projesini Kaydet",
"openProject": "OpenScreen Projesini Aç",
"gifImage": "GIF Görüntüsü",
"mp4Video": "MP4 Video",
"videoFiles": "Video Dosyaları",
"openscreenProject": "OpenScreen Projesi",
"allFiles": "Tüm Dosyalar"
}
}
+35
View File
@@ -0,0 +1,35 @@
{
"errors": {
"noVideoLoaded": "Video yüklenmedi",
"videoNotReady": "Video hazır değil",
"unableToDetermineSourcePath": "Kaynak video yolu belirlenemiyor",
"failedToSaveGif": "GIF kaydedilemedi",
"gifExportFailed": "GIF dışa aktarımı başarısız oldu",
"failedToSaveVideo": "Video kaydedilemedi",
"exportFailed": "Dışa aktarım başarısız oldu",
"exportFailedWithError": "Dışa aktarım başarısız: {{error}}",
"failedToSaveExport": "Dışa aktarım kaydedilemedi",
"failedToSaveExportedVideo": "Dışa aktarılan video kaydedilemedi",
"failedToRevealInFolder": "Klasörde gösterme hatası: {{error}}"
},
"export": {
"canceled": "Dışa aktarım iptal edildi",
"exportedSuccessfully": "{{format}} başarıyla dışa aktarıldı"
},
"project": {
"saveCanceled": "Proje kaydetme iptal edildi",
"failedToSave": "Proje kaydedilemedi",
"savedTo": "Proje şuraya kaydedildi: {{path}}",
"failedToLoad": "Proje yüklenemedi",
"invalidFormat": "Geçersiz proje dosyası biçimi",
"loadedFrom": "Proje şuradan yüklendi: {{path}}"
},
"recording": {
"failedCameraAccess": "Kamera erişimi istenemedi.",
"cameraBlocked": "Kamera erişimi engellendi. Kamerayı kullanmak için sistem ayarlarından izin verin.",
"systemAudioUnavailable": "Sistem sesi kullanılamıyor. Sistem sesi olmadan kaydediliyor.",
"microphoneDenied": "Mikrofon erişimi reddedildi. Kayıt ses olmadan devam edecek.",
"cameraDenied": "Kamera erişimi reddedildi. Kayıt kamera olmadan devam edecek.",
"permissionDenied": "Kayıt izni reddedildi. Lütfen ekran kaydına izin verin."
}
}
+48
View File
@@ -0,0 +1,48 @@
{
"tooltips": {
"hideHUD": "Kontrol panelini gizle",
"closeApp": "Uygulamayı kapat",
"restartRecording": "Kaydı yeniden başlat",
"cancelRecording": "Kaydı iptal et",
"pauseRecording": "Kaydı duraklat",
"resumeRecording": "Kayda devam et",
"openVideoFile": "Video dosyası aç",
"openProject": "Proje aç"
},
"audio": {
"enableSystemAudio": "Sistem sesini etkinleştir",
"disableSystemAudio": "Sistem sesini devre dışı bırak",
"enableMicrophone": "Mikrofonu etkinleştir",
"disableMicrophone": "Mikrofonu devre dışı bırak",
"defaultMicrophone": "Varsayılan Mikrofon",
"enableNoiseReduction": "Gürültü azaltmayı etkinleştir (yapay zeka destekli)",
"disableNoiseReduction": "Gürültü azaltmayı devre dışı bırak",
"noiseReduction": "Gürültü azaltma",
"clickToCycle": "Seviye değiştirmek için tıklayın",
"nrLevel": {
"light": "Hafif",
"moderate": "Orta",
"aggressive": "Güçlü"
},
"noiseReductionPrompt": "Daha net ses için yapay zeka destekli gürültü azaltmayı etkinleştirmek ister misiniz?",
"enableNoiseReductionShort": "Etkinleştir"
},
"webcam": {
"enableWebcam": "Kamerayı etkinleştir",
"disableWebcam": "Kamerayı devre dışı bırak",
"defaultCamera": "Varsayılan Kamera",
"searching": "Aranıyor...",
"noneFound": "Kamera bulunamadı",
"unavailable": "Kamera kullanılamıyor"
},
"sourceSelector": {
"loading": "Kaynaklar yükleniyor...",
"screens": "Ekranlar ({{count}})",
"windows": "Pencereler ({{count}})",
"defaultSourceName": "Ekran"
},
"recording": {
"selectSource": "Lütfen kayıt için bir kaynak seçin"
},
"language": "Dil"
}
+176
View File
@@ -0,0 +1,176 @@
{
"zoom": {
"level": "Yakınlaştırma Seviyesi",
"selectRegion": "Ayarlamak için bir yakınlaştırma bölgesi seçin",
"deleteZoom": "Yakınlaştırmayı Sil",
"focusMode": {
"title": "Odak Modu",
"manual": "Manuel",
"auto": "Otomatik",
"autoDescription": "Kamera kaydedilen imleç konumunu takip eder"
}
},
"speed": {
"playbackSpeed": "Oynatma Hızı",
"selectRegion": "Ayarlamak için bir hız bölgesi seçin",
"deleteRegion": "Hız Bölgesini Sil"
},
"trim": {
"deleteRegion": "Kırpma Bölgesini Sil"
},
"layout": {
"title": "Düzen",
"preset": "Ön Ayar",
"selectPreset": "Ön ayar seçin",
"pictureInPicture": "Resim İçinde Resim",
"verticalStack": "Dikey Yığın",
"webcamShape": "Kamera Şekli"
},
"effects": {
"title": "Video Efektleri",
"blurBg": "Arka Planı Bulanıklaştır",
"motionBlur": "Hareket Bulanıklığı",
"off": "kapalı",
"shadow": "Gölge",
"roundness": "Yuvarlaklık",
"padding": "Dolgu"
},
"background": {
"title": "Arka Plan",
"image": "Görüntü",
"color": "Renk",
"gradient": "Gradyan",
"uploadCustom": "Özel Yükle",
"gradientLabel": "Gradyan {{index}}"
},
"crop": {
"title": "Kırpma",
"cropVideo": "Videoyu Kırp",
"dragInstruction": "Kırpma alanını ayarlamak için her kenarı sürükleyin",
"ratio": "Oran",
"free": "Serbest",
"done": "Tamam",
"lockAspectRatio": "En boy oranını kilitle",
"unlockAspectRatio": "En boy oranının kilidini aç"
},
"exportFormat": {
"mp4": "MP4",
"gif": "GIF",
"mp4Video": "MP4 Video",
"mp4Description": "Yüksek kaliteli video dosyası",
"gifAnimation": "GIF Animasyon",
"gifDescription": "Paylaşım için hareketli görüntü"
},
"exportQuality": {
"title": "Dışa Aktarım Kalitesi",
"low": "Düşük",
"medium": "Orta",
"high": "Yüksek"
},
"gifSettings": {
"frameRate": "GIF Kare Hızı",
"size": "GIF Boyutu",
"loop": "GIF Döngüsü"
},
"project": {
"save": "Projeyi Kaydet",
"load": "Proje Yükle"
},
"export": {
"videoButton": "Videoyu Dışa Aktar",
"gifButton": "GIF Olarak Dışa Aktar",
"chooseSaveLocation": "Kayıt Konumu Seç"
},
"links": {
"reportBug": "Hata Bildir",
"starOnGithub": "GitHub'da Yıldızla"
},
"imageUpload": {
"invalidFileType": "Geçersiz dosya türü",
"jpgOnly": "Lütfen bir JPG veya JPEG görüntü dosyası yükleyin.",
"uploadSuccess": "Özel görüntü başarıyla yüklendi!",
"failedToUpload": "Görüntü yüklenemedi",
"errorReading": "Dosya okunurken bir hata oluştu."
},
"annotation": {
"title": "Açıklama Ayarları",
"active": "Aktif",
"typeText": "Metin",
"typeImage": "Görüntü",
"typeArrow": "Ok",
"typeBlur": "Bulanık",
"textContent": "Metin İçeriği",
"textPlaceholder": "Metninizi girin...",
"fontStyle": "Yazı Tipi Stili",
"selectStyle": "Stil seçin",
"size": "Boyut",
"customFonts": "Özel Yazı Tipleri",
"textColor": "Metin Rengi",
"background": "Arka Plan",
"none": "Yok",
"color": "Renk",
"clearBackground": "Arka Planı Temizle",
"uploadImage": "Görüntü Yükle",
"supportedFormats": "Desteklenen biçimler: JPG, PNG, GIF, WebP",
"arrowDirection": "Ok Yönü",
"strokeWidth": "Çizgi Kalınlığı: {{width}}px",
"arrowColor": "Ok Rengi",
"blurShape": "Bulanık Şekli",
"blurIntensity": "Bulanıklık Yoğunluğu",
"blurShapeRectangle": "Dikdörtgen",
"blurShapeOval": "Oval",
"blurShapeFreehand": "Serbest",
"deleteAnnotation": "Açıklamayı Sil",
"shortcutsAndTips": "Kısayollar ve İpuçları",
"tipMovePlayhead": "Oynatma imlecini çakışan açıklama bölümüne taşıyın ve bir öğe seçin.",
"tipTabCycle": "Çakışan öğeler arasında geçiş yapmak için Tab tuşunu kullanın.",
"tipShiftTabCycle": "Geriye doğru geçiş yapmak için Shift+Tab kullanın.",
"invalidImageType": "Geçersiz dosya türü",
"imageFormatsOnly": "Lütfen bir JPG, PNG, GIF veya WebP görüntü dosyası yükleyin.",
"imageUploadSuccess": "Görüntü başarıyla yüklendi!",
"failedImageUpload": "Görüntü yüklenemedi"
},
"fontStyles": {
"classic": "Klasik",
"editor": "Editör",
"strong": "Kalın",
"typewriter": "Daktilo",
"deco": "Dekoratif",
"simple": "Sade",
"modern": "Modern",
"clean": "Temiz"
},
"customFont": {
"dialogTitle": "Google Yazı Tipi Ekle",
"urlLabel": "Google Fonts İçe Aktarım URL'si",
"urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
"urlHelp": "Google Fonts'tan alabilirsiniz: Bir yazı tipi seçin → \"Get font\"a tıklayın → @import URL'sini kopyalayın",
"nameLabel": "Görünen Ad",
"namePlaceholder": "Özel Yazı Tipim",
"nameHelp": "Yazı tipinin seçicide nasıl görüneceğini belirler",
"addButton": "Yazı Tipi Ekle",
"addingButton": "Ekleniyor...",
"errorEmptyUrl": "Lütfen bir Google Fonts içe aktarım URL'si girin",
"errorInvalidUrl": "Lütfen geçerli bir Google Fonts URL'si girin",
"errorEmptyName": "Lütfen bir yazı tipi adı girin",
"errorExtractFailed": "URL'den yazı tipi ailesi çıkarılamadı",
"successMessage": "\"{{fontName}}\" yazı tipi başarıyla eklendi",
"failedToAdd": "Yazı tipi eklenemedi",
"errorTimeout": "Yazı tipinin yüklenmesi çok uzun sürdü. Lütfen URL'yi kontrol edip tekrar deneyin.",
"errorLoadFailed": "Yazı tipi yüklenemedi. Lütfen Google Fonts URL'sinin doğruluğunu kontrol edin."
},
"language": {
"title": "Dil"
},
"audio": {
"title": "Ses",
"noiseReduction": "Gürültü Azaltma",
"level": "Seviye",
"nrLevel": {
"light": "Hafif",
"moderate": "Orta",
"aggressive": "Güçlü"
},
"nrDescription": "Yapay zeka destekli gürültü azaltma arka plan gürültüsünü temizler. Daha yüksek seviyeler daha agresiftir ancak ses kalitesini etkileyebilir."
}
}
+37
View File
@@ -0,0 +1,37 @@
{
"title": "Klavye Kısayolları",
"customize": "Özelleştir",
"configurable": "Yapılandırılabilir",
"fixed": "Sabit",
"pressKey": "Bir tuşa basın…",
"clickToChange": "Değiştirmek için tıklayın",
"pressEscToCancel": "İptal etmek için Esc tuşuna basın",
"helpText": "Bir kısayola tıklayın, ardından yeni tuş kombinasyonuna basın. İptal etmek için Esc tuşuna basın.",
"resetToDefaults": "Varsayılanlara sıfırla",
"alreadyUsedBy": "\"{{action}}\" tarafından zaten kullanılıyor",
"swap": "Değiştir",
"reservedShortcut": "Bu kısayol \"{{label}}\" için ayrılmıştır ve yeniden atanamaz.",
"savedToast": "Klavye kısayolları kaydedildi",
"resetToast": "Varsayılan kısayollara sıfırlandı — uygulamak için Kaydet'e tıklayın",
"actions": {
"addZoom": "Yakınlaştırma Ekle",
"addTrim": "Kırpma Ekle",
"addSpeed": "Hız Ekle",
"addAnnotation": "Açıklama Ekle",
"addBlur": "Bulanik Ekle",
"addKeyframe": "Anahtar Kare Ekle",
"deleteSelected": "Seçileni Sil",
"playPause": "Oynat / Duraklat"
},
"fixedActions": {
"undo": "Geri Al",
"redo": "Yinele",
"cycleAnnotationsForward": "Açıklamalar Arasında İleri Geç",
"cycleAnnotationsBackward": "Açıklamalar Arasında Geri Geç",
"deleteSelectedAlt": "Seçileni Sil (alternatif)",
"panTimeline": "Zaman Çizelgesini Kaydır",
"zoomTimeline": "Zaman Çizelgesini Yakınlaştır",
"frameBack": "Önceki Kare",
"frameForward": "Sonraki Kare"
}
}
+53
View File
@@ -0,0 +1,53 @@
{
"buttons": {
"addZoom": "Yakınlaştırma Ekle (Z)",
"suggestZooms": "İmleçten Yakınlaştırma Öner",
"addTrim": "Kırpma Ekle (T)",
"addAnnotation": "Açıklama Ekle (A)",
"addSpeed": "Hız Ekle (S)",
"addBlur": "Bulanık ekle (B)"
},
"hints": {
"pressZoom": "Yakınlaştırma eklemek için Z tuşuna basın",
"pressTrim": "Kırpma eklemek için T tuşuna basın",
"pressAnnotation": "Açıklama eklemek için A tuşuna basın",
"pressSpeed": "Hız eklemek için S tuşuna basın",
"pressBlur": "Bulanık bölge eklemek için B tuşuna basın"
},
"labels": {
"pan": "Kaydır",
"zoom": "Yakınlaştır",
"zoomItem": "Yakınlaştırma {{index}}",
"trimItem": "Kırpma {{index}}",
"speedItem": "Hız {{index}}",
"annotationItem": "Açıklama",
"imageItem": "Görüntü",
"emptyText": "Boş metin",
"blurItem": "Bulanık {{index}}"
},
"emptyState": {
"noVideo": "Video Yüklenmedi",
"dragAndDrop": "Düzenlemeye başlamak için bir video sürükleyip bırakın"
},
"errors": {
"cannotPlaceZoom": "Buraya yakınlaştırma yerleştirilemiyor",
"zoomExistsAtLocation": "Bu konumda zaten bir yakınlaştırma var veya yeterli alan yok.",
"zoomSuggestionUnavailable": "Yakınlaştırma öneri işleyicisi kullanılamıyor",
"noCursorTelemetry": "İmleç telemetrisi mevcut değil",
"noCursorTelemetryDescription": "İmleç tabanlı öneriler oluşturmak için önce bir ekran kaydı yapın.",
"noUsableTelemetry": "Kullanılabilir imleç telemetrisi yok",
"noUsableTelemetryDescription": "Kayıt yeterli imleç hareketi verisi içermiyor.",
"noDwellMoments": "Belirgin imleç bekleme anları bulunamadı",
"noDwellMomentsDescription": "Önemli işlemlerde daha yavaş imleç duraklamaları olan bir kayıt deneyin.",
"noAutoZoomSlots": "Otomatik yakınlaştırma alanı yok",
"noAutoZoomSlotsDescription": "Algılanan bekleme noktaları mevcut yakınlaştırma bölgeleriyle çakışıyor.",
"cannotPlaceTrim": "Buraya kırpma yerleştirilemiyor",
"trimExistsAtLocation": "Bu konumda zaten bir kırpma var veya yeterli alan yok.",
"cannotPlaceSpeed": "Buraya hız yerleştirilemiyor",
"speedExistsAtLocation": "Bu konumda zaten bir hız bölgesi var veya yeterli alan yok."
},
"success": {
"addedZoomSuggestions": "{{count}} imleç tabanlı yakınlaştırma önerisi eklendi",
"addedZoomSuggestionsPlural": "{{count}} imleç tabanlı yakınlaştırma önerisi eklendi"
}
}
+7 -5
View File
@@ -27,10 +27,11 @@
"triggerLabel": "剪辑功能说明",
"title": "剪辑功能说明",
"description": "了解如何剪掉视频中不需要的部分。",
"explanation": "剪辑工具通过定义您要",
"explanationRemove": "移除",
"explanationCovered": "覆盖",
"explanationEnd": "的片段来工作。被红色剪辑区域覆盖的部分将在导出时被剪掉。",
"explanationBefore": "剪辑工具通过定义您要",
"remove": "移除",
"explanationMiddle": "——任何被",
"covered": "覆盖",
"explanationAfter": "的红色剪辑区域部分将在导出时被剪掉。",
"visualExample": "示例演示",
"removed": "已移除",
"kept": "保留",
@@ -39,7 +40,8 @@
"part3": "第 3 部分",
"finalVideo": "最终视频",
"step1Title": "1. 添加剪辑",
"step1Description": "按 T 或点击剪刀图标来标记要移除的片段。",
"step1DescriptionBefore": "按",
"step1DescriptionAfter": "键或点击剪刀图标来标记要移除的片段。",
"step2Title": "2. 调整",
"step2Description": "拖动红色区域的边缘,精确覆盖您要剪掉的部分。"
},
+11 -2
View File
@@ -13,7 +13,9 @@
"speed": {
"playbackSpeed": "播放速度",
"selectRegion": "选择要调整的速度区域",
"deleteRegion": "删除速度区域"
"deleteRegion": "删除速度区域",
"customPlaybackSpeed": "自定义播放速度",
"maxSpeedError": "速度不能超过 16×"
},
"trim": {
"deleteRegion": "删除剪辑区域"
@@ -24,7 +26,8 @@
"selectPreset": "选择预设",
"pictureInPicture": "画中画",
"verticalStack": "垂直堆叠",
"webcamShape": "摄像头形状"
"webcamShape": "摄像头形状",
"webcamSize": "摄像头大小"
},
"effects": {
"title": "视频效果",
@@ -98,6 +101,7 @@
"typeText": "文本",
"typeImage": "图片",
"typeArrow": "箭头",
"typeBlur": "模糊",
"textContent": "文本内容",
"textPlaceholder": "输入您的文本...",
"fontStyle": "字体样式",
@@ -114,6 +118,11 @@
"arrowDirection": "箭头方向",
"strokeWidth": "描边宽度:{{width}}px",
"arrowColor": "箭头颜色",
"blurShape": "模糊形状",
"blurIntensity": "模糊强度",
"blurShapeRectangle": "矩形",
"blurShapeOval": "椭圆",
"blurShapeFreehand": "自由手绘",
"deleteAnnotation": "删除标注",
"shortcutsAndTips": "快捷键与提示",
"tipMovePlayhead": "将播放头移动到重叠的标注区域并选择一个项目。",
+1
View File
@@ -18,6 +18,7 @@
"addTrim": "添加剪辑",
"addSpeed": "添加速度",
"addAnnotation": "添加标注",
"addBlur": "添加模糊",
"addKeyframe": "添加关键帧",
"deleteSelected": "删除所选",
"playPause": "播放 / 暂停"
+6 -3
View File
@@ -4,13 +4,15 @@
"suggestZooms": "根据光标建议缩放",
"addTrim": "添加剪辑 (T)",
"addAnnotation": "添加标注 (A)",
"addSpeed": "添加速度 (S)"
"addSpeed": "添加速度 (S)",
"addBlur": "添加模糊 (B)"
},
"hints": {
"pressZoom": "按 Z 添加缩放",
"pressTrim": "按 T 添加剪辑",
"pressAnnotation": "按 A 添加标注",
"pressSpeed": "按 S 添加速度"
"pressSpeed": "按 S 添加速度",
"pressBlur": "按 B 添加模糊区域"
},
"labels": {
"pan": "平移",
@@ -20,7 +22,8 @@
"speedItem": "速度 {{index}}",
"annotationItem": "标注",
"imageItem": "图片",
"emptyText": "空文本"
"emptyText": "空文本",
"blurItem": "模糊 {{index}}"
},
"emptyState": {
"noVideo": "未加载视频",
+109 -18
View File
@@ -24,16 +24,111 @@ describe("computeCompositeLayout", () => {
webcamSize: { width: 1920, height: 1080 },
});
const refDim = Math.sqrt(1280 * 720);
const defaultFraction = 25 / 100; // DEFAULT_WEBCAM_SIZE_PRESET = 25
expect(layout).not.toBeNull();
expect(layout!.webcamRect).not.toBeNull();
expect(layout!.webcamRect!.width).toBeLessThanOrEqual(Math.round(1280 * 0.18) + 1);
expect(layout!.webcamRect!.height).toBeLessThanOrEqual(Math.round(720 * 0.18) + 1);
expect(layout!.webcamRect!.width).toBeLessThanOrEqual(Math.round(refDim * defaultFraction) + 1);
expect(layout!.webcamRect!.height).toBeLessThanOrEqual(
Math.round(refDim * defaultFraction) + 1,
);
expect(
Math.abs(layout!.webcamRect!.width * 1080 - layout!.webcamRect!.height * 1920),
).toBeLessThanOrEqual(1920);
});
it("uses cover-style full-width stacking in vertical stack mode", () => {
it("produces consistent webcam size across landscape and portrait aspect ratios", () => {
const webcamSize = { width: 1280, height: 720 };
const landscape = computeCompositeLayout({
canvasSize: { width: 1920, height: 1080 },
screenSize: { width: 1920, height: 1080 },
webcamSize,
webcamSizePreset: 50,
});
const portrait = computeCompositeLayout({
canvasSize: { width: 1080, height: 1920 },
screenSize: { width: 1080, height: 1920 },
webcamSize,
webcamSizePreset: 50,
});
expect(landscape).not.toBeNull();
expect(portrait).not.toBeNull();
// Same total pixel count — webcam area should be comparable
const landscapeArea = landscape!.webcamRect!.width * landscape!.webcamRect!.height;
const portraitArea = portrait!.webcamRect!.width * portrait!.webcamRect!.height;
expect(landscapeArea).toBe(portraitArea);
});
it("scales the webcam proportionally as webcamSizePreset increases", () => {
const canvasSize = { width: 1920, height: 1080 };
const screenSize = { width: 1920, height: 1080 };
const webcamSize = { width: 1280, height: 720 };
const small = computeCompositeLayout({
canvasSize,
screenSize,
webcamSize,
webcamSizePreset: 10,
});
const medium = computeCompositeLayout({
canvasSize,
screenSize,
webcamSize,
webcamSizePreset: 25,
});
const large = computeCompositeLayout({
canvasSize,
screenSize,
webcamSize,
webcamSizePreset: 50,
});
expect(small!.webcamRect!.width).toBeLessThan(medium!.webcamRect!.width);
expect(medium!.webcamRect!.width).toBeLessThan(large!.webcamRect!.width);
expect(small!.webcamRect!.height).toBeLessThan(medium!.webcamRect!.height);
expect(medium!.webcamRect!.height).toBeLessThan(large!.webcamRect!.height);
});
it("clamps webcamSizePreset to the valid range (10–50)", () => {
const canvasSize = { width: 1920, height: 1080 };
const screenSize = { width: 1920, height: 1080 };
const webcamSize = { width: 1280, height: 720 };
const atMin = computeCompositeLayout({
canvasSize,
screenSize,
webcamSize,
webcamSizePreset: 10,
});
const belowMin = computeCompositeLayout({
canvasSize,
screenSize,
webcamSize,
webcamSizePreset: 1,
});
const atMax = computeCompositeLayout({
canvasSize,
screenSize,
webcamSize,
webcamSizePreset: 50,
});
const aboveMax = computeCompositeLayout({
canvasSize,
screenSize,
webcamSize,
webcamSizePreset: 100,
});
// Values below 10 should clamp to 10
expect(belowMin!.webcamRect!.width).toBe(atMin!.webcamRect!.width);
expect(belowMin!.webcamRect!.height).toBe(atMin!.webcamRect!.height);
// Values above 50 should clamp to 50
expect(aboveMax!.webcamRect!.width).toBe(atMax!.webcamRect!.width);
expect(aboveMax!.webcamRect!.height).toBe(atMax!.webcamRect!.height);
});
it("centers the combined screen and webcam stack in vertical stack mode", () => {
const layout = computeCompositeLayout({
canvasSize: { width: 1920, height: 1080 },
maxContentSize: { width: 1536, height: 864 },
@@ -43,23 +138,19 @@ describe("computeCompositeLayout", () => {
});
expect(layout).not.toBeNull();
expect(layout?.screenRect).toEqual({
x: 0,
y: 0,
width: 1920,
height: 0,
});
expect(layout?.webcamRect).toEqual({
x: 0,
y: 0,
width: 1920,
height: 1080,
borderRadius: 0,
});
expect(layout?.screenCover).toBe(true);
// Webcam is full-width at the bottom
expect(layout!.webcamRect).not.toBeNull();
expect(layout!.webcamRect!.x).toBe(0);
expect(layout!.webcamRect!.width).toBe(1920);
expect(layout!.webcamRect!.borderRadius).toBe(0);
// Screen fills remaining space at the top (cover mode)
expect(layout!.screenRect.x).toBe(0);
expect(layout!.screenRect.y).toBe(0);
expect(layout!.screenRect.width).toBe(1920);
expect(layout!.screenCover).toBe(true);
});
it("fills the canvas with the screen when vertical stack has no webcam", () => {
it("keeps the screen full-canvas and omits the webcam when dimensions are unavailable in stack mode", () => {
const layout = computeCompositeLayout({
canvasSize: { width: 1920, height: 1080 },
maxContentSize: { width: 1536, height: 864 },
+18 -5
View File
@@ -16,6 +16,8 @@ export interface Size {
}
export type WebcamLayoutPreset = "picture-in-picture" | "vertical-stack";
/** Webcam size as a percentage of the canvas reference dimension (10–50). */
export type WebcamSizePreset = number;
export interface WebcamLayoutShadow {
color: string;
@@ -32,7 +34,6 @@ interface BorderRadiusRule {
interface OverlayTransform {
type: "overlay";
maxStageFraction: number;
marginFraction: number;
minMargin: number;
minSize: number;
@@ -57,7 +58,13 @@ export interface WebcamCompositeLayout {
screenCover?: boolean;
}
const MAX_STAGE_FRACTION = 0.18;
/** Convert a webcam size percentage (10–50) to a fraction of the reference dimension. */
function webcamSizeToFraction(percent: number): number {
const safe = Number.isFinite(percent) ? percent : 25;
const clamped = Math.max(10, Math.min(50, safe));
return clamped / 100;
}
const MARGIN_FRACTION = 0.02;
const MAX_BORDER_RADIUS = 24;
const WEBCAM_LAYOUT_PRESET_MAP: Record<WebcamLayoutPreset, WebcamLayoutPresetDefinition> = {
@@ -65,7 +72,6 @@ const WEBCAM_LAYOUT_PRESET_MAP: Record<WebcamLayoutPreset, WebcamLayoutPresetDef
label: "Picture in Picture",
transform: {
type: "overlay",
maxStageFraction: MAX_STAGE_FRACTION,
marginFraction: MARGIN_FRACTION,
minMargin: 0,
minSize: 0,
@@ -125,6 +131,7 @@ export function computeCompositeLayout(params: {
screenSize: Size;
webcamSize?: Size | null;
layoutPreset?: WebcamLayoutPreset;
webcamSizePreset?: WebcamSizePreset;
webcamPosition?: { cx: number; cy: number } | null;
webcamMaskShape?: import("@/components/video-editor/types").WebcamMaskShape;
}): WebcamCompositeLayout | null {
@@ -134,6 +141,7 @@ export function computeCompositeLayout(params: {
screenSize,
webcamSize,
layoutPreset = "picture-in-picture",
webcamSizePreset = 25,
webcamPosition,
webcamMaskShape = "rectangle",
} = params;
@@ -143,6 +151,8 @@ export function computeCompositeLayout(params: {
const webcamHeight = webcamSize?.height;
const preset = getWebcamLayoutPresetDefinition(layoutPreset);
const MAX_STAGE_FRACTION = webcamSizeToFraction(webcamSizePreset);
if (canvasWidth <= 0 || canvasHeight <= 0 || screenWidth <= 0 || screenHeight <= 0) {
return null;
}
@@ -198,8 +208,11 @@ export function computeCompositeLayout(params: {
transform.minMargin,
Math.round(Math.min(canvasWidth, canvasHeight) * transform.marginFraction),
);
const maxWidth = Math.max(transform.minSize, canvasWidth * transform.maxStageFraction);
const maxHeight = Math.max(transform.minSize, canvasHeight * transform.maxStageFraction);
// Use geometric mean so the webcam occupies a consistent visual proportion
// regardless of whether the canvas is portrait or landscape.
const referenceDim = Math.sqrt(canvasWidth * canvasHeight);
const maxWidth = Math.max(transform.minSize, referenceDim * MAX_STAGE_FRACTION);
const maxHeight = Math.max(transform.minSize, referenceDim * MAX_STAGE_FRACTION);
const scale = Math.min(maxWidth / webcamWidth, maxHeight / webcamHeight);
let width = Math.round(webcamWidth * scale);
let height = Math.round(webcamHeight * scale);
+101 -1
View File
@@ -1,4 +1,13 @@
import type { AnnotationRegion, ArrowDirection } from "@/components/video-editor/types";
import {
type AnnotationRegion,
type ArrowDirection,
DEFAULT_BLUR_INTENSITY,
MAX_BLUR_INTENSITY,
MIN_BLUR_INTENSITY,
} from "@/components/video-editor/types";
let blurScratchCanvas: HTMLCanvasElement | null = null;
let blurScratchCtx: CanvasRenderingContext2D | null = null;
// SVG path data for each arrow direction
const ARROW_PATHS: Record<ArrowDirection, string[]> = {
@@ -96,6 +105,93 @@ function renderArrow(
ctx.restore();
}
function drawBlurPath(
ctx: CanvasRenderingContext2D,
annotation: AnnotationRegion,
x: number,
y: number,
width: number,
height: number,
) {
const shape = annotation.blurData?.shape || "rectangle";
if (shape === "rectangle") {
ctx.beginPath();
ctx.rect(x, y, width, height);
return;
}
if (shape === "oval") {
ctx.beginPath();
ctx.ellipse(x + width / 2, y + height / 2, width / 2, height / 2, 0, 0, Math.PI * 2);
return;
}
const points = annotation.blurData?.freehandPoints;
if (shape === "freehand" && points && points.length >= 3) {
ctx.beginPath();
ctx.moveTo(x + (points[0].x / 100) * width, y + (points[0].y / 100) * height);
for (let i = 1; i < points.length; i++) {
ctx.lineTo(x + (points[i].x / 100) * width, y + (points[i].y / 100) * height);
}
ctx.closePath();
return;
}
ctx.beginPath();
ctx.rect(x, y, width, height);
}
function renderBlur(
ctx: CanvasRenderingContext2D,
annotation: AnnotationRegion,
x: number,
y: number,
width: number,
height: number,
scaleFactor: number,
) {
const canvas = ctx.canvas;
const configuredIntensity = annotation.blurData?.intensity ?? DEFAULT_BLUR_INTENSITY;
const blurRadius = Math.max(
1,
Math.round(clamp(configuredIntensity, MIN_BLUR_INTENSITY, MAX_BLUR_INTENSITY) * scaleFactor),
);
// Sample pixels around the target shape too; without this padding, small blur regions
// lose intensity because the filter has no neighboring pixels to blend with.
const samplePadding = Math.max(2, Math.ceil(blurRadius * 2));
const sx = Math.max(0, Math.floor(x) - samplePadding);
const sy = Math.max(0, Math.floor(y) - samplePadding);
const ex = Math.min(canvas.width, Math.ceil(x + width) + samplePadding);
const ey = Math.min(canvas.height, Math.ceil(y + height) + samplePadding);
const sw = Math.max(0, ex - sx);
const sh = Math.max(0, ey - sy);
if (sw <= 0 || sh <= 0) return;
if (!blurScratchCanvas || !blurScratchCtx) {
blurScratchCanvas = document.createElement("canvas");
blurScratchCtx = blurScratchCanvas.getContext("2d");
}
if (!blurScratchCanvas || !blurScratchCtx) return;
blurScratchCanvas.width = sw;
blurScratchCanvas.height = sh;
blurScratchCtx.clearRect(0, 0, sw, sh);
blurScratchCtx.drawImage(canvas, sx, sy, sw, sh, 0, 0, sw, sh);
ctx.save();
drawBlurPath(ctx, annotation, x, y, width, height);
ctx.clip();
ctx.filter = `blur(${blurRadius}px)`;
ctx.drawImage(blurScratchCanvas, sx, sy);
ctx.filter = "none";
ctx.restore();
}
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
function renderText(
ctx: CanvasRenderingContext2D,
annotation: AnnotationRegion,
@@ -304,6 +400,10 @@ export async function renderAnnotations(
);
}
break;
case "blur":
renderBlur(ctx, annotation, x, y, width, height, scaleFactor);
break;
}
}
}
+52 -1
View File
@@ -13,6 +13,7 @@ import type {
CropRegion,
SpeedRegion,
WebcamLayoutPreset,
WebcamSizePreset,
ZoomDepth,
ZoomRegion,
} from "@/components/video-editor/types";
@@ -70,6 +71,7 @@ interface FrameRenderConfig {
webcamSize?: Size | null;
webcamLayoutPreset?: WebcamLayoutPreset;
webcamMaskShape?: import("@/components/video-editor/types").WebcamMaskShape;
webcamSizePreset?: WebcamSizePreset;
webcamPosition?: { cx: number; cy: number } | null;
annotationRegions?: AnnotationRegion[];
speedRegions?: SpeedRegion[];
@@ -112,6 +114,8 @@ export class FrameRenderer {
private shadowCtx: CanvasRenderingContext2D | null = null;
private compositeCanvas: HTMLCanvasElement | null = null;
private compositeCtx: CanvasRenderingContext2D | null = null;
private rasterCanvas: HTMLCanvasElement | null = null;
private rasterCtx: CanvasRenderingContext2D | null = null;
private config: FrameRenderConfig;
private animationState: AnimationState;
private layoutCache: LayoutCache | null = null;
@@ -191,6 +195,14 @@ export class FrameRenderer {
throw new Error("Failed to get 2D context for composite canvas");
}
this.rasterCanvas = document.createElement("canvas");
this.rasterCanvas.width = this.config.width;
this.rasterCanvas.height = this.config.height;
this.rasterCtx = this.rasterCanvas.getContext("2d");
if (!this.rasterCtx) {
throw new Error("Failed to get 2D context for raster canvas");
}
// Setup shadow canvas if needed
if (this.config.showShadow) {
this.shadowCanvas = document.createElement("canvas");
@@ -453,6 +465,7 @@ export class FrameRenderer {
screenSize: { width: croppedVideoWidth, height: croppedVideoHeight },
webcamSize: webcamFrame ? this.config.webcamSize : null,
layoutPreset: this.config.webcamLayoutPreset,
webcamSizePreset: this.config.webcamSizePreset,
webcamPosition: this.config.webcamPosition,
webcamMaskShape: this.config.webcamMaskShape,
});
@@ -675,10 +688,46 @@ export class FrameRenderer {
);
}
// On Linux/Wayland the implicit GPU→2D texture-sharing path
// used by drawImage(webglCanvas) can fail silently (EGL/Ozone),
// producing green/empty frames. Explicit gl.readPixels always
// copies from GPU to CPU memory, bypassing that path.
private readbackVideoCanvas(): HTMLCanvasElement {
const glCanvas = this.app!.canvas as HTMLCanvasElement;
const gl =
(glCanvas.getContext("webgl2") as WebGL2RenderingContext | null) ??
(glCanvas.getContext("webgl") as WebGLRenderingContext | null);
if (!gl || !this.rasterCanvas || !this.rasterCtx) {
return glCanvas;
}
const w = glCanvas.width;
const h = glCanvas.height;
const buf = new Uint8Array(w * h * 4);
gl.readPixels(0, 0, w, h, gl.RGBA, gl.UNSIGNED_BYTE, buf);
// readPixels returns rows bottom-to-top; flip vertically
const rowSize = w * 4;
const temp = new Uint8Array(rowSize);
for (let top = 0, bot = h - 1; top < bot; top++, bot--) {
const tOff = top * rowSize;
const bOff = bot * rowSize;
temp.set(buf.subarray(tOff, tOff + rowSize));
buf.copyWithin(tOff, bOff, bOff + rowSize);
buf.set(temp, bOff);
}
const imageData = new ImageData(new Uint8ClampedArray(buf.buffer), w, h);
this.rasterCtx.putImageData(imageData, 0, 0);
return this.rasterCanvas;
}
private compositeWithShadows(webcamFrame?: VideoFrame | null): void {
if (!this.compositeCanvas || !this.compositeCtx || !this.app) return;
const videoCanvas = this.app.canvas as HTMLCanvasElement;
const videoCanvas = this.readbackVideoCanvas();
const ctx = this.compositeCtx;
const w = this.compositeCanvas.width;
const h = this.compositeCanvas.height;
@@ -795,5 +844,7 @@ export class FrameRenderer {
this.shadowCtx = null;
this.compositeCanvas = null;
this.compositeCtx = null;
this.rasterCanvas = null;
this.rasterCtx = null;
}
}
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import sampleVideoUrl from "../../../tests/fixtures/sample.webm?url";
import { GifExporter } from "./gifExporter";
import type { ExportProgress } from "./types";
describe("GifExporter (real browser)", () => {
it("exports a valid GIF blob from a real video", async () => {
const progressEvents: ExportProgress[] = [];
const exporter = new GifExporter({
videoUrl: sampleVideoUrl,
width: 320,
height: 180,
frameRate: 15,
loop: true,
sizePreset: "medium",
wallpaper: "#1a1a2e",
zoomRegions: [],
showShadow: false,
shadowIntensity: 0,
showBlur: false,
cropRegion: { x: 0, y: 0, width: 1, height: 1 },
onProgress: (p) => progressEvents.push(p),
});
const result = await exporter.export();
expect(result.success, result.error).toBe(true);
expect(result.blob).toBeInstanceOf(Blob);
const buf = await result.blob!.arrayBuffer();
const header = new TextDecoder().decode(new Uint8Array(buf, 0, 6));
expect(header).toMatch(/^GIF8[79]a/);
expect(result.blob!.size).toBeGreaterThan(1024);
expect(progressEvents.length).toBeGreaterThan(0);
const finalizing = progressEvents.filter((p) => p.phase === "finalizing");
expect(finalizing.length).toBeGreaterThan(0);
expect(finalizing.at(-1)!.percentage).toBe(100);
});
});
+3
View File
@@ -5,6 +5,7 @@ import type {
SpeedRegion,
TrimRegion,
WebcamLayoutPreset,
WebcamSizePreset,
ZoomRegion,
} from "@/components/video-editor/types";
import { AsyncVideoFrameQueue } from "./asyncVideoFrameQueue";
@@ -42,6 +43,7 @@ interface GifExporterConfig {
cropRegion: CropRegion;
webcamLayoutPreset?: WebcamLayoutPreset;
webcamMaskShape?: import("@/components/video-editor/types").WebcamMaskShape;
webcamSizePreset?: WebcamSizePreset;
webcamPosition?: { cx: number; cy: number } | null;
annotationRegions?: AnnotationRegion[];
previewWidth?: number;
@@ -144,6 +146,7 @@ export class GifExporter {
webcamSize: webcamInfo ? { width: webcamInfo.width, height: webcamInfo.height } : null,
webcamLayoutPreset: this.config.webcamLayoutPreset,
webcamMaskShape: this.config.webcamMaskShape,
webcamSizePreset: this.config.webcamSizePreset,
webcamPosition: this.config.webcamPosition,
annotationRegions: this.config.annotationRegions,
speedRegions: this.config.speedRegions,
+57 -1
View File
@@ -3,6 +3,52 @@ import type { SpeedRegion, TrimRegion } from "@/components/video-editor/types";
const SOURCE_LOAD_TIMEOUT_MS = 60_000;
/**
* Build a full WebCodecs-compatible AV1 codec string from the AV1CodecConfigurationRecord.
* web-demuxer may return a bare "av01" when the WASM-side parser fails to read
* the extradata (e.g. raw OBU sequence header from WebM instead of ISOBMFF av1C box).
* This function parses the record if present, otherwise returns a safe default.
*
* @see https://aomediacodec.github.io/av1-isobmff/#av1codecconfigurationbox-section
*/
function buildAV1CodecString(description?: BufferSource): string {
const fallback = "av01.0.01M.08";
if (!description) return fallback;
const bytes =
description instanceof ArrayBuffer
? new Uint8Array(description)
: new Uint8Array(description.buffer, description.byteOffset, description.byteLength);
// AV1CodecConfigurationRecord layout (4+ bytes):
// Byte 0: marker (1) | version (7)
// Byte 1: seq_profile (3) | seq_level_idx_0 (5)
// Byte 2: seq_tier_0 (1) | high_bitdepth (1) | twelve_bit (1) | ...
// The spec says version should be 1, but Chrome/Electron's MediaRecorder
// may write version 127 (0xFF first byte). We accept any version as long
// as the marker bit is set and the record is long enough.
if (bytes.length < 4) return fallback;
if (!(bytes[0] & 0x80)) return fallback; // marker bit must be 1
// Byte 1: seq_profile (3) | seq_level_idx_0 (5)
const profile = (bytes[1] >> 5) & 0x07;
const level = bytes[1] & 0x1f;
// Byte 2: seq_tier_0 (1) | high_bitdepth (1) | twelve_bit (1) | monochrome (1) | ...
const tier = (bytes[2] >> 7) & 0x01;
const highBitdepth = (bytes[2] >> 6) & 0x01;
const twelveBit = (bytes[2] >> 5) & 0x01;
let bitdepth = 8;
if (highBitdepth) bitdepth = twelveBit ? 12 : 10;
const tierChar = tier ? "H" : "M";
const levelStr = level.toString().padStart(2, "0");
const bitdepthStr = bitdepth.toString().padStart(2, "0");
return `av01.${profile}.${levelStr}${tierChar}.${bitdepthStr}`;
}
export interface DecodedVideoInfo {
width: number;
height: number;
@@ -183,7 +229,17 @@ export class StreamingVideoDecoder {
}
const decoderConfig = await this.demuxer.getDecoderConfig("video");
const codec = this.metadata.codec.toLowerCase();
// web-demuxer may return a bare "av01" for AV1 in WebM containers when the
// extradata isn't in the expected ISOBMFF format. WebCodecs requires the
// full parametrized form (e.g. "av01.0.05M.08").
if (/^av01$/i.test(decoderConfig.codec)) {
decoderConfig.codec = buildAV1CodecString(
decoderConfig.description as BufferSource | undefined,
);
}
const codec = decoderConfig.codec.toLowerCase();
const shouldPreferSoftwareDecode = codec.includes("av01") || codec.includes("av1");
const segments = this.splitBySpeed(
this.computeSegments(this.metadata.duration, trimRegions),
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import sampleVideoUrl from "../../../tests/fixtures/sample.webm?url";
import type { ExportProgress } from "./types";
import { VideoExporter } from "./videoExporter";
describe("VideoExporter (real browser)", () => {
it("exports a valid MP4 blob from a real video", async () => {
const progressEvents: ExportProgress[] = [];
const exporter = new VideoExporter({
videoUrl: sampleVideoUrl,
width: 320,
height: 180,
frameRate: 15,
bitrate: 1_000_000,
wallpaper: "#1a1a2e",
zoomRegions: [],
showShadow: false,
shadowIntensity: 0,
showBlur: false,
cropRegion: { x: 0, y: 0, width: 1, height: 1 },
onProgress: (p) => progressEvents.push(p),
});
const result = await exporter.export();
expect(result.success, result.error).toBe(true);
expect(result.blob).toBeInstanceOf(Blob);
const buf = await result.blob!.arrayBuffer();
const bytes = new Uint8Array(buf);
const ftyp = new TextDecoder().decode(bytes.slice(4, 8));
expect(ftyp).toBe("ftyp");
expect(result.blob!.size).toBeGreaterThan(1024);
expect(progressEvents.length).toBeGreaterThan(0);
const finalizing = progressEvents.filter((p) => p.phase === "finalizing");
expect(finalizing.length).toBeGreaterThan(0);
expect(finalizing.at(-1)!.percentage).toBe(100);
});
});
+3
View File
@@ -4,6 +4,7 @@ import type {
SpeedRegion,
TrimRegion,
WebcamLayoutPreset,
WebcamSizePreset,
ZoomRegion,
} from "@/components/video-editor/types";
import { AsyncVideoFrameQueue } from "./asyncVideoFrameQueue";
@@ -33,6 +34,7 @@ interface VideoExporterConfig extends ExportConfig {
cropRegion: CropRegion;
webcamLayoutPreset?: WebcamLayoutPreset;
webcamMaskShape?: import("@/components/video-editor/types").WebcamMaskShape;
webcamSizePreset?: WebcamSizePreset;
webcamPosition?: { cx: number; cy: number } | null;
annotationRegions?: AnnotationRegion[];
previewWidth?: number;
@@ -137,6 +139,7 @@ export class VideoExporter {
webcamSize: webcamInfo ? { width: webcamInfo.width, height: webcamInfo.height } : null,
webcamLayoutPreset: this.config.webcamLayoutPreset,
webcamMaskShape: this.config.webcamMaskShape,
webcamSizePreset: this.config.webcamSizePreset,
webcamPosition: this.config.webcamPosition,
annotationRegions: this.config.annotationRegions,
speedRegions: this.config.speedRegions,
+5 -1
View File
@@ -3,6 +3,7 @@ export const SHORTCUT_ACTIONS = [
"addTrim",
"addSpeed",
"addAnnotation",
"addBlur",
"addKeyframe",
"deleteSelected",
"playPause",
@@ -108,6 +109,7 @@ export const DEFAULT_SHORTCUTS: ShortcutsConfig = {
addTrim: { key: "t" },
addSpeed: { key: "s" },
addAnnotation: { key: "a" },
addBlur: { key: "b" },
addKeyframe: { key: "f" },
deleteSelected: { key: "d", ctrl: true },
playPause: { key: " " },
@@ -118,6 +120,7 @@ export const SHORTCUT_LABELS: Record<ShortcutAction, string> = {
addTrim: "Add Trim",
addSpeed: "Add Speed",
addAnnotation: "Add Annotation",
addBlur: "Add Blur",
addKeyframe: "Add Keyframe",
deleteSelected: "Delete Selected",
playPause: "Play / Pause",
@@ -125,9 +128,10 @@ export const SHORTCUT_LABELS: Record<ShortcutAction, string> = {
export function matchesShortcut(
e: KeyboardEvent,
binding: ShortcutBinding,
binding: ShortcutBinding | undefined,
isMacPlatform: boolean,
): boolean {
if (!binding) return false;
if (e.key.toLowerCase() !== binding.key.toLowerCase()) return false;
const primaryMod = isMacPlatform ? e.metaKey : e.ctrlKey;
+2
View File
@@ -19,6 +19,8 @@ interface Window {
electronAPI: {
getSources: (opts: Electron.SourcesOptions) => Promise<ProcessedDesktopSource[]>;
switchToEditor: () => Promise<void>;
switchToHud: () => Promise<void>;
startNewRecording: () => Promise<{ success: boolean; error?: string }>;
openSourceSelector: () => Promise<void>;
selectSource: (source: ProcessedDesktopSource) => Promise<ProcessedDesktopSource | null>;
getSelectedSource: () => Promise<ProcessedDesktopSource | null>;
+28
View File
@@ -0,0 +1,28 @@
import path from "node:path";
import { playwright } from "@vitest/browser-playwright";
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["src/**/*.browser.test.{ts,tsx}"],
browser: {
enabled: true,
provider: playwright({
launch: {
// Software WebGL so Pixi.js works in headless CI without a GPU.
args: ["--enable-unsafe-swiftshader", "--use-gl=swiftshader"],
},
}),
headless: true,
instances: [{ browser: "chromium" }],
},
testTimeout: 120_000,
hookTimeout: 30_000,
},
resolve: {
alias: {
"@": path.resolve(__dirname, "src"),
},
},
assetsInclude: ["**/*.webm"],
});