diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx
index daf5f424..a52690de 100644
--- a/src/components/video-editor/SettingsPanel.tsx
+++ b/src/components/video-editor/SettingsPanel.tsx
@@ -36,8 +36,14 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useScopedT } from "@/contexts/I18nContext";
import { getAssetPath } from "@/lib/assetPath";
import { WEBCAM_LAYOUT_PRESETS } from "@/lib/compositeLayout";
-import type { ExportFormat, ExportQuality, GifFrameRate, GifSizePreset } from "@/lib/exporter";
-import { GIF_FRAME_RATES, GIF_SIZE_PRESETS } from "@/lib/exporter";
+import type {
+ ExportFormat,
+ ExportQuality,
+ GifFrameRate,
+ GifSizePreset,
+ Mp4FrameRate,
+} from "@/lib/exporter";
+import { GIF_FRAME_RATES, GIF_SIZE_PRESETS, MP4_FRAME_RATES } from "@/lib/exporter";
import { cn } from "@/lib/utils";
import { type AspectRatio, isPortraitAspectRatio } from "@/utils/aspectRatioUtils";
import { getTestId } from "@/utils/getTestId";
@@ -188,6 +194,8 @@ interface SettingsPanelProps {
// Export format settings
exportFormat?: ExportFormat;
onExportFormatChange?: (format: ExportFormat) => void;
+ mp4FrameRate?: Mp4FrameRate;
+ onMp4FrameRateChange?: (rate: Mp4FrameRate) => void;
gifFrameRate?: GifFrameRate;
onGifFrameRateChange?: (rate: GifFrameRate) => void;
gifLoop?: boolean;
@@ -268,6 +276,8 @@ export function SettingsPanel({
onExportQualityChange,
exportFormat = "mp4",
onExportFormatChange,
+ mp4FrameRate = 60,
+ onMp4FrameRateChange,
gifFrameRate = 15,
onGifFrameRateChange,
gifLoop = true,
@@ -1306,40 +1316,58 @@ export function SettingsPanel({
{exportFormat === "mp4" && (
-
-
-
-
+
+
+
+
+
+
+
+ {MP4_FRAME_RATES.map((rate) => (
+
+ ))}
+
)}
diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx
index 88c3aae0..05c18037 100644
--- a/src/components/video-editor/VideoEditor.tsx
+++ b/src/components/video-editor/VideoEditor.tsx
@@ -26,6 +26,7 @@ import {
GifExporter,
type GifFrameRate,
type GifSizePreset,
+ type Mp4FrameRate,
VideoExporter,
} from "@/lib/exporter";
import { computeFrameStepTime } from "@/lib/frameStep";
@@ -129,6 +130,7 @@ export default function VideoEditor() {
const [showNewRecordingDialog, setShowNewRecordingDialog] = useState(false);
const [exportQuality, setExportQuality] = useState
("good");
const [exportFormat, setExportFormat] = useState("mp4");
+ const [mp4FrameRate, setMp4FrameRate] = useState(60);
const [gifFrameRate, setGifFrameRate] = useState(15);
const [gifLoop, setGifLoop] = useState(true);
const [gifSizePreset, setGifSizePreset] = useState("medium");
@@ -221,6 +223,7 @@ export default function VideoEditor() {
});
setExportQuality(normalizedEditor.exportQuality);
setExportFormat(normalizedEditor.exportFormat);
+ setMp4FrameRate(normalizedEditor.mp4FrameRate);
setGifFrameRate(normalizedEditor.gifFrameRate);
setGifLoop(normalizedEditor.gifLoop);
setGifSizePreset(normalizedEditor.gifSizePreset);
@@ -287,6 +290,7 @@ export default function VideoEditor() {
webcamPosition,
exportQuality,
exportFormat,
+ mp4FrameRate,
gifFrameRate,
gifLoop,
gifSizePreset,
@@ -307,10 +311,10 @@ export default function VideoEditor() {
aspectRatio,
webcamLayoutPreset,
webcamMaskShape,
- webcamSizePreset,
webcamPosition,
exportQuality,
exportFormat,
+ mp4FrameRate,
gifFrameRate,
gifLoop,
gifSizePreset,
@@ -392,14 +396,15 @@ export default function VideoEditor() {
});
setExportQuality(prefs.exportQuality);
setExportFormat(prefs.exportFormat);
+ setMp4FrameRate(prefs.mp4FrameRate);
setPrefsHydrated(true);
}, [updateState]);
// Auto-save user preferences when settings change
useEffect(() => {
if (!prefsHydrated) return;
- saveUserPreferences({ padding, aspectRatio, exportQuality, exportFormat });
- }, [prefsHydrated, padding, aspectRatio, exportQuality, exportFormat]);
+ saveUserPreferences({ padding, aspectRatio, exportQuality, exportFormat, mp4FrameRate });
+ }, [prefsHydrated, padding, aspectRatio, exportQuality, exportFormat, mp4FrameRate]);
const saveProject = useCallback(
async (forceSaveAs: boolean) => {
@@ -432,6 +437,7 @@ export default function VideoEditor() {
webcamPosition,
exportQuality,
exportFormat,
+ mp4FrameRate,
gifFrameRate,
gifLoop,
gifSizePreset,
@@ -488,6 +494,7 @@ export default function VideoEditor() {
webcamPosition,
exportQuality,
exportFormat,
+ mp4FrameRate,
gifFrameRate,
gifLoop,
gifSizePreset,
@@ -1339,7 +1346,7 @@ export default function VideoEditor() {
webcamVideoUrl: webcamVideoPath || undefined,
width: exportWidth,
height: exportHeight,
- frameRate: 60,
+ frameRate: mp4FrameRate,
bitrate,
codec: "avc1.640033",
wallpaper,
@@ -1430,6 +1437,7 @@ export default function VideoEditor() {
webcamSizePreset,
webcamPosition,
exportQuality,
+ mp4FrameRate,
handleExportSaved,
cursorTelemetry,
],
@@ -1809,6 +1817,8 @@ export default function VideoEditor() {
onExportQualityChange={setExportQuality}
exportFormat={exportFormat}
onExportFormatChange={setExportFormat}
+ mp4FrameRate={mp4FrameRate}
+ onMp4FrameRateChange={setMp4FrameRate}
gifFrameRate={gifFrameRate}
onGifFrameRateChange={setGifFrameRate}
gifLoop={gifLoop}
diff --git a/src/components/video-editor/projectPersistence.ts b/src/components/video-editor/projectPersistence.ts
index 45513d4c..0c594f7a 100644
--- a/src/components/video-editor/projectPersistence.ts
+++ b/src/components/video-editor/projectPersistence.ts
@@ -1,4 +1,10 @@
-import type { ExportFormat, ExportQuality, GifFrameRate, GifSizePreset } from "@/lib/exporter";
+import type {
+ ExportFormat,
+ ExportQuality,
+ GifFrameRate,
+ GifSizePreset,
+ Mp4FrameRate,
+} from "@/lib/exporter";
import type { ProjectMedia } from "@/lib/recordingSession";
import { normalizeProjectMedia } from "@/lib/recordingSession";
import { ASPECT_RATIOS, type AspectRatio } from "@/utils/aspectRatioUtils";
@@ -56,6 +62,7 @@ export interface ProjectEditorState {
webcamPosition: WebcamPosition | null;
exportQuality: ExportQuality;
exportFormat: ExportFormat;
+ mp4FrameRate: Mp4FrameRate;
gifFrameRate: GifFrameRate;
gifLoop: boolean;
gifSizePreset: GifSizePreset;
@@ -384,6 +391,10 @@ export function normalizeProjectEditor(editor: Partial): Pro
? editor.exportQuality
: "good",
exportFormat: editor.exportFormat === "gif" ? "gif" : "mp4",
+ mp4FrameRate:
+ editor.mp4FrameRate === 24 || editor.mp4FrameRate === 30 || editor.mp4FrameRate === 60
+ ? editor.mp4FrameRate
+ : 60,
gifFrameRate:
editor.gifFrameRate === 15 ||
editor.gifFrameRate === 20 ||
diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json
index 16ede59e..a6455d74 100644
--- a/src/i18n/locales/en/settings.json
+++ b/src/i18n/locales/en/settings.json
@@ -70,6 +70,9 @@
"medium": "Medium",
"high": "High"
},
+ "mp4Settings": {
+ "frameRate": "Frame Rate"
+ },
"gifSettings": {
"frameRate": "GIF Frame Rate",
"size": "GIF Size",
diff --git a/src/i18n/locales/es/settings.json b/src/i18n/locales/es/settings.json
index b7a1bde5..9efff712 100644
--- a/src/i18n/locales/es/settings.json
+++ b/src/i18n/locales/es/settings.json
@@ -70,6 +70,9 @@
"medium": "Media",
"high": "Alta"
},
+ "mp4Settings": {
+ "frameRate": "Frecuencia de fotogramas"
+ },
"gifSettings": {
"frameRate": "Velocidad de cuadros del GIF",
"size": "Tamaño del GIF",
diff --git a/src/i18n/locales/fr/settings.json b/src/i18n/locales/fr/settings.json
index dd7610fa..19b5e5ba 100644
--- a/src/i18n/locales/fr/settings.json
+++ b/src/i18n/locales/fr/settings.json
@@ -67,6 +67,9 @@
"medium": "Moyenne",
"high": "Haute"
},
+ "mp4Settings": {
+ "frameRate": "Fréquence d'images"
+ },
"gifSettings": {
"frameRate": "Fréquence d'images GIF",
"size": "Taille du GIF",
diff --git a/src/i18n/locales/tr/settings.json b/src/i18n/locales/tr/settings.json
index 1fa4668d..ebc830d3 100644
--- a/src/i18n/locales/tr/settings.json
+++ b/src/i18n/locales/tr/settings.json
@@ -67,6 +67,9 @@
"medium": "Orta",
"high": "Yüksek"
},
+ "mp4Settings": {
+ "frameRate": "Kare Hızı"
+ },
"gifSettings": {
"frameRate": "GIF Kare Hızı",
"size": "GIF Boyutu",
diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json
index da5f4dcf..10679b0d 100644
--- a/src/i18n/locales/zh-CN/settings.json
+++ b/src/i18n/locales/zh-CN/settings.json
@@ -70,6 +70,9 @@
"medium": "中",
"high": "高"
},
+ "mp4Settings": {
+ "frameRate": "帧率"
+ },
"gifSettings": {
"frameRate": "GIF 帧率",
"size": "GIF 尺寸",
diff --git a/src/lib/exporter/index.ts b/src/lib/exporter/index.ts
index e93166c7..67bff893 100644
--- a/src/lib/exporter/index.ts
+++ b/src/lib/exporter/index.ts
@@ -12,13 +12,17 @@ export type {
GifExportConfig,
GifFrameRate,
GifSizePreset,
+ Mp4FrameRate,
VideoFrameData,
} from "./types";
export {
GIF_FRAME_RATES,
GIF_SIZE_PRESETS,
isValidGifFrameRate,
+ isValidMp4FrameRate,
+ MP4_FRAME_RATES,
VALID_GIF_FRAME_RATES,
+ VALID_MP4_FRAME_RATES,
} from "./types";
export { VideoFileDecoder } from "./videoDecoder";
export { VideoExporter } from "./videoExporter";
diff --git a/src/lib/exporter/types.ts b/src/lib/exporter/types.ts
index b6e08e8b..f175cd92 100644
--- a/src/lib/exporter/types.ts
+++ b/src/lib/exporter/types.ts
@@ -34,6 +34,8 @@ export type ExportFormat = "mp4" | "gif";
export type GifFrameRate = 15 | 20 | 25 | 30;
+export type Mp4FrameRate = 24 | 30 | 60;
+
export type GifSizePreset = "medium" | "large" | "original";
export interface GifExportConfig {
@@ -52,12 +54,34 @@ export interface ExportSettings {
gifConfig?: GifExportConfig;
}
+/** Display metadata for each GIF size preset, mapping preset name to max height and label. */
export const GIF_SIZE_PRESETS: Record = {
medium: { maxHeight: 720, label: "Medium (720p)" },
large: { maxHeight: 1080, label: "Large (1080p)" },
original: { maxHeight: Infinity, label: "Original" },
};
+/** Display metadata for each supported MP4 frame rate option. */
+export const MP4_FRAME_RATES: { value: Mp4FrameRate; label: string }[] = [
+ { value: 24, label: "24 FPS" },
+ { value: 30, label: "30 FPS" },
+ { value: 60, label: "60 FPS" },
+];
+
+/** Tuple of every valid MP4 frame rate value used for runtime validation. */
+export const VALID_MP4_FRAME_RATES: readonly Mp4FrameRate[] = [24, 30, 60] as const;
+
+/**
+ * Type guard that checks whether a number is a valid {@link Mp4FrameRate}.
+ *
+ * @param rate - The frame rate value to test.
+ * @returns `true` if `rate` is 24, 30, or 60; `false` otherwise.
+ */
+export function isValidMp4FrameRate(rate: number): rate is Mp4FrameRate {
+ return VALID_MP4_FRAME_RATES.includes(rate as Mp4FrameRate);
+}
+
+/** Display metadata for each supported GIF frame rate option. */
export const GIF_FRAME_RATES: { value: GifFrameRate; label: string }[] = [
{ value: 15, label: "15 FPS - Balanced" },
{ value: 20, label: "20 FPS - Smooth" },
@@ -65,9 +89,15 @@ export const GIF_FRAME_RATES: { value: GifFrameRate; label: string }[] = [
{ value: 30, label: "30 FPS - Maximum" },
];
-// Valid frame rates for validation
+/** Tuple of every valid GIF frame rate value used for runtime validation. */
export const VALID_GIF_FRAME_RATES: readonly GifFrameRate[] = [15, 20, 25, 30] as const;
+/**
+ * Type guard that checks whether a number is a valid {@link GifFrameRate}.
+ *
+ * @param rate - The frame rate value to test.
+ * @returns `true` if `rate` is 15, 20, 25, or 30; `false` otherwise.
+ */
export function isValidGifFrameRate(rate: number): rate is GifFrameRate {
return VALID_GIF_FRAME_RATES.includes(rate as GifFrameRate);
}
diff --git a/src/lib/userPreferences.ts b/src/lib/userPreferences.ts
index e0607880..3cb06330 100644
--- a/src/lib/userPreferences.ts
+++ b/src/lib/userPreferences.ts
@@ -1,4 +1,4 @@
-import type { ExportFormat, ExportQuality } from "@/lib/exporter";
+import type { ExportFormat, ExportQuality, Mp4FrameRate } from "@/lib/exporter";
import type { AspectRatio } from "@/utils/aspectRatioUtils";
const PREFS_KEY = "openscreen_user_preferences";
@@ -23,6 +23,8 @@ export interface UserPreferences {
exportQuality: ExportQuality;
/** Default export format */
exportFormat: ExportFormat;
+ /** Default MP4 frame rate */
+ mp4FrameRate: Mp4FrameRate;
}
const DEFAULT_PREFS: UserPreferences = {
@@ -30,6 +32,7 @@ const DEFAULT_PREFS: UserPreferences = {
aspectRatio: "16:9",
exportQuality: "good",
exportFormat: "mp4",
+ mp4FrameRate: 60,
};
function safeJsonParse(text: string | null): Record | null {
@@ -76,6 +79,10 @@ export function loadUserPreferences(): UserPreferences {
raw.exportFormat === "gif" || raw.exportFormat === "mp4"
? (raw.exportFormat as ExportFormat)
: DEFAULT_PREFS.exportFormat,
+ mp4FrameRate:
+ raw.mp4FrameRate === 24 || raw.mp4FrameRate === 30 || raw.mp4FrameRate === 60
+ ? (raw.mp4FrameRate as Mp4FrameRate)
+ : DEFAULT_PREFS.mp4FrameRate,
};
}