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) AGPL 3.0 license

-### 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

- Recordly editor screenshot + Recordly recording interface screenshot

- Recordly recording interface screenshot + Recordly editor screenshot

@@ -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} > + + + + + +

+
{ + event.preventDefault(); + handleSavePresetSubmit(); + }} + className="space-y-2" + > +

+ {t("editor.presets.saveCurrentAs", "Save current preset as")} +

+
+ 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")} + /> + +
+
+ +
+

+ {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 ( +
+ + +
+ ); + }) + )} +
+
+
+ + @@ -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 +}