diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 947599ee..62a6ea1b 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -49,7 +49,7 @@ Areas where help is especially valuable:
## Reporting Issues
-If you encounter a bug or have a feature request, please open an issue in the [Issues](https://github.com/webadderall/Recordly/issues) section of this repository. Provide as much detail as possible to help us address the issue effectively.
+If you encounter a bug or have a feature request, please open an issue in the [Issues](https://github.com/webadderallorg/Recordly/issues) section of this repository. Provide as much detail as possible to help us address the issue effectively.
## Style Guide
diff --git a/README.md b/README.md
index f9ae83d9..d42c71f6 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,3 @@
-# Recordly
-
Language: EN | [简中](README.zh-CN.md)
@@ -11,12 +9,11 @@ Language: EN | [简中](README.zh-CN.md)
-### Create polished, pro-grade screen recordings.
+### Create polished screen recordings without editing.
[Recordly](https://www.recordly.dev) is an **open-source screen recorder** and editor for **walkthroughs, demos, product videos**, and more.
+**Accepting PRs.** [Donate](https://ko-fi.com/webadderall/goal?g=0)
-**Contribution encouraged.** [Donate](https://ko-fi.com/webadderall/goal?g=0)
-
-https://github.com/user-attachments/assets/1446cd12-c053-4b9c-b49f-d9c93db77fc4
+https://github.com/user-attachments/assets/9b66c71d-ac97-49ff-a0c9-63ac26edf2e4
---
@@ -151,11 +148,11 @@ Browse and install community extensions from the [Recordly Marketplace](https://
# Screenshots
-
+
-
+
@@ -170,7 +167,7 @@ Browse and install community extensions from the [Recordly Marketplace](https://
Prebuilt releases are available at:
-https://github.com/webadderall/Recordly/releases
+https://github.com/webadderallorg/Recordly/releases
---
@@ -203,7 +200,7 @@ sudo apt install build-essential cmake libx11-dev libxtst-dev libxrandr-dev libx
### Steps
```bash
-git clone https://github.com/webadderall/Recordly.git recordly
+git clone https://github.com/webadderallorg/Recordly.git recordly
cd recordly
npm install
npm run dev
@@ -360,7 +357,7 @@ See `CONTRIBUTING.md` for guidelines.
Bug reports and feature requests:
-https://github.com/webadderall/Recordly/issues
+https://github.com/webadderallorg/Recordly/issues
Pull requests are welcome.
@@ -374,6 +371,8 @@ Pull requests are welcome.
- buildwithfur
- Tobias
- Anonymous Supporter
+- Tandava Appadoo
+- Digitalfastmind
- Roberto Marcelino
- Rajan RK
- Francesco
diff --git a/README.zh-CN.md b/README.zh-CN.md
index e84897ea..1aa0d240 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -171,7 +171,7 @@ Recordly 拥有一个社区驱动的扩展系统。任何人都可以构建和
预构建发布版本请见:
-https://github.com/webadderall/Recordly/releases
+https://github.com/webadderallorg/Recordly/releases
---
@@ -204,7 +204,7 @@ sudo apt install build-essential cmake libx11-dev libxtst-dev libxrandr-dev libx
### 步骤
```bash
-git clone https://github.com/webadderall/Recordly.git recordly
+git clone https://github.com/webadderallorg/Recordly.git recordly
cd recordly
npm install
npm run dev
@@ -361,7 +361,7 @@ Recordly 将平台相关的捕获层与基于渲染器的编辑、导出流程
问题反馈和功能建议:
-https://github.com/webadderall/Recordly/issues
+https://github.com/webadderallorg/Recordly/issues
欢迎提交 Pull Request。
diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts
index bda33cdf..8b634980 100644
--- a/electron/electron-env.d.ts
+++ b/electron/electron-env.d.ts
@@ -151,6 +151,16 @@ interface Window {
message?: string;
error?: string;
}>;
+ pauseCursorCapture: (boundaryMs?: number) => Promise<{
+ success: boolean;
+ message?: string;
+ error?: string;
+ }>;
+ resumeCursorCapture: (boundaryMs?: number) => Promise<{
+ success: boolean;
+ message?: string;
+ error?: string;
+ }>;
startFfmpegRecording: (
source: ProcessedDesktopSource,
) => Promise<{ success: boolean; path?: string; message?: string; error?: string }>;
diff --git a/electron/ipc/cursor/interaction.ts b/electron/ipc/cursor/interaction.ts
index 37cfc633..c11258f1 100644
--- a/electron/ipc/cursor/interaction.ts
+++ b/electron/ipc/cursor/interaction.ts
@@ -2,7 +2,6 @@ import { createRequire } from "node:module";
import type { HookMouseEvent, UiohookLike, UiohookModuleNamespace, CursorInteractionType } from "../types";
import {
isCursorCaptureActive,
- cursorCaptureStartTimeMs,
interactionCaptureCleanup,
setInteractionCaptureCleanup,
hasLoggedInteractionHookFailure,
@@ -13,7 +12,9 @@ import {
} from "../state";
import {
getNormalizedCursorPoint,
+ getCursorCaptureElapsedMs,
getHookCursorScreenPoint,
+ isCursorCapturePaused,
pushCursorSample,
} from "./telemetry";
@@ -119,7 +120,7 @@ export async function startInteractionCapture() {
}
const onMouseDown = (event: HookMouseEvent) => {
- if (!isCursorCaptureActive) {
+ if (!isCursorCaptureActive || isCursorCapturePaused()) {
return;
}
@@ -128,7 +129,7 @@ export async function startInteractionCapture() {
return;
}
- const timeMs = Date.now() - cursorCaptureStartTimeMs;
+ const timeMs = getCursorCaptureElapsedMs();
const button = getHookMouseButton(event);
let interactionType: CursorInteractionType = "click";
@@ -157,7 +158,7 @@ export async function startInteractionCapture() {
};
const onMouseUp = () => {
- if (!isCursorCaptureActive) {
+ if (!isCursorCaptureActive || isCursorCapturePaused()) {
return;
}
@@ -166,12 +167,16 @@ export async function startInteractionCapture() {
return;
}
- const timeMs = Date.now() - cursorCaptureStartTimeMs;
+ const timeMs = getCursorCaptureElapsedMs();
pushCursorSample(point.cx, point.cy, timeMs, "mouseup");
};
const onMouseMove = (event: HookMouseEvent) => {
- if (process.platform !== "linux" || !isCursorCaptureActive) {
+ if (
+ process.platform !== "linux" ||
+ !isCursorCaptureActive ||
+ isCursorCapturePaused()
+ ) {
return;
}
diff --git a/electron/ipc/cursor/telemetry.test.ts b/electron/ipc/cursor/telemetry.test.ts
new file mode 100644
index 00000000..de9b65e7
--- /dev/null
+++ b/electron/ipc/cursor/telemetry.test.ts
@@ -0,0 +1,51 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+vi.mock("electron", () => ({
+ app: {
+ getPath: vi.fn(() => "/tmp"),
+ },
+}));
+
+vi.mock("../utils", () => ({
+ getTelemetryPathForVideo: vi.fn(() => "/tmp/recording.cursor.json"),
+ getScreen: vi.fn(() => ({
+ getCursorScreenPoint: () => ({ x: 0, y: 0 }),
+ getPrimaryDisplay: () => ({ scaleFactor: 1 }),
+ getDisplayNearestPoint: () => ({ bounds: { x: 0, y: 0, width: 1, height: 1 } }),
+ getAllDisplays: () => [],
+ })),
+}));
+
+import {
+ getCursorCaptureElapsedMs,
+ pauseCursorCapture,
+ resetCursorCaptureClock,
+ resumeCursorCapture,
+} from "./telemetry";
+import { setCursorCaptureStartTimeMs } from "../state";
+
+describe("cursor telemetry pause clock", () => {
+ beforeEach(() => {
+ setCursorCaptureStartTimeMs(1_000);
+ resetCursorCaptureClock();
+ });
+
+ it("subtracts paused time from elapsed cursor timestamps", () => {
+ expect(getCursorCaptureElapsedMs(1_120)).toBe(120);
+
+ pauseCursorCapture(1_200);
+ expect(getCursorCaptureElapsedMs(1_450)).toBe(200);
+
+ resumeCursorCapture(1_700);
+ expect(getCursorCaptureElapsedMs(1_900)).toBe(400);
+ });
+
+ it("ignores duplicate pause or resume transitions", () => {
+ pauseCursorCapture(1_150);
+ pauseCursorCapture(1_250);
+ resumeCursorCapture(1_500);
+ resumeCursorCapture(1_650);
+
+ expect(getCursorCaptureElapsedMs(1_900)).toBe(550);
+ });
+});
diff --git a/electron/ipc/cursor/telemetry.ts b/electron/ipc/cursor/telemetry.ts
index aa6f4784..18b8a174 100644
--- a/electron/ipc/cursor/telemetry.ts
+++ b/electron/ipc/cursor/telemetry.ts
@@ -9,6 +9,8 @@ import type { CursorVisualType, CursorInteractionType, CursorTelemetryPoint } fr
import {
cursorCaptureInterval,
setCursorCaptureInterval,
+ cursorCaptureAccumulatedPausedMs,
+ cursorCapturePauseStartedAtMs,
cursorCaptureStartTimeMs,
activeCursorSamples,
pendingCursorSamples,
@@ -18,6 +20,8 @@ import {
linuxCursorScreenPoint,
selectedSource,
selectedWindowBounds,
+ setCursorCaptureAccumulatedPausedMs,
+ setCursorCapturePauseStartedAtMs,
} from "../state";
export function clamp(value: number, min: number, max: number) {
@@ -31,6 +35,55 @@ export function stopCursorCapture() {
}
}
+export function resetCursorCaptureClock() {
+ setCursorCaptureAccumulatedPausedMs(0);
+ setCursorCapturePauseStartedAtMs(null);
+}
+
+export function isCursorCapturePaused() {
+ return cursorCapturePauseStartedAtMs !== null;
+}
+
+export function pauseCursorCapture(pausedAtMs: number) {
+ if (cursorCapturePauseStartedAtMs !== null) {
+ return;
+ }
+
+ setCursorCapturePauseStartedAtMs(pausedAtMs);
+}
+
+export function resumeCursorCapture(resumedAtMs: number) {
+ if (cursorCapturePauseStartedAtMs === null) {
+ return;
+ }
+
+ const pauseDurationMs = Math.max(0, resumedAtMs - cursorCapturePauseStartedAtMs);
+ setCursorCaptureAccumulatedPausedMs(
+ cursorCaptureAccumulatedPausedMs + pauseDurationMs,
+ );
+ setCursorCapturePauseStartedAtMs(null);
+}
+
+export function getCursorCaptureElapsedMs(nowMs = Date.now()) {
+ if (!Number.isFinite(cursorCaptureStartTimeMs) || cursorCaptureStartTimeMs <= 0) {
+ return 0;
+ }
+
+ const safeNowMs = Math.max(cursorCaptureStartTimeMs, nowMs);
+ const activePauseDurationMs =
+ cursorCapturePauseStartedAtMs === null
+ ? 0
+ : Math.max(0, safeNowMs - cursorCapturePauseStartedAtMs);
+
+ return Math.max(
+ 0,
+ safeNowMs -
+ cursorCaptureStartTimeMs -
+ Math.max(0, cursorCaptureAccumulatedPausedMs) -
+ activePauseDurationMs,
+ );
+}
+
export function getNormalizedCursorPoint() {
const fallbackCursor = getScreen().getCursorScreenPoint();
const linuxCursorCache = process.platform === "linux" ? linuxCursorScreenPoint : null;
@@ -115,9 +168,9 @@ export function pushCursorSample(
}
}
-export function sampleCursorPoint() {
+export function sampleCursorPoint(sampledAtMs = Date.now()) {
const point = getNormalizedCursorPoint();
- pushCursorSample(point.cx, point.cy, Date.now() - cursorCaptureStartTimeMs, "move");
+ pushCursorSample(point.cx, point.cy, getCursorCaptureElapsedMs(sampledAtMs), "move");
}
export async function persistPendingCursorTelemetry(videoPath: string) {
@@ -163,7 +216,7 @@ export function startCursorSampling() {
let nextExpectedMs = Date.now() + CURSOR_SAMPLE_INTERVAL_MS;
const tick = () => {
- if (isCursorCaptureActive) {
+ if (isCursorCaptureActive && !isCursorCapturePaused()) {
sampleCursorPoint();
}
diff --git a/electron/ipc/register/recording.ts b/electron/ipc/register/recording.ts
index 9286efab..0e491b80 100644
--- a/electron/ipc/register/recording.ts
+++ b/electron/ipc/register/recording.ts
@@ -19,6 +19,9 @@ import { startInteractionCapture, stopInteractionCapture } from "../cursor/inter
import { startNativeCursorMonitor, stopNativeCursorMonitor } from "../cursor/monitor";
import {
clamp,
+ pauseCursorCapture,
+ resumeCursorCapture,
+ resetCursorCaptureClock,
sampleCursorPoint,
snapshotCursorTelemetryForPersistence,
startCursorSampling,
@@ -1275,6 +1278,7 @@ export function registerRecordingHandlers(
setActiveCursorSamples([]);
setPendingCursorSamples([]);
setCursorCaptureStartTimeMs(Date.now());
+ resetCursorCaptureClock();
setLinuxCursorScreenPoint(null);
setLastLeftClick(null);
sampleCursorPoint();
@@ -1288,6 +1292,7 @@ export function registerRecordingHandlers(
stopNativeCursorMonitor();
showCursor();
setLinuxCursorScreenPoint(null);
+ resetCursorCaptureClock();
snapshotCursorTelemetryForPersistence();
setActiveCursorSamples([]);
}
@@ -1307,6 +1312,26 @@ export function registerRecordingHandlers(
}
});
+ ipcMain.handle("pause-cursor-capture", (_event, boundaryMs?: number) => {
+ const timestamp =
+ typeof boundaryMs === "number" && Number.isFinite(boundaryMs)
+ ? boundaryMs
+ : Date.now();
+ sampleCursorPoint(timestamp);
+ pauseCursorCapture(timestamp);
+ return { success: true };
+ });
+
+ ipcMain.handle("resume-cursor-capture", (_event, boundaryMs?: number) => {
+ const timestamp =
+ typeof boundaryMs === "number" && Number.isFinite(boundaryMs)
+ ? boundaryMs
+ : Date.now();
+ resumeCursorCapture(timestamp);
+ sampleCursorPoint(timestamp);
+ return { success: true };
+ });
+
ipcMain.handle("get-cursor-telemetry", async (_, videoPath?: string) => {
const targetVideoPath = normalizeVideoSourcePath(videoPath ?? currentVideoPath);
if (!targetVideoPath) {
diff --git a/electron/ipc/state.ts b/electron/ipc/state.ts
index b1d809b8..a0a41744 100644
--- a/electron/ipc/state.ts
+++ b/electron/ipc/state.ts
@@ -76,6 +76,8 @@ export let currentCursorVisualType: CursorVisualType | undefined = undefined;
// ── Cursor telemetry ──────────────────────────────────────────────────────────
export let cursorCaptureInterval: NodeJS.Timeout | null = null;
export let cursorCaptureStartTimeMs = 0;
+export let cursorCaptureAccumulatedPausedMs = 0;
+export let cursorCapturePauseStartedAtMs: number | null = null;
export let activeCursorSamples: CursorTelemetryPoint[] = [];
export let pendingCursorSamples: CursorTelemetryPoint[] = [];
export let isCursorCaptureActive = false;
@@ -237,6 +239,12 @@ export function setCursorCaptureInterval(v: NodeJS.Timeout | null) {
export function setCursorCaptureStartTimeMs(v: number) {
cursorCaptureStartTimeMs = v;
}
+export function setCursorCaptureAccumulatedPausedMs(v: number) {
+ cursorCaptureAccumulatedPausedMs = v;
+}
+export function setCursorCapturePauseStartedAtMs(v: number | null) {
+ cursorCapturePauseStartedAtMs = v;
+}
export function setActiveCursorSamples(v: CursorTelemetryPoint[]) {
activeCursorSamples = v;
}
diff --git a/electron/preload.ts b/electron/preload.ts
index e41acc26..c9e464f2 100644
--- a/electron/preload.ts
+++ b/electron/preload.ts
@@ -293,6 +293,12 @@ contextBridge.exposeInMainWorld("electronAPI", {
resumeNativeScreenRecording: () => {
return ipcRenderer.invoke("resume-native-screen-recording");
},
+ pauseCursorCapture: (boundaryMs?: number) => {
+ return ipcRenderer.invoke("pause-cursor-capture", boundaryMs);
+ },
+ resumeCursorCapture: (boundaryMs?: number) => {
+ return ipcRenderer.invoke("resume-cursor-capture", boundaryMs);
+ },
startFfmpegRecording: (source: ProcessedDesktopSource) => {
return ipcRenderer.invoke("start-ffmpeg-recording", source);
},
diff --git a/package.json b/package.json
index 8e2a6a8a..dc1c95b9 100644
--- a/package.json
+++ b/package.json
@@ -3,13 +3,13 @@
"productName": "Recordly",
"description": "A free, creator-focused screen recorder with auto-zoom, cursor effects, backgrounds, annotations, and more - built for polished videos out of the box.",
"author": "webadderall",
- "homepage": "https://github.com/webadderall/Recordly",
+ "homepage": "https://github.com/webadderallorg/Recordly",
"repository": {
"type": "git",
- "url": "https://github.com/webadderall/Recordly.git"
+ "url": "https://github.com/webadderallorg/Recordly.git"
},
"bugs": {
- "url": "https://github.com/webadderall/Recordly/issues"
+ "url": "https://github.com/webadderallorg/Recordly/issues"
},
"private": true,
"version": "1.2.0",
diff --git a/recordly.rb b/recordly.rb
index 8d3e7db8..f037c928 100644
--- a/recordly.rb
+++ b/recordly.rb
@@ -5,10 +5,10 @@ cask "recordly" do
sha256 arm: "e669ab7c8bdd4596211937183ee2374545da5482702cab0dfe477c1466422b0f",
intel: "85f5183219de0b656400625797ff9299893bd8fbda8455b0f745878b2c729526"
- url "https://github.com/webadderall/Recordly/releases/download/v#{version}/Recordly-#{arch}.dmg"
+ url "https://github.com/webadderallorg/Recordly/releases/download/v#{version}/Recordly-#{arch}.dmg"
name "Recordly"
desc "Creator-focused screen recorder with auto-zoom, cursor effects, and more"
- homepage "https://github.com/webadderall/Recordly"
+ homepage "https://github.com/webadderallorg/Recordly"
livecheck do
url :url
diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx
index 4344b7c5..9c300475 100644
--- a/src/components/launch/LaunchWindow.tsx
+++ b/src/components/launch/LaunchWindow.tsx
@@ -61,11 +61,14 @@ interface DesktopSource {
}
const LOCALE_LABELS: Record = {
- en: "EN",
- es: "ES",
- nl: "NL",
- "zh-CN": "中文",
+ en: "English",
+ es: "Español",
+ fr: "Français",
+ nl: "Nederlands",
ko: "한국어",
+ "pt-BR": "Português",
+ "zh-CN": "簡體中文",
+ "zh-TW": "繁體中文",
};
const COUNTDOWN_OPTIONS = [0, 3, 5, 10];
diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx
index 5f8ece52..9b55f272 100644
--- a/src/components/video-editor/SettingsPanel.tsx
+++ b/src/components/video-editor/SettingsPanel.tsx
@@ -1886,7 +1886,7 @@ export function SettingsPanel({
value={borderRadius}
defaultValue={initialEditorPreferences.borderRadius}
min={0}
- max={50}
+ max={200}
step={0.5}
onChange={(v) => onBorderRadiusChange?.(v)}
formatValue={(v) => `${v}px`}
diff --git a/src/components/video-editor/TutorialHelp.tsx b/src/components/video-editor/TutorialHelp.tsx
index b6c7950d..b773683f 100644
--- a/src/components/video-editor/TutorialHelp.tsx
+++ b/src/components/video-editor/TutorialHelp.tsx
@@ -15,7 +15,7 @@ import { formatBinding, SHORTCUT_ACTIONS, SHORTCUT_LABELS } from "@/lib/shortcut
import { formatShortcut } from "@/utils/platformUtils";
import { toast } from "sonner";
-export const RECORDLY_ISSUES_URL = "https://github.com/webadderall/Recordly/issues";
+export const RECORDLY_ISSUES_URL = "https://github.com/webadderallorg/Recordly/issues";
const RECORDLY_DISCORD_URL = "https://discord.gg/sdv2FBVNgE";
const RECORDLY_X_URL = "https://x.com/webadderall";
const CONTACT_EMAIL = "youngchen3442@gmail.com";
diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx
index 2749de16..cb184632 100644
--- a/src/components/video-editor/VideoEditor.tsx
+++ b/src/components/video-editor/VideoEditor.tsx
@@ -1,4 +1,5 @@
import {
+ BookmarkSimple,
Check,
CaretDown as ChevronDown,
CaretUp as ChevronUp,
@@ -38,6 +39,8 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
+import { Input } from "@/components/ui/input";
+import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Toaster } from "@/components/ui/sonner";
import { useI18n } from "@/contexts/I18nContext";
import { useShortcuts } from "@/contexts/ShortcutsContext";
@@ -77,6 +80,7 @@ import {
getAspectRatioLabel,
getAspectRatioValue,
} from "@/utils/aspectRatioUtils";
+import { cn } from "@/lib/utils";
import { ExtensionIcon } from "./ExtensionIcon";
const PhCursorFill = (props: { className?: string; weight?: "fill" | "regular" }) => (
@@ -101,9 +105,18 @@ const PhSettings = (props: { className?: string; weight?: "fill" | "regular" })
import { extensionHost } from "@/lib/extensions";
import { resolveAutoCaptionSourcePath } from "./autoCaptionSource";
import { CropControl } from "./CropControl";
+import { updateCaptionCuesForEditedTarget, type CaptionEditTarget } from "./captionEditing";
import { ExportSettingsMenu } from "./ExportSettingsMenu";
import ExtensionManager from "./ExtensionManager";
-import { loadEditorPreferences, saveEditorPreferences } from "./editorPreferences";
+import {
+ loadEditorPreferences,
+ loadEditorPresets,
+ saveEditorPreferences,
+ saveEditorPresets,
+ serializeEditorPresetSnapshot,
+ type EditorPreset,
+ type EditorPresetSnapshot,
+} from "./editorPreferences";
import ProjectBrowserDialog, { type ProjectLibraryEntry } from "./ProjectBrowserDialog";
import {
createProjectData,
@@ -717,6 +730,10 @@ export default function VideoEditor() {
const [exportedFilePath, setExportedFilePath] = useState(undefined);
const [hasPendingExportSave, setHasPendingExportSave] = useState(false);
const [lastSavedSnapshot, setLastSavedSnapshot] = useState(null);
+ const [editorPresets, setEditorPresets] = useState(() => loadEditorPresets());
+ const [activeEditorPresetId, setActiveEditorPresetId] = useState(null);
+ const [presetPopoverOpen, setPresetPopoverOpen] = useState(false);
+ const [presetNameDraft, setPresetNameDraft] = useState("");
const [showCropModal, setShowCropModal] = useState(false);
const [previewVersion, setPreviewVersion] = useState(0);
const [isPreviewReady, setIsPreviewReady] = useState(false);
@@ -794,6 +811,285 @@ export default function VideoEditor() {
setHistoryVersion((version) => version + 1);
}, []);
+ const captureEditorPresetSnapshot = useCallback(
+ (): EditorPresetSnapshot => ({
+ wallpaper,
+ shadowIntensity,
+ backgroundBlur,
+ zoomMotionBlur,
+ connectZooms,
+ zoomInDurationMs,
+ zoomInOverlapMs,
+ zoomOutDurationMs,
+ connectedZoomGapMs,
+ connectedZoomDurationMs,
+ zoomInEasing,
+ zoomOutEasing,
+ connectedZoomEasing,
+ showCursor,
+ loopCursor,
+ cursorStyle,
+ cursorSize,
+ cursorSmoothing,
+ cursorMotionBlur,
+ cursorClickBounce,
+ cursorClickBounceDuration,
+ cursorSway,
+ borderRadius,
+ padding: { ...padding },
+ frame,
+ webcam: { ...webcam },
+ aspectRatio,
+ exportEncodingMode,
+ exportBackendPreference,
+ exportPipelineModel,
+ exportQuality,
+ mp4FrameRate,
+ exportFormat,
+ gifFrameRate,
+ gifLoop,
+ gifSizePreset,
+ autoCaptionSettings: { ...autoCaptionSettings },
+ whisperExecutablePath,
+ whisperModelPath,
+ }),
+ [
+ wallpaper,
+ shadowIntensity,
+ backgroundBlur,
+ zoomMotionBlur,
+ connectZooms,
+ zoomInDurationMs,
+ zoomInOverlapMs,
+ zoomOutDurationMs,
+ connectedZoomGapMs,
+ connectedZoomDurationMs,
+ zoomInEasing,
+ zoomOutEasing,
+ connectedZoomEasing,
+ showCursor,
+ loopCursor,
+ cursorStyle,
+ cursorSize,
+ cursorSmoothing,
+ cursorMotionBlur,
+ cursorClickBounce,
+ cursorClickBounceDuration,
+ cursorSway,
+ borderRadius,
+ padding,
+ frame,
+ webcam,
+ aspectRatio,
+ exportEncodingMode,
+ exportBackendPreference,
+ exportPipelineModel,
+ exportQuality,
+ mp4FrameRate,
+ exportFormat,
+ gifFrameRate,
+ gifLoop,
+ gifSizePreset,
+ autoCaptionSettings,
+ whisperExecutablePath,
+ whisperModelPath,
+ ],
+ );
+
+ const currentPresetSnapshot = useMemo(
+ () => captureEditorPresetSnapshot(),
+ [captureEditorPresetSnapshot],
+ );
+ const currentPresetSignature = useMemo(
+ () => serializeEditorPresetSnapshot(currentPresetSnapshot),
+ [currentPresetSnapshot],
+ );
+ const currentEditorPreset = useMemo(
+ () => editorPresets.find((preset) => preset.id === activeEditorPresetId) ?? null,
+ [activeEditorPresetId, editorPresets],
+ );
+
+ useEffect(() => {
+ const activePreset = currentEditorPreset;
+ if (
+ activePreset &&
+ serializeEditorPresetSnapshot(activePreset.snapshot) === currentPresetSignature
+ ) {
+ return;
+ }
+
+ const matchingPreset =
+ editorPresets.find(
+ (preset) =>
+ serializeEditorPresetSnapshot(preset.snapshot) === currentPresetSignature,
+ ) ?? null;
+ const nextActivePresetId = matchingPreset?.id ?? null;
+ if (nextActivePresetId !== activeEditorPresetId) {
+ setActiveEditorPresetId(nextActivePresetId);
+ }
+ }, [activeEditorPresetId, currentEditorPreset, currentPresetSignature, editorPresets]);
+
+ useEffect(() => {
+ if (!presetPopoverOpen) {
+ setPresetNameDraft("");
+ }
+ }, [presetPopoverOpen]);
+
+ const applyEditorPresetSnapshot = useCallback((snapshot: EditorPresetSnapshot) => {
+ setWallpaper(snapshot.wallpaper);
+ setShadowIntensity(snapshot.shadowIntensity);
+ setBackgroundBlur(snapshot.backgroundBlur);
+ setZoomMotionBlur(snapshot.zoomMotionBlur);
+ setConnectZooms(snapshot.connectZooms);
+ setZoomInDurationMs(snapshot.zoomInDurationMs);
+ setZoomInOverlapMs(snapshot.zoomInOverlapMs);
+ setZoomOutDurationMs(snapshot.zoomOutDurationMs);
+ setConnectedZoomGapMs(snapshot.connectedZoomGapMs);
+ setConnectedZoomDurationMs(snapshot.connectedZoomDurationMs);
+ setZoomInEasing(snapshot.zoomInEasing);
+ setZoomOutEasing(snapshot.zoomOutEasing);
+ setConnectedZoomEasing(snapshot.connectedZoomEasing);
+ setShowCursor(snapshot.showCursor);
+ setLoopCursor(snapshot.loopCursor);
+ setCursorStyle(snapshot.cursorStyle);
+ setCursorSize(snapshot.cursorSize);
+ setCursorSmoothing(snapshot.cursorSmoothing);
+ setCursorMotionBlur(snapshot.cursorMotionBlur);
+ setCursorClickBounce(snapshot.cursorClickBounce);
+ setCursorClickBounceDuration(snapshot.cursorClickBounceDuration);
+ setCursorSway(snapshot.cursorSway);
+ setBorderRadius(snapshot.borderRadius);
+ setPadding({ ...snapshot.padding });
+ setFrame(snapshot.frame);
+ setWebcam({ ...snapshot.webcam });
+ setAspectRatio(snapshot.aspectRatio);
+ setExportEncodingMode(snapshot.exportEncodingMode);
+ setExportBackendPreference(snapshot.exportBackendPreference);
+ setExportPipelineModel(snapshot.exportPipelineModel);
+ setExportQuality(snapshot.exportQuality);
+ setMp4FrameRate(snapshot.mp4FrameRate);
+ setExportFormat(snapshot.exportFormat);
+ setGifFrameRate(snapshot.gifFrameRate);
+ setGifLoop(snapshot.gifLoop);
+ setGifSizePreset(snapshot.gifSizePreset);
+ setAutoCaptionSettings({ ...snapshot.autoCaptionSettings });
+ setWhisperExecutablePath(snapshot.whisperExecutablePath);
+ setWhisperModelPath(snapshot.whisperModelPath);
+ }, []);
+
+ const handleApplyEditorPreset = useCallback(
+ (presetId: string) => {
+ const preset = editorPresets.find((item) => item.id === presetId);
+ if (!preset) {
+ return;
+ }
+
+ setActiveEditorPresetId(preset.id);
+ applyEditorPresetSnapshot(preset.snapshot);
+ toast.success(
+ t("editor.presets.toasts.applied", "Applied preset \"{{name}}\"", {
+ name: preset.name,
+ }),
+ );
+ },
+ [applyEditorPresetSnapshot, editorPresets, t],
+ );
+
+ const handleSaveEditorPreset = useCallback(
+ (name: string) => {
+ const normalizedName = name.trim().replace(/\s+/g, " ");
+ if (normalizedName.length === 0) {
+ toast.error(t("editor.presets.errors.nameRequired", "Enter a preset name."));
+ return false;
+ }
+
+ const hasDuplicateName = editorPresets.some(
+ (preset) => preset.name.toLocaleLowerCase() === normalizedName.toLocaleLowerCase(),
+ );
+ if (hasDuplicateName) {
+ toast.error(
+ t(
+ "editor.presets.errors.duplicateName",
+ "A preset with that name already exists.",
+ ),
+ );
+ return false;
+ }
+
+ const snapshot = captureEditorPresetSnapshot();
+ const timestamp = new Date().toISOString();
+ const nextPreset: EditorPreset = {
+ id: crypto.randomUUID(),
+ name: normalizedName,
+ createdAt: timestamp,
+ updatedAt: timestamp,
+ snapshot,
+ };
+ const nextPresets = [
+ nextPreset,
+ ...editorPresets,
+ ];
+
+ if (!saveEditorPresets(nextPresets)) {
+ toast.error(
+ t(
+ "editor.presets.errors.saveFailed",
+ "Could not save that preset. Check your browser storage settings and try again.",
+ ),
+ );
+ return false;
+ }
+
+ setEditorPresets(nextPresets);
+ setActiveEditorPresetId(nextPreset.id);
+ toast.success(
+ t("editor.presets.toasts.saved", "Saved preset \"{{name}}\"", {
+ name: normalizedName,
+ }),
+ );
+ return true;
+ },
+ [captureEditorPresetSnapshot, editorPresets, t],
+ );
+
+ const handleDeleteEditorPreset = useCallback(
+ (presetId: string) => {
+ const preset = editorPresets.find((item) => item.id === presetId);
+ if (!preset) {
+ return;
+ }
+
+ const nextPresets = editorPresets.filter((item) => item.id !== presetId);
+ if (!saveEditorPresets(nextPresets)) {
+ toast.error(
+ t(
+ "editor.presets.errors.deleteFailed",
+ "Could not delete that preset. Check your browser storage settings and try again.",
+ ),
+ );
+ return;
+ }
+
+ setEditorPresets(nextPresets);
+ if (preset.id === activeEditorPresetId) {
+ setActiveEditorPresetId(null);
+ }
+ toast.success(
+ t("editor.presets.toasts.deleted", "Deleted preset \"{{name}}\"", {
+ name: preset.name,
+ }),
+ );
+ },
+ [activeEditorPresetId, editorPresets, t],
+ );
+
+ const handleSavePresetSubmit = useCallback(() => {
+ const didSave = handleSaveEditorPreset(presetNameDraft);
+ if (didSave) {
+ setPresetNameDraft("");
+ }
+ }, [handleSaveEditorPreset, presetNameDraft]);
+
const clearPendingExportSave = useCallback(() => {
const pending = pendingExportSaveRef.current;
pendingExportSaveRef.current = null;
@@ -2337,6 +2633,14 @@ export default function VideoEditor() {
setAutoCaptionSettings((prev) => ({ ...prev, enabled: false }));
}, []);
+ const handleSaveAutoCaptionEdit = useCallback(
+ (target: CaptionEditTarget, text: string) => {
+ setAutoCaptions((captions) => updateCaptionCuesForEditedTarget(captions, target, text));
+ toast.success(t("settings.captions.editSaved", "Caption updated"));
+ },
+ [t],
+ );
+
const saveProject = useCallback(
async (forceSaveAs: boolean, options?: SaveProjectOptions) => {
clearPendingProjectAutosave();
@@ -4878,6 +5182,111 @@ export default function VideoEditor() {
className="flex items-center gap-2 justify-self-end pr-3"
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
>
+
+
+
+
+
+ {currentEditorPreset?.name ?? t("editor.presets.label", "Presets")}
+
+
+
+
+
+
+
+
+
+
+ {t("editor.presets.savedList", "Saved presets")}
+
+
+ {editorPresets.length === 0 ? (
+
+ {t("editor.presets.empty", "No presets yet.")}
+
+ ) : (
+ editorPresets.map((preset) => {
+ const isActive = preset.id === currentEditorPreset?.id;
+ return (
+
+ handleApplyEditorPreset(preset.id)}
+ className="flex min-w-0 flex-1 items-center justify-between text-left"
+ >
+ {preset.name}
+ {isActive ? : null}
+
+ handleDeleteEditorPreset(preset.id)}
+ className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-foreground/8 hover:text-foreground"
+ aria-label={t(
+ "editor.presets.deleteAriaLabel",
+ "Delete preset {{name}}",
+ { name: preset.name },
+ )}
+ title={t(
+ "editor.presets.deleteAriaLabel",
+ "Delete preset {{name}}",
+ { name: preset.name },
+ )}
+ >
+
+
+
+ );
+ })
+ )}
+
+
+
+
+
@@ -5440,6 +5849,7 @@ export default function VideoEditor() {
annotationRegions={annotationRegions}
autoCaptions={autoCaptions}
autoCaptionSettings={autoCaptionSettings}
+ onEditAutoCaption={handleSaveAutoCaptionEdit}
selectedAnnotationId={selectedAnnotationId}
onSelectAnnotation={handleSelectAnnotation}
onAnnotationPositionChange={
diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx
index c6c50f0b..99362abb 100644
--- a/src/components/video-editor/VideoPlayback.tsx
+++ b/src/components/video-editor/VideoPlayback.tsx
@@ -18,6 +18,7 @@ import {
useRef,
useState,
} from "react";
+import { useI18n } from "@/contexts/I18nContext";
import { getAssetPath, getRenderableAssetUrl, getRenderableVideoUrl } from "@/lib/assetPath";
import { clampMediaTimeToDuration, getMediaSyncPlaybackRate } from "@/lib/mediaTiming";
import {
@@ -25,6 +26,7 @@ import {
DEFAULT_WALLPAPER_RELATIVE_PATH,
isVideoWallpaperSource,
} from "@/lib/wallpapers";
+import { type CaptionEditTarget, normalizeCaptionEditText } from "./captionEditing";
import { buildActiveCaptionLayout } from "./captionLayout";
import {
CAPTION_FONT_WEIGHT,
@@ -254,6 +256,7 @@ interface VideoPlaybackProps {
annotationRegions?: AnnotationRegion[];
autoCaptions?: CaptionCue[];
autoCaptionSettings?: AutoCaptionSettings;
+ onEditAutoCaption?: (target: CaptionEditTarget, text: string) => void;
selectedAnnotationId?: string | null;
onSelectAnnotation?: (id: string | null) => void;
onAnnotationPositionChange?: (id: string, position: { x: number; y: number }) => void;
@@ -272,6 +275,11 @@ interface VideoPlaybackProps {
volume?: number;
}
+type CaptionEditSession = {
+ target: CaptionEditTarget;
+ draft: string;
+};
+
export interface VideoPlaybackRef {
video: HTMLVideoElement | null;
app: Application | null;
@@ -324,6 +332,7 @@ const VideoPlayback = forwardRef(
annotationRegions = [],
autoCaptions = [],
autoCaptionSettings,
+ onEditAutoCaption,
selectedAnnotationId,
onSelectAnnotation,
onAnnotationPositionChange,
@@ -343,6 +352,8 @@ const VideoPlayback = forwardRef(
},
ref,
) => {
+ const { t } = useI18n();
+ const editCurrentCaptionLabel = t("settings.captions.editCurrent", "Edit current caption");
const videoRef = useRef(null);
const containerRef = useRef(null);
const appRef = useRef(null);
@@ -359,6 +370,11 @@ const VideoPlayback = forwardRef(
const webcamBubbleRef = useRef(null);
const webcamBubbleInnerRef = useRef(null);
const captionBoxRef = useRef(null);
+ const captionEditInputRef = useRef(null);
+ const captionEditSessionRef = useRef(null);
+ const [captionEditSession, setCaptionEditSession] = useState(
+ null,
+ );
const currentTimeRef = useRef(0);
const zoomRegionsRef = useRef([]);
const selectedZoomIdRef = useRef(null);
@@ -471,6 +487,148 @@ const VideoPlayback = forwardRef(
measureText: (text) => measurementContext.measureText(text).width,
});
}, [autoCaptionSettings, autoCaptions, currentTime]);
+ const activeCaptionEditTarget = activeCaptionLayout?.editTarget ?? null;
+ const activeCaptionEditTargetId = activeCaptionEditTarget?.id ?? null;
+ const isCaptionEditing = captionEditSession !== null;
+ const captionEditDraft = captionEditSession?.draft ?? "";
+ const captionEditTargetId = captionEditSession?.target.id ?? null;
+ const captionEditTextMetrics = useMemo(() => {
+ if (!captionEditSession || !autoCaptionSettings || typeof document === "undefined") {
+ return null;
+ }
+
+ const overlayWidth = overlayRef.current?.clientWidth || 960;
+ const fontSize = getCaptionScaledFontSize(
+ autoCaptionSettings.fontSize,
+ overlayWidth,
+ autoCaptionSettings.maxWidth,
+ );
+ const maxTextWidthPx = getCaptionTextMaxWidth(
+ overlayWidth,
+ autoCaptionSettings.maxWidth,
+ fontSize,
+ );
+ const measurementCanvas = document.createElement("canvas");
+ const measurementContext = measurementCanvas.getContext("2d");
+ if (!measurementContext) {
+ return null;
+ }
+
+ measurementContext.font = `${CAPTION_FONT_WEIGHT} ${fontSize}px ${getDefaultCaptionFontFamily()}`;
+ const measuredWidth = Math.max(
+ ...captionEditSession.draft
+ .split(/\r?\n/)
+ .map((line) => measurementContext.measureText(line || " ").width),
+ );
+
+ return {
+ fontSize,
+ maxTextWidthPx,
+ widthPx: Math.ceil(
+ Math.min(maxTextWidthPx, Math.max(fontSize * 2, measuredWidth + 2)),
+ ),
+ };
+ }, [autoCaptionSettings, captionEditSession]);
+ const captionEditSizeKey = captionEditSession
+ ? `${captionEditTextMetrics?.widthPx ?? 0}:${captionEditDraft}`
+ : "";
+
+ const beginCaptionEdit = useCallback(() => {
+ if (!activeCaptionLayout?.editTarget || !onEditAutoCaption) {
+ return;
+ }
+
+ videoRef.current?.pause();
+ onPlayStateChange(false);
+ const nextSession = {
+ target: activeCaptionLayout.editTarget,
+ draft: activeCaptionLayout.editTarget.text,
+ };
+ captionEditSessionRef.current = nextSession;
+ setCaptionEditSession(nextSession);
+ }, [activeCaptionLayout, onEditAutoCaption, onPlayStateChange]);
+
+ const commitCaptionEdit = useCallback(() => {
+ const session = captionEditSessionRef.current;
+ if (!session || !onEditAutoCaption) {
+ captionEditSessionRef.current = null;
+ setCaptionEditSession(null);
+ return;
+ }
+
+ const normalizedDraft = normalizeCaptionEditText(session.draft);
+ captionEditSessionRef.current = null;
+ if (!normalizedDraft) {
+ setCaptionEditSession(null);
+ return;
+ }
+
+ if (normalizedDraft !== normalizeCaptionEditText(session.target.text)) {
+ onEditAutoCaption(session.target, session.draft);
+ }
+ setCaptionEditSession(null);
+ }, [onEditAutoCaption]);
+
+ const cancelCaptionEdit = useCallback(() => {
+ captionEditSessionRef.current = null;
+ setCaptionEditSession(null);
+ }, []);
+
+ useEffect(() => {
+ if (!activeCaptionEditTarget) {
+ return;
+ }
+
+ setCaptionEditSession((session) => {
+ if (!session || session.target.id === activeCaptionEditTargetId) {
+ return session;
+ }
+
+ const nextSession = {
+ ...session,
+ target: activeCaptionEditTarget,
+ };
+ captionEditSessionRef.current = nextSession;
+ return nextSession;
+ });
+ }, [activeCaptionEditTarget, activeCaptionEditTargetId]);
+
+ useEffect(() => {
+ if (!captionEditTargetId) {
+ return;
+ }
+
+ const frame = requestAnimationFrame(() => {
+ const input = captionEditInputRef.current;
+ if (!input) {
+ return;
+ }
+
+ input.focus();
+ const cursorPosition = input.value.length;
+ input.setSelectionRange(cursorPosition, cursorPosition);
+ });
+
+ return () => cancelAnimationFrame(frame);
+ }, [captionEditTargetId]);
+
+ useEffect(() => {
+ if (!captionEditSizeKey) {
+ return;
+ }
+
+ const frame = requestAnimationFrame(() => {
+ const input = captionEditInputRef.current;
+ if (!input) {
+ return;
+ }
+
+ input.style.height = "auto";
+ input.style.height = `${input.scrollHeight}px`;
+ });
+
+ return () => cancelAnimationFrame(frame);
+ }, [captionEditSizeKey]);
useEffect(() => {
const captionBox = captionBoxRef.current;
@@ -483,6 +641,12 @@ const VideoPlayback = forwardRef(
}
const frame = requestAnimationFrame(() => {
+ if (isCaptionEditing) {
+ captionBox.dataset.editingCaption = captionEditSizeKey;
+ } else {
+ delete captionBox.dataset.editingCaption;
+ }
+
const width = captionBox.offsetWidth;
const height = captionBox.offsetHeight;
if (width <= 0 || height <= 0) {
@@ -507,7 +671,7 @@ const VideoPlayback = forwardRef(
});
return () => cancelAnimationFrame(frame);
- }, [activeCaptionLayout, autoCaptionSettings]);
+ }, [activeCaptionLayout, autoCaptionSettings, captionEditSizeKey, isCaptionEditing]);
const motionBlurStateRef = useRef(createMotionBlurState());
const applyWebcamBubbleLayout = useCallback(
@@ -1020,7 +1184,8 @@ const VideoPlayback = forwardRef(
: clampMediaTimeToDuration(clipTimelineTime, videoDuration);
const activeSpeedRegion = speedRegionsRef.current.find(
- (region) => currentTime * 1000 >= region.startMs && currentTime * 1000 < region.endMs,
+ (region) =>
+ currentTime * 1000 >= region.startMs && currentTime * 1000 < region.endMs,
);
const targetPlaybackRate = activeSpeedRegion ? activeSpeedRegion.speed : 1;
const syncedPlaybackRate = getMediaSyncPlaybackRate({
@@ -2368,7 +2533,34 @@ const VideoPlayback = forwardRef(
}}
>
{
+ if (!captionEditSession) {
+ beginCaptionEdit();
+ }
+ }}
+ onKeyDown={(event) => {
+ if (!onEditAutoCaption || captionEditSession) {
+ return;
+ }
+ if (event.key === "Enter" || event.key === " ") {
+ event.preventDefault();
+ beginCaptionEdit();
+ }
+ }}
style={{
backgroundColor: `rgba(0, 0, 0, ${autoCaptionSettings.backgroundOpacity})`,
fontFamily: getDefaultCaptionFontFamily(),
@@ -2406,42 +2598,137 @@ const VideoPlayback = forwardRef
(
),
)}px`,
boxSizing: "border-box",
+ cursor:
+ onEditAutoCaption && !captionEditSession
+ ? "text"
+ : undefined,
+ pointerEvents: onEditAutoCaption ? "auto" : undefined,
}}
>
- {activeCaptionLayout.visibleLines.map((line) => (
- {
+ const draft = event.target.value;
+ setCaptionEditSession((session) => {
+ const nextSession = session
+ ? {
+ ...session,
+ draft,
+ }
+ : session;
+ captionEditSessionRef.current = nextSession;
+ return nextSession;
+ });
}}
- >
- {line.words.map((word) => {
- const visualState = getCaptionWordVisualState(
- activeCaptionLayout.hasWordTimings,
- word.state,
- );
+ onBlur={commitCaptionEdit}
+ onClick={(event) => event.stopPropagation()}
+ onKeyDown={(event) => {
+ if (event.key === "Escape") {
+ event.preventDefault();
+ cancelCaptionEdit();
+ return;
+ }
+ if (event.key === "Enter" && !event.shiftKey) {
+ event.preventDefault();
+ event.currentTarget.blur();
+ }
+ }}
+ rows={Math.max(
+ 1,
+ activeCaptionLayout.visibleLines.length,
+ )}
+ aria-label={editCurrentCaptionLabel}
+ style={{
+ display: "block",
+ width: `${
+ captionEditTextMetrics?.widthPx ??
+ Math.max(
+ 48,
+ activeCaptionLayout.visibleLines.reduce(
+ (width, line) =>
+ Math.max(width, line.width),
+ 0,
+ ),
+ )
+ }px`,
+ maxWidth: `${
+ captionEditTextMetrics?.maxTextWidthPx ??
+ getCaptionTextMaxWidth(
+ overlayRef.current?.clientWidth || 960,
+ autoCaptionSettings.maxWidth,
+ getCaptionScaledFontSize(
+ autoCaptionSettings.fontSize,
+ overlayRef.current?.clientWidth ||
+ 960,
+ autoCaptionSettings.maxWidth,
+ ),
+ )
+ }px`,
+ minHeight: `${
+ Math.max(
+ 1,
+ activeCaptionLayout.visibleLines.length,
+ ) *
+ getCaptionScaledFontSize(
+ autoCaptionSettings.fontSize,
+ overlayRef.current?.clientWidth || 960,
+ autoCaptionSettings.maxWidth,
+ ) *
+ CAPTION_LINE_HEIGHT
+ }px`,
+ resize: "none",
+ border: "0",
+ outline: "0",
+ padding: "0",
+ margin: "0",
+ overflow: "hidden",
+ background: "transparent",
+ color: autoCaptionSettings.textColor,
+ font: "inherit",
+ fontWeight: "inherit",
+ lineHeight: "inherit",
+ textAlign: "center",
+ }}
+ />
+ ) : (
+ activeCaptionLayout.visibleLines.map((line) => (
+
+ {line.words.map((word) => {
+ const visualState =
+ getCaptionWordVisualState(
+ activeCaptionLayout.hasWordTimings,
+ word.state,
+ );
- return (
-
- {`${word.leadingSpace ? " " : ""}${word.text}`}
-
- );
- })}
-
- ))}
+ return (
+
+ {`${word.leadingSpace ? " " : ""}${word.text}`}
+
+ );
+ })}
+
+ ))
+ )}
diff --git a/src/components/video-editor/captionEditing.test.ts b/src/components/video-editor/captionEditing.test.ts
new file mode 100644
index 00000000..7cfc1831
--- /dev/null
+++ b/src/components/video-editor/captionEditing.test.ts
@@ -0,0 +1,117 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ type CaptionEditTarget,
+ normalizeCaptionEditText,
+ updateCaptionCuesForEditedTarget,
+} from "./captionEditing";
+import { buildActiveCaptionLayout } from "./captionLayout";
+import { type CaptionCue, DEFAULT_AUTO_CAPTION_SETTINGS } from "./types";
+
+const visibleTarget: CaptionEditTarget = {
+ id: "visible-page",
+ startMs: 1_000,
+ endMs: 2_400,
+ text: "Hello Hello 你们好啊",
+ words: [
+ {
+ cueId: "a",
+ cueWordIndex: 0,
+ startMs: 1_000,
+ endMs: 1_500,
+ text: "Hello",
+ leadingSpace: false,
+ },
+ {
+ cueId: "a",
+ cueWordIndex: 1,
+ startMs: 1_500,
+ endMs: 2_000,
+ text: "Hello",
+ leadingSpace: true,
+ },
+ {
+ cueId: "b",
+ cueWordIndex: 0,
+ startMs: 2_000,
+ endMs: 2_400,
+ text: "你们好啊",
+ leadingSpace: true,
+ },
+ ],
+};
+
+describe("captionEditing", () => {
+ it("normalizes edited caption text", () => {
+ expect(normalizeCaptionEditText(" hello \n edited\tcaption ")).toBe(
+ "hello edited caption",
+ );
+ expect(normalizeCaptionEditText(" \n\t ")).toBe("");
+ });
+
+ it("keeps text-only captions text-only after editing", () => {
+ const updated = updateCaptionCuesForEditedTarget(
+ [
+ { id: "a", startMs: 1_000, endMs: 2_000, text: "Hello Hello" },
+ { id: "b", startMs: 2_000, endMs: 3_000, text: "你们好啊 这个是我的屏幕" },
+ ],
+ visibleTarget,
+ "Hi 大家好",
+ );
+
+ expect(updated.map((caption) => caption.text)).toEqual(["Hi", "大家好 这个是我的屏幕"]);
+ expect(updated.every((caption) => caption.words === undefined)).toBe(true);
+
+ const layout = buildActiveCaptionLayout({
+ cues: updated,
+ timeMs: 1_500,
+ settings: DEFAULT_AUTO_CAPTION_SETTINGS,
+ maxWidthPx: 500,
+ measureText: (text) => text.length * 10,
+ });
+ expect(layout?.hasWordTimings).toBe(false);
+ });
+
+ it("preserves cue identity and timing when editing captions with word timings", () => {
+ const cues: CaptionCue[] = [
+ {
+ id: "a",
+ startMs: 1_000,
+ endMs: 2_000,
+ text: "Hello Hello",
+ words: [
+ { text: "Hello", startMs: 1_000, endMs: 1_500 },
+ { text: "Hello", startMs: 1_500, endMs: 2_000, leadingSpace: true },
+ ],
+ },
+ {
+ id: "b",
+ startMs: 2_000,
+ endMs: 3_000,
+ text: "你们好啊 这个是我的屏幕",
+ words: [
+ { text: "你们好啊", startMs: 2_000, endMs: 2_400 },
+ { text: "这个是我的屏幕", startMs: 2_400, endMs: 3_000, leadingSpace: true },
+ ],
+ },
+ ];
+
+ const updated = updateCaptionCuesForEditedTarget(cues, visibleTarget, "Hi 大家好");
+
+ expect(updated.map((caption) => [caption.id, caption.startMs, caption.endMs])).toEqual([
+ ["a", 1_000, 2_000],
+ ["b", 2_000, 3_000],
+ ]);
+ expect(updated[0].words).toEqual([{ text: "Hi", startMs: 1_000, endMs: 2_000 }]);
+ expect(updated[1].words).toEqual([
+ { text: "大家好", startMs: 2_000, endMs: 2_400 },
+ { text: "这个是我的屏幕", startMs: 2_400, endMs: 3_000, leadingSpace: true },
+ ]);
+ });
+
+ it("does not update captions when edited text is blank", () => {
+ const cues: CaptionCue[] = [{ id: "a", startMs: 1_000, endMs: 2_000, text: "Hello Hello" }];
+
+ expect(updateCaptionCuesForEditedTarget(cues, visibleTarget, " \n\t ")).toBe(cues);
+ });
+});
diff --git a/src/components/video-editor/captionEditing.ts b/src/components/video-editor/captionEditing.ts
new file mode 100644
index 00000000..b5084426
--- /dev/null
+++ b/src/components/video-editor/captionEditing.ts
@@ -0,0 +1,234 @@
+import type { CaptionCue, CaptionCueWord } from "./types";
+
+export interface CaptionEditWordRef {
+ cueId: string;
+ cueWordIndex: number;
+ startMs: number;
+ endMs: number;
+ text: string;
+ leadingSpace: boolean;
+}
+
+export interface CaptionEditTarget {
+ id: string;
+ startMs: number;
+ endMs: number;
+ text: string;
+ words: CaptionEditWordRef[];
+}
+
+export function normalizeCaptionEditText(text: string) {
+ return text.trim().replace(/\s+/g, " ");
+}
+
+function buildCaptionWordsForEditedText(
+ text: string,
+ startMs: number,
+ endMs: number,
+): CaptionCueWord[] {
+ const normalizedText = normalizeCaptionEditText(text);
+ const tokens = normalizedText.match(/\S+/g) ?? [];
+ const normalizedStartMs = Math.max(0, Math.round(startMs));
+ const normalizedEndMs = Math.max(normalizedStartMs + 1, Math.round(endMs));
+ const durationMs = normalizedEndMs - normalizedStartMs;
+
+ return tokens.map((token, index) => {
+ const wordStartMs = Math.min(
+ normalizedEndMs - 1,
+ Math.max(
+ normalizedStartMs,
+ Math.round(normalizedStartMs + (durationMs * index) / tokens.length),
+ ),
+ );
+ const nextBoundaryMs =
+ index === tokens.length - 1
+ ? normalizedEndMs
+ : Math.round(normalizedStartMs + (durationMs * (index + 1)) / tokens.length);
+ const wordEndMs = Math.min(normalizedEndMs, Math.max(wordStartMs + 1, nextBoundaryMs));
+
+ return {
+ text: token,
+ startMs: wordStartMs,
+ endMs: wordEndMs,
+ ...(index > 0 ? { leadingSpace: true } : {}),
+ };
+ });
+}
+
+function normalizeCaptionWords(cue: CaptionCue): CaptionCueWord[] {
+ const sourceWords =
+ Array.isArray(cue.words) && cue.words.length > 0
+ ? cue.words
+ : buildCaptionWordsForEditedText(cue.text, cue.startMs, cue.endMs);
+
+ return sourceWords
+ .filter((word): word is CaptionCueWord => Boolean(word && typeof word.text === "string"))
+ .map((word) => {
+ const startMs = Math.max(
+ cue.startMs,
+ Math.min(cue.endMs - 1, Math.round(word.startMs)),
+ );
+ const endMs = Math.max(startMs + 1, Math.min(cue.endMs, Math.round(word.endMs)));
+
+ return {
+ text: normalizeCaptionEditText(word.text),
+ startMs,
+ endMs,
+ ...(word.leadingSpace ? { leadingSpace: true } : {}),
+ };
+ })
+ .filter((word) => word.text.length > 0);
+}
+
+function captionWordsToText(words: CaptionCueWord[]) {
+ return words
+ .map((word, index) => `${index > 0 && word.leadingSpace ? " " : ""}${word.text}`)
+ .join("")
+ .trim();
+}
+
+function normalizeCaptionWordSpacing(words: CaptionCueWord[]): CaptionCueWord[] {
+ return words
+ .slice()
+ .sort((a, b) => a.startMs - b.startMs || a.endMs - b.endMs)
+ .map((word, index) => ({
+ text: word.text,
+ startMs: word.startMs,
+ endMs: word.endMs,
+ ...(index > 0 ? { leadingSpace: true } : {}),
+ }));
+}
+
+function shouldPreserveCaptionWords(cue: CaptionCue) {
+ return Array.isArray(cue.words) && cue.words.length > 0;
+}
+
+export function updateCaptionCuesForEditedTarget(
+ cues: CaptionCue[],
+ target: CaptionEditTarget,
+ text: string,
+): CaptionCue[] {
+ const normalizedText = normalizeCaptionEditText(text);
+ if (!normalizedText || target.words.length === 0) {
+ return cues;
+ }
+
+ const targetWordsByCue = new Map();
+ for (const word of target.words) {
+ const words = targetWordsByCue.get(word.cueId) ?? [];
+ words.push(word);
+ targetWordsByCue.set(word.cueId, words);
+ }
+
+ const tokens = normalizedText.match(/\S+/g) ?? [];
+ const targetCueIds = new Set(targetWordsByCue.keys());
+ const cueSegments = cues
+ .filter((cue) => targetCueIds.has(cue.id))
+ .map((cue) => {
+ const words = targetWordsByCue.get(cue.id) ?? [];
+ return {
+ cue,
+ startMs: Math.min(...words.map((word) => word.startMs)),
+ endMs: Math.max(...words.map((word) => word.endMs)),
+ };
+ })
+ .filter((segment) => Number.isFinite(segment.startMs) && Number.isFinite(segment.endMs));
+ const editedWordsByCue = new Map();
+ const tokenCountsByCue = distributeEditedTokensAcrossCueSegments(tokens.length, cueSegments);
+ let tokenCursor = 0;
+
+ for (const segment of cueSegments) {
+ const tokenCount = tokenCountsByCue.get(segment.cue.id) ?? 0;
+ if (tokenCount <= 0) {
+ continue;
+ }
+
+ const segmentTokens = tokens.slice(tokenCursor, tokenCursor + tokenCount);
+ tokenCursor += tokenCount;
+ editedWordsByCue.set(
+ segment.cue.id,
+ buildCaptionWordsForEditedText(segmentTokens.join(" "), segment.startMs, segment.endMs),
+ );
+ }
+
+ return cues.map((cue) => {
+ const targetWords = targetWordsByCue.get(cue.id);
+ if (!targetWords) {
+ return cue;
+ }
+
+ const targetIndexes = new Set(targetWords.map((word) => word.cueWordIndex));
+ const existingWords = normalizeCaptionWords(cue);
+ const keptWords = existingWords.filter((_, index) => !targetIndexes.has(index));
+ const nextWords = normalizeCaptionWordSpacing([
+ ...keptWords,
+ ...(editedWordsByCue.get(cue.id) ?? []),
+ ]);
+ const shouldKeepWords = shouldPreserveCaptionWords(cue);
+
+ return {
+ id: cue.id,
+ startMs: cue.startMs,
+ endMs: cue.endMs,
+ text: captionWordsToText(nextWords),
+ ...(shouldKeepWords && nextWords.length > 0 ? { words: nextWords } : {}),
+ };
+ });
+}
+
+function distributeEditedTokensAcrossCueSegments(
+ tokenCount: number,
+ segments: Array<{ cue: CaptionCue; startMs: number; endMs: number }>,
+) {
+ const tokenCountsByCue = new Map();
+ if (tokenCount <= 0 || segments.length === 0) {
+ return tokenCountsByCue;
+ }
+
+ if (tokenCount < segments.length) {
+ const largestSegments = [...segments]
+ .sort((a, b) => b.endMs - b.startMs - (a.endMs - a.startMs))
+ .slice(0, tokenCount);
+ const selectedCueIds = new Set(largestSegments.map((segment) => segment.cue.id));
+ for (const segment of segments) {
+ tokenCountsByCue.set(segment.cue.id, selectedCueIds.has(segment.cue.id) ? 1 : 0);
+ }
+ return tokenCountsByCue;
+ }
+
+ const baseTokenCount = 1;
+ const remainingTokens = tokenCount - segments.length;
+ const totalDuration = Math.max(
+ 1,
+ segments.reduce(
+ (total, segment) => total + Math.max(1, segment.endMs - segment.startMs),
+ 0,
+ ),
+ );
+ const weightedCounts = segments.map((segment) => {
+ const exactCount =
+ (Math.max(1, segment.endMs - segment.startMs) / totalDuration) * remainingTokens;
+ const extraCount = Math.floor(exactCount);
+ return {
+ segment,
+ count: baseTokenCount + extraCount,
+ remainder: exactCount - extraCount,
+ };
+ });
+ let assignedTokens = weightedCounts.reduce((total, item) => total + item.count, 0);
+
+ for (const item of [...weightedCounts].sort((a, b) => b.remainder - a.remainder)) {
+ if (assignedTokens >= tokenCount) {
+ break;
+ }
+
+ item.count += 1;
+ assignedTokens += 1;
+ }
+
+ for (const item of weightedCounts) {
+ tokenCountsByCue.set(item.segment.cue.id, item.count);
+ }
+
+ return tokenCountsByCue;
+}
diff --git a/src/components/video-editor/captionLayout.ts b/src/components/video-editor/captionLayout.ts
index 076cb5db..6c0d987e 100644
--- a/src/components/video-editor/captionLayout.ts
+++ b/src/components/video-editor/captionLayout.ts
@@ -1,3 +1,4 @@
+import type { CaptionEditTarget } from "./captionEditing";
import type {
AutoCaptionAnimation,
AutoCaptionSettings,
@@ -8,12 +9,15 @@ import type {
export type CaptionWordState = "spoken" | "active" | "upcoming";
export interface CaptionWordLayout {
+ cueId: string;
+ cueWordIndex: number;
text: string;
index: number;
forcedBreakBefore: boolean;
leadingSpace: boolean;
startMs: number;
endMs: number;
+ hasRealTiming: boolean;
state: CaptionWordState;
}
@@ -37,6 +41,7 @@ export interface ActiveCaptionLayout {
hasWordTimings: boolean;
activeWordIndex: number;
activeWordProgress: number;
+ editTarget: CaptionEditTarget;
visiblePageIndex: number;
opacity: number;
translateY: number;
@@ -45,6 +50,7 @@ export interface ActiveCaptionLayout {
type CaptionSourceWord = {
cueId: string;
+ cueWordIndex: number;
text: string;
forcedBreakBefore: boolean;
leadingSpace?: boolean;
@@ -77,6 +83,7 @@ function splitCaptionWordsFromText(text: string) {
.forEach((word, wordIndex) => {
words.push({
cueId: "",
+ cueWordIndex: words.length,
text: word,
forcedBreakBefore: lineIndex > 0 && wordIndex === 0,
});
@@ -92,8 +99,9 @@ function splitCaptionWords(cue: CaptionCue) {
.filter((word): word is CaptionCueWord =>
Boolean(word && typeof word.text === "string"),
)
- .map((word) => ({
+ .map((word, cueWordIndex) => ({
cueId: cue.id,
+ cueWordIndex,
text: word.text.trim(),
forcedBreakBefore: false,
leadingSpace: Boolean(word.leadingSpace),
@@ -103,7 +111,10 @@ function splitCaptionWords(cue: CaptionCue) {
.filter((word) => word.text.length > 0);
}
- return splitCaptionWordsFromText(cue.text);
+ return splitCaptionWordsFromText(cue.text).map((word) => ({
+ ...word,
+ cueId: cue.id,
+ }));
}
function getActiveCaptionCue(cues: CaptionCue[], timeMs: number) {
@@ -119,6 +130,7 @@ function getActiveCaptionCue(cues: CaptionCue[], timeMs: number) {
function flattenCaptionWords(cues: CaptionCue[]) {
const flattened: Array<{
cueId: string;
+ cueWordIndex: number;
text: string;
forcedBreakBefore: boolean;
leadingSpace: boolean;
@@ -160,6 +172,7 @@ function flattenCaptionWords(cues: CaptionCue[]) {
flattened.push({
cueId: cue.id,
+ cueWordIndex: word.cueWordIndex,
text: word.text,
forcedBreakBefore:
word.forcedBreakBefore || (wordIndex === 0 && shouldForceCueBreak),
@@ -380,6 +393,18 @@ function getVisibleCaptionPageIndex(pages: CaptionPageLayout[], timeMs: number)
return -1;
}
+function getVisibleCaptionText(lines: CaptionLineLayout[]) {
+ return lines
+ .map((line) =>
+ line.words
+ .map((word, index) => `${index > 0 && word.leadingSpace ? " " : ""}${word.text}`)
+ .join("")
+ .trim(),
+ )
+ .filter(Boolean)
+ .join(" ");
+}
+
export function buildActiveCaptionLayout(options: {
cues: CaptionCue[];
timeMs: number;
@@ -392,34 +417,32 @@ export function buildActiveCaptionLayout(options: {
return null;
}
- const hasWordTimings = sourceWords.every((word) => word.hasRealTiming);
-
let activeWordIndex = -1;
- if (hasWordTimings) {
- activeWordIndex = sourceWords.findIndex(
- (word) => options.timeMs >= word.startMs && options.timeMs < word.endMs,
- );
- if (activeWordIndex < 0) {
- activeWordIndex = sourceWords.findIndex((word) => options.timeMs < word.startMs);
- activeWordIndex =
- activeWordIndex < 0
- ? sourceWords.length - 1
- : clamp(activeWordIndex - 1, 0, sourceWords.length - 1);
- }
+ activeWordIndex = sourceWords.findIndex(
+ (word) => options.timeMs >= word.startMs && options.timeMs < word.endMs,
+ );
+ if (activeWordIndex < 0) {
+ activeWordIndex = sourceWords.findIndex((word) => options.timeMs < word.startMs);
+ activeWordIndex =
+ activeWordIndex < 0
+ ? sourceWords.length - 1
+ : clamp(activeWordIndex - 1, 0, sourceWords.length - 1);
}
const maxRows = clamp(Math.round(options.settings.maxRows || 1), 1, 4);
const words: CaptionWordLayout[] = sourceWords.map((word, index) => {
return {
+ cueId: word.cueId,
+ cueWordIndex: word.cueWordIndex,
text: word.text,
index,
forcedBreakBefore: word.forcedBreakBefore,
leadingSpace: word.leadingSpace,
startMs: word.startMs,
endMs: word.endMs,
- state: !hasWordTimings
- ? "spoken"
- : index < activeWordIndex
+ hasRealTiming: word.hasRealTiming,
+ state:
+ index < activeWordIndex
? "spoken"
: index === activeWordIndex
? "active"
@@ -461,6 +484,8 @@ export function buildActiveCaptionLayout(options: {
const animationStartMs = visiblePage?.startMs ?? sourceWords[0].startMs;
const animationEndMs = visiblePage?.endMs ?? sourceWords[sourceWords.length - 1].endMs;
const pageStartWordIndex = visibleLines[0]?.startWordIndex ?? 0;
+ const visibleWords = visibleLines.flatMap((line) => line.words);
+ const visibleHasWordTimings = visibleWords.every((word) => word.hasRealTiming);
const pageCueId = sourceWords[pageStartWordIndex]?.cueId ?? sourceWords[0].cueId;
const activeCue =
getActiveCaptionCue(options.cues, options.timeMs) ??
@@ -478,9 +503,25 @@ export function buildActiveCaptionLayout(options: {
cue: activeCue,
blockKey: `${Math.round(animationStartMs)}-${Math.round(animationEndMs)}`,
visibleLines,
- hasWordTimings,
+ hasWordTimings: visibleHasWordTimings,
activeWordIndex,
activeWordProgress,
+ editTarget: {
+ id: `${Math.round(animationStartMs)}-${Math.round(animationEndMs)}:${visibleWords
+ .map((word) => `${word.cueId}:${word.cueWordIndex}`)
+ .join("|")}`,
+ startMs: animationStartMs,
+ endMs: animationEndMs,
+ text: getVisibleCaptionText(visibleLines),
+ words: visibleWords.map((word) => ({
+ cueId: word.cueId,
+ cueWordIndex: word.cueWordIndex,
+ startMs: word.startMs,
+ endMs: word.endMs,
+ text: word.text,
+ leadingSpace: word.leadingSpace,
+ })),
+ },
visiblePageIndex,
opacity: animation.opacity,
translateY: animation.translateY,
diff --git a/src/components/video-editor/editorPreferences.test.ts b/src/components/video-editor/editorPreferences.test.ts
index 2aa70013..f9a079cf 100644
--- a/src/components/video-editor/editorPreferences.test.ts
+++ b/src/components/video-editor/editorPreferences.test.ts
@@ -3,10 +3,14 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import {
DEFAULT_EDITOR_PREFERENCES,
EDITOR_PREFERENCES_STORAGE_KEY,
+ EDITOR_PRESETS_STORAGE_KEY,
+ loadEditorPresets,
loadEditorPreferences,
normalizeEditorPreferences,
+ saveEditorPresets,
saveEditorPreferences,
} from "./editorPreferences";
+import { DEFAULT_AUTO_CAPTION_SETTINGS } from "./types";
function createStorageMock(initialValues: Record = {}): Storage {
const store = new Map(Object.entries(initialValues));
@@ -318,4 +322,42 @@ describe("editorPreferences", () => {
whisperModelPath: "/Users/test/models/ggml-small.bin",
});
});
+
+ it("saves editor presets and reports success", () => {
+ const localStorage = createStorageMock();
+ vi.stubGlobal("localStorage", localStorage);
+
+ expect(
+ saveEditorPresets([
+ {
+ id: "preset-1",
+ name: " Demo Preset ",
+ createdAt: "2026-05-01T00:00:00.000Z",
+ updatedAt: "2026-05-01T00:00:00.000Z",
+ snapshot: {
+ ...DEFAULT_EDITOR_PREFERENCES,
+ autoCaptionSettings: DEFAULT_AUTO_CAPTION_SETTINGS,
+ },
+ },
+ ]),
+ ).toBe(true);
+
+ expect(localStorage.getItem(EDITOR_PRESETS_STORAGE_KEY)).not.toBeNull();
+ expect(loadEditorPresets()).toMatchObject([
+ {
+ id: "preset-1",
+ name: "Demo Preset",
+ },
+ ]);
+ });
+
+ it("returns false when preset persistence fails", () => {
+ const localStorage = createStorageMock();
+ localStorage.setItem = () => {
+ throw new Error("quota exceeded");
+ };
+ vi.stubGlobal("localStorage", localStorage);
+
+ expect(saveEditorPresets([])).toBe(false);
+ });
});
diff --git a/src/components/video-editor/editorPreferences.ts b/src/components/video-editor/editorPreferences.ts
index c9be18ab..0b97dd34 100644
--- a/src/components/video-editor/editorPreferences.ts
+++ b/src/components/video-editor/editorPreferences.ts
@@ -48,6 +48,22 @@ type PersistedEditorControls = Pick<
type PartialEditorControls = Partial;
+type PresetAutoCaptionSettings = ProjectEditorState["autoCaptionSettings"];
+
+export interface EditorPresetSnapshot extends PersistedEditorControls {
+ autoCaptionSettings: PresetAutoCaptionSettings;
+ whisperExecutablePath: string | null;
+ whisperModelPath: string | null;
+}
+
+export interface EditorPreset {
+ id: string;
+ name: string;
+ createdAt: string;
+ updatedAt: string;
+ snapshot: EditorPresetSnapshot;
+}
+
export interface EditorPreferences extends PersistedEditorControls {
customAspectWidth: string;
customAspectHeight: string;
@@ -58,6 +74,7 @@ export interface EditorPreferences extends PersistedEditorControls {
}
export const EDITOR_PREFERENCES_STORAGE_KEY = "recordly.editor.preferences";
+export const EDITOR_PRESETS_STORAGE_KEY = "recordly.editor.presets";
const DEFAULT_EDITOR_CONTROLS = normalizeProjectEditor({});
@@ -144,6 +161,88 @@ function normalizeNullablePath(value: unknown): string | null {
return trimmed.length > 0 ? trimmed : null;
}
+function normalizePresetAutoCaptionSettings(value: unknown): PresetAutoCaptionSettings {
+ return normalizeProjectEditor({
+ autoCaptionSettings:
+ value && typeof value === "object"
+ ? (value as PresetAutoCaptionSettings)
+ : undefined,
+ }).autoCaptionSettings;
+}
+
+function normalizeEditorPresetSnapshot(candidate: unknown): EditorPresetSnapshot {
+ const normalizedPreferences = normalizeEditorPreferences(candidate);
+ const raw =
+ candidate && typeof candidate === "object"
+ ? (candidate as Partial)
+ : {};
+
+ return {
+ ...normalizeEditorControls(normalizedPreferences, normalizedPreferences),
+ autoCaptionSettings: normalizePresetAutoCaptionSettings(raw.autoCaptionSettings),
+ whisperExecutablePath:
+ normalizeNullablePath(raw.whisperExecutablePath) ?? normalizedPreferences.whisperExecutablePath,
+ whisperModelPath:
+ normalizeNullablePath(raw.whisperModelPath) ?? normalizedPreferences.whisperModelPath,
+ };
+}
+
+function normalizePresetName(value: unknown): string | null {
+ if (typeof value !== "string") {
+ return null;
+ }
+
+ const trimmed = value.trim().replace(/\s+/g, " ");
+ return trimmed.length > 0 ? trimmed : null;
+}
+
+function normalizePresetTimestamp(value: unknown, fallback: string): string {
+ if (typeof value !== "string") {
+ return fallback;
+ }
+
+ const parsed = Date.parse(value);
+ return Number.isFinite(parsed) ? new Date(parsed).toISOString() : fallback;
+}
+
+function normalizeEditorPreset(candidate: unknown): EditorPreset | null {
+ if (!candidate || typeof candidate !== "object") {
+ return null;
+ }
+
+ const raw = candidate as Partial;
+ const name = normalizePresetName(raw.name);
+ if (!name) {
+ return null;
+ }
+
+ const timestamp = new Date().toISOString();
+ const id = typeof raw.id === "string" && raw.id.trim().length > 0 ? raw.id : crypto.randomUUID();
+
+ return {
+ id,
+ name,
+ createdAt: normalizePresetTimestamp(raw.createdAt, timestamp),
+ updatedAt: normalizePresetTimestamp(raw.updatedAt, timestamp),
+ snapshot: normalizeEditorPresetSnapshot(raw.snapshot),
+ };
+}
+
+function normalizeEditorPresets(candidates: unknown): EditorPreset[] {
+ if (!Array.isArray(candidates)) {
+ return [];
+ }
+
+ return candidates
+ .map((item) => normalizeEditorPreset(item))
+ .filter((preset): preset is EditorPreset => preset !== null)
+ .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
+}
+
+export function serializeEditorPresetSnapshot(snapshot: EditorPresetSnapshot): string {
+ return JSON.stringify(normalizeEditorPresetSnapshot(snapshot));
+}
+
function normalizeEditorControls(
raw: Partial,
fallback: EditorPreferences,
@@ -300,3 +399,34 @@ export function saveEditorPreferences(preferences: Partial):
// Ignore storage failures so editor controls still work.
}
}
+
+export function loadEditorPresets(): EditorPreset[] {
+ if (typeof globalThis.localStorage === "undefined") {
+ return [];
+ }
+
+ try {
+ const stored = globalThis.localStorage.getItem(EDITOR_PRESETS_STORAGE_KEY);
+ if (!stored) {
+ return [];
+ }
+
+ return normalizeEditorPresets(JSON.parse(stored));
+ } catch {
+ return [];
+ }
+}
+
+export function saveEditorPresets(presets: EditorPreset[]): boolean {
+ if (typeof globalThis.localStorage === "undefined") {
+ return false;
+ }
+
+ try {
+ const normalized = normalizeEditorPresets(presets);
+ globalThis.localStorage.setItem(EDITOR_PRESETS_STORAGE_KEY, JSON.stringify(normalized));
+ return true;
+ } catch {
+ return false;
+ }
+}
diff --git a/src/components/video-editor/timeline/TimelineEditor.tsx b/src/components/video-editor/timeline/TimelineEditor.tsx
index 6528c13b..538337b9 100644
--- a/src/components/video-editor/timeline/TimelineEditor.tsx
+++ b/src/components/video-editor/timeline/TimelineEditor.tsx
@@ -1479,14 +1479,11 @@ const TimelineEditor = forwardRef(
const activeClip = clipRegions.find(
(clip) => startPos >= clip.startMs && startPos < clip.endMs,
);
- if (!activeClip) {
- return false;
- }
const sorted = [...zoomRegions].sort((a, b) => a.startMs - b.startMs);
const nextRegion = sorted.find((region) => region.startMs > startPos);
- const gapToNextClipEdge = activeClip.endMs - startPos;
- const gapToNextRegion = nextRegion ? nextRegion.startMs - startPos : gapToNextClipEdge;
+ const gapToNextClipEdge = activeClip ? activeClip.endMs - startPos : totalMs - startPos;
+ const gapToNextRegion = nextRegion ? nextRegion.startMs - startPos : totalMs - startPos;
const availableDuration = Math.min(gapToNextClipEdge, gapToNextRegion);
const isOverlapping = sorted.some(
@@ -1513,7 +1510,7 @@ const TimelineEditor = forwardRef(
if (!canPlaceZoomAtMs(startPos)) {
toast.error("Cannot place zoom here", {
description:
- "Place zooms inside a kept clip and leave enough room before the clip ends.",
+ "Zoom already exists here or there is not enough room before the next zoom or clip end.",
});
return;
}
diff --git a/src/components/video-editor/timeline/zoomSuggestionUtils.test.ts b/src/components/video-editor/timeline/zoomSuggestionUtils.test.ts
new file mode 100644
index 00000000..77080bfc
--- /dev/null
+++ b/src/components/video-editor/timeline/zoomSuggestionUtils.test.ts
@@ -0,0 +1,207 @@
+import { describe, expect, it } from "vitest";
+import {
+ CLICK_CLUSTER_MERGE_GAP_MS,
+ CLICK_CLUSTER_PAD_MS,
+ buildInteractionZoomSuggestions,
+} from "./zoomSuggestionUtils";
+import type { CursorTelemetryPoint } from "../types";
+
+function makeClick(
+ timeMs: number,
+ cx = 0.5,
+ cy = 0.5,
+ interactionType: CursorTelemetryPoint["interactionType"] = "click",
+): CursorTelemetryPoint {
+ return { timeMs, cx, cy, interactionType };
+}
+
+function makeMove(timeMs: number, cx = 0.5, cy = 0.5): CursorTelemetryPoint {
+ return { timeMs, cx, cy, interactionType: "move" };
+}
+
+/** Wraps click samples with surrounding move events to mimic real mixed telemetry. */
+function withMoves(
+ clicks: CursorTelemetryPoint[],
+ totalMs: number,
+): CursorTelemetryPoint[] {
+ return [
+ makeMove(0),
+ ...clicks,
+ makeMove(totalMs),
+ ];
+}
+
+const TOTAL_MS = 30_000;
+
+describe("buildInteractionZoomSuggestions (click-cluster logic)", () => {
+ it("creates one zoom track for a single isolated click with 500ms padding", () => {
+ const telemetry = withMoves([makeClick(5_000)], TOTAL_MS);
+
+ const result = buildInteractionZoomSuggestions({
+ cursorTelemetry: telemetry,
+ totalMs: TOTAL_MS,
+ defaultDurationMs: 3_000,
+ });
+
+ expect(result.status).toBe("ok");
+ expect(result.suggestions).toHaveLength(1);
+
+ const [s] = result.suggestions;
+ expect(s.start).toBe(5_000 - CLICK_CLUSTER_PAD_MS);
+ expect(s.end).toBe(5_000 + CLICK_CLUSTER_PAD_MS);
+ });
+
+ it("accepts a single explicit click sample without needing surrounding moves", () => {
+ const result = buildInteractionZoomSuggestions({
+ cursorTelemetry: [makeClick(5_000)],
+ totalMs: TOTAL_MS,
+ defaultDurationMs: 3_000,
+ });
+
+ expect(result.status).toBe("ok");
+ expect(result.suggestions).toHaveLength(1);
+ });
+
+ it.each(["right-click", "middle-click"] as const)(
+ "accepts %s telemetry like a standard click",
+ (interactionType) => {
+ const result = buildInteractionZoomSuggestions({
+ cursorTelemetry: withMoves([makeClick(5_000, 0.5, 0.5, interactionType)], TOTAL_MS),
+ totalMs: TOTAL_MS,
+ defaultDurationMs: 3_000,
+ });
+
+ expect(result.status).toBe("ok");
+ expect(result.suggestions).toHaveLength(1);
+
+ const [suggestion] = result.suggestions;
+ expect(suggestion.start).toBe(5_000 - CLICK_CLUSTER_PAD_MS);
+ expect(suggestion.end).toBe(5_000 + CLICK_CLUSTER_PAD_MS);
+ },
+ );
+
+ it("merges two clicks within 2500ms into one zoom track", () => {
+ const telemetry = withMoves(
+ [makeClick(4_000), makeClick(4_000 + CLICK_CLUSTER_MERGE_GAP_MS - 1)],
+ TOTAL_MS,
+ );
+
+ const result = buildInteractionZoomSuggestions({
+ cursorTelemetry: telemetry,
+ totalMs: TOTAL_MS,
+ defaultDurationMs: 3_000,
+ });
+
+ expect(result.status).toBe("ok");
+ expect(result.suggestions).toHaveLength(1);
+
+ const [s] = result.suggestions;
+ const lastClickMs = 4_000 + CLICK_CLUSTER_MERGE_GAP_MS - 1;
+ expect(s.start).toBe(4_000 - CLICK_CLUSTER_PAD_MS);
+ expect(s.end).toBe(lastClickMs + CLICK_CLUSTER_PAD_MS);
+ });
+
+ it("splits two clicks more than 2500ms apart into separate zoom tracks", () => {
+ const click1 = 3_000;
+ const click2 = 3_000 + CLICK_CLUSTER_MERGE_GAP_MS + 1; // just outside the merge gap
+
+ const telemetry = withMoves([makeClick(click1), makeClick(click2)], TOTAL_MS);
+
+ const result = buildInteractionZoomSuggestions({
+ cursorTelemetry: telemetry,
+ totalMs: TOTAL_MS,
+ defaultDurationMs: 3_000,
+ });
+
+ expect(result.status).toBe("ok");
+ expect(result.suggestions).toHaveLength(2);
+
+ const [a, b] = result.suggestions;
+ expect(a.start).toBe(click1 - CLICK_CLUSTER_PAD_MS);
+ expect(a.end).toBe(click1 + CLICK_CLUSTER_PAD_MS);
+ expect(b.start).toBe(click2 - CLICK_CLUSTER_PAD_MS);
+ expect(b.end).toBe(click2 + CLICK_CLUSTER_PAD_MS);
+ });
+
+ it("chains multiple clicks: 3 in a row within 2500ms each become one track", () => {
+ // click at 0, 2000, 4000 — each gap is 2000ms < 2500ms
+ const telemetry = withMoves([makeClick(0), makeClick(2_000), makeClick(4_000)], TOTAL_MS);
+
+ const result = buildInteractionZoomSuggestions({
+ cursorTelemetry: telemetry,
+ totalMs: TOTAL_MS,
+ defaultDurationMs: 3_000,
+ });
+
+ expect(result.status).toBe("ok");
+ expect(result.suggestions).toHaveLength(1);
+
+ const [s] = result.suggestions;
+ expect(s.start).toBe(0); // clamped to 0 (would be -500)
+ expect(s.end).toBe(4_000 + CLICK_CLUSTER_PAD_MS);
+ });
+
+ it("returns no-interactions when there are no click telemetry points", () => {
+ // Move events only — no clicks
+ const telemetry: CursorTelemetryPoint[] = [
+ { timeMs: 0, cx: 0.5, cy: 0.5, interactionType: "move" },
+ { timeMs: 1_000, cx: 0.5, cy: 0.5, interactionType: "move" },
+ { timeMs: 2_000, cx: 0.6, cy: 0.6, interactionType: "move" },
+ { timeMs: TOTAL_MS, cx: 0.6, cy: 0.6, interactionType: "move" },
+ ];
+
+ const result = buildInteractionZoomSuggestions({
+ cursorTelemetry: telemetry,
+ totalMs: TOTAL_MS,
+ defaultDurationMs: 3_000,
+ });
+
+ expect(result.status).toBe("no-interactions");
+ expect(result.suggestions).toHaveLength(0);
+ });
+
+ it("ignores dwell-derived click-like heuristics when there are no explicit clicks", () => {
+ const telemetry: CursorTelemetryPoint[] = [
+ makeMove(0, 0.5, 0.5),
+ makeMove(200, 0.5005, 0.5005),
+ makeMove(400, 0.5008, 0.5008),
+ makeMove(600, 0.501, 0.501),
+ ];
+
+ const result = buildInteractionZoomSuggestions({
+ cursorTelemetry: telemetry,
+ totalMs: TOTAL_MS,
+ defaultDurationMs: 3_000,
+ });
+
+ expect(result.status).toBe("no-interactions");
+ expect(result.suggestions).toHaveLength(0);
+ });
+
+ it("skips clusters that overlap reserved spans", () => {
+ const click = 5_000;
+
+ const result = buildInteractionZoomSuggestions({
+ cursorTelemetry: withMoves([makeClick(click)], TOTAL_MS),
+ totalMs: TOTAL_MS,
+ defaultDurationMs: 3_000,
+ reservedSpans: [{ start: 4_000, end: 6_000 }], // overlaps the cluster window
+ });
+
+ expect(result.status).toBe("no-slots");
+ expect(result.suggestions).toHaveLength(0);
+ });
+
+ it("clamps start to 0 and end to totalMs at video boundaries", () => {
+ const result = buildInteractionZoomSuggestions({
+ cursorTelemetry: withMoves([makeClick(200)], 1_000),
+ totalMs: 1_000,
+ defaultDurationMs: 3_000,
+ });
+
+ expect(result.status).toBe("ok");
+ const [s] = result.suggestions;
+ expect(s.start).toBeGreaterThanOrEqual(0);
+ expect(s.end).toBeLessThanOrEqual(1_000);
+ });
+});
diff --git a/src/components/video-editor/timeline/zoomSuggestionUtils.ts b/src/components/video-editor/timeline/zoomSuggestionUtils.ts
index 2df2db85..189c7604 100644
--- a/src/components/video-editor/timeline/zoomSuggestionUtils.ts
+++ b/src/components/video-editor/timeline/zoomSuggestionUtils.ts
@@ -19,6 +19,7 @@ export interface CursorInteractionCandidate extends ZoomDwellCandidate {
| "dropdown-open"
| "text-selection"
| "text-field-click";
+ source: "explicit" | "heuristic";
}
export interface SuggestedZoomRegion {
@@ -38,8 +39,22 @@ export interface InteractionZoomSuggestionResult {
suggestions: SuggestedZoomRegion[];
}
-const DEFAULT_SUGGESTION_SPACING_MS = 1800;
-const DEFAULT_MERGE_NEARBY_GAP_MS = 1500;
+/** Max gap between consecutive clicks before they are split into separate zoom clusters. */
+export const CLICK_CLUSTER_MERGE_GAP_MS = 2500;
+/** Padding added before the first click and after the last click in a cluster. */
+export const CLICK_CLUSTER_PAD_MS = 500;
+const EXPLICIT_CLICK_TYPES = new Set>([
+ "click",
+ "double-click",
+ "right-click",
+ "middle-click",
+]);
+
+function isExplicitClickType(
+ interactionType: CursorTelemetryPoint["interactionType"],
+): interactionType is NonNullable {
+ return typeof interactionType === "string" && EXPLICIT_CLICK_TYPES.has(interactionType);
+}
function normalizeTelemetrySample(
sample: CursorTelemetryPoint,
@@ -185,9 +200,7 @@ export function detectInteractionCandidates(
samples: CursorTelemetryPoint[],
): CursorInteractionCandidate[] {
// --- Phase 1: Explicit interaction events (from uiohook telemetry) ---
- const clickEvents = samples.filter(
- (s) => s.interactionType && s.interactionType !== "move" && s.interactionType !== "mouseup",
- );
+ const clickEvents = samples.filter((sample) => isExplicitClickType(sample.interactionType));
const explicitInteractionCandidates: CursorInteractionCandidate[] = [];
@@ -211,6 +224,7 @@ export function detectInteractionCandidates(
focus: { cx: clickSample.cx, cy: clickSample.cy },
strength: baseStrength,
kind,
+ source: "explicit",
});
}
@@ -218,12 +232,12 @@ export function detectInteractionCandidates(
const dwellCandidates = detectZoomDwellCandidates(samples).map(
(candidate) => {
if (candidate.strength >= 1100) {
- return { ...candidate, kind: "text-focus-like" };
+ return { ...candidate, kind: "text-focus-like", source: "heuristic" };
}
if (candidate.strength <= 800) {
- return { ...candidate, kind: "click-like" };
+ return { ...candidate, kind: "click-like", source: "heuristic" };
}
- return { ...candidate, kind: "dwell" };
+ return { ...candidate, kind: "dwell", source: "heuristic" };
},
);
@@ -247,6 +261,7 @@ export function detectInteractionCandidates(
},
strength: prev.strength + curr.strength + 500,
kind: "double-click-like",
+ source: "heuristic",
});
}
}
@@ -254,6 +269,73 @@ export function detectInteractionCandidates(
return [...explicitInteractionCandidates, ...dwellCandidates, ...doubleClickCandidates];
}
+/**
+ * Groups a sorted list of click timestamps into clusters where consecutive
+ * clicks are no more than `mergeGapMs` apart. Returns an array of
+ * `{ firstMs, lastMs, focus }` objects, one per cluster. The focus is taken
+ * from the click with the highest interaction strength, falling back to the
+ * centroid of all clicks in the cluster.
+ */
+function buildClickClusters(
+ clicks: CursorInteractionCandidate[],
+ mergeGapMs: number,
+): Array<{ firstMs: number; lastMs: number; focus: ZoomFocus }> {
+ if (clicks.length === 0) {
+ return [];
+ }
+
+ const sorted = [...clicks].sort((a, b) => a.centerTimeMs - b.centerTimeMs);
+ const clusters: Array<{ firstMs: number; lastMs: number; focus: ZoomFocus }> = [];
+
+ let clusterStart = sorted[0].centerTimeMs;
+ let clusterEnd = sorted[0].centerTimeMs;
+ let bestStrength = sorted[0].strength;
+ let bestFocus = sorted[0].focus;
+ let sumCx = sorted[0].focus.cx;
+ let sumCy = sorted[0].focus.cy;
+ let count = 1;
+
+ for (let i = 1; i < sorted.length; i++) {
+ const click = sorted[i];
+ const gap = click.centerTimeMs - clusterEnd;
+
+ if (gap <= mergeGapMs) {
+ // Extend current cluster
+ clusterEnd = Math.max(clusterEnd, click.centerTimeMs);
+ if (click.strength > bestStrength) {
+ bestStrength = click.strength;
+ bestFocus = click.focus;
+ }
+ sumCx += click.focus.cx;
+ sumCy += click.focus.cy;
+ count += 1;
+ } else {
+ // Flush current cluster and start a new one
+ clusters.push({
+ firstMs: clusterStart,
+ lastMs: clusterEnd,
+ focus: bestFocus ?? { cx: sumCx / count, cy: sumCy / count },
+ });
+ clusterStart = click.centerTimeMs;
+ clusterEnd = click.centerTimeMs;
+ bestStrength = click.strength;
+ bestFocus = click.focus;
+ sumCx = click.focus.cx;
+ sumCy = click.focus.cy;
+ count = 1;
+ }
+ }
+
+ // Flush last cluster
+ clusters.push({
+ firstMs: clusterStart,
+ lastMs: clusterEnd,
+ focus: bestFocus ?? { cx: sumCx / count, cy: sumCy / count },
+ });
+
+ return clusters;
+}
+
export function buildInteractionZoomSuggestions(params: {
cursorTelemetry: CursorTelemetryPoint[];
totalMs: number;
@@ -261,82 +343,79 @@ export function buildInteractionZoomSuggestions(params: {
reservedSpans?: Array<{ start: number; end: number }>;
spacingMs?: number;
mergeGapMs?: number;
+ padMs?: number;
}): InteractionZoomSuggestionResult {
const {
cursorTelemetry,
totalMs,
- defaultDurationMs,
reservedSpans = [],
- spacingMs = DEFAULT_SUGGESTION_SPACING_MS,
- mergeGapMs = DEFAULT_MERGE_NEARBY_GAP_MS,
+ mergeGapMs = CLICK_CLUSTER_MERGE_GAP_MS,
+ padMs = CLICK_CLUSTER_PAD_MS,
} = params;
- const defaultDuration = Math.min(defaultDurationMs, totalMs);
- if (defaultDuration <= 0) {
+ if (totalMs <= 0) {
return { status: "no-slots", suggestions: [] };
}
const normalizedSamples = normalizeCursorTelemetry(cursorTelemetry, totalMs);
- if (normalizedSamples.length < 2) {
+ if (normalizedSamples.length === 0) {
return { status: "no-telemetry", suggestions: [] };
}
- const interactionCandidates = detectInteractionCandidates(normalizedSamples);
- if (interactionCandidates.length === 0) {
+ if (
+ normalizedSamples.length === 1 &&
+ !isExplicitClickType(normalizedSamples[0].interactionType)
+ ) {
+ return { status: "no-telemetry", suggestions: [] };
+ }
+
+ // Only use explicit click events (uiohook telemetry) – ignore dwell heuristics
+ const clickCandidates = detectInteractionCandidates(normalizedSamples).filter(
+ (candidate) => candidate.source === "explicit",
+ );
+
+ if (clickCandidates.length === 0) {
return { status: "no-interactions", suggestions: [] };
}
- const sortedCandidates = [...interactionCandidates].sort((a, b) => b.strength - a.strength);
- const acceptedCenters: number[] = [];
- const accepted: SuggestedZoomRegion[] = [];
+ // Group nearby clicks into clusters, then derive zoom windows from those clusters
+ const clusters = buildClickClusters(clickCandidates, mergeGapMs);
+
const reserved = [...reservedSpans].sort((a, b) => a.start - b.start);
+ const suggestions: SuggestedZoomRegion[] = [];
- sortedCandidates.forEach((candidate) => {
- const tooCloseToAccepted = acceptedCenters.some(
- (center) => Math.abs(center - candidate.centerTimeMs) < spacingMs,
- );
+ for (const cluster of clusters) {
+ const regionStart = Math.max(0, cluster.firstMs - padMs);
+ const regionEnd = Math.min(totalMs, cluster.lastMs + padMs);
- if (tooCloseToAccepted) {
- return;
- }
-
- const centeredStart = Math.round(candidate.centerTimeMs - defaultDuration / 2);
- const candidateStart = Math.max(0, Math.min(centeredStart, totalMs - defaultDuration));
- const candidateEnd = candidateStart + defaultDuration;
- const hasOverlap = reserved.some(
- (span) => candidateEnd > span.start && candidateStart < span.end,
- );
-
- if (hasOverlap) {
- return;
- }
-
- reserved.push({ start: candidateStart, end: candidateEnd });
- acceptedCenters.push(candidate.centerTimeMs);
- accepted.push({
- start: candidateStart,
- end: candidateEnd,
- focus: candidate.focus,
- });
- });
-
- const sortedAccepted = [...accepted].sort((a, b) => a.start - b.start);
- const merged: SuggestedZoomRegion[] = [];
- for (const region of sortedAccepted) {
- const previous = merged[merged.length - 1];
- if (previous && region.start - previous.end <= mergeGapMs) {
- previous.end = Math.max(previous.end, region.end);
+ if (regionEnd <= regionStart) {
continue;
}
- merged.push({ ...region });
+ const hasOverlap = reserved.some(
+ (span) => regionEnd > span.start && regionStart < span.end,
+ );
+
+ if (hasOverlap) {
+ continue;
+ }
+
+ reserved.push({ start: regionStart, end: regionEnd });
+ suggestions.push({
+ start: regionStart,
+ end: regionEnd,
+ focus: cluster.focus,
+ });
}
- if (merged.length === 0) {
+ if (suggestions.length === 0) {
return { status: "no-slots", suggestions: [] };
}
- return { status: "ok", suggestions: merged };
+ // Sort chronologically
+ suggestions.sort((a, b) => a.start - b.start);
+
+ return { status: "ok", suggestions };
}
/**
diff --git a/src/components/video-editor/videoPlayback/motionSmoothing.ts b/src/components/video-editor/videoPlayback/motionSmoothing.ts
index 4c15fd58..1f5c4392 100644
--- a/src/components/video-editor/videoPlayback/motionSmoothing.ts
+++ b/src/components/video-editor/videoPlayback/motionSmoothing.ts
@@ -1,4 +1,4 @@
-// Friendly reminder: Recordly is licensed under AGPL-3.0, author @webadderall, repo-> https://github.com/webadderall/Recordly
+// Friendly reminder: Recordly is licensed under AGPL-3.0, author @webadderall, repo-> https://github.com/webadderallorg/Recordly
// Please use this code with the right attribution.
export interface SpringState {
diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts
index 3709fa84..994d3e02 100644
--- a/src/hooks/useScreenRecorder.ts
+++ b/src/hooks/useScreenRecorder.ts
@@ -1094,7 +1094,6 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
}
}
- const wantsAudioCapture = microphoneEnabled || systemAudioEnabled;
const browserCaptureSource = await resolveBrowserCaptureSource(selectedSource);
if (
@@ -1128,11 +1127,12 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
cursor: "never" as const,
};
- if (wantsAudioCapture) {
- let screenMediaStream: MediaStream;
- const useLinuxPortal = selectedSource.id === "screen:linux-portal";
- const acquireLinuxPortalStream = (withAudio: boolean) =>
- mediaDevices.getDisplayMedia({
+
+
+ const acquireLinuxPortalStream = async (withAudio: boolean): Promise => {
+
+ try {
+ return await mediaDevices.getDisplayMedia({
audio: withAudio,
video: {
displaySurface: "monitor",
@@ -1144,6 +1144,43 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
selfBrowserSurface: "exclude",
surfaceSwitching: "exclude",
});
+ }
+
+ catch (err) {
+ console.warn("Linux portal failed, falling back to desktop capture(no audio):", err);
+ if (withAudio) {
+ alert("System audio is not supported in fallback mode. Recording will continue without audio.");
+ }
+
+
+ const sources = await window.electronAPI.getSources({ types: ["screen"] });
+
+ if (!sources.length) {
+ throw new Error("No screen sources available");
+ }
+
+ const source = sources[0];
+ console.log("Using fallback source:", source);
+
+
+
+ return await navigator.mediaDevices.getUserMedia({
+ audio: false, //intentional
+ video: {
+ mandatory: {
+ chromeMediaSource: "desktop",
+ chromeMediaSourceId: source.id,
+ maxWidth: TARGET_WIDTH,
+ maxHeight: TARGET_HEIGHT,
+ maxFrameRate: TARGET_FRAME_RATE,
+ },
+ },
+ } as any);
+ }
+ };
+
+ let screenMediaStream: MediaStream;
+ const useLinuxPortal = selectedSource.id === "screen:linux-portal";
if (systemAudioEnabled) {
try {
@@ -1248,26 +1285,10 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
} else if (micAudioTrack) {
stream.current.addTrack(micAudioTrack);
}
- } else {
- const mediaStream = await mediaDevices.getDisplayMedia({
- audio: false,
- video: {
- displaySurface: selectedSource.id?.startsWith("window:")
- ? "window"
- : "monitor",
- width: { ideal: TARGET_WIDTH, max: TARGET_WIDTH },
- height: { ideal: TARGET_HEIGHT, max: TARGET_HEIGHT },
- frameRate: { ideal: TARGET_FRAME_RATE, max: TARGET_FRAME_RATE },
- cursor: "never",
- },
- selfBrowserSurface: "exclude",
- surfaceSwitching: "exclude",
- });
-
- stream.current = mediaStream;
- videoTrack = mediaStream.getVideoTracks()[0];
- }
+
+
+
if (!stream.current || !videoTrack) {
throw new Error("Media stream is not available.");
}
@@ -1419,7 +1440,32 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
if (webcamRecorder.current?.state === "recording") {
webcamRecorder.current.pause();
}
- markRecordingPaused(Date.now());
+ const boundaryMs = Date.now();
+ try {
+ await window.electronAPI.pauseCursorCapture(boundaryMs);
+ } catch (error) {
+ console.warn("Failed to pause cursor capture:", error);
+ try {
+ const rollbackResult =
+ await window.electronAPI.resumeNativeScreenRecording();
+ if (!rollbackResult.success) {
+ console.warn(
+ "Failed to roll back native pause after cursor pause failure:",
+ rollbackResult.error ?? rollbackResult.message,
+ );
+ }
+ } catch (rollbackError) {
+ console.warn(
+ "Failed to roll back native pause after cursor pause failure:",
+ rollbackError,
+ );
+ }
+ if (webcamRecorder.current?.state === "paused") {
+ webcamRecorder.current.resume();
+ }
+ return;
+ }
+ markRecordingPaused(boundaryMs);
setPaused(true);
})();
return;
@@ -1429,8 +1475,23 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
if (webcamRecorder.current?.state === "recording") {
webcamRecorder.current.pause();
}
- markRecordingPaused(Date.now());
- setPaused(true);
+ const boundaryMs = Date.now();
+ void (async () => {
+ try {
+ await window.electronAPI.pauseCursorCapture(boundaryMs);
+ } catch (error) {
+ console.warn("Failed to pause cursor capture:", error);
+ if (mediaRecorder.current?.state === "paused") {
+ mediaRecorder.current.resume();
+ }
+ if (webcamRecorder.current?.state === "paused") {
+ webcamRecorder.current.resume();
+ }
+ return;
+ }
+ markRecordingPaused(boundaryMs);
+ setPaused(true);
+ })();
}
}, [markRecordingPaused, paused, recording]);
@@ -1450,7 +1511,32 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
if (webcamRecorder.current?.state === "paused") {
webcamRecorder.current.resume();
}
- markRecordingResumed(Date.now());
+ const boundaryMs = Date.now();
+ try {
+ await window.electronAPI.resumeCursorCapture(boundaryMs);
+ } catch (error) {
+ console.warn("Failed to resume cursor capture:", error);
+ try {
+ const rollbackResult =
+ await window.electronAPI.pauseNativeScreenRecording();
+ if (!rollbackResult.success) {
+ console.warn(
+ "Failed to roll back native resume after cursor resume failure:",
+ rollbackResult.error ?? rollbackResult.message,
+ );
+ }
+ } catch (rollbackError) {
+ console.warn(
+ "Failed to roll back native resume after cursor resume failure:",
+ rollbackError,
+ );
+ }
+ if (webcamRecorder.current?.state === "recording") {
+ webcamRecorder.current.pause();
+ }
+ return;
+ }
+ markRecordingResumed(boundaryMs);
setPaused(false);
})();
return;
@@ -1460,8 +1546,23 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
if (webcamRecorder.current?.state === "paused") {
webcamRecorder.current.resume();
}
- markRecordingResumed(Date.now());
- setPaused(false);
+ const boundaryMs = Date.now();
+ void (async () => {
+ try {
+ await window.electronAPI.resumeCursorCapture(boundaryMs);
+ } catch (error) {
+ console.warn("Failed to resume cursor capture:", error);
+ if (mediaRecorder.current?.state === "recording") {
+ mediaRecorder.current.pause();
+ }
+ if (webcamRecorder.current?.state === "recording") {
+ webcamRecorder.current.pause();
+ }
+ return;
+ }
+ markRecordingResumed(boundaryMs);
+ setPaused(false);
+ })();
}
}, [markRecordingResumed, paused, recording]);
diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json
index c788b132..2ad584e4 100644
--- a/src/i18n/locales/en/settings.json
+++ b/src/i18n/locales/en/settings.json
@@ -120,6 +120,8 @@
"generateFull": "Generate Captions",
"regenerateFull": "Regenerate Captions",
"clearFull": "Clear Captions",
+ "editCurrent": "Edit current caption",
+ "editSaved": "Caption updated",
"fontSettings": "Font Settings",
"defaultFont": "Default",
"fontFamily": "Font",
diff --git a/src/i18n/locales/es/settings.json b/src/i18n/locales/es/settings.json
index ab686b30..dd2d16b0 100644
--- a/src/i18n/locales/es/settings.json
+++ b/src/i18n/locales/es/settings.json
@@ -120,6 +120,8 @@
"generateFull": "Generar subtítulos",
"regenerateFull": "Regenerar subtítulos",
"clearFull": "Borrar subtítulos",
+ "editCurrent": "Editar subtítulo actual",
+ "editSaved": "Subtítulo actualizado",
"fontSettings": "Tipografía",
"defaultFont": "Predeterminado",
"fontFamily": "Fuente",
diff --git a/src/i18n/locales/fr/settings.json b/src/i18n/locales/fr/settings.json
index c4748e3d..1f6335b2 100644
--- a/src/i18n/locales/fr/settings.json
+++ b/src/i18n/locales/fr/settings.json
@@ -120,6 +120,8 @@
"generateFull": "Générer les sous-titres",
"regenerateFull": "Régénérer les sous-titres",
"clearFull": "Effacer les sous-titres",
+ "editCurrent": "Modifier le sous-titre actuel",
+ "editSaved": "Sous-titre mis à jour",
"fontSettings": "Paramètres de police",
"defaultFont": "Par défaut",
"fontFamily": "Police",
diff --git a/src/i18n/locales/ko/settings.json b/src/i18n/locales/ko/settings.json
index 62f6f8fd..68907074 100644
--- a/src/i18n/locales/ko/settings.json
+++ b/src/i18n/locales/ko/settings.json
@@ -120,6 +120,8 @@
"generateFull": "자막 생성",
"regenerateFull": "자막 다시 생성",
"clearFull": "자막 지우기",
+ "editCurrent": "현재 자막 편집",
+ "editSaved": "자막이 업데이트되었습니다",
"fontSettings": "글꼴 설정",
"defaultFont": "기본값",
"fontFamily": "글꼴",
diff --git a/src/i18n/locales/nl/settings.json b/src/i18n/locales/nl/settings.json
index 2aaa43e9..3cbaf488 100644
--- a/src/i18n/locales/nl/settings.json
+++ b/src/i18n/locales/nl/settings.json
@@ -120,6 +120,8 @@
"generateFull": "Ondertiteling genereren",
"regenerateFull": "Ondertiteling opnieuw genereren",
"clearFull": "Ondertiteling wissen",
+ "editCurrent": "Huidige ondertiteling bewerken",
+ "editSaved": "Ondertiteling bijgewerkt",
"fontSettings": "Lettertype-instellingen",
"defaultFont": "Standaard",
"fontFamily": "Lettertype",
diff --git a/src/i18n/locales/pt-BR/settings.json b/src/i18n/locales/pt-BR/settings.json
index 86e969d5..ddde00ae 100644
--- a/src/i18n/locales/pt-BR/settings.json
+++ b/src/i18n/locales/pt-BR/settings.json
@@ -120,6 +120,8 @@
"generateFull": "Gerar legendas",
"regenerateFull": "Gerar legendas novamente",
"clearFull": "Limpar legendas",
+ "editCurrent": "Editar legenda atual",
+ "editSaved": "Legenda atualizada",
"fontSettings": "Configurações da fonte",
"defaultFont": "Padrão",
"fontFamily": "Fonte",
diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json
index dae55a79..1c2ddf35 100644
--- a/src/i18n/locales/zh-CN/settings.json
+++ b/src/i18n/locales/zh-CN/settings.json
@@ -120,6 +120,8 @@
"generateFull": "生成字幕",
"regenerateFull": "重新生成字幕",
"clearFull": "清除字幕",
+ "editCurrent": "编辑当前字幕",
+ "editSaved": "字幕已更新",
"fontSettings": "字体设置",
"defaultFont": "默认",
"fontFamily": "字体",
diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json
index b2185b3c..1a6b905a 100644
--- a/src/i18n/locales/zh-TW/settings.json
+++ b/src/i18n/locales/zh-TW/settings.json
@@ -29,15 +29,16 @@
"loopCursor": "游標循環",
"cursorStyle": "游標樣式",
"cursorStyleOptions": {
- "tahoe": "Tahoe",
- "dot": "圓點",
- "figma": "極簡",
- "mono": "反相",
- "lavender": "Lavender",
- "parched": "Parched",
- "chooper": "Chooper",
- "amongus": "Among Us",
- "turtle": "Turtle"
+ "macos": "macOS",
+ "tahoe": "Tahoe",
+ "tahoe-inverted": "Tahoe Inverted",
+ "dot": "圓點",
+ "figma": "極簡",
+ "lavender": "Lavender",
+ "parched": "Parched",
+ "chooper": "Chooper",
+ "amongus": "Among Us",
+ "turtle": "Turtle"
},
"backgroundBlur": "背景模糊",
"zoomMotionBlur": "縮放動態模糊",
@@ -62,11 +63,11 @@
"connectedZoomDuration": "連接縮放時間",
"connectedZoomEasing": "連接平移曲線",
"zoomEasingOptions": {
- "recordly": "Recordly",
- "glide": "滑行",
- "smooth": "平滑",
- "snappy": "俐落",
- "linear": "線性"
+ "recordly": "Recordly",
+ "glide": "滑行",
+ "smooth": "平滑",
+ "snappy": "俐落",
+ "linear": "線性"
},
"cursorSize": "游標大小",
"cursorSmoothing": "游標平滑",
@@ -91,9 +92,6 @@
"radius": "半徑",
"roundness": "圓角",
"padding": "內距",
- "paddingAdvanced": "進階",
- "paddingAdvancedShow": "顯示進階內距控制",
- "paddingAdvancedHide": "隱藏進階內距控制",
"paddingLinked": "連動(等距)",
"paddingUnlinked": "不連動(不對稱)",
"paddingTop": "上",
@@ -122,6 +120,8 @@
"generateFull": "產生字幕",
"regenerateFull": "重新產生字幕",
"clearFull": "清除字幕",
+ "editCurrent": "編輯目前字幕",
+ "editSaved": "字幕已更新",
"fontSettings": "字型設定",
"defaultFont": "預設",
"fontFamily": "字型",
@@ -163,10 +163,10 @@
"mp4": "MP4",
"gif": "GIF",
"quality": {
- "low": "低",
- "medium": "中",
- "high": "高",
- "original": "原始"
+ "low": "低",
+ "medium": "中",
+ "high": "高",
+ "original": "原始"
},
"fpsTitle": "FPS",
"loop": "循環",
@@ -180,4 +180,4 @@
"reportBug": "回報錯誤",
"starOnGithub": "在 GitHub 按讚"
}
-}
\ No newline at end of file
+}