-
{label}
+
+ {t(`fixedActions.${i18nKey}`, { defaultValue: label })}
+
{display}
diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx
index c810277..cbe80f2 100644
--- a/src/components/video-editor/VideoEditor.tsx
+++ b/src/components/video-editor/VideoEditor.tsx
@@ -20,8 +20,10 @@ import {
type GifSizePreset,
VideoExporter,
} from "@/lib/exporter";
+import { computeFrameStepTime } from "@/lib/frameStep";
import type { ProjectMedia } from "@/lib/recordingSession";
import { matchesShortcut } from "@/lib/shortcuts";
+import { loadUserPreferences, saveUserPreferences } from "@/lib/userPreferences";
import {
getAspectRatioValue,
getNativeAspectRatioValue,
@@ -31,8 +33,10 @@ import { ExportDialog } from "./ExportDialog";
import PlaybackControls from "./PlaybackControls";
import {
createProjectData,
+ createProjectSnapshot,
deriveNextId,
fromFileUrl,
+ hasProjectUnsavedChanges,
normalizeProjectEditor,
resolveProjectMedia,
toFileUrl,
@@ -101,6 +105,10 @@ export default function VideoEditor() {
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
+ const currentTimeRef = useRef(currentTime);
+ currentTimeRef.current = currentTime;
+ const durationRef = useRef(duration);
+ durationRef.current = duration;
const [cursorTelemetry, setCursorTelemetry] = useState
([]);
const [selectedZoomId, setSelectedZoomId] = useState(null);
const [selectedTrimId, setSelectedTrimId] = useState(null);
@@ -236,16 +244,11 @@ export default function VideoEditor() {
) + 1;
setLastSavedSnapshot(
- JSON.stringify(
- createProjectData(
- webcamSourcePath
- ? {
- screenVideoPath: sourcePath,
- webcamVideoPath: webcamSourcePath,
- }
- : { screenVideoPath: sourcePath },
- normalizedEditor,
- ),
+ createProjectSnapshot(
+ webcamSourcePath
+ ? { screenVideoPath: sourcePath, webcamVideoPath: webcamSourcePath }
+ : { screenVideoPath: sourcePath },
+ normalizedEditor,
),
);
return true;
@@ -257,31 +260,28 @@ export default function VideoEditor() {
if (!currentProjectMedia) {
return null;
}
- return JSON.stringify(
- createProjectData(currentProjectMedia, {
- wallpaper,
- shadowIntensity,
- showBlur,
- motionBlurAmount,
- borderRadius,
- padding,
- cropRegion,
- zoomRegions,
- trimRegions,
- speedRegions,
- annotationRegions,
- aspectRatio,
- webcamLayoutPreset,
- webcamMaskShape,
- webcamSizePreset,
- webcamPosition,
- exportQuality,
- exportFormat,
- gifFrameRate,
- gifLoop,
- gifSizePreset,
- }),
- );
+ return createProjectSnapshot(currentProjectMedia, {
+ wallpaper,
+ shadowIntensity,
+ showBlur,
+ motionBlurAmount,
+ borderRadius,
+ padding,
+ cropRegion,
+ zoomRegions,
+ trimRegions,
+ speedRegions,
+ annotationRegions,
+ aspectRatio,
+ webcamLayoutPreset,
+ webcamMaskShape,
+ webcamPosition,
+ exportQuality,
+ exportFormat,
+ gifFrameRate,
+ gifLoop,
+ gifSizePreset,
+ });
}, [
currentProjectMedia,
wallpaper,
@@ -307,12 +307,7 @@ export default function VideoEditor() {
gifSizePreset,
]);
- const hasUnsavedChanges = Boolean(
- currentProjectPath &&
- currentProjectSnapshot &&
- lastSavedSnapshot &&
- currentProjectSnapshot !== lastSavedSnapshot,
- );
+ const hasUnsavedChanges = hasProjectUnsavedChanges(currentProjectSnapshot, lastSavedSnapshot);
useEffect(() => {
async function loadInitialData() {
@@ -340,7 +335,14 @@ export default function VideoEditor() {
setWebcamVideoSourcePath(webcamSourcePath);
setWebcamVideoPath(webcamSourcePath ? toFileUrl(webcamSourcePath) : null);
setCurrentProjectPath(null);
- setLastSavedSnapshot(null);
+ setLastSavedSnapshot(
+ createProjectSnapshot(
+ webcamSourcePath
+ ? { screenVideoPath: sourcePath, webcamVideoPath: webcamSourcePath }
+ : { screenVideoPath: sourcePath },
+ INITIAL_EDITOR_STATE,
+ ),
+ );
return;
}
@@ -352,7 +354,9 @@ export default function VideoEditor() {
setWebcamVideoSourcePath(null);
setWebcamVideoPath(null);
setCurrentProjectPath(null);
- setLastSavedSnapshot(null);
+ setLastSavedSnapshot(
+ createProjectSnapshot({ screenVideoPath: sourcePath }, INITIAL_EDITOR_STATE),
+ );
} else {
setError("No video to load. Please record or select a video.");
}
@@ -366,6 +370,28 @@ export default function VideoEditor() {
loadInitialData();
}, [applyLoadedProject]);
+ // Track whether user preferences have been loaded to avoid
+ // overwriting saved prefs with defaults on the first render
+ const [prefsHydrated, setPrefsHydrated] = useState(false);
+
+ // Load persisted user preferences on mount (intentionally runs once)
+ useEffect(() => {
+ const prefs = loadUserPreferences();
+ updateState({
+ padding: prefs.padding,
+ aspectRatio: prefs.aspectRatio,
+ });
+ setExportQuality(prefs.exportQuality);
+ setExportFormat(prefs.exportFormat);
+ setPrefsHydrated(true);
+ }, [updateState]);
+
+ // Auto-save user preferences when settings change
+ useEffect(() => {
+ if (!prefsHydrated) return;
+ saveUserPreferences({ padding, aspectRatio, exportQuality, exportFormat });
+ }, [prefsHydrated, padding, aspectRatio, exportQuality, exportFormat]);
+
const saveProject = useCallback(
async (forceSaveAs: boolean) => {
if (!videoPath) {
@@ -978,6 +1004,40 @@ export default function VideoEditor() {
return;
}
+ // Frame-step navigation (arrow keys, no modifiers)
+ if (
+ (e.key === "ArrowLeft" || e.key === "ArrowRight") &&
+ !e.ctrlKey &&
+ !e.metaKey &&
+ !e.shiftKey &&
+ !e.altKey
+ ) {
+ const target = e.target;
+ if (
+ target instanceof HTMLInputElement ||
+ target instanceof HTMLTextAreaElement ||
+ target instanceof HTMLSelectElement ||
+ (target instanceof HTMLElement &&
+ (target.isContentEditable ||
+ target.closest('[role="separator"], [role="slider"], [role="spinbutton"]')))
+ ) {
+ return;
+ }
+ e.preventDefault();
+ const video = videoPlaybackRef.current?.video;
+ if (!video) {
+ return;
+ }
+ const direction = e.key === "ArrowLeft" ? "backward" : "forward";
+ const newTime = computeFrameStepTime(
+ video.currentTime,
+ Number.isFinite(video.duration) ? video.duration : durationRef.current,
+ direction,
+ );
+ video.currentTime = newTime;
+ return;
+ }
+
const isInput =
e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement;
diff --git a/src/components/video-editor/projectPersistence.test.ts b/src/components/video-editor/projectPersistence.test.ts
index 3243aca..fdf5f66 100644
--- a/src/components/video-editor/projectPersistence.test.ts
+++ b/src/components/video-editor/projectPersistence.test.ts
@@ -1,6 +1,8 @@
import { describe, expect, it } from "vitest";
import {
createProjectData,
+ createProjectSnapshot,
+ hasProjectUnsavedChanges,
normalizeProjectEditor,
PROJECT_VERSION,
resolveProjectMedia,
@@ -65,3 +67,39 @@ describe("projectPersistence media compatibility", () => {
).toBe("rectangle");
});
});
+
+it("creates stable snapshots for identical project state", () => {
+ const media = {
+ screenVideoPath: "/tmp/screen.webm",
+ webcamVideoPath: "/tmp/webcam.webm",
+ };
+ const editor = normalizeProjectEditor({
+ wallpaper: "/wallpapers/wallpaper1.jpg",
+ shadowIntensity: 0,
+ showBlur: false,
+ motionBlurAmount: 0,
+ borderRadius: 0,
+ padding: 50,
+ cropRegion: { x: 0, y: 0, width: 1, height: 1 },
+ zoomRegions: [],
+ trimRegions: [],
+ speedRegions: [],
+ annotationRegions: [],
+ aspectRatio: "16:9",
+ webcamLayoutPreset: "picture-in-picture",
+ webcamMaskShape: "circle",
+ exportQuality: "good",
+ exportFormat: "mp4",
+ gifFrameRate: 15,
+ gifLoop: true,
+ gifSizePreset: "medium",
+ });
+
+ expect(createProjectSnapshot(media, editor)).toBe(createProjectSnapshot(media, editor));
+});
+
+it("detects unsaved changes from differing snapshots", () => {
+ expect(hasProjectUnsavedChanges(null, null)).toBe(false);
+ expect(hasProjectUnsavedChanges("same", "same")).toBe(false);
+ expect(hasProjectUnsavedChanges("current", "baseline")).toBe(true);
+});
diff --git a/src/components/video-editor/projectPersistence.ts b/src/components/video-editor/projectPersistence.ts
index faa86fb..3a1ca0f 100644
--- a/src/components/video-editor/projectPersistence.ts
+++ b/src/components/video-editor/projectPersistence.ts
@@ -412,3 +412,19 @@ export function createProjectData(
editor,
};
}
+
+export function createProjectSnapshot(
+ media: ProjectMedia,
+ editor: Partial,
+): string {
+ return JSON.stringify(createProjectData(media, normalizeProjectEditor(editor)));
+}
+
+export function hasProjectUnsavedChanges(
+ currentSnapshot: string | null,
+ baselineSnapshot: string | null,
+): boolean {
+ return Boolean(
+ currentSnapshot !== null && baselineSnapshot !== null && currentSnapshot !== baselineSnapshot,
+ );
+}
diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts
index 0c418c1..2b07e24 100644
--- a/src/hooks/useScreenRecorder.ts
+++ b/src/hooks/useScreenRecorder.ts
@@ -41,8 +41,12 @@ const WEBCAM_TARGET_FRAME_RATE = 30;
type UseScreenRecorderReturn = {
recording: boolean;
+ paused: boolean;
+ elapsedSeconds: number;
toggleRecording: () => void;
+ togglePaused: () => void;
restartRecording: () => void;
+ cancelRecording: () => void;
microphoneEnabled: boolean;
setMicrophoneEnabled: (enabled: boolean) => void;
microphoneDeviceId: string | undefined;
@@ -85,6 +89,8 @@ function createRecorderHandle(stream: MediaStream, options: MediaRecorderOptions
export function useScreenRecorder(): UseScreenRecorderReturn {
const t = useScopedT("editor");
const [recording, setRecording] = useState(false);
+ const [paused, setPaused] = useState(false);
+ const [elapsedSeconds, setElapsedSeconds] = useState(0);
const [microphoneEnabled, setMicrophoneEnabled] = useState(false);
const [microphoneDeviceId, setMicrophoneDeviceId] = useState(undefined);
const [webcamDeviceId, setWebcamDeviceId] = useState(undefined);
@@ -97,13 +103,20 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
const microphoneStream = useRef(null);
const webcamStream = useRef(null);
const mixingContext = useRef(null);
- const startTime = useRef(0);
const recordingId = useRef(0);
+ const accumulatedDurationMs = useRef(0);
+ const segmentStartedAt = useRef(null);
const finalizingRecordingId = useRef(null);
const allowAutoFinalize = useRef(false);
const discardRecordingId = useRef(null);
const restarting = useRef(false);
+ const getRecordingDurationMs = useCallback(() => {
+ const segmentDuration =
+ segmentStartedAt.current === null ? 0 : Date.now() - segmentStartedAt.current;
+ return accumulatedDurationMs.current + segmentDuration;
+ }, []);
+
const selectMimeType = () => {
const preferred = [
"video/webm;codecs=av1",
@@ -202,6 +215,10 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
teardownMedia();
setRecording(false);
+ setPaused(false);
+ setElapsedSeconds(0);
+ accumulatedDurationMs.current = 0;
+ segmentStartedAt.current = null;
window.electronAPI?.setRecordingState(false);
void (async () => {
@@ -273,7 +290,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
}
const activeWebcamRecorder = webcamRecorder.current;
- const duration = Date.now() - startTime.current;
+ const duration = getRecordingDurationMs();
const activeRecordingId = recordingId.current;
finalizeRecording(
@@ -283,7 +300,10 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
activeRecordingId,
);
- if (activeScreenRecorder.recorder.state === "recording") {
+ if (
+ activeScreenRecorder.recorder.state === "recording" ||
+ activeScreenRecorder.recorder.state === "paused"
+ ) {
try {
activeScreenRecorder.recorder.stop();
} catch {
@@ -291,7 +311,10 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
}
}
if (activeWebcamRecorder) {
- if (activeWebcamRecorder.recorder.state === "recording") {
+ if (
+ activeWebcamRecorder.recorder.state === "recording" ||
+ activeWebcamRecorder.recorder.state === "paused"
+ ) {
try {
activeWebcamRecorder.recorder.stop();
} catch {
@@ -316,14 +339,20 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
restarting.current = false;
discardRecordingId.current = null;
- if (screenRecorder.current?.recorder.state === "recording") {
+ if (
+ screenRecorder.current?.recorder.state === "recording" ||
+ screenRecorder.current?.recorder.state === "paused"
+ ) {
try {
screenRecorder.current.recorder.stop();
} catch {
// Ignore recorder teardown errors during cleanup.
}
}
- if (webcamRecorder.current?.recorder.state === "recording") {
+ if (
+ webcamRecorder.current?.recorder.state === "recording" ||
+ webcamRecorder.current?.recorder.state === "paused"
+ ) {
try {
webcamRecorder.current.recorder.stop();
} catch {
@@ -518,9 +547,12 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
}
recordingId.current = Date.now();
- startTime.current = recordingId.current;
+ accumulatedDurationMs.current = 0;
+ segmentStartedAt.current = Date.now();
allowAutoFinalize.current = true;
setRecording(true);
+ setPaused(false);
+ setElapsedSeconds(0);
window.electronAPI?.setRecordingState(true);
const activeScreenRecorder = screenRecorder.current;
@@ -536,7 +568,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
finalizeRecording(
activeScreenRecorder,
activeWebcamRecorder ?? null,
- Math.max(0, Date.now() - startTime.current),
+ Math.max(0, getRecordingDurationMs()),
activeRecordingId,
);
},
@@ -552,12 +584,56 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
toast.error(errorMsg);
}
setRecording(false);
+ setPaused(false);
+ setElapsedSeconds(0);
+ accumulatedDurationMs.current = 0;
+ segmentStartedAt.current = null;
screenRecorder.current = null;
webcamRecorder.current = null;
teardownMedia();
}
};
+ const togglePaused = () => {
+ const activeScreenRecorder = screenRecorder.current?.recorder;
+ if (!activeScreenRecorder || activeScreenRecorder.state === "inactive") {
+ return;
+ }
+
+ const activeWebcamRecorder = webcamRecorder.current?.recorder;
+
+ if (activeScreenRecorder.state === "paused") {
+ try {
+ activeScreenRecorder.resume();
+ if (activeWebcamRecorder?.state === "paused") {
+ activeWebcamRecorder.resume();
+ }
+ segmentStartedAt.current = Date.now();
+ setPaused(false);
+ } catch (error) {
+ console.error("Failed to resume recording:", error);
+ }
+ return;
+ }
+
+ if (activeScreenRecorder.state !== "recording") {
+ return;
+ }
+
+ try {
+ accumulatedDurationMs.current = getRecordingDurationMs();
+ segmentStartedAt.current = null;
+ setElapsedSeconds(Math.floor(accumulatedDurationMs.current / 1000));
+ activeScreenRecorder.pause();
+ if (activeWebcamRecorder?.state === "recording") {
+ activeWebcamRecorder.pause();
+ }
+ setPaused(true);
+ } catch (error) {
+ console.error("Failed to pause recording:", error);
+ }
+ };
+
const toggleRecording = () => {
recording ? stopRecording.current() : startRecording();
};
@@ -566,7 +642,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
if (restarting.current) return;
const activeScreenRecorder = screenRecorder.current;
- if (!activeScreenRecorder || activeScreenRecorder.recorder.state !== "recording") return;
+ if (!activeScreenRecorder || activeScreenRecorder.recorder.state === "inactive") return;
const activeWebcamRecorder = webcamRecorder.current;
const activeRecordingId = recordingId.current;
@@ -581,7 +657,10 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
}),
];
- if (activeWebcamRecorder?.recorder.state === "recording") {
+ if (
+ activeWebcamRecorder?.recorder.state === "recording" ||
+ activeWebcamRecorder?.recorder.state === "paused"
+ ) {
stopPromises.push(
new Promise((resolve) => {
activeWebcamRecorder.recorder.addEventListener("stop", () => resolve(), {
@@ -601,10 +680,43 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
}
};
+ useEffect(() => {
+ if (!recording) {
+ setElapsedSeconds(0);
+ return;
+ }
+
+ setElapsedSeconds(Math.floor(getRecordingDurationMs() / 1000));
+ if (paused) {
+ return;
+ }
+
+ const interval = window.setInterval(() => {
+ setElapsedSeconds(Math.floor(getRecordingDurationMs() / 1000));
+ }, 250);
+
+ return () => window.clearInterval(interval);
+ }, [getRecordingDurationMs, paused, recording]);
+
+ const cancelRecording = () => {
+ const activeScreenRecorder = screenRecorder.current;
+ if (!activeScreenRecorder || activeScreenRecorder.recorder.state !== "recording") return;
+
+ const activeRecordingId = recordingId.current;
+ discardRecordingId.current = activeRecordingId;
+ allowAutoFinalize.current = false;
+
+ stopRecording.current();
+ };
+
return {
recording,
+ paused,
+ elapsedSeconds,
toggleRecording,
+ togglePaused,
restartRecording,
+ cancelRecording,
microphoneEnabled,
setMicrophoneEnabled,
microphoneDeviceId,
diff --git a/src/i18n/locales/en/launch.json b/src/i18n/locales/en/launch.json
index 6e4a4ed..cf111c4 100644
--- a/src/i18n/locales/en/launch.json
+++ b/src/i18n/locales/en/launch.json
@@ -3,6 +3,9 @@
"hideHUD": "Hide HUD",
"closeApp": "Close App",
"restartRecording": "Restart recording",
+ "cancelRecording": "Cancel recording",
+ "pauseRecording": "Pause recording",
+ "resumeRecording": "Resume recording",
"openVideoFile": "Open video file",
"openProject": "Open project"
},
diff --git a/src/i18n/locales/en/shortcuts.json b/src/i18n/locales/en/shortcuts.json
index e943e61..790302c 100644
--- a/src/i18n/locales/en/shortcuts.json
+++ b/src/i18n/locales/en/shortcuts.json
@@ -29,6 +29,8 @@
"cycleAnnotationsBackward": "Cycle Annotations Backward",
"deleteSelectedAlt": "Delete Selected (alt)",
"panTimeline": "Pan Timeline",
- "zoomTimeline": "Zoom Timeline"
+ "zoomTimeline": "Zoom Timeline",
+ "frameBack": "Frame Back",
+ "frameForward": "Frame Forward"
}
}
diff --git a/src/i18n/locales/es/launch.json b/src/i18n/locales/es/launch.json
index b25ec3d..f47bc81 100644
--- a/src/i18n/locales/es/launch.json
+++ b/src/i18n/locales/es/launch.json
@@ -3,6 +3,9 @@
"hideHUD": "Ocultar HUD",
"closeApp": "Cerrar aplicación",
"restartRecording": "Reiniciar grabación",
+ "cancelRecording": "Cancelar grabación",
+ "pauseRecording": "Pausar grabación",
+ "resumeRecording": "Reanudar grabación",
"openVideoFile": "Abrir archivo de video",
"openProject": "Abrir proyecto"
},
diff --git a/src/i18n/locales/es/shortcuts.json b/src/i18n/locales/es/shortcuts.json
index ede18bf..a03420e 100644
--- a/src/i18n/locales/es/shortcuts.json
+++ b/src/i18n/locales/es/shortcuts.json
@@ -29,6 +29,8 @@
"cycleAnnotationsBackward": "Recorrer anotaciones hacia atrás",
"deleteSelectedAlt": "Eliminar seleccionado (alt)",
"panTimeline": "Desplazar línea de tiempo",
- "zoomTimeline": "Zoom en línea de tiempo"
+ "zoomTimeline": "Zoom en línea de tiempo",
+ "frameBack": "Fotograma anterior",
+ "frameForward": "Fotograma siguiente"
}
}
diff --git a/src/i18n/locales/zh-CN/launch.json b/src/i18n/locales/zh-CN/launch.json
index 84fdcef..6b63df1 100644
--- a/src/i18n/locales/zh-CN/launch.json
+++ b/src/i18n/locales/zh-CN/launch.json
@@ -3,6 +3,9 @@
"hideHUD": "隐藏控制面板",
"closeApp": "关闭应用",
"restartRecording": "重新开始录制",
+ "cancelRecording": "取消录制",
+ "pauseRecording": "暂停录制",
+ "resumeRecording": "继续录制",
"openVideoFile": "打开视频文件",
"openProject": "打开项目"
},
diff --git a/src/i18n/locales/zh-CN/shortcuts.json b/src/i18n/locales/zh-CN/shortcuts.json
index 5099b27..e2faa2f 100644
--- a/src/i18n/locales/zh-CN/shortcuts.json
+++ b/src/i18n/locales/zh-CN/shortcuts.json
@@ -29,6 +29,8 @@
"cycleAnnotationsBackward": "向后切换标注",
"deleteSelectedAlt": "删除所选(替代)",
"panTimeline": "平移时间轴",
- "zoomTimeline": "缩放时间轴"
+ "zoomTimeline": "缩放时间轴",
+ "frameBack": "上一帧",
+ "frameForward": "下一帧"
}
}
diff --git a/src/lib/__tests__/frameStepNavigation.test.ts b/src/lib/__tests__/frameStepNavigation.test.ts
new file mode 100644
index 0000000..ab0393b
--- /dev/null
+++ b/src/lib/__tests__/frameStepNavigation.test.ts
@@ -0,0 +1,42 @@
+import { describe, expect, it } from "vitest";
+
+import { computeFrameStepTime, FRAME_DURATION_SEC } from "@/lib/frameStep";
+
+describe("computeFrameStepTime", () => {
+ const duration = 10;
+
+ it("moves forward by one frame from the middle", () => {
+ const result = computeFrameStepTime(5, duration, "forward");
+ expect(result).toBeCloseTo(5 + FRAME_DURATION_SEC, 10);
+ });
+
+ it("moves backward by one frame from the middle", () => {
+ const result = computeFrameStepTime(5, duration, "backward");
+ expect(result).toBeCloseTo(5 - FRAME_DURATION_SEC, 10);
+ });
+
+ it("clamps to 0 when stepping backward at the beginning", () => {
+ const result = computeFrameStepTime(0, duration, "backward");
+ expect(result).toBe(0);
+ });
+
+ it("clamps to 0 when stepping backward near the beginning", () => {
+ const result = computeFrameStepTime(FRAME_DURATION_SEC / 2, duration, "backward");
+ expect(result).toBe(0);
+ });
+
+ it("clamps to duration when stepping forward at the end", () => {
+ const result = computeFrameStepTime(duration, duration, "forward");
+ expect(result).toBe(duration);
+ });
+
+ it("clamps to duration when stepping forward near the end", () => {
+ const result = computeFrameStepTime(duration - FRAME_DURATION_SEC / 2, duration, "forward");
+ expect(result).toBe(duration);
+ });
+
+ it("handles duration of 0 gracefully", () => {
+ expect(computeFrameStepTime(0, 0, "forward")).toBe(0);
+ expect(computeFrameStepTime(0, 0, "backward")).toBe(0);
+ });
+});
diff --git a/src/lib/frameStep.ts b/src/lib/frameStep.ts
new file mode 100644
index 0000000..dc42d78
--- /dev/null
+++ b/src/lib/frameStep.ts
@@ -0,0 +1,15 @@
+/** Duration of a single frame in seconds at 60 FPS (~16.67ms). */
+export const FRAME_DURATION_SEC = 1 / 60;
+
+/**
+ * Compute the new playhead time after stepping one frame forward or backward.
+ * The result is clamped to the range [0, duration].
+ */
+export function computeFrameStepTime(
+ currentTime: number,
+ duration: number,
+ direction: "forward" | "backward",
+): number {
+ const delta = direction === "forward" ? FRAME_DURATION_SEC : -FRAME_DURATION_SEC;
+ return Math.min(duration, Math.max(0, currentTime + delta));
+}
diff --git a/src/lib/shortcuts.ts b/src/lib/shortcuts.ts
index 69af499..00b96b6 100644
--- a/src/lib/shortcuts.ts
+++ b/src/lib/shortcuts.ts
@@ -21,14 +21,16 @@ export interface ShortcutBinding {
export type ShortcutsConfig = Record;
export interface FixedShortcut {
+ i18nKey: string;
label: string;
display: string;
bindings: ShortcutBinding[];
}
export const FIXED_SHORTCUTS: FixedShortcut[] = [
- { label: "Undo", display: "Ctrl + Z", bindings: [{ key: "z", ctrl: true }] },
+ { i18nKey: "undo", label: "Undo", display: "Ctrl + Z", bindings: [{ key: "z", ctrl: true }] },
{
+ i18nKey: "redo",
label: "Redo",
display: "Ctrl + Shift + Z / Ctrl + Y",
bindings: [
@@ -36,19 +38,38 @@ export const FIXED_SHORTCUTS: FixedShortcut[] = [
{ key: "y", ctrl: true },
],
},
- { label: "Cycle Annotations Forward", display: "Tab", bindings: [{ key: "tab" }] },
{
+ i18nKey: "cycleAnnotationsForward",
+ label: "Cycle Annotations Forward",
+ display: "Tab",
+ bindings: [{ key: "tab" }],
+ },
+ {
+ i18nKey: "cycleAnnotationsBackward",
label: "Cycle Annotations Backward",
display: "Shift + Tab",
bindings: [{ key: "tab", shift: true }],
},
{
+ i18nKey: "deleteSelectedAlt",
label: "Delete Selected (alt)",
display: "Del / ⌫",
bindings: [{ key: "delete" }, { key: "backspace" }],
},
- { label: "Pan Timeline", display: "Shift + Ctrl + Scroll", bindings: [] },
- { label: "Zoom Timeline", display: "Ctrl + Scroll", bindings: [] },
+ {
+ i18nKey: "panTimeline",
+ label: "Pan Timeline",
+ display: "Shift + Ctrl + Scroll",
+ bindings: [],
+ },
+ { i18nKey: "zoomTimeline", label: "Zoom Timeline", display: "Ctrl + Scroll", bindings: [] },
+ { i18nKey: "frameBack", label: "Frame Back", display: "←", bindings: [{ key: "arrowleft" }] },
+ {
+ i18nKey: "frameForward",
+ label: "Frame Forward",
+ display: "→",
+ bindings: [{ key: "arrowright" }],
+ },
];
export type ShortcutConflict =
diff --git a/src/lib/userPreferences.ts b/src/lib/userPreferences.ts
new file mode 100644
index 0000000..e060788
--- /dev/null
+++ b/src/lib/userPreferences.ts
@@ -0,0 +1,94 @@
+import type { ExportFormat, ExportQuality } from "@/lib/exporter";
+import type { AspectRatio } from "@/utils/aspectRatioUtils";
+
+const PREFS_KEY = "openscreen_user_preferences";
+
+const VALID_ASPECT_RATIOS: readonly string[] = [
+ "16:9",
+ "9:16",
+ "1:1",
+ "4:3",
+ "4:5",
+ "16:10",
+ "10:16",
+ "native",
+];
+
+export interface UserPreferences {
+ /** Default padding % */
+ padding: number;
+ /** Default aspect ratio */
+ aspectRatio: AspectRatio;
+ /** Default export quality */
+ exportQuality: ExportQuality;
+ /** Default export format */
+ exportFormat: ExportFormat;
+}
+
+const DEFAULT_PREFS: UserPreferences = {
+ padding: 50,
+ aspectRatio: "16:9",
+ exportQuality: "good",
+ exportFormat: "mp4",
+};
+
+function safeJsonParse(text: string | null): Record | null {
+ if (!text) return null;
+ try {
+ return JSON.parse(text);
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Load persisted user preferences from localStorage.
+ * Returns defaults for any missing or invalid fields.
+ */
+export function loadUserPreferences(): UserPreferences {
+ let raw: Record | null = null;
+ try {
+ raw = safeJsonParse(localStorage.getItem(PREFS_KEY));
+ } catch {
+ return { ...DEFAULT_PREFS };
+ }
+ if (!raw || typeof raw !== "object") return { ...DEFAULT_PREFS };
+
+ return {
+ padding:
+ typeof raw.padding === "number" &&
+ Number.isFinite(raw.padding) &&
+ raw.padding >= 0 &&
+ raw.padding <= 100
+ ? raw.padding
+ : DEFAULT_PREFS.padding,
+ aspectRatio:
+ typeof raw.aspectRatio === "string" && VALID_ASPECT_RATIOS.includes(raw.aspectRatio)
+ ? (raw.aspectRatio as AspectRatio)
+ : DEFAULT_PREFS.aspectRatio,
+ exportQuality:
+ raw.exportQuality === "medium" ||
+ raw.exportQuality === "good" ||
+ raw.exportQuality === "source"
+ ? (raw.exportQuality as ExportQuality)
+ : DEFAULT_PREFS.exportQuality,
+ exportFormat:
+ raw.exportFormat === "gif" || raw.exportFormat === "mp4"
+ ? (raw.exportFormat as ExportFormat)
+ : DEFAULT_PREFS.exportFormat,
+ };
+}
+
+/**
+ * Persist user preferences to localStorage.
+ * Only the explicitly provided fields are updated.
+ */
+export function saveUserPreferences(partial: Partial): void {
+ const current = loadUserPreferences();
+ const merged = { ...current, ...partial };
+ try {
+ localStorage.setItem(PREFS_KEY, JSON.stringify(merged));
+ } catch {
+ // localStorage may be unavailable (e.g. private browsing quota exceeded)
+ }
+}
diff --git a/tests/e2e/gif-export.spec.ts b/tests/e2e/gif-export.spec.ts
index a851546..d1fa3f7 100644
--- a/tests/e2e/gif-export.spec.ts
+++ b/tests/e2e/gif-export.spec.ts
@@ -11,12 +11,15 @@ const TEST_VIDEO = path.join(__dirname, "../fixtures/sample.webm");
test("exports a GIF from a loaded video", async () => {
const outputPath = path.join(os.tmpdir(), `test-gif-export-${Date.now()}.gif`);
+ let testVideoInRecordings = "";
const app = await electron.launch({
args: [
MAIN_JS,
// Required in CI sandbox environments (GitHub Actions, Docker, etc.)
"--no-sandbox",
+ // Force software WebGL in headless CI to avoid GPU framebuffer errors.
+ "--enable-unsafe-swiftshader",
],
env: {
...process.env,
@@ -58,14 +61,25 @@ test("exports a GIF from a loaded video", async () => {
);
});
- await hudWindow.evaluate((videoPath: string) => {
- window.electronAPI.setCurrentVideoPath(videoPath);
- try {
+ // Copy the test fixture into the app's recordings directory so it passes
+ // the path security check in set-current-video-path.
+ const userDataDir = await app.evaluate(({ app: electronApp }) => {
+ return electronApp.getPath("userData");
+ });
+ const recordingsDir = path.join(userDataDir, "recordings");
+ testVideoInRecordings = path.join(recordingsDir, "test-sample.webm");
+ fs.mkdirSync(recordingsDir, { recursive: true });
+ fs.copyFileSync(TEST_VIDEO, testVideoInRecordings);
+
+ try {
+ await hudWindow.evaluate((videoPath: string) => {
+ window.electronAPI.setCurrentVideoPath(videoPath);
window.electronAPI.switchToEditor();
- } catch {
- // Expected: HUD window closes during this call, killing the context.
- }
- }, TEST_VIDEO);
+ }, testVideoInRecordings);
+ } catch {
+ // Expected: switchToEditor() closes the HUD window, terminating
+ // the Playwright page context before evaluate() can resolve.
+ }
// ── 3. Switch to the editor window. This closes the HUD and opens
// a new BrowserWindow with ?windowType=editor.
@@ -116,5 +130,8 @@ test("exports a GIF from a loaded video", async () => {
if (fs.existsSync(outputPath)) {
fs.unlinkSync(outputPath);
}
+ if (testVideoInRecordings && fs.existsSync(testVideoInRecordings)) {
+ fs.unlinkSync(testVideoInRecordings);
+ }
}
});