Merge remote-tracking branch 'origin/main' into codex/clip-flow-cleanup

# Conflicts:
#	src/components/video-editor/timeline/TimelineEditor.tsx
This commit is contained in:
webadderall
2026-05-04 13:21:05 +10:00
35 changed files with 2025 additions and 206 deletions
+1 -1
View File
@@ -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
+10 -11
View File
@@ -1,5 +1,3 @@
# Recordly
Language: EN | [简中](README.zh-CN.md)
<p align="center">
@@ -11,12 +9,11 @@ Language: EN | [简中](README.zh-CN.md)
<img src="https://img.shields.io/badge/open%20source-AGPL3.0-2563eb?style=for-the-badge" alt="AGPL 3.0 license" />
</p>
### 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
<p align="center">
<img src="https://i.postimg.cc/CKxm8DRs/Screenshot-2026-03-20-at-7-07-22-pm.png" width="700" alt="Recordly editor screenshot">
<img src="https://i.postimg.cc/8CrQtGJf/Screenshot-2026-04-30-at-5-11-52-pm.png" width="700" alt="Recordly recording interface screenshot">
</p>
<p align="center">
<img src="https://i.postimg.cc/hjwdYRyV/Screenshot-2026-03-20-at-1-53-57-pm.png" width="700" alt="Recordly recording interface screenshot">
<img src="https://i.postimg.cc/pLSMfrTM/Screenshot-2026-04-30-at-5-11-45-pm.png" width="700" alt="Recordly editor screenshot">
</p>
<p align="center">
@@ -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
+3 -3
View File
@@ -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。
+10
View File
@@ -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 }>;
+11 -6
View File
@@ -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;
}
+51
View File
@@ -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);
});
});
+56 -3
View File
@@ -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();
}
+25
View File
@@ -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) {
+8
View File
@@ -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;
}
+6
View File
@@ -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);
},
+3 -3
View File
@@ -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",
+2 -2
View File
@@ -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
+7 -4
View File
@@ -61,11 +61,14 @@ interface DesktopSource {
}
const LOCALE_LABELS: Record<string, string> = {
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];
@@ -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`}
+1 -1
View File
@@ -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";
+412 -2
View File
@@ -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<string | undefined>(undefined);
const [hasPendingExportSave, setHasPendingExportSave] = useState(false);
const [lastSavedSnapshot, setLastSavedSnapshot] = useState<EditorProjectData | null>(null);
const [editorPresets, setEditorPresets] = useState<EditorPreset[]>(() => loadEditorPresets());
const [activeEditorPresetId, setActiveEditorPresetId] = useState<string | null>(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}
>
<Popover open={presetPopoverOpen} onOpenChange={setPresetPopoverOpen}>
<PopoverTrigger asChild>
<button
type="button"
title={t("editor.presets.open", "Open presets")}
aria-label={t("editor.presets.open", "Open presets")}
className="inline-flex items-center gap-1.5 bg-transparent p-0 text-sm font-medium tracking-tight text-foreground outline-none transition-opacity hover:opacity-80"
>
<span className="flex items-center gap-1.5">
<BookmarkSimple weight="fill" className="h-4 w-4" />
<span>{currentEditorPreset?.name ?? t("editor.presets.label", "Presets")}</span>
</span>
<ChevronDown className="h-3.5 w-3.5 text-foreground" />
</button>
</PopoverTrigger>
<PopoverContent
align="end"
sideOffset={10}
className="w-[300px] rounded-2xl border border-foreground/10 bg-editor-surface-alt p-3 shadow-xl"
>
<div className="space-y-3">
<form
onSubmit={(event) => {
event.preventDefault();
handleSavePresetSubmit();
}}
className="space-y-2"
>
<p className="text-[11px] font-medium text-foreground">
{t("editor.presets.saveCurrentAs", "Save current preset as")}
</p>
<div className="flex items-center gap-2">
<Input
value={presetNameDraft}
onChange={(event) => setPresetNameDraft(event.target.value)}
className="h-9 rounded-xl border-foreground/10 bg-background/70 text-sm"
placeholder={t("editor.presets.namePlaceholder", "Preset name")}
aria-label={t("editor.presets.namePlaceholder", "Preset name")}
/>
<Button
type="submit"
size="sm"
className="h-9 rounded-xl bg-[#2563EB] px-3 text-white hover:bg-[#1d4ed8]"
>
{t("common.actions.save", "Save")}
</Button>
</div>
</form>
<div className="space-y-2">
<p className="text-[11px] font-medium text-foreground">
{t("editor.presets.savedList", "Saved presets")}
</p>
<div className="max-h-56 space-y-1 overflow-y-auto pr-1 custom-scrollbar">
{editorPresets.length === 0 ? (
<div className="rounded-xl border border-dashed border-foreground/10 px-3 py-4 text-center text-[11px] text-muted-foreground">
{t("editor.presets.empty", "No presets yet.")}
</div>
) : (
editorPresets.map((preset) => {
const isActive = preset.id === currentEditorPreset?.id;
return (
<div
key={preset.id}
className={cn(
"flex items-center gap-2 rounded-xl border px-2 py-2 text-sm transition-colors",
isActive
? "border-[#2563EB]/20 bg-[#2563EB]/10 text-foreground"
: "border-foreground/8 bg-foreground/[0.03] text-muted-foreground hover:bg-foreground/[0.06] hover:text-foreground",
)}
>
<button
type="button"
onClick={() => handleApplyEditorPreset(preset.id)}
className="flex min-w-0 flex-1 items-center justify-between text-left"
>
<span className="truncate pr-3">{preset.name}</span>
{isActive ? <Check className="h-3.5 w-3.5 shrink-0 text-[#2563EB]" /> : null}
</button>
<button
type="button"
onClick={() => 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 },
)}
>
<X className="h-3.5 w-3.5" />
</button>
</div>
);
})
)}
</div>
</div>
</div>
</PopoverContent>
</Popover>
<DropdownMenu
open={showExportDropdown}
onOpenChange={setShowExportDropdown}
@@ -4887,7 +5296,7 @@ export default function VideoEditor() {
<Button
type="button"
onClick={handleOpenExportDropdown}
className="inline-flex h-8 min-w-[112px] items-center justify-center gap-2 rounded-[5px] bg-[#2563EB] px-4.5 text-white transition-colors hover:bg-[#2563EB]/92"
className="ml-3 inline-flex h-8 min-w-[112px] items-center justify-center gap-2 rounded-[5px] bg-[#2563EB] px-4.5 text-white transition-colors hover:bg-[#2563EB]/92"
>
<Download className="h-4 w-4" />
<span className="text-sm font-semibold tracking-tight">
@@ -5440,6 +5849,7 @@ export default function VideoEditor() {
annotationRegions={annotationRegions}
autoCaptions={autoCaptions}
autoCaptionSettings={autoCaptionSettings}
onEditAutoCaption={handleSaveAutoCaptionEdit}
selectedAnnotationId={selectedAnnotationId}
onSelectAnnotation={handleSelectAnnotation}
onAnnotationPositionChange={
+321 -34
View File
@@ -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<VideoPlaybackRef, VideoPlaybackProps>(
annotationRegions = [],
autoCaptions = [],
autoCaptionSettings,
onEditAutoCaption,
selectedAnnotationId,
onSelectAnnotation,
onAnnotationPositionChange,
@@ -343,6 +352,8 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
},
ref,
) => {
const { t } = useI18n();
const editCurrentCaptionLabel = t("settings.captions.editCurrent", "Edit current caption");
const videoRef = useRef<HTMLVideoElement | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const appRef = useRef<Application | null>(null);
@@ -359,6 +370,11 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
const webcamBubbleRef = useRef<HTMLDivElement | null>(null);
const webcamBubbleInnerRef = useRef<HTMLDivElement | null>(null);
const captionBoxRef = useRef<HTMLDivElement | null>(null);
const captionEditInputRef = useRef<HTMLTextAreaElement | null>(null);
const captionEditSessionRef = useRef<CaptionEditSession | null>(null);
const [captionEditSession, setCaptionEditSession] = useState<CaptionEditSession | null>(
null,
);
const currentTimeRef = useRef(0);
const zoomRegionsRef = useRef<ZoomRegion[]>([]);
const selectedZoomIdRef = useRef<string | null>(null);
@@ -471,6 +487,148 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
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<VideoPlaybackRef, VideoPlaybackProps>(
}
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<VideoPlaybackRef, VideoPlaybackProps>(
});
return () => cancelAnimationFrame(frame);
}, [activeCaptionLayout, autoCaptionSettings]);
}, [activeCaptionLayout, autoCaptionSettings, captionEditSizeKey, isCaptionEditing]);
const motionBlurStateRef = useRef<MotionBlurState>(createMotionBlurState());
const applyWebcamBubbleLayout = useCallback(
@@ -1020,7 +1184,8 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
: 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<VideoPlaybackRef, VideoPlaybackProps>(
}}
>
<div
role={
onEditAutoCaption && !captionEditSession
? "button"
: undefined
}
tabIndex={
onEditAutoCaption && !captionEditSession ? 0 : undefined
}
aria-label={
onEditAutoCaption && !captionEditSession
? editCurrentCaptionLabel
: undefined
}
ref={captionBoxRef}
onClick={() => {
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<VideoPlaybackRef, VideoPlaybackProps>(
),
)}px`,
boxSizing: "border-box",
cursor:
onEditAutoCaption && !captionEditSession
? "text"
: undefined,
pointerEvents: onEditAutoCaption ? "auto" : undefined,
}}
>
{activeCaptionLayout.visibleLines.map((line) => (
<div
key={`${activeCaptionLayout.blockKey}-${line.startWordIndex}`}
style={{
display: "flex",
justifyContent: "center",
flexWrap: "nowrap",
whiteSpace: "nowrap",
{captionEditSession ? (
<textarea
ref={captionEditInputRef}
value={captionEditSession.draft}
onChange={(event) => {
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) => (
<div
key={`${activeCaptionLayout.blockKey}-${line.startWordIndex}`}
style={{
display: "flex",
justifyContent: "center",
flexWrap: "nowrap",
whiteSpace: "nowrap",
}}
>
{line.words.map((word) => {
const visualState =
getCaptionWordVisualState(
activeCaptionLayout.hasWordTimings,
word.state,
);
return (
<span
key={`${activeCaptionLayout.blockKey}-${word.index}`}
style={{
display: "inline-block",
whiteSpace: "pre",
color: visualState.isInactive
? autoCaptionSettings.inactiveTextColor
: autoCaptionSettings.textColor,
opacity: visualState.opacity,
}}
>
{`${word.leadingSpace ? " " : ""}${word.text}`}
</span>
);
})}
</div>
))}
return (
<span
key={`${activeCaptionLayout.blockKey}-${word.index}`}
style={{
display: "inline-block",
whiteSpace: "pre",
color: visualState.isInactive
? autoCaptionSettings.inactiveTextColor
: autoCaptionSettings.textColor,
opacity: visualState.opacity,
}}
>
{`${word.leadingSpace ? " " : ""}${word.text}`}
</span>
);
})}
</div>
))
)}
</div>
</div>
</div>
@@ -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);
});
});
@@ -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<string, CaptionEditWordRef[]>();
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<string, CaptionCueWord[]>();
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<string, number>();
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;
}
+60 -19
View File
@@ -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,
@@ -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<string, string> = {}): 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);
});
});
@@ -48,6 +48,22 @@ type PersistedEditorControls = Pick<
type PartialEditorControls = Partial<PersistedEditorControls>;
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<EditorPresetSnapshot>)
: {};
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<EditorPreset>;
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<EditorPreferences>,
fallback: EditorPreferences,
@@ -300,3 +399,34 @@ export function saveEditorPreferences(preferences: Partial<EditorPreferences>):
// 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;
}
}
@@ -1479,14 +1479,11 @@ const TimelineEditor = forwardRef<TimelineEditorHandle, TimelineEditorProps>(
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<TimelineEditorHandle, TimelineEditorProps>(
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;
}
@@ -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);
});
});
@@ -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<NonNullable<CursorTelemetryPoint["interactionType"]>>([
"click",
"double-click",
"right-click",
"middle-click",
]);
function isExplicitClickType(
interactionType: CursorTelemetryPoint["interactionType"],
): interactionType is NonNullable<CursorTelemetryPoint["interactionType"]> {
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<CursorInteractionCandidate>(
(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 };
}
/**
@@ -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 {
+132 -31
View File
@@ -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<MediaStream> => {
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]);
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -120,6 +120,8 @@
"generateFull": "자막 생성",
"regenerateFull": "자막 다시 생성",
"clearFull": "자막 지우기",
"editCurrent": "현재 자막 편집",
"editSaved": "자막이 업데이트되었습니다",
"fontSettings": "글꼴 설정",
"defaultFont": "기본값",
"fontFamily": "글꼴",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -120,6 +120,8 @@
"generateFull": "生成字幕",
"regenerateFull": "重新生成字幕",
"clearFull": "清除字幕",
"editCurrent": "编辑当前字幕",
"editSaved": "字幕已更新",
"fontSettings": "字体设置",
"defaultFont": "默认",
"fontFamily": "字体",
+22 -22
View File
@@ -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 按讚"
}
}
}