diff --git a/src/components/video-editor/autoCaptionSource.test.ts b/src/components/video-editor/autoCaptionSource.test.ts index 20d3855a..6a969397 100644 --- a/src/components/video-editor/autoCaptionSource.test.ts +++ b/src/components/video-editor/autoCaptionSource.test.ts @@ -22,6 +22,21 @@ describe("resolveAutoCaptionSourcePath", () => { ).toBe("/Users/test/Desktop/capture.mp4"); }); + it("extracts local paths from Recordly media-server URLs", () => { + expect( + resolveAutoCaptionSourcePath({ + videoPath: + "http://127.0.0.1:43123/video?path=%2FUsers%2Ftest%2FDesktop%2Fcapture.mp4", + }), + ).toBe("/Users/test/Desktop/capture.mp4"); + }); + + it("rejects remote URLs that native captioning cannot read", () => { + expect( + resolveAutoCaptionSourcePath({ videoPath: "https://example.com/capture.mp4" }), + ).toBeNull(); + }); + it("uses session and current video fallbacks only when nothing is loaded", () => { expect( resolveAutoCaptionSourcePath({ diff --git a/src/components/video-editor/autoCaptionSource.ts b/src/components/video-editor/autoCaptionSource.ts index c74b1b7c..c39f9d4b 100644 --- a/src/components/video-editor/autoCaptionSource.ts +++ b/src/components/video-editor/autoCaptionSource.ts @@ -1,4 +1,4 @@ -import { fromFileUrl } from "./projectPersistence"; +import { getLocalFilePathFromResource } from "@/lib/exporter/mediaResource"; type AutoCaptionSourceOptions = { videoSourcePath?: string | null; @@ -9,19 +9,19 @@ type AutoCaptionSourceOptions = { export function resolveAutoCaptionSourcePath(options: AutoCaptionSourceOptions): string | null { if (options.videoSourcePath) { - return options.videoSourcePath; + return getLocalFilePathFromResource(options.videoSourcePath); } if (options.videoPath) { - return fromFileUrl(options.videoPath); + return getLocalFilePathFromResource(options.videoPath); } if (options.recordingSessionVideoPath) { - return fromFileUrl(options.recordingSessionVideoPath); + return getLocalFilePathFromResource(options.recordingSessionVideoPath); } if (options.currentVideoPath) { - return fromFileUrl(options.currentVideoPath); + return getLocalFilePathFromResource(options.currentVideoPath); } return null; diff --git a/src/components/video-editor/editorPreferences.ts b/src/components/video-editor/editorPreferences.ts index 731e6418..b087cc88 100644 --- a/src/components/video-editor/editorPreferences.ts +++ b/src/components/video-editor/editorPreferences.ts @@ -66,9 +66,11 @@ type PartialEditorControls = Partial; type PresetAutoCaptionSettings = ProjectEditorState["autoCaptionSettings"]; type PresetCropRegion = ProjectEditorState["cropRegion"]; +type PresetWebcamSettings = Omit; -export interface EditorPresetSnapshot extends PersistedEditorControls { +export interface EditorPresetSnapshot extends Omit { cropRegion: PresetCropRegion; + webcam: PresetWebcamSettings; autoCaptionSettings: PresetAutoCaptionSettings; whisperExecutablePath: string | null; whisperModelPath: string | null; @@ -209,9 +211,15 @@ function normalizeEditorPresetSnapshot(candidate: unknown): EditorPresetSnapshot const normalizedCropRegion = normalizeProjectEditor({ cropRegion: raw.cropRegion, }).cropRegion; + const normalizedControls = normalizeEditorControls( + normalizedPreferences, + normalizedPreferences, + ); + const { sourcePath: _sourcePath, ...webcam } = normalizedControls.webcam; return { - ...normalizeEditorControls(normalizedPreferences, normalizedPreferences), + ...normalizedControls, + webcam, cropRegion: normalizedCropRegion, autoCaptionSettings: normalizePresetAutoCaptionSettings(raw.autoCaptionSettings), whisperExecutablePath: diff --git a/src/components/video-editor/export/buildExportRenderOptions.ts b/src/components/video-editor/export/buildExportRenderOptions.ts index 137de934..4ec4214d 100644 --- a/src/components/video-editor/export/buildExportRenderOptions.ts +++ b/src/components/video-editor/export/buildExportRenderOptions.ts @@ -2,7 +2,7 @@ import type { ExportProgress } from "@/lib/exporter"; import { toFileUrl } from "../projectPersistence"; import type { useAppearanceState } from "../state/useAppearanceState"; import type { useTimelineState } from "../state/useTimelineState"; -import type { CaptionCue, CursorTelemetryPoint, SpeedRegion, ZoomRegion } from "../types"; +import type { CursorTelemetryPoint, SpeedRegion, ZoomRegion } from "../types"; type AppearanceState = ReturnType; type TimelineState = ReturnType; @@ -61,7 +61,7 @@ export function buildExportRenderOptions({ appearance.resolvedWebcamVideoUrl ?? (appearance.webcam.sourcePath ? toFileUrl(appearance.webcam.sourcePath) : null), annotationRegions: timeline.annotationRegions, - autoCaptions: timeline.autoCaptions as CaptionCue[], + autoCaptions: timeline.autoCaptions, autoCaptionSettings: timeline.autoCaptionSettings, zoomRegions: effectiveZoomRegions, cursorTelemetry: effectiveCursorTelemetry, diff --git a/src/components/video-editor/export/useExportDialogActions.ts b/src/components/video-editor/export/useExportDialogActions.ts index 94ee9405..87de829a 100644 --- a/src/components/video-editor/export/useExportDialogActions.ts +++ b/src/components/video-editor/export/useExportDialogActions.ts @@ -56,10 +56,14 @@ export function useExportDialogActions({ toast.error("Video not ready"); return; } + if (video.videoWidth <= 0 || video.videoHeight <= 0) { + toast.error("Video metadata is still loading"); + return; + } const resolvedSettings = resolveExportStartSettings({ - sourceWidth: video.videoWidth || 1920, - sourceHeight: video.videoHeight || 1080, + sourceWidth: video.videoWidth, + sourceHeight: video.videoHeight, exportFormat: settings.exportFormat, includeCaptionSidecar: hasCaptionsForSidecar && settings.includeCaptionSidecar, exportEncodingMode: settings.exportEncodingMode, diff --git a/src/components/video-editor/export/useExportDimensions.ts b/src/components/video-editor/export/useExportDimensions.ts index f7be627f..2e6721f9 100644 --- a/src/components/video-editor/export/useExportDimensions.ts +++ b/src/components/video-editor/export/useExportDimensions.ts @@ -47,20 +47,20 @@ export function useExportDimensions({ }); const requestRef = useRef(0); const previousProbeRef = useRef(null); - const gifOutputDimensions = useMemo( - () => - calculateOutputDimensions( - videoPlaybackRef.current?.video?.videoWidth || 1920, - videoPlaybackRef.current?.video?.videoHeight || 1080, - gifSizePreset, - GIF_SIZE_PRESETS, - ), - [gifSizePreset, videoPlaybackRef], - ); const sourceDimensions = useMemo(() => { const video = isPreviewReady ? videoPlaybackRef.current?.video : null; return { width: video?.videoWidth || 1920, height: video?.videoHeight || 1080 }; }, [isPreviewReady, videoPlaybackRef]); + const gifOutputDimensions = useMemo( + () => + calculateOutputDimensions( + sourceDimensions.width, + sourceDimensions.height, + gifSizePreset, + GIF_SIZE_PRESETS, + ), + [gifSizePreset, sourceDimensions], + ); const desiredSourceDimensions = useMemo( () => calculateMp4SourceDimensions( diff --git a/src/components/video-editor/export/useExportRunner.ts b/src/components/video-editor/export/useExportRunner.ts index 89bbb4e0..95aef628 100644 --- a/src/components/video-editor/export/useExportRunner.ts +++ b/src/components/video-editor/export/useExportRunner.ts @@ -461,7 +461,7 @@ export function useExportRunner(input: ExportRunnerInput) { } if (wasPlaying) { - videoPlaybackRef.current?.play(); + await videoPlaybackRef.current?.play().catch(() => undefined); } else { video.currentTime = restoreTime; } diff --git a/src/components/video-editor/hooks/useClipRegionCommands.ts b/src/components/video-editor/hooks/useClipRegionCommands.ts index aa40256f..e94793e3 100644 --- a/src/components/video-editor/hooks/useClipRegionCommands.ts +++ b/src/components/video-editor/hooks/useClipRegionCommands.ts @@ -79,32 +79,21 @@ export function useClipRegionCommands({ const handleClipSplit = useCallback( (splitMs: number) => { - setClipRegions((current) => { - const target = current.find( - (clip) => splitMs > clip.startMs && splitMs < clip.endMs, - ); - if (!target) return current; - const leftId = `clip-${nextClipIdRef.current++}`; - const rightId = `clip-${nextClipIdRef.current++}`; - const left: ClipRegion = { - id: leftId, - startMs: target.startMs, - endMs: Math.round(splitMs), - speed: target.speed, - muted: target.muted, - }; - const right: ClipRegion = { - id: rightId, - startMs: Math.round(splitMs), - endMs: target.endMs, - speed: target.speed, - muted: target.muted, - }; - if (selectedClipId === target.id) setSelectedClipId(leftId); - return current.flatMap((clip) => (clip.id === target.id ? [left, right] : [clip])); - }); + const target = clipRegions.find( + (clip) => splitMs > clip.startMs && splitMs < clip.endMs, + ); + if (!target) return; + const leftId = `clip-${nextClipIdRef.current++}`; + const rightId = `clip-${nextClipIdRef.current++}`; + const splitAt = Math.round(splitMs); + const left: ClipRegion = { ...target, id: leftId, endMs: splitAt }; + const right: ClipRegion = { ...target, id: rightId, startMs: splitAt }; + setClipRegions((current) => + current.flatMap((clip) => (clip.id === target.id ? [left, right] : [clip])), + ); + if (selectedClipId === target.id) setSelectedClipId(leftId); }, - [nextClipIdRef, selectedClipId, setClipRegions, setSelectedClipId], + [clipRegions, nextClipIdRef, selectedClipId, setClipRegions, setSelectedClipId], ); const handleClipSpanChange = useCallback( diff --git a/src/components/video-editor/hooks/useCursorTelemetry.ts b/src/components/video-editor/hooks/useCursorTelemetry.ts index 4913a76c..67d64239 100644 --- a/src/components/video-editor/hooks/useCursorTelemetry.ts +++ b/src/components/video-editor/hooks/useCursorTelemetry.ts @@ -32,6 +32,20 @@ export function useCursorTelemetry({ useEffect(() => { let mounted = true; let retryAttempts = 0; + const scheduleRetry = () => { + if ( + pendingFreshRecordingAutoZoomPathRef.current !== videoPath || + autoSuggestedVideoPathRef.current === videoPath || + retryAttempts >= 12 + ) { + return; + } + retryAttempts += 1; + pendingRetryTimeoutRef.current = window.setTimeout(() => { + pendingRetryTimeoutRef.current = null; + if (mounted) void load(); + }, 350); + }; async function load() { if (!videoPath || !videoSourcePath) { if (mounted) { @@ -45,33 +59,13 @@ export function useCursorTelemetry({ if (!mounted) return; setCursorTelemetry(result.success ? result.samples : []); setCursorTelemetrySourcePath(videoSourcePath); - if ( - pendingFreshRecordingAutoZoomPathRef.current === videoPath && - autoSuggestedVideoPathRef.current !== videoPath && - retryAttempts < 12 - ) { - retryAttempts += 1; - pendingRetryTimeoutRef.current = window.setTimeout(() => { - pendingRetryTimeoutRef.current = null; - if (mounted) void load(); - }, 350); - } + if (!result.success || result.samples.length === 0) scheduleRetry(); } catch (error) { console.warn("Unable to load cursor telemetry:", error); if (!mounted) return; setCursorTelemetry([]); setCursorTelemetrySourcePath(videoSourcePath); - if ( - pendingFreshRecordingAutoZoomPathRef.current === videoPath && - autoSuggestedVideoPathRef.current !== videoPath && - retryAttempts < 12 - ) { - retryAttempts += 1; - pendingRetryTimeoutRef.current = window.setTimeout(() => { - pendingRetryTimeoutRef.current = null; - if (mounted) void load(); - }, 350); - } + scheduleRetry(); } } diff --git a/src/components/video-editor/hooks/useEditorGlobalInteractions.ts b/src/components/video-editor/hooks/useEditorGlobalInteractions.ts index 259da9d6..f5e7ec49 100644 --- a/src/components/video-editor/hooks/useEditorGlobalInteractions.ts +++ b/src/components/video-editor/hooks/useEditorGlobalInteractions.ts @@ -48,7 +48,6 @@ export function useEditorGlobalInteractions({ } return; } - if (event.key === "Tab" && !editable) event.preventDefault(); if (!matchesShortcut(event, shortcuts.playPause, isMac) || editable) return; event.preventDefault(); const playback = videoPlaybackRef.current; diff --git a/src/components/video-editor/hooks/useEditorHistory.ts b/src/components/video-editor/hooks/useEditorHistory.ts index 992817b6..afcb643d 100644 --- a/src/components/video-editor/hooks/useEditorHistory.ts +++ b/src/components/video-editor/hooks/useEditorHistory.ts @@ -52,8 +52,16 @@ export function useEditorHistory({ } = timeline; const historyRef = useRef(createEditorHistoryStack()); const applyingRef = useRef(false); - const [version, setVersion] = useState(0); - const syncButtons = useCallback(() => setVersion((value) => value + 1), []); + const [historyFlags, setHistoryFlags] = useState({ canUndo: false, canRedo: false }); + const syncButtons = useCallback(() => { + const next = { + canUndo: historyRef.current.past.length > 0, + canRedo: historyRef.current.future.length > 0, + }; + setHistoryFlags((current) => + current.canUndo === next.canUndo && current.canRedo === next.canRedo ? current : next, + ); + }, []); const buildSnapshot = useCallback( (): EditorHistorySnapshot => ({ zoomRegions, @@ -160,10 +168,8 @@ export function useEditorHistory({ if (result !== "unchanged") syncButtons(); }, [buildSnapshot, syncButtons]); - void version; return { - canUndo: historyRef.current.past.length > 0, - canRedo: historyRef.current.future.length > 0, + ...historyFlags, handleUndo, handleRedo, resetHistory, diff --git a/src/components/video-editor/hooks/useTimelineProjection.ts b/src/components/video-editor/hooks/useTimelineProjection.ts index 29d3d9cc..b49f13c8 100644 --- a/src/components/video-editor/hooks/useTimelineProjection.ts +++ b/src/components/video-editor/hooks/useTimelineProjection.ts @@ -57,7 +57,6 @@ export function useTimelineProjection({ ); } timeline.setClipRegions(nextRegions); - if (speedRegions.length > 0) timeline.setSpeedRegions([]); } initializedRef.current = true; return; @@ -72,15 +71,7 @@ export function useTimelineProjection({ if (!extended) return; autoFullTrackEndRef.current = totalMs; timeline.setClipRegions(extended); - }, [ - duration, - clipRegions, - trimRegions, - speedRegions, - nextClipIdRef, - timeline.setClipRegions, - timeline.setSpeedRegions, - ]); + }, [duration, clipRegions, trimRegions, nextClipIdRef, timeline.setClipRegions]); useEffect(() => { const totalMs = Math.round(duration * 1000); diff --git a/src/components/video-editor/hooks/useZoomRegionCommands.ts b/src/components/video-editor/hooks/useZoomRegionCommands.ts index 425f53f8..1d6d3b64 100644 --- a/src/components/video-editor/hooks/useZoomRegionCommands.ts +++ b/src/components/video-editor/hooks/useZoomRegionCommands.ts @@ -75,7 +75,7 @@ export function useZoomRegionCommands({ endMs: Math.round(span.end), depth, focus: clampFocusToDepth({ cx: 0.5, cy: 0.5 }, depth), - mode: "auto", + mode: "manual", }; markFreshRecordingSuggestion(); setZoomRegions((current) => [...current, newRegion]); diff --git a/src/components/video-editor/layout/CropEditorDialog.tsx b/src/components/video-editor/layout/CropEditorDialog.tsx index fe1e88e6..420a3b98 100644 --- a/src/components/video-editor/layout/CropEditorDialog.tsx +++ b/src/components/video-editor/layout/CropEditorDialog.tsx @@ -1,6 +1,6 @@ -import { X } from "@phosphor-icons/react"; import type { Dispatch, SetStateAction } from "react"; import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; import type { useI18n } from "@/contexts/I18nContext"; import type { AspectRatio } from "@/utils/aspectRatioUtils"; import { CropControl } from "../CropControl"; @@ -27,31 +27,18 @@ export function CropEditorDialog({ onCancel, onDone, }: Props) { - if (!open) return null; return ( - <> -
-
+ !nextOpen && onCancel()}> +
- + {t("settings.crop.title")} - +

{t("settings.crop.instruction")}

-
-
- + + ); } diff --git a/src/components/video-editor/layout/EditorDialogs.tsx b/src/components/video-editor/layout/EditorDialogs.tsx index 0f195e2e..3b22a63c 100644 --- a/src/components/video-editor/layout/EditorDialogs.tsx +++ b/src/components/video-editor/layout/EditorDialogs.tsx @@ -136,9 +136,15 @@ export function EditorDialogs({ > - Unsaved changes + + {t("editor.project.unsavedChangesTitle", "Unsaved changes")} + - {`Save your current project before you ${unsavedChangesDialogActionLabel}?`} + {t( + "editor.project.unsavedChangesDescription", + "Save your current project before you {{action}}?", + { action: unsavedChangesDialogActionLabel }, + )} @@ -154,10 +160,10 @@ export function EditorDialogs({ variant="ghost" onClick={() => resolveUnsavedChangesDialog("discard")} > - Discard changes + {t("editor.project.discardChanges", "Discard changes")} diff --git a/src/components/video-editor/layout/EditorExportMenu.tsx b/src/components/video-editor/layout/EditorExportMenu.tsx index bd52e3c2..64b247fd 100644 --- a/src/components/video-editor/layout/EditorExportMenu.tsx +++ b/src/components/video-editor/layout/EditorExportMenu.tsx @@ -240,7 +240,7 @@ export function EditorExportMenu(props: Props) {

) : null}

- {exportedFilePath.split("/").pop()} + {exportedFilePath.split(/[\\/]/).pop()}