From 39673f1c658e83f5c12bd47cf537148fc8d0e7e8 Mon Sep 17 00:00:00 2001 From: Amaan Date: Mon, 27 Apr 2026 16:09:00 +0530 Subject: [PATCH 01/19] fix(linux): fallback to desktop capture when getDisplayMedia fails --- src/hooks/useScreenRecorder.ts | 51 +++++++++++++++++++++++++--------- 1 file changed, 38 insertions(+), 13 deletions(-) diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index 3709fa84..3475a84a 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -1131,20 +1131,45 @@ export function useScreenRecorder(): UseScreenRecorderReturn { if (wantsAudioCapture) { let screenMediaStream: MediaStream; const useLinuxPortal = selectedSource.id === "screen:linux-portal"; - const acquireLinuxPortalStream = (withAudio: boolean) => - mediaDevices.getDisplayMedia({ - audio: withAudio, - video: { - displaySurface: "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", - }); + const acquireLinuxPortalStream = async (withAudio: boolean): Promise => { + try { + return await mediaDevices.getDisplayMedia({ + audio: withAudio, + video: { + displaySurface: "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", + }); + } catch (err) { + console.warn("Linux portal failed, falling back to desktop capture:", err); + const sources = await window.electronAPI.getSources({ types: ["screen"] }); + + if (!sources.length) { + throw new Error("No screen sources available"); + } + + const source = sources[0]; + + 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); + } + }; if (systemAudioEnabled) { try { screenMediaStream = useLinuxPortal From ba89539b3e62f025caabb6f3b61e427b417df15b Mon Sep 17 00:00:00 2001 From: Amaan Date: Mon, 27 Apr 2026 17:10:12 +0530 Subject: [PATCH 02/19] fix(linux): move fallback out of audio-only branch and surface audio downgrade --- src/hooks/useScreenRecorder.ts | 113 ++++++++++++++++----------------- 1 file changed, 55 insertions(+), 58 deletions(-) diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index 3475a84a..8edd3d5f 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -1128,48 +1128,61 @@ export function useScreenRecorder(): UseScreenRecorderReturn { cursor: "never" as const, }; - if (wantsAudioCapture) { + + + const acquireLinuxPortalStream = async (withAudio: boolean): Promise => { + + try { + return await mediaDevices.getDisplayMedia({ + audio: withAudio, + video: { + displaySurface: "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", + }); + } + + 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"; - const acquireLinuxPortalStream = async (withAudio: boolean): Promise => { - try { - return await mediaDevices.getDisplayMedia({ - audio: withAudio, - video: { - displaySurface: "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", - }); - } catch (err) { - console.warn("Linux portal failed, falling back to desktop capture:", err); - const sources = await window.electronAPI.getSources({ types: ["screen"] }); - - if (!sources.length) { - throw new Error("No screen sources available"); - } - - const source = sources[0]; - - 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); - } - }; if (systemAudioEnabled) { try { screenMediaStream = useLinuxPortal @@ -1273,26 +1286,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."); } From a9e70069c824884e0ac3d25d99f0fcb10742efdd Mon Sep 17 00:00:00 2001 From: Shuwn Hsu <20023822+shuwn@users.noreply.github.com> Date: Wed, 29 Apr 2026 00:29:24 +0800 Subject: [PATCH 03/19] feat(i18n): Fix locale tags --- src/components/launch/LaunchWindow.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) 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]; From 5610f436b151a831d056866e55fadcf571d34cb3 Mon Sep 17 00:00:00 2001 From: kingfive Date: Sat, 25 Apr 2026 16:44:39 +0800 Subject: [PATCH 04/19] feat: increase frame corner radius --- src/components/video-editor/SettingsPanel.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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`} From a095ab050c3875e2427058957c58f90aca39dcd2 Mon Sep 17 00:00:00 2001 From: webadderall <131426131+webadderall@users.noreply.github.com> Date: Thu, 30 Apr 2026 11:17:01 +1000 Subject: [PATCH 05/19] Add editor presets menu --- src/components/video-editor/VideoEditor.tsx | 333 +++++++++++++++++- .../video-editor/editorPreferences.ts | 125 +++++++ 2 files changed, 456 insertions(+), 2 deletions(-) diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 7dc6d812..610fe594 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" }) => ( @@ -103,7 +107,15 @@ import { resolveAutoCaptionSourcePath } from "./autoCaptionSource"; import { CropControl } from "./CropControl"; 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, @@ -670,6 +682,9 @@ 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 [presetPopoverOpen, setPresetPopoverOpen] = useState(false); + const [presetNameDraft, setPresetNameDraft] = useState(""); const [showCropModal, setShowCropModal] = useState(false); const [previewVersion, setPreviewVersion] = useState(0); const [isPreviewReady, setIsPreviewReady] = useState(false); @@ -749,6 +764,228 @@ 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) => + serializeEditorPresetSnapshot(preset.snapshot) === currentPresetSignature, + ) ?? null, + [editorPresets, currentPresetSignature], + ); + + 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; + } + + applyEditorPresetSnapshot(preset.snapshot); + toast.success(`Applied preset \"${preset.name}\"`); + }, + [applyEditorPresetSnapshot, editorPresets], + ); + + const handleSaveEditorPreset = useCallback( + (name: string) => { + const normalizedName = name.trim().replace(/\s+/g, " "); + if (normalizedName.length === 0) { + toast.error("Enter a preset name."); + return false; + } + + const hasDuplicateName = editorPresets.some( + (preset) => preset.name.toLocaleLowerCase() === normalizedName.toLocaleLowerCase(), + ); + if (hasDuplicateName) { + toast.error("A preset with that name already exists."); + return false; + } + + const snapshot = captureEditorPresetSnapshot(); + const timestamp = new Date().toISOString(); + const nextPresets = [ + { + id: crypto.randomUUID(), + name: normalizedName, + createdAt: timestamp, + updatedAt: timestamp, + snapshot, + }, + ...editorPresets, + ]; + + setEditorPresets(nextPresets); + saveEditorPresets(nextPresets); + toast.success(`Saved preset \"${normalizedName}\"`); + return true; + }, + [captureEditorPresetSnapshot, editorPresets], + ); + + const handleDeleteEditorPreset = useCallback( + (presetId: string) => { + const preset = editorPresets.find((item) => item.id === presetId); + if (!preset) { + return; + } + + const nextPresets = editorPresets.filter((item) => item.id !== presetId); + setEditorPresets(nextPresets); + saveEditorPresets(nextPresets); + toast.success(`Deleted preset \"${preset.name}\"`); + }, + [editorPresets], + ); + + const handleSavePresetSubmit = useCallback(() => { + const didSave = handleSaveEditorPreset(presetNameDraft); + if (didSave) { + setPresetNameDraft(""); + } + }, [handleSaveEditorPreset, presetNameDraft]); + const clearPendingExportSave = useCallback(() => { const pending = pendingExportSaveRef.current; pendingExportSaveRef.current = null; @@ -4957,6 +5194,98 @@ 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" + > +

+ Save current preset as +

+
+ setPresetNameDraft(event.target.value)} + placeholder="Preset name" + className="h-9 rounded-xl border-foreground/10 bg-background/70 text-sm" + /> + +
+
+ +
+

Saved presets

+
+ {editorPresets.length === 0 ? ( +
+ No presets yet. +
+ ) : ( + editorPresets.map((preset) => { + const isActive = preset.id === currentEditorPreset?.id; + return ( +
+ + +
+ ); + }) + )} +
+
+
+
+
diff --git a/src/components/video-editor/editorPreferences.ts b/src/components/video-editor/editorPreferences.ts index c9be18ab..f60b1c03 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,77 @@ 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), + }; +} + +export function serializeEditorPresetSnapshot(snapshot: EditorPresetSnapshot): string { + return JSON.stringify(normalizeEditorPresetSnapshot(snapshot)); +} + function normalizeEditorControls( raw: Partial, fallback: EditorPreferences, @@ -300,3 +388,40 @@ 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 []; + } + + const parsed = JSON.parse(stored); + if (!Array.isArray(parsed)) { + return []; + } + + return parsed + .map((item) => normalizeEditorPreset(item)) + .filter((preset): preset is EditorPreset => preset !== null) + .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)); + } catch { + return []; + } +} + +export function saveEditorPresets(presets: EditorPreset[]): void { + if (typeof globalThis.localStorage === "undefined") { + return; + } + + try { + globalThis.localStorage.setItem(EDITOR_PRESETS_STORAGE_KEY, JSON.stringify(presets)); + } catch { + // Ignore storage failures so editor controls still work. + } +} From 18916b1afcc7c431f989e6279bafcf8841d0ff58 Mon Sep 17 00:00:00 2001 From: webadderall <131426131+webadderall@users.noreply.github.com> Date: Thu, 30 Apr 2026 15:23:27 +1000 Subject: [PATCH 06/19] Stream blob exports through temp files --- src/components/video-editor/VideoEditor.tsx | 129 ++++++++++++++++---- 1 file changed, 106 insertions(+), 23 deletions(-) diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 7dc6d812..4254b569 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -226,6 +226,57 @@ type SmokeExportConfig = { fps?: ExportMp4FrameRate; }; +const EXPORT_BLOB_STREAM_CHUNK_BYTES = 16 * 1024 * 1024; + +async function streamExportBlobToTempFile(blob: Blob, extension: string): Promise { + if ( + typeof window === "undefined" || + !window.electronAPI?.openExportStream || + !window.electronAPI?.writeExportStreamChunk || + !window.electronAPI?.closeExportStream + ) { + return null; + } + + const openResult = await window.electronAPI.openExportStream({ extension }); + if (!openResult.success || !openResult.streamId || !openResult.tempPath) { + throw new Error(openResult.error || "Failed to open export stream"); + } + + const { streamId } = openResult; + let position = 0; + + try { + while (position < blob.size) { + const chunk = blob.slice(position, position + EXPORT_BLOB_STREAM_CHUNK_BYTES); + const chunkBuffer = await chunk.arrayBuffer(); + const writeResult = await window.electronAPI.writeExportStreamChunk( + streamId, + position, + new Uint8Array(chunkBuffer), + ); + if (!writeResult.success) { + throw new Error(writeResult.error || "Failed to write export stream chunk"); + } + position += chunkBuffer.byteLength; + } + + const closeResult = await window.electronAPI.closeExportStream(streamId); + if (!closeResult.success || !closeResult.tempPath) { + throw new Error(closeResult.error || "Failed to close export stream"); + } + + return closeResult.tempPath; + } catch (error) { + try { + await window.electronAPI.closeExportStream(streamId, { abort: true }); + } catch { + // Best-effort cleanup; preserve the original error below. + } + throw error; + } +} + type SaveProjectOptions = { silent?: boolean; remountPreviewAfterSave?: boolean; @@ -1005,6 +1056,43 @@ export default function VideoEditor() { return run; }, []); + const saveBlobExport = useCallback( + async (blob: Blob, fileName: string, outputPath: string | null = null) => { + const extension = fileName.split(".").pop()?.toLowerCase() || "bin"; + + try { + const tempFilePath = await streamExportBlobToTempFile(blob, extension); + if (tempFilePath) { + return { + saveResult: await window.electronAPI.finalizeExportedVideo({ + tempPath: tempFilePath, + fileName, + outputPath, + }), + pendingSave: { + fileName, + tempFilePath, + } satisfies PendingExportSave, + }; + } + } catch (error) { + console.warn("[export] Falling back to in-memory blob save", error); + } + + const arrayBuffer = await blob.arrayBuffer(); + return { + saveResult: outputPath + ? await window.electronAPI.writeExportedVideoToPath(arrayBuffer, outputPath) + : await window.electronAPI.saveExportedVideo(arrayBuffer, fileName), + pendingSave: { + fileName, + arrayBuffer, + } satisfies PendingExportSave, + }; + }, + [], + ); + useEffect(() => { return () => { exporterRef.current?.cancel(); @@ -1398,6 +1486,7 @@ export default function VideoEditor() { borderRadius, padding, frame, + cropRegion, webcam, zoomRegions, trimRegions, @@ -1446,6 +1535,7 @@ export default function VideoEditor() { cursorSway, borderRadius, padding, + cropRegion, webcam, zoomRegions, trimRegions, @@ -4059,21 +4149,18 @@ export default function VideoEditor() { const result = await gifExporter.export(); if (result.success && result.blob) { - const arrayBuffer = await result.blob.arrayBuffer(); const timestamp = Date.now(); const fileName = `export-${timestamp}.gif`; markExportAsSaving(); - const saveResult = - smokeExportConfig.enabled && smokeExportConfig.outputPath - ? await window.electronAPI.writeExportedVideoToPath( - arrayBuffer, - smokeExportConfig.outputPath, - ) - : await window.electronAPI.saveExportedVideo(arrayBuffer, fileName); + const { saveResult, pendingSave } = await saveBlobExport( + result.blob, + fileName, + smokeExportConfig.enabled ? smokeExportConfig.outputPath : null, + ); if (saveResult.canceled) { - pendingExportSaveRef.current = { arrayBuffer, fileName }; + pendingExportSaveRef.current = pendingSave; setHasPendingExportSave(true); setExportError( "Save dialog canceled. Click Save Again to save without re-rendering.", @@ -4273,20 +4360,16 @@ export default function VideoEditor() { }); pendingOnCancel = { fileName, tempFilePath: result.tempFilePath }; } else if (result.blob) { - // Legacy fallback: small exports may still surface a Blob (GIF, - // smoke tests in non-Electron environments, etc.). - const arrayBuffer = await result.blob.arrayBuffer(); - saveResult = - smokeExportConfig.enabled && smokeExportConfig.outputPath - ? await window.electronAPI.writeExportedVideoToPath( - arrayBuffer, - smokeExportConfig.outputPath, - ) - : await window.electronAPI.saveExportedVideo( - arrayBuffer, - fileName, - ); - pendingOnCancel = { fileName, arrayBuffer }; + // Legacy fallback: some export paths still surface a Blob, but in + // Electron we stream it into a temp file first so save/finalize + // never requires a giant renderer ArrayBuffer. + const blobSave = await saveBlobExport( + result.blob, + fileName, + smokeExportConfig.enabled ? smokeExportConfig.outputPath : null, + ); + saveResult = blobSave.saveResult; + pendingOnCancel = blobSave.pendingSave; } else { saveResult = { success: false, message: "Export produced no output" }; pendingOnCancel = { fileName }; From d02f3692493483eeaa53fc3806ecf8d3a901b6ac Mon Sep 17 00:00:00 2001 From: webadderall <131426131+webadderall@users.noreply.github.com> Date: Thu, 30 Apr 2026 15:26:02 +1000 Subject: [PATCH 07/19] Revert "Stream blob exports through temp files" This reverts commit 18916b1afcc7c431f989e6279bafcf8841d0ff58. --- src/components/video-editor/VideoEditor.tsx | 129 ++++---------------- 1 file changed, 23 insertions(+), 106 deletions(-) diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 4254b569..7dc6d812 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -226,57 +226,6 @@ type SmokeExportConfig = { fps?: ExportMp4FrameRate; }; -const EXPORT_BLOB_STREAM_CHUNK_BYTES = 16 * 1024 * 1024; - -async function streamExportBlobToTempFile(blob: Blob, extension: string): Promise { - if ( - typeof window === "undefined" || - !window.electronAPI?.openExportStream || - !window.electronAPI?.writeExportStreamChunk || - !window.electronAPI?.closeExportStream - ) { - return null; - } - - const openResult = await window.electronAPI.openExportStream({ extension }); - if (!openResult.success || !openResult.streamId || !openResult.tempPath) { - throw new Error(openResult.error || "Failed to open export stream"); - } - - const { streamId } = openResult; - let position = 0; - - try { - while (position < blob.size) { - const chunk = blob.slice(position, position + EXPORT_BLOB_STREAM_CHUNK_BYTES); - const chunkBuffer = await chunk.arrayBuffer(); - const writeResult = await window.electronAPI.writeExportStreamChunk( - streamId, - position, - new Uint8Array(chunkBuffer), - ); - if (!writeResult.success) { - throw new Error(writeResult.error || "Failed to write export stream chunk"); - } - position += chunkBuffer.byteLength; - } - - const closeResult = await window.electronAPI.closeExportStream(streamId); - if (!closeResult.success || !closeResult.tempPath) { - throw new Error(closeResult.error || "Failed to close export stream"); - } - - return closeResult.tempPath; - } catch (error) { - try { - await window.electronAPI.closeExportStream(streamId, { abort: true }); - } catch { - // Best-effort cleanup; preserve the original error below. - } - throw error; - } -} - type SaveProjectOptions = { silent?: boolean; remountPreviewAfterSave?: boolean; @@ -1056,43 +1005,6 @@ export default function VideoEditor() { return run; }, []); - const saveBlobExport = useCallback( - async (blob: Blob, fileName: string, outputPath: string | null = null) => { - const extension = fileName.split(".").pop()?.toLowerCase() || "bin"; - - try { - const tempFilePath = await streamExportBlobToTempFile(blob, extension); - if (tempFilePath) { - return { - saveResult: await window.electronAPI.finalizeExportedVideo({ - tempPath: tempFilePath, - fileName, - outputPath, - }), - pendingSave: { - fileName, - tempFilePath, - } satisfies PendingExportSave, - }; - } - } catch (error) { - console.warn("[export] Falling back to in-memory blob save", error); - } - - const arrayBuffer = await blob.arrayBuffer(); - return { - saveResult: outputPath - ? await window.electronAPI.writeExportedVideoToPath(arrayBuffer, outputPath) - : await window.electronAPI.saveExportedVideo(arrayBuffer, fileName), - pendingSave: { - fileName, - arrayBuffer, - } satisfies PendingExportSave, - }; - }, - [], - ); - useEffect(() => { return () => { exporterRef.current?.cancel(); @@ -1486,7 +1398,6 @@ export default function VideoEditor() { borderRadius, padding, frame, - cropRegion, webcam, zoomRegions, trimRegions, @@ -1535,7 +1446,6 @@ export default function VideoEditor() { cursorSway, borderRadius, padding, - cropRegion, webcam, zoomRegions, trimRegions, @@ -4149,18 +4059,21 @@ export default function VideoEditor() { const result = await gifExporter.export(); if (result.success && result.blob) { + const arrayBuffer = await result.blob.arrayBuffer(); const timestamp = Date.now(); const fileName = `export-${timestamp}.gif`; markExportAsSaving(); - const { saveResult, pendingSave } = await saveBlobExport( - result.blob, - fileName, - smokeExportConfig.enabled ? smokeExportConfig.outputPath : null, - ); + const saveResult = + smokeExportConfig.enabled && smokeExportConfig.outputPath + ? await window.electronAPI.writeExportedVideoToPath( + arrayBuffer, + smokeExportConfig.outputPath, + ) + : await window.electronAPI.saveExportedVideo(arrayBuffer, fileName); if (saveResult.canceled) { - pendingExportSaveRef.current = pendingSave; + pendingExportSaveRef.current = { arrayBuffer, fileName }; setHasPendingExportSave(true); setExportError( "Save dialog canceled. Click Save Again to save without re-rendering.", @@ -4360,16 +4273,20 @@ export default function VideoEditor() { }); pendingOnCancel = { fileName, tempFilePath: result.tempFilePath }; } else if (result.blob) { - // Legacy fallback: some export paths still surface a Blob, but in - // Electron we stream it into a temp file first so save/finalize - // never requires a giant renderer ArrayBuffer. - const blobSave = await saveBlobExport( - result.blob, - fileName, - smokeExportConfig.enabled ? smokeExportConfig.outputPath : null, - ); - saveResult = blobSave.saveResult; - pendingOnCancel = blobSave.pendingSave; + // Legacy fallback: small exports may still surface a Blob (GIF, + // smoke tests in non-Electron environments, etc.). + const arrayBuffer = await result.blob.arrayBuffer(); + saveResult = + smokeExportConfig.enabled && smokeExportConfig.outputPath + ? await window.electronAPI.writeExportedVideoToPath( + arrayBuffer, + smokeExportConfig.outputPath, + ) + : await window.electronAPI.saveExportedVideo( + arrayBuffer, + fileName, + ); + pendingOnCancel = { fileName, arrayBuffer }; } else { saveResult = { success: false, message: "Export produced no output" }; pendingOnCancel = { fileName }; From fa7aee06e8eb68a8853ad7e3b86f7a2c4b2edad0 Mon Sep 17 00:00:00 2001 From: webadderall <131426131+webadderall@users.noreply.github.com> Date: Thu, 30 Apr 2026 17:44:00 +1000 Subject: [PATCH 08/19] Update README with updated images and video Updated README to reflect new screenshots and videos. --- README.md | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index f9ae83d9..09081c95 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,3 @@ -# Recordly - Language: EN | [简中](README.zh-CN.md)

@@ -13,10 +11,13 @@ Language: EN | [简中](README.zh-CN.md) ### Create polished, pro-grade screen recordings. [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) +

+ Recordly recording interface screenshot +

-https://github.com/user-attachments/assets/1446cd12-c053-4b9c-b49f-d9c93db77fc4 +https://github.com/user-attachments/assets/c328cb35-fbb3-46c1-8cb5-504f8910705f --- @@ -151,11 +152,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

From cace34d3ad0556763057c2d68f5c232cd0dcf31b Mon Sep 17 00:00:00 2001 From: wizardAEI Date: Thu, 30 Apr 2026 16:51:53 +0800 Subject: [PATCH 09/19] Add inline auto-caption editing --- src/components/video-editor/VideoEditor.tsx | 10 + src/components/video-editor/VideoPlayback.tsx | 331 ++++++++++++++++-- .../video-editor/captionEditing.test.ts | 117 +++++++ src/components/video-editor/captionEditing.ts | 234 +++++++++++++ src/components/video-editor/captionLayout.ts | 79 ++++- src/i18n/locales/en/settings.json | 1 + src/i18n/locales/es/settings.json | 1 + src/i18n/locales/fr/settings.json | 1 + src/i18n/locales/ko/settings.json | 1 + src/i18n/locales/nl/settings.json | 1 + src/i18n/locales/pt-BR/settings.json | 1 + src/i18n/locales/zh-CN/settings.json | 1 + src/i18n/locales/zh-TW/settings.json | 43 ++- 13 files changed, 746 insertions(+), 75 deletions(-) create mode 100644 src/components/video-editor/captionEditing.test.ts create mode 100644 src/components/video-editor/captionEditing.ts diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 7dc6d812..81b14d31 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -101,6 +101,7 @@ 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"; @@ -2273,6 +2274,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(); @@ -5530,6 +5539,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..bfb8623e 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -25,6 +25,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 +255,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 +274,11 @@ interface VideoPlaybackProps { volume?: number; } +type CaptionEditSession = { + target: CaptionEditTarget; + draft: string; +}; + export interface VideoPlaybackRef { video: HTMLVideoElement | null; app: Application | null; @@ -324,6 +331,7 @@ const VideoPlayback = forwardRef( annotationRegions = [], autoCaptions = [], autoCaptionSettings, + onEditAutoCaption, selectedAnnotationId, onSelectAnnotation, onAnnotationPositionChange, @@ -359,6 +367,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 +484,127 @@ const VideoPlayback = forwardRef( measureText: (text) => measurementContext.measureText(text).width, }); }, [autoCaptionSettings, autoCaptions, currentTime]); + 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 (!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 +617,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 +647,7 @@ const VideoPlayback = forwardRef( }); return () => cancelAnimationFrame(frame); - }, [activeCaptionLayout, autoCaptionSettings]); + }, [activeCaptionLayout, autoCaptionSettings, captionEditSizeKey, isCaptionEditing]); const motionBlurStateRef = useRef(createMotionBlurState()); const applyWebcamBubbleLayout = useCallback( @@ -1020,7 +1160,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 +2509,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 +2574,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="Edit current caption" + 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/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index c788b132..9a214fef 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -120,6 +120,7 @@ "generateFull": "Generate Captions", "regenerateFull": "Regenerate Captions", "clearFull": "Clear Captions", + "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..f1b88cbb 100644 --- a/src/i18n/locales/es/settings.json +++ b/src/i18n/locales/es/settings.json @@ -120,6 +120,7 @@ "generateFull": "Generar subtítulos", "regenerateFull": "Regenerar subtítulos", "clearFull": "Borrar subtítulos", + "editSaved": "Caption updated", "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..22feb6fb 100644 --- a/src/i18n/locales/fr/settings.json +++ b/src/i18n/locales/fr/settings.json @@ -120,6 +120,7 @@ "generateFull": "Générer les sous-titres", "regenerateFull": "Régénérer les sous-titres", "clearFull": "Effacer les sous-titres", + "editSaved": "Caption updated", "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..ce8db65a 100644 --- a/src/i18n/locales/ko/settings.json +++ b/src/i18n/locales/ko/settings.json @@ -120,6 +120,7 @@ "generateFull": "자막 생성", "regenerateFull": "자막 다시 생성", "clearFull": "자막 지우기", + "editSaved": "Caption updated", "fontSettings": "글꼴 설정", "defaultFont": "기본값", "fontFamily": "글꼴", diff --git a/src/i18n/locales/nl/settings.json b/src/i18n/locales/nl/settings.json index 2aaa43e9..8b8bd169 100644 --- a/src/i18n/locales/nl/settings.json +++ b/src/i18n/locales/nl/settings.json @@ -120,6 +120,7 @@ "generateFull": "Ondertiteling genereren", "regenerateFull": "Ondertiteling opnieuw genereren", "clearFull": "Ondertiteling wissen", + "editSaved": "Caption updated", "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..dbb751e6 100644 --- a/src/i18n/locales/pt-BR/settings.json +++ b/src/i18n/locales/pt-BR/settings.json @@ -120,6 +120,7 @@ "generateFull": "Gerar legendas", "regenerateFull": "Gerar legendas novamente", "clearFull": "Limpar legendas", + "editSaved": "Caption updated", "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..92674a8b 100644 --- a/src/i18n/locales/zh-CN/settings.json +++ b/src/i18n/locales/zh-CN/settings.json @@ -120,6 +120,7 @@ "generateFull": "生成字幕", "regenerateFull": "重新生成字幕", "clearFull": "清除字幕", + "editSaved": "字幕已更新", "fontSettings": "字体设置", "defaultFont": "默认", "fontFamily": "字体", diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json index b2185b3c..8edf24d3 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,7 @@ "generateFull": "產生字幕", "regenerateFull": "重新產生字幕", "clearFull": "清除字幕", + "editSaved": "Caption updated", "fontSettings": "字型設定", "defaultFont": "預設", "fontFamily": "字型", @@ -163,10 +162,10 @@ "mp4": "MP4", "gif": "GIF", "quality": { - "low": "低", - "medium": "中", - "high": "高", - "original": "原始" + "low": "低", + "medium": "中", + "high": "高", + "original": "原始" }, "fpsTitle": "FPS", "loop": "循環", @@ -180,4 +179,4 @@ "reportBug": "回報錯誤", "starOnGithub": "在 GitHub 按讚" } -} \ No newline at end of file +} From 8bfd739ecaf92ebe80349ed160909b27f70456ee Mon Sep 17 00:00:00 2001 From: wizardAEI Date: Thu, 30 Apr 2026 18:00:09 +0800 Subject: [PATCH 10/19] Keep caption edit target current --- src/components/video-editor/VideoPlayback.tsx | 28 +++++++++++++++++-- src/i18n/locales/en/settings.json | 1 + src/i18n/locales/es/settings.json | 3 +- src/i18n/locales/fr/settings.json | 3 +- src/i18n/locales/ko/settings.json | 3 +- src/i18n/locales/nl/settings.json | 3 +- src/i18n/locales/pt-BR/settings.json | 3 +- src/i18n/locales/zh-CN/settings.json | 1 + src/i18n/locales/zh-TW/settings.json | 3 +- 9 files changed, 40 insertions(+), 8 deletions(-) diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index bfb8623e..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 { @@ -351,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); @@ -484,6 +487,8 @@ 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; @@ -569,6 +574,25 @@ const VideoPlayback = forwardRef( 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; @@ -2519,7 +2543,7 @@ const VideoPlayback = forwardRef( } aria-label={ onEditAutoCaption && !captionEditSession - ? "Edit current caption" + ? editCurrentCaptionLabel : undefined } ref={captionBoxRef} @@ -2615,7 +2639,7 @@ const VideoPlayback = forwardRef( 1, activeCaptionLayout.visibleLines.length, )} - aria-label="Edit current caption" + aria-label={editCurrentCaptionLabel} style={{ display: "block", width: `${ diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index 9a214fef..2ad584e4 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -120,6 +120,7 @@ "generateFull": "Generate Captions", "regenerateFull": "Regenerate Captions", "clearFull": "Clear Captions", + "editCurrent": "Edit current caption", "editSaved": "Caption updated", "fontSettings": "Font Settings", "defaultFont": "Default", diff --git a/src/i18n/locales/es/settings.json b/src/i18n/locales/es/settings.json index f1b88cbb..dd2d16b0 100644 --- a/src/i18n/locales/es/settings.json +++ b/src/i18n/locales/es/settings.json @@ -120,7 +120,8 @@ "generateFull": "Generar subtítulos", "regenerateFull": "Regenerar subtítulos", "clearFull": "Borrar subtítulos", - "editSaved": "Caption updated", + "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 22feb6fb..1f6335b2 100644 --- a/src/i18n/locales/fr/settings.json +++ b/src/i18n/locales/fr/settings.json @@ -120,7 +120,8 @@ "generateFull": "Générer les sous-titres", "regenerateFull": "Régénérer les sous-titres", "clearFull": "Effacer les sous-titres", - "editSaved": "Caption updated", + "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 ce8db65a..68907074 100644 --- a/src/i18n/locales/ko/settings.json +++ b/src/i18n/locales/ko/settings.json @@ -120,7 +120,8 @@ "generateFull": "자막 생성", "regenerateFull": "자막 다시 생성", "clearFull": "자막 지우기", - "editSaved": "Caption updated", + "editCurrent": "현재 자막 편집", + "editSaved": "자막이 업데이트되었습니다", "fontSettings": "글꼴 설정", "defaultFont": "기본값", "fontFamily": "글꼴", diff --git a/src/i18n/locales/nl/settings.json b/src/i18n/locales/nl/settings.json index 8b8bd169..3cbaf488 100644 --- a/src/i18n/locales/nl/settings.json +++ b/src/i18n/locales/nl/settings.json @@ -120,7 +120,8 @@ "generateFull": "Ondertiteling genereren", "regenerateFull": "Ondertiteling opnieuw genereren", "clearFull": "Ondertiteling wissen", - "editSaved": "Caption updated", + "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 dbb751e6..ddde00ae 100644 --- a/src/i18n/locales/pt-BR/settings.json +++ b/src/i18n/locales/pt-BR/settings.json @@ -120,7 +120,8 @@ "generateFull": "Gerar legendas", "regenerateFull": "Gerar legendas novamente", "clearFull": "Limpar legendas", - "editSaved": "Caption updated", + "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 92674a8b..1c2ddf35 100644 --- a/src/i18n/locales/zh-CN/settings.json +++ b/src/i18n/locales/zh-CN/settings.json @@ -120,6 +120,7 @@ "generateFull": "生成字幕", "regenerateFull": "重新生成字幕", "clearFull": "清除字幕", + "editCurrent": "编辑当前字幕", "editSaved": "字幕已更新", "fontSettings": "字体设置", "defaultFont": "默认", diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json index 8edf24d3..1a6b905a 100644 --- a/src/i18n/locales/zh-TW/settings.json +++ b/src/i18n/locales/zh-TW/settings.json @@ -120,7 +120,8 @@ "generateFull": "產生字幕", "regenerateFull": "重新產生字幕", "clearFull": "清除字幕", - "editSaved": "Caption updated", + "editCurrent": "編輯目前字幕", + "editSaved": "字幕已更新", "fontSettings": "字型設定", "defaultFont": "預設", "fontFamily": "字型", From 9d97e415a986f73881440a92d3ff33acb8a66ddc Mon Sep 17 00:00:00 2001 From: webadderall <131426131+webadderall@users.noreply.github.com> Date: Fri, 1 May 2026 11:52:36 +1000 Subject: [PATCH 11/19] timeline: add hover ghost preview and safer zoom placement --- src/components/video-editor/VideoEditor.tsx | 8 +- src/components/video-editor/timeline/Row.tsx | 21 +- .../video-editor/timeline/TimelineEditor.tsx | 291 +++++++++++++++--- 3 files changed, 274 insertions(+), 46 deletions(-) diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 7dc6d812..cff0b469 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -150,7 +150,6 @@ import { DEFAULT_PLAYBACK_SPEED, DEFAULT_WEBCAM_OVERLAY, DEFAULT_WEBCAM_TIME_OFFSET_MS, - DEFAULT_ZOOM_DEPTH, DEFAULT_ZOOM_IN_DURATION_MS, DEFAULT_ZOOM_IN_EASING, DEFAULT_ZOOM_IN_OVERLAP_MS, @@ -2850,13 +2849,14 @@ export default function VideoEditor() { const handleZoomAdded = useCallback( (span: Span) => { const id = `zoom-${nextZoomIdRef.current++}`; + const defaultDepth: ZoomDepth = 2; const newRegion: ZoomRegion = { id, startMs: Math.round(span.start), endMs: Math.round(span.end), - depth: DEFAULT_ZOOM_DEPTH, - focus: { cx: 0.5, cy: 0.5 }, - mode: "manual", + depth: defaultDepth, + focus: clampFocusToDepth({ cx: 0.5, cy: 0.5 }, defaultDepth), + mode: "auto", }; if (videoPath && pendingFreshRecordingAutoZoomPathRef.current === videoPath) { autoSuggestedVideoPathRef.current = videoPath; diff --git a/src/components/video-editor/timeline/Row.tsx b/src/components/video-editor/timeline/Row.tsx index f54e5a9c..0bf28afe 100644 --- a/src/components/video-editor/timeline/Row.tsx +++ b/src/components/video-editor/timeline/Row.tsx @@ -7,9 +7,24 @@ interface RowProps extends RowDefinition { hint?: string; isEmpty?: boolean; labelColor?: string; + onMouseEnter?: React.MouseEventHandler; + onMouseMove?: React.MouseEventHandler; + onMouseLeave?: React.MouseEventHandler; + onClick?: React.MouseEventHandler; } -export default function Row({ id, children, label, hint, isEmpty, labelColor = "#666" }: RowProps) { +export default function Row({ + id, + children, + label, + hint, + isEmpty, + labelColor = "#666", + onMouseEnter, + onMouseMove, + onMouseLeave, + onClick, +}: RowProps) { const { setNodeRef, rowWrapperStyle, rowStyle } = useRow({ id }); return ( @@ -34,6 +49,10 @@ export default function Row({ id, children, label, hint, isEmpty, labelColor = " ref={setNodeRef} className="relative h-full min-h-[26px] overflow-hidden" style={rowStyle} + onMouseEnter={onMouseEnter} + onMouseMove={onMouseMove} + onMouseLeave={onMouseLeave} + onClick={onClick} > {children} diff --git a/src/components/video-editor/timeline/TimelineEditor.tsx b/src/components/video-editor/timeline/TimelineEditor.tsx index 128ac5c9..32643bec 100644 --- a/src/components/video-editor/timeline/TimelineEditor.tsx +++ b/src/components/video-editor/timeline/TimelineEditor.tsx @@ -57,6 +57,7 @@ import type { } from "../types"; import AudioWaveform from "./AudioWaveform"; import Item from "./Item"; +import glassStyles from "./ItemGlass.module.css"; import KeyframeMarkers from "./KeyframeMarkers"; import Row from "./Row"; import TimelineWrapper from "./TimelineWrapper"; @@ -410,11 +411,14 @@ function PlaybackCursor({ >
- {isDragging && ( -
- {formatPlayheadTime(clampedTime)} -
- )} +
+ {formatPlayheadTime(clampedTime)} +
); @@ -594,6 +598,8 @@ function Timeline({ videoDurationMs, currentTimeMs, onSeek, + onAddZoomAtMs, + canPlaceZoomAtMs, onSelectZoom, onSelectTrim, onSelectClip, @@ -615,12 +621,14 @@ function Timeline({ videoDurationMs: number; currentTimeMs: number; onSeek?: (time: number) => void; + canPlaceZoomAtMs?: (startMs: number) => boolean; onSelectZoom?: (id: string | null) => void; onSelectTrim?: (id: string | null) => void; onSelectClip?: (id: string | null) => void; onSelectAnnotation?: (id: string | null) => void; onSelectSpeed?: (id: string | null) => void; onSelectAudio?: (id: string | null) => void; + onAddZoomAtMs?: (startMs: number) => void; selectedZoomId: string | null; selectedTrimId?: string | null; selectedClipId?: string | null; @@ -632,8 +640,13 @@ function Timeline({ keyframes?: { id: string; time: number }[]; audioPeaks?: AudioPeaksData | null; }) { - const { setTimelineRef, style, sidebarWidth, range, pixelsToValue } = useTimelineContext(); + const { setTimelineRef, style, sidebarWidth, direction, range, valueToPixels, pixelsToValue } = + useTimelineContext(); const localTimelineRef = useRef(null); + const [isTimelineHovered, setIsTimelineHovered] = useState(false); + const [timelineHoverMs, setTimelineHoverMs] = useState(null); + const [isZoomRowHovered, setIsZoomRowHovered] = useState(false); + const [zoomRowHoverMs, setZoomRowHoverMs] = useState(null); const setRefs = useCallback( (node: HTMLDivElement | null) => { @@ -712,6 +725,125 @@ function Timeline({ const timelineRowsMinHeightPx = getTimelineRowsMinHeightPx(timelineRowCount); const timelineContentMinHeightPx = getTimelineContentMinHeightPx(timelineRowCount); const timelineViewportStretchFactor = getTimelineViewportStretchFactor(timelineRowCount); + const sideProperty = direction === "rtl" ? "right" : "left"; + const visibleDurationMs = Math.max(1, range.end - range.start); + const ghostStartMs = + zoomRowHoverMs === null ? null : Math.max(0, Math.min(zoomRowHoverMs, videoDurationMs)); + const ghostDurationMs = Math.min(1000, videoDurationMs); + const ghostEndMs = + ghostStartMs === null + ? null + : Math.max(ghostStartMs, Math.min(videoDurationMs, ghostStartMs + ghostDurationMs)); + const ghostStartOffsetPx = + ghostStartMs === null ? 0 : valueToPixels(Math.max(0, ghostStartMs - range.start)); + const ghostEndOffsetPx = + ghostEndMs === null ? 0 : valueToPixels(Math.max(0, ghostEndMs - range.start)); + const ghostWidthPx = Math.max(18, ghostEndOffsetPx - ghostStartOffsetPx); + const timelineGhostOffsetPx = + timelineHoverMs === null ? 0 : valueToPixels(Math.max(0, timelineHoverMs - range.start)); + const canShowGhostPlayhead = isTimelineHovered && timelineHoverMs !== null; + const canShowGhostZoom = + isZoomRowHovered && + ghostStartMs !== null && + (onAddZoomAtMs ? (canPlaceZoomAtMs?.(ghostStartMs) ?? true) : false); + + const updateTimelineHoverTime = useCallback( + (clientX: number, rect: DOMRect) => { + const contentWidth = Math.max(1, rect.width - sidebarWidth); + + const contentX = + direction === "rtl" + ? rect.right - sidebarWidth - clientX + : clientX - rect.left - sidebarWidth; + const clampedX = Math.max(0, Math.min(contentX, contentWidth)); + const ratio = clampedX / contentWidth; + const nextMs = range.start + ratio * visibleDurationMs; + setTimelineHoverMs(Math.max(0, Math.min(nextMs, videoDurationMs))); + }, + [direction, range.start, sidebarWidth, videoDurationMs, visibleDurationMs], + ); + + const handleTimelineMouseEnter = useCallback( + (event: React.MouseEvent) => { + setIsTimelineHovered(true); + updateTimelineHoverTime(event.clientX, event.currentTarget.getBoundingClientRect()); + }, + [updateTimelineHoverTime], + ); + + const handleTimelineMouseMove = useCallback( + (event: React.MouseEvent) => { + if (!isTimelineHovered) { + setIsTimelineHovered(true); + } + updateTimelineHoverTime(event.clientX, event.currentTarget.getBoundingClientRect()); + }, + [isTimelineHovered, updateTimelineHoverTime], + ); + + const handleTimelineMouseLeave = useCallback(() => { + setIsTimelineHovered(false); + setTimelineHoverMs(null); + setIsZoomRowHovered(false); + setZoomRowHoverMs(null); + }, []); + + const updateZoomRowHoverTime = useCallback( + (clientX: number, rect: DOMRect) => { + if (rect.width <= 0) { + return; + } + + const position = + direction === "rtl" + ? Math.max(0, Math.min(rect.right - clientX, rect.width)) + : Math.max(0, Math.min(clientX - rect.left, rect.width)); + const ratio = position / rect.width; + const nextMs = range.start + ratio * visibleDurationMs; + setZoomRowHoverMs(Math.max(0, Math.min(nextMs, videoDurationMs))); + }, + [direction, range.start, videoDurationMs, visibleDurationMs], + ); + + const handleZoomRowMouseEnter = useCallback( + (event: React.MouseEvent) => { + setIsZoomRowHovered(true); + updateZoomRowHoverTime(event.clientX, event.currentTarget.getBoundingClientRect()); + }, + [updateZoomRowHoverTime], + ); + + const handleZoomRowMouseMove = useCallback( + (event: React.MouseEvent) => { + if (!isZoomRowHovered) { + setIsZoomRowHovered(true); + } + updateZoomRowHoverTime(event.clientX, event.currentTarget.getBoundingClientRect()); + }, + [isZoomRowHovered, updateZoomRowHoverTime], + ); + + const handleZoomRowMouseLeave = useCallback(() => { + setIsZoomRowHovered(false); + setZoomRowHoverMs(null); + }, []); + + const handleZoomRowClick = useCallback( + (event: React.MouseEvent) => { + event.stopPropagation(); + if (!onAddZoomAtMs || zoomRowHoverMs === null) { + return; + } + + const startMs = Math.max(0, Math.min(zoomRowHoverMs, videoDurationMs)); + if (canPlaceZoomAtMs && !canPlaceZoomAtMs(startMs)) { + return; + } + + onAddZoomAtMs(startMs); + }, + [canPlaceZoomAtMs, onAddZoomAtMs, videoDurationMs, zoomRowHoverMs], + ); return (
@@ -732,6 +867,20 @@ function Timeline({ timelineRef={localTimelineRef} keyframes={keyframes} /> + {canShowGhostPlayhead && ( +
+
+
+ )}
- + + {canShowGhostZoom && ghostStartMs !== null && ( +
+
+
+
+
+
+ +
+
+
+
+ )} {zoomItems.map((item) => ( ( // scaling them with the full recording length. const defaultRegionDurationMs = useMemo(() => Math.min(1000, totalMs), [totalMs]); + const canPlaceZoomAtMs = useCallback( + (startMs: number) => { + if (!videoDuration || videoDuration === 0 || totalMs === 0) { + return false; + } + + const defaultDuration = Math.min(defaultRegionDurationMs, totalMs); + if (defaultDuration <= 0) { + return false; + } + + const startPos = Math.max(0, Math.min(startMs, totalMs)); + const sorted = [...zoomRegions].sort((a, b) => a.startMs - b.startMs); + const nextRegion = sorted.find((region) => region.startMs > startPos); + const gapToNext = nextRegion ? nextRegion.startMs - startPos : totalMs - startPos; + + const isOverlapping = sorted.some( + (region) => startPos >= region.startMs && startPos < region.endMs, + ); + + return !isOverlapping && gapToNext >= defaultDuration; + }, + [videoDuration, totalMs, zoomRegions, defaultRegionDurationMs], + ); + + const addZoomAtMs = useCallback( + (startMs: number) => { + if (!videoDuration || videoDuration === 0 || totalMs === 0) { + return; + } + + const defaultDuration = Math.min(defaultRegionDurationMs, totalMs); + if (defaultDuration <= 0) { + return; + } + + const startPos = Math.max(0, Math.min(startMs, totalMs)); + if (!canPlaceZoomAtMs(startPos)) { + toast.error("Cannot place zoom here", { + description: + "Zoom already exists at this location or not enough space available.", + }); + return; + } + + onZoomAdded({ start: startPos, end: startPos + defaultDuration }); + }, + [videoDuration, totalMs, onZoomAdded, defaultRegionDurationMs, canPlaceZoomAtMs], + ); + const handleAddZoom = useCallback(() => { if (!videoDuration || videoDuration === 0 || totalMs === 0) { return; } - const defaultDuration = Math.min(defaultRegionDurationMs, totalMs); - if (defaultDuration <= 0) { - return; - } - - // Always place zoom at playhead - const startPos = Math.max(0, Math.min(currentTimeMs, totalMs)); - // Find the next zoom region after the playhead - const sorted = [...zoomRegions].sort((a, b) => a.startMs - b.startMs); - const nextRegion = sorted.find((region) => region.startMs > startPos); - const gapToNext = nextRegion ? nextRegion.startMs - startPos : totalMs - startPos; - - // Check if playhead is inside any zoom region - const isOverlapping = sorted.some( - (region) => startPos >= region.startMs && startPos < region.endMs, - ); - if (isOverlapping || gapToNext <= 0) { - toast.error("Cannot place zoom here", { - description: - "Zoom already exists at this location or not enough space available.", - }); - return; - } - - const actualDuration = Math.min(defaultRegionDurationMs, gapToNext); - onZoomAdded({ start: startPos, end: startPos + actualDuration }); - }, [ - videoDuration, - totalMs, - currentTimeMs, - zoomRegions, - onZoomAdded, - defaultRegionDurationMs, - ]); + addZoomAtMs(currentTimeMs); + }, [addZoomAtMs, currentTimeMs, totalMs, videoDuration]); const handleSuggestZooms = useCallback(() => { if (!videoDuration || videoDuration === 0 || totalMs === 0) { @@ -2269,6 +2476,8 @@ const TimelineEditor = forwardRef( videoDurationMs={totalMs} currentTimeMs={currentTimeMs} onSeek={onSeek} + onAddZoomAtMs={addZoomAtMs} + canPlaceZoomAtMs={canPlaceZoomAtMs} onSelectZoom={handleSelectZoom} onSelectTrim={handleSelectTrim} onSelectClip={handleSelectClip} From bebac5cc6036fd6482afcab33d2fedff0894c7a9 Mon Sep 17 00:00:00 2001 From: webadderall <131426131+webadderall@users.noreply.github.com> Date: Fri, 1 May 2026 16:39:59 +1000 Subject: [PATCH 12/19] update readme vid --- README.md | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 09081c95..b73364f3 100644 --- a/README.md +++ b/README.md @@ -9,15 +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) -

- Recordly recording interface screenshot -

- -https://github.com/user-attachments/assets/c328cb35-fbb3-46c1-8cb5-504f8910705f +https://github.com/user-attachments/assets/9b66c71d-ac97-49ff-a0c9-63ac26edf2e4 --- From 7a87763ca39e633c3d307d7768e45429ec6d2cfe Mon Sep 17 00:00:00 2001 From: webadderall <131426131+webadderall@users.noreply.github.com> Date: Fri, 1 May 2026 16:44:10 +1000 Subject: [PATCH 13/19] Fix preset review feedback --- src/components/video-editor/VideoEditor.tsx | 131 ++++++++++++++---- .../video-editor/editorPreferences.test.ts | 42 ++++++ .../video-editor/editorPreferences.ts | 31 +++-- 3 files changed, 161 insertions(+), 43 deletions(-) diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 610fe594..02074c62 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -683,6 +683,7 @@ export default function VideoEditor() { 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); @@ -858,13 +859,29 @@ export default function VideoEditor() { [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, - [editorPresets, currentPresetSignature], - ); + ) ?? null; + const nextActivePresetId = matchingPreset?.id ?? null; + if (nextActivePresetId !== activeEditorPresetId) { + setActiveEditorPresetId(nextActivePresetId); + } + }, [activeEditorPresetId, currentEditorPreset, currentPresetSignature, editorPresets]); useEffect(() => { if (!presetPopoverOpen) { @@ -921,17 +938,22 @@ export default function VideoEditor() { return; } + setActiveEditorPresetId(preset.id); applyEditorPresetSnapshot(preset.snapshot); - toast.success(`Applied preset \"${preset.name}\"`); + toast.success( + t("editor.presets.toasts.applied", "Applied preset \"{{name}}\"", { + name: preset.name, + }), + ); }, - [applyEditorPresetSnapshot, editorPresets], + [applyEditorPresetSnapshot, editorPresets, t], ); const handleSaveEditorPreset = useCallback( (name: string) => { const normalizedName = name.trim().replace(/\s+/g, " "); if (normalizedName.length === 0) { - toast.error("Enter a preset name."); + toast.error(t("editor.presets.errors.nameRequired", "Enter a preset name.")); return false; } @@ -939,29 +961,49 @@ export default function VideoEditor() { (preset) => preset.name.toLocaleLowerCase() === normalizedName.toLocaleLowerCase(), ); if (hasDuplicateName) { - toast.error("A preset with that name already exists."); + 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 = [ - { - id: crypto.randomUUID(), - name: normalizedName, - createdAt: timestamp, - updatedAt: timestamp, - snapshot, - }, + 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); - saveEditorPresets(nextPresets); - toast.success(`Saved preset \"${normalizedName}\"`); + setActiveEditorPresetId(nextPreset.id); + toast.success( + t("editor.presets.toasts.saved", "Saved preset \"{{name}}\"", { + name: normalizedName, + }), + ); return true; }, - [captureEditorPresetSnapshot, editorPresets], + [captureEditorPresetSnapshot, editorPresets, t], ); const handleDeleteEditorPreset = useCallback( @@ -972,11 +1014,27 @@ export default function VideoEditor() { } 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); - saveEditorPresets(nextPresets); - toast.success(`Deleted preset \"${preset.name}\"`); + if (preset.id === activeEditorPresetId) { + setActiveEditorPresetId(null); + } + toast.success( + t("editor.presets.toasts.deleted", "Deleted preset \"{{name}}\"", { + name: preset.name, + }), + ); }, - [editorPresets], + [activeEditorPresetId, editorPresets, t], ); const handleSavePresetSubmit = useCallback(() => { @@ -5198,11 +5256,13 @@ export default function VideoEditor() { @@ -5221,31 +5281,34 @@ export default function VideoEditor() { className="space-y-2" >

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

setPresetNameDraft(event.target.value)} - placeholder="Preset name" 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")} />
-

Saved presets

+

+ {t("editor.presets.savedList", "Saved presets")} +

{editorPresets.length === 0 ? (
- No presets yet. + {t("editor.presets.empty", "No presets yet.")}
) : ( editorPresets.map((preset) => { @@ -5266,14 +5329,22 @@ export default function VideoEditor() { className="flex min-w-0 flex-1 items-center justify-between text-left" > {preset.name} - {isActive && } + {isActive ? : null} 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 f60b1c03..0b97dd34 100644 --- a/src/components/video-editor/editorPreferences.ts +++ b/src/components/video-editor/editorPreferences.ts @@ -228,6 +228,17 @@ function normalizeEditorPreset(candidate: unknown): EditorPreset | null { }; } +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)); } @@ -400,28 +411,22 @@ export function loadEditorPresets(): EditorPreset[] { return []; } - const parsed = JSON.parse(stored); - if (!Array.isArray(parsed)) { - return []; - } - - return parsed - .map((item) => normalizeEditorPreset(item)) - .filter((preset): preset is EditorPreset => preset !== null) - .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)); + return normalizeEditorPresets(JSON.parse(stored)); } catch { return []; } } -export function saveEditorPresets(presets: EditorPreset[]): void { +export function saveEditorPresets(presets: EditorPreset[]): boolean { if (typeof globalThis.localStorage === "undefined") { - return; + return false; } try { - globalThis.localStorage.setItem(EDITOR_PRESETS_STORAGE_KEY, JSON.stringify(presets)); + const normalized = normalizeEditorPresets(presets); + globalThis.localStorage.setItem(EDITOR_PRESETS_STORAGE_KEY, JSON.stringify(normalized)); + return true; } catch { - // Ignore storage failures so editor controls still work. + return false; } } From 5570c119cadc3e9bc10558ad8cae092c6fc2c200 Mon Sep 17 00:00:00 2001 From: webadderall <131426131+webadderall@users.noreply.github.com> Date: Fri, 1 May 2026 17:18:35 +1000 Subject: [PATCH 14/19] [codex] Fix cursor sync after recording pause (#399) * Fix cursor sync after recording pause * Align recorder and cursor pause boundaries --- electron/electron-env.d.ts | 10 +++ electron/ipc/cursor/interaction.ts | 17 +++-- electron/ipc/cursor/telemetry.test.ts | 51 +++++++++++++++ electron/ipc/cursor/telemetry.ts | 59 ++++++++++++++++- electron/ipc/register/recording.ts | 25 +++++++ electron/ipc/state.ts | 8 +++ electron/preload.ts | 6 ++ src/hooks/useScreenRecorder.ts | 93 +++++++++++++++++++++++++-- 8 files changed, 253 insertions(+), 16 deletions(-) create mode 100644 electron/ipc/cursor/telemetry.test.ts 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/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index 8edd3d5f..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 ( @@ -1441,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; @@ -1451,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]); @@ -1472,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; @@ -1482,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]); From df3527c1e96590d626fc62fe7d6806a14fe5a818 Mon Sep 17 00:00:00 2001 From: sharkcreep87 Date: Sat, 2 May 2026 10:08:28 +0800 Subject: [PATCH 15/19] chore: update repository URLs to canonical webadderallorg org (#402) The repo was moved from github.com/webadderall to github.com/webadderallorg but several files still reference the old URL. GitHub serves a 301 redirect today, but package.json metadata, the Homebrew cask, and the in-app issues link should point to the canonical location. Updated: - package.json (homepage, repository.url, bugs.url) - README.md and README.zh-CN.md (releases / clone / issues links) - CONTRIBUTING.md (issues link) - recordly.rb (Homebrew cask url + homepage) - src/components/video-editor/TutorialHelp.tsx (RECORDLY_ISSUES_URL) - src/components/video-editor/videoPlayback/motionSmoothing.ts (attribution comment) --- CONTRIBUTING.md | 2 +- README.md | 6 +++--- README.zh-CN.md | 6 +++--- package.json | 6 +++--- recordly.rb | 4 ++-- src/components/video-editor/TutorialHelp.tsx | 2 +- .../video-editor/videoPlayback/motionSmoothing.ts | 2 +- 7 files changed, 14 insertions(+), 14 deletions(-) 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 b73364f3..4f4f0fe2 100644 --- a/README.md +++ b/README.md @@ -167,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 --- @@ -200,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 @@ -357,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. 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/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/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/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 { From 5090910ce3bf03a949987ebe11b4cbf2a5ac3d99 Mon Sep 17 00:00:00 2001 From: webadderall <131426131+webadderall@users.noreply.github.com> Date: Sat, 2 May 2026 16:30:50 +1000 Subject: [PATCH 16/19] Add Tandava Appadoo and Digitalfastmind to supporters --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 4f4f0fe2..d42c71f6 100644 --- a/README.md +++ b/README.md @@ -371,6 +371,8 @@ Pull requests are welcome. - buildwithfur - Tobias - Anonymous Supporter +- Tandava Appadoo +- Digitalfastmind - Roberto Marcelino - Rajan RK - Francesco From d4b5cf77ecd461cc210151566ff83292bef5d3d3 Mon Sep 17 00:00:00 2001 From: webadderall <131426131+webadderall@users.noreply.github.com> Date: Sat, 2 May 2026 20:00:40 +1000 Subject: [PATCH 17/19] timeline: suggest zoom tracks from click clusters instead of dwell heuristics Replace the dwell-ranking-based auto-zoom algorithm with a pure click-cluster approach: - Extract explicit click events (click, double-click, right-click, middle-click) from cursor telemetry via detectInteractionCandidates - Chain-merge clicks that occur within 2500 ms of each other into one cluster - Expand each cluster to a zoom window of [firstClick - 500 ms, lastClick + 500 ms] - Skip clusters that overlap already-placed zoom regions (reservedSpans) New exported constants: CLICK_CLUSTER_MERGE_GAP_MS (2500) and CLICK_CLUSTER_PAD_MS (500). Adds zoomSuggestionUtils.test.ts with 7 unit tests covering the new logic. --- .../timeline/zoomSuggestionUtils.test.ts | 152 +++++++++++++++++ .../timeline/zoomSuggestionUtils.ts | 157 ++++++++++++------ 2 files changed, 260 insertions(+), 49 deletions(-) create mode 100644 src/components/video-editor/timeline/zoomSuggestionUtils.test.ts 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..6994025d --- /dev/null +++ b/src/components/video-editor/timeline/zoomSuggestionUtils.test.ts @@ -0,0 +1,152 @@ +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): CursorTelemetryPoint { + return { timeMs, cx, cy, interactionType: "click" }; +} + +/** Wraps a list of click samples with surrounding move events so the telemetry + * passes the `normalizedSamples.length < 2` guard. */ +function withMoves( + clicks: CursorTelemetryPoint[], + totalMs: number, +): CursorTelemetryPoint[] { + return [ + { timeMs: 0, cx: 0.5, cy: 0.5, interactionType: "move" }, + ...clicks, + { timeMs: totalMs, cx: 0.5, cy: 0.5, interactionType: "move" }, + ]; +} + +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("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("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..10de1548 100644 --- a/src/components/video-editor/timeline/zoomSuggestionUtils.ts +++ b/src/components/video-editor/timeline/zoomSuggestionUtils.ts @@ -38,8 +38,10 @@ 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; function normalizeTelemetrySample( sample: CursorTelemetryPoint, @@ -254,6 +256,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,18 +330,17 @@ 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: [] }; } @@ -281,62 +349,53 @@ export function buildInteractionZoomSuggestions(params: { return { status: "no-telemetry", suggestions: [] }; } - const interactionCandidates = detectInteractionCandidates(normalizedSamples); - if (interactionCandidates.length === 0) { + // Only use explicit click events (uiohook telemetry) – ignore dwell heuristics + const clickCandidates = detectInteractionCandidates(normalizedSamples).filter( + (c) => c.kind === "click-like" || c.kind === "double-click-like" || c.kind === "dropdown-open" || c.kind === "text-field-click" || c.kind === "text-selection", + ); + + 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 }; } /** From 676d01827f7367bd54f40a95ce88b696202501f5 Mon Sep 17 00:00:00 2001 From: webadderall <131426131+webadderall@users.noreply.github.com> Date: Sat, 2 May 2026 20:21:35 +1000 Subject: [PATCH 18/19] tighten click-cluster zoom suggestions --- .../timeline/zoomSuggestionUtils.test.ts | 40 +++++++++++++++++-- .../timeline/zoomSuggestionUtils.ts | 23 ++++++++--- 2 files changed, 54 insertions(+), 9 deletions(-) diff --git a/src/components/video-editor/timeline/zoomSuggestionUtils.test.ts b/src/components/video-editor/timeline/zoomSuggestionUtils.test.ts index 6994025d..c7514668 100644 --- a/src/components/video-editor/timeline/zoomSuggestionUtils.test.ts +++ b/src/components/video-editor/timeline/zoomSuggestionUtils.test.ts @@ -10,16 +10,19 @@ function makeClick(timeMs: number, cx = 0.5, cy = 0.5): CursorTelemetryPoint { return { timeMs, cx, cy, interactionType: "click" }; } -/** Wraps a list of click samples with surrounding move events so the telemetry - * passes the `normalizedSamples.length < 2` guard. */ +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 [ - { timeMs: 0, cx: 0.5, cy: 0.5, interactionType: "move" }, + makeMove(0), ...clicks, - { timeMs: totalMs, cx: 0.5, cy: 0.5, interactionType: "move" }, + makeMove(totalMs), ]; } @@ -43,6 +46,17 @@ describe("buildInteractionZoomSuggestions (click-cluster logic)", () => { 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("merges two clicks within 2500ms into one zoom track", () => { const telemetry = withMoves( [makeClick(4_000), makeClick(4_000 + CLICK_CLUSTER_MERGE_GAP_MS - 1)], @@ -123,6 +137,24 @@ describe("buildInteractionZoomSuggestions (click-cluster logic)", () => { 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; diff --git a/src/components/video-editor/timeline/zoomSuggestionUtils.ts b/src/components/video-editor/timeline/zoomSuggestionUtils.ts index 10de1548..52eddfe8 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 { @@ -213,6 +214,7 @@ export function detectInteractionCandidates( focus: { cx: clickSample.cx, cy: clickSample.cy }, strength: baseStrength, kind, + source: "explicit", }); } @@ -220,12 +222,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" }; }, ); @@ -249,6 +251,7 @@ export function detectInteractionCandidates( }, strength: prev.strength + curr.strength + 500, kind: "double-click-like", + source: "heuristic", }); } } @@ -345,13 +348,23 @@ export function buildInteractionZoomSuggestions(params: { } const normalizedSamples = normalizeCursorTelemetry(cursorTelemetry, totalMs); - if (normalizedSamples.length < 2) { + if (normalizedSamples.length === 0) { + return { status: "no-telemetry", suggestions: [] }; + } + + if ( + normalizedSamples.length === 1 && + normalizedSamples[0].interactionType !== "click" && + normalizedSamples[0].interactionType !== "double-click" && + normalizedSamples[0].interactionType !== "right-click" && + normalizedSamples[0].interactionType !== "middle-click" + ) { return { status: "no-telemetry", suggestions: [] }; } // Only use explicit click events (uiohook telemetry) – ignore dwell heuristics const clickCandidates = detectInteractionCandidates(normalizedSamples).filter( - (c) => c.kind === "click-like" || c.kind === "double-click-like" || c.kind === "dropdown-open" || c.kind === "text-field-click" || c.kind === "text-selection", + (candidate) => candidate.source === "explicit", ); if (clickCandidates.length === 0) { From dad073ebed46cfc28f34d463a5dab5dd542bc5b8 Mon Sep 17 00:00:00 2001 From: webadderall <131426131+webadderall@users.noreply.github.com> Date: Sat, 2 May 2026 20:38:07 +1000 Subject: [PATCH 19/19] harden explicit click zoom suggestions --- .../timeline/zoomSuggestionUtils.test.ts | 27 +++++++++++++++++-- .../timeline/zoomSuggestionUtils.ts | 21 ++++++++++----- 2 files changed, 39 insertions(+), 9 deletions(-) diff --git a/src/components/video-editor/timeline/zoomSuggestionUtils.test.ts b/src/components/video-editor/timeline/zoomSuggestionUtils.test.ts index c7514668..77080bfc 100644 --- a/src/components/video-editor/timeline/zoomSuggestionUtils.test.ts +++ b/src/components/video-editor/timeline/zoomSuggestionUtils.test.ts @@ -6,8 +6,13 @@ import { } from "./zoomSuggestionUtils"; import type { CursorTelemetryPoint } from "../types"; -function makeClick(timeMs: number, cx = 0.5, cy = 0.5): CursorTelemetryPoint { - return { timeMs, cx, cy, interactionType: "click" }; +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 { @@ -57,6 +62,24 @@ describe("buildInteractionZoomSuggestions (click-cluster logic)", () => { 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)], diff --git a/src/components/video-editor/timeline/zoomSuggestionUtils.ts b/src/components/video-editor/timeline/zoomSuggestionUtils.ts index 52eddfe8..189c7604 100644 --- a/src/components/video-editor/timeline/zoomSuggestionUtils.ts +++ b/src/components/video-editor/timeline/zoomSuggestionUtils.ts @@ -43,6 +43,18 @@ export interface InteractionZoomSuggestionResult { 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, @@ -188,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[] = []; @@ -354,10 +364,7 @@ export function buildInteractionZoomSuggestions(params: { if ( normalizedSamples.length === 1 && - normalizedSamples[0].interactionType !== "click" && - normalizedSamples[0].interactionType !== "double-click" && - normalizedSamples[0].interactionType !== "right-click" && - normalizedSamples[0].interactionType !== "middle-click" + !isExplicitClickType(normalizedSamples[0].interactionType) ) { return { status: "no-telemetry", suggestions: [] }; }