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