From ae471b58e30d6cb3dc2a34b87815f534f311635e Mon Sep 17 00:00:00 2001 From: Scott Lexium Date: Sun, 12 Apr 2026 00:22:44 +0100 Subject: [PATCH] feat: add frame rate selector for MP4 export (24/30/60 FPS) Adds a 3-button FPS toggle (24 / 30 / 60) to the MP4 export panel, directly below the existing quality selector. The selected frame rate replaces the previously hardcoded 60 FPS value and is persisted to user preferences and project files. - Add Mp4FrameRate type and MP4_FRAME_RATES constant to exporter types - Add mp4FrameRate to UserPreferences and ProjectEditorState - Wire state, persistence, and export config in VideoEditor - Add FPS button row UI in SettingsPanel matching the GIF FPS pattern - Add mp4Settings.frameRate i18n key to all 5 locale files --- src/components/video-editor/SettingsPanel.tsx | 100 +++++++++++------- src/components/video-editor/VideoEditor.tsx | 18 +++- .../video-editor/projectPersistence.ts | 13 ++- src/i18n/locales/en/settings.json | 3 + src/i18n/locales/es/settings.json | 3 + src/i18n/locales/fr/settings.json | 3 + src/i18n/locales/tr/settings.json | 3 + src/i18n/locales/zh-CN/settings.json | 3 + src/lib/exporter/index.ts | 4 + src/lib/exporter/types.ts | 32 +++++- src/lib/userPreferences.ts | 9 +- 11 files changed, 148 insertions(+), 43 deletions(-) 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, }; }