Skip to content

Commit 43f915f

Browse files
committed
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
1 parent e7d5f51 commit 43f915f

11 files changed

Lines changed: 139 additions & 42 deletions

File tree

src/components/video-editor/SettingsPanel.tsx

Lines changed: 64 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,14 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
3636
import { useScopedT } from "@/contexts/I18nContext";
3737
import { getAssetPath } from "@/lib/assetPath";
3838
import { WEBCAM_LAYOUT_PRESETS } from "@/lib/compositeLayout";
39-
import type { ExportFormat, ExportQuality, GifFrameRate, GifSizePreset } from "@/lib/exporter";
40-
import { GIF_FRAME_RATES, GIF_SIZE_PRESETS } from "@/lib/exporter";
39+
import type {
40+
ExportFormat,
41+
ExportQuality,
42+
GifFrameRate,
43+
GifSizePreset,
44+
Mp4FrameRate,
45+
} from "@/lib/exporter";
46+
import { GIF_FRAME_RATES, GIF_SIZE_PRESETS, MP4_FRAME_RATES } from "@/lib/exporter";
4147
import { cn } from "@/lib/utils";
4248
import { type AspectRatio, isPortraitAspectRatio } from "@/utils/aspectRatioUtils";
4349
import { getTestId } from "@/utils/getTestId";
@@ -188,6 +194,8 @@ interface SettingsPanelProps {
188194
// Export format settings
189195
exportFormat?: ExportFormat;
190196
onExportFormatChange?: (format: ExportFormat) => void;
197+
mp4FrameRate?: Mp4FrameRate;
198+
onMp4FrameRateChange?: (rate: Mp4FrameRate) => void;
191199
gifFrameRate?: GifFrameRate;
192200
onGifFrameRateChange?: (rate: GifFrameRate) => void;
193201
gifLoop?: boolean;
@@ -268,6 +276,8 @@ export function SettingsPanel({
268276
onExportQualityChange,
269277
exportFormat = "mp4",
270278
onExportFormatChange,
279+
mp4FrameRate = 60,
280+
onMp4FrameRateChange,
271281
gifFrameRate = 15,
272282
onGifFrameRateChange,
273283
gifLoop = true,
@@ -1306,40 +1316,58 @@ export function SettingsPanel({
13061316
</div>
13071317

13081318
{exportFormat === "mp4" && (
1309-
<div className="mb-3 bg-white/5 border border-white/5 p-0.5 w-full grid grid-cols-3 h-7 rounded-lg">
1310-
<button
1311-
onClick={() => onExportQualityChange?.("medium")}
1312-
className={cn(
1313-
"rounded-md transition-all text-[10px] font-medium",
1314-
exportQuality === "medium"
1315-
? "bg-white text-black"
1316-
: "text-slate-400 hover:text-slate-200",
1317-
)}
1318-
>
1319-
{t("exportQuality.low")}
1320-
</button>
1321-
<button
1322-
onClick={() => onExportQualityChange?.("good")}
1323-
className={cn(
1324-
"rounded-md transition-all text-[10px] font-medium",
1325-
exportQuality === "good"
1326-
? "bg-white text-black"
1327-
: "text-slate-400 hover:text-slate-200",
1328-
)}
1329-
>
1330-
{t("exportQuality.medium")}
1331-
</button>
1332-
<button
1333-
onClick={() => onExportQualityChange?.("source")}
1334-
className={cn(
1335-
"rounded-md transition-all text-[10px] font-medium",
1336-
exportQuality === "source"
1337-
? "bg-white text-black"
1338-
: "text-slate-400 hover:text-slate-200",
1339-
)}
1340-
>
1341-
{t("exportQuality.high")}
1342-
</button>
1319+
<div className="mb-3 space-y-1.5">
1320+
<div className="bg-white/5 border border-white/5 p-0.5 w-full grid grid-cols-3 h-7 rounded-lg">
1321+
<button
1322+
onClick={() => onExportQualityChange?.("medium")}
1323+
className={cn(
1324+
"rounded-md transition-all text-[10px] font-medium",
1325+
exportQuality === "medium"
1326+
? "bg-white text-black"
1327+
: "text-slate-400 hover:text-slate-200",
1328+
)}
1329+
>
1330+
{t("exportQuality.low")}
1331+
</button>
1332+
<button
1333+
onClick={() => onExportQualityChange?.("good")}
1334+
className={cn(
1335+
"rounded-md transition-all text-[10px] font-medium",
1336+
exportQuality === "good"
1337+
? "bg-white text-black"
1338+
: "text-slate-400 hover:text-slate-200",
1339+
)}
1340+
>
1341+
{t("exportQuality.medium")}
1342+
</button>
1343+
<button
1344+
onClick={() => onExportQualityChange?.("source")}
1345+
className={cn(
1346+
"rounded-md transition-all text-[10px] font-medium",
1347+
exportQuality === "source"
1348+
? "bg-white text-black"
1349+
: "text-slate-400 hover:text-slate-200",
1350+
)}
1351+
>
1352+
{t("exportQuality.high")}
1353+
</button>
1354+
</div>
1355+
<div className="bg-white/5 border border-white/5 p-0.5 w-full grid grid-cols-3 h-7 rounded-lg">
1356+
{MP4_FRAME_RATES.map((rate) => (
1357+
<button
1358+
key={rate.value}
1359+
onClick={() => onMp4FrameRateChange?.(rate.value)}
1360+
className={cn(
1361+
"rounded-md transition-all text-[10px] font-medium",
1362+
mp4FrameRate === rate.value
1363+
? "bg-white text-black"
1364+
: "text-slate-400 hover:text-slate-200",
1365+
)}
1366+
>
1367+
{rate.value}
1368+
</button>
1369+
))}
1370+
</div>
13431371
</div>
13441372
)}
13451373

src/components/video-editor/VideoEditor.tsx

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
GifExporter,
2727
type GifFrameRate,
2828
type GifSizePreset,
29+
type Mp4FrameRate,
2930
VideoExporter,
3031
} from "@/lib/exporter";
3132
import { computeFrameStepTime } from "@/lib/frameStep";
@@ -129,6 +130,7 @@ export default function VideoEditor() {
129130
const [showNewRecordingDialog, setShowNewRecordingDialog] = useState(false);
130131
const [exportQuality, setExportQuality] = useState<ExportQuality>("good");
131132
const [exportFormat, setExportFormat] = useState<ExportFormat>("mp4");
133+
const [mp4FrameRate, setMp4FrameRate] = useState<Mp4FrameRate>(60);
132134
const [gifFrameRate, setGifFrameRate] = useState<GifFrameRate>(15);
133135
const [gifLoop, setGifLoop] = useState(true);
134136
const [gifSizePreset, setGifSizePreset] = useState<GifSizePreset>("medium");
@@ -221,6 +223,7 @@ export default function VideoEditor() {
221223
});
222224
setExportQuality(normalizedEditor.exportQuality);
223225
setExportFormat(normalizedEditor.exportFormat);
226+
setMp4FrameRate(normalizedEditor.mp4FrameRate);
224227
setGifFrameRate(normalizedEditor.gifFrameRate);
225228
setGifLoop(normalizedEditor.gifLoop);
226229
setGifSizePreset(normalizedEditor.gifSizePreset);
@@ -287,6 +290,7 @@ export default function VideoEditor() {
287290
webcamPosition,
288291
exportQuality,
289292
exportFormat,
293+
mp4FrameRate,
290294
gifFrameRate,
291295
gifLoop,
292296
gifSizePreset,
@@ -307,10 +311,10 @@ export default function VideoEditor() {
307311
aspectRatio,
308312
webcamLayoutPreset,
309313
webcamMaskShape,
310-
webcamSizePreset,
311314
webcamPosition,
312315
exportQuality,
313316
exportFormat,
317+
mp4FrameRate,
314318
gifFrameRate,
315319
gifLoop,
316320
gifSizePreset,
@@ -392,14 +396,15 @@ export default function VideoEditor() {
392396
});
393397
setExportQuality(prefs.exportQuality);
394398
setExportFormat(prefs.exportFormat);
399+
setMp4FrameRate(prefs.mp4FrameRate);
395400
setPrefsHydrated(true);
396401
}, [updateState]);
397402

398403
// Auto-save user preferences when settings change
399404
useEffect(() => {
400405
if (!prefsHydrated) return;
401-
saveUserPreferences({ padding, aspectRatio, exportQuality, exportFormat });
402-
}, [prefsHydrated, padding, aspectRatio, exportQuality, exportFormat]);
406+
saveUserPreferences({ padding, aspectRatio, exportQuality, exportFormat, mp4FrameRate });
407+
}, [prefsHydrated, padding, aspectRatio, exportQuality, exportFormat, mp4FrameRate]);
403408

404409
const saveProject = useCallback(
405410
async (forceSaveAs: boolean) => {
@@ -432,6 +437,7 @@ export default function VideoEditor() {
432437
webcamPosition,
433438
exportQuality,
434439
exportFormat,
440+
mp4FrameRate,
435441
gifFrameRate,
436442
gifLoop,
437443
gifSizePreset,
@@ -488,6 +494,7 @@ export default function VideoEditor() {
488494
webcamPosition,
489495
exportQuality,
490496
exportFormat,
497+
mp4FrameRate,
491498
gifFrameRate,
492499
gifLoop,
493500
gifSizePreset,
@@ -1339,7 +1346,7 @@ export default function VideoEditor() {
13391346
webcamVideoUrl: webcamVideoPath || undefined,
13401347
width: exportWidth,
13411348
height: exportHeight,
1342-
frameRate: 60,
1349+
frameRate: mp4FrameRate,
13431350
bitrate,
13441351
codec: "avc1.640033",
13451352
wallpaper,
@@ -1430,6 +1437,7 @@ export default function VideoEditor() {
14301437
webcamSizePreset,
14311438
webcamPosition,
14321439
exportQuality,
1440+
mp4FrameRate,
14331441
handleExportSaved,
14341442
cursorTelemetry,
14351443
],
@@ -1809,6 +1817,8 @@ export default function VideoEditor() {
18091817
onExportQualityChange={setExportQuality}
18101818
exportFormat={exportFormat}
18111819
onExportFormatChange={setExportFormat}
1820+
mp4FrameRate={mp4FrameRate}
1821+
onMp4FrameRateChange={setMp4FrameRate}
18121822
gifFrameRate={gifFrameRate}
18131823
onGifFrameRateChange={setGifFrameRate}
18141824
gifLoop={gifLoop}

src/components/video-editor/projectPersistence.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
import type { ExportFormat, ExportQuality, GifFrameRate, GifSizePreset } from "@/lib/exporter";
1+
import type {
2+
ExportFormat,
3+
ExportQuality,
4+
GifFrameRate,
5+
GifSizePreset,
6+
Mp4FrameRate,
7+
} from "@/lib/exporter";
28
import type { ProjectMedia } from "@/lib/recordingSession";
39
import { normalizeProjectMedia } from "@/lib/recordingSession";
410
import { ASPECT_RATIOS, type AspectRatio } from "@/utils/aspectRatioUtils";
@@ -56,6 +62,7 @@ export interface ProjectEditorState {
5662
webcamPosition: WebcamPosition | null;
5763
exportQuality: ExportQuality;
5864
exportFormat: ExportFormat;
65+
mp4FrameRate: Mp4FrameRate;
5966
gifFrameRate: GifFrameRate;
6067
gifLoop: boolean;
6168
gifSizePreset: GifSizePreset;
@@ -384,6 +391,10 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
384391
? editor.exportQuality
385392
: "good",
386393
exportFormat: editor.exportFormat === "gif" ? "gif" : "mp4",
394+
mp4FrameRate:
395+
editor.mp4FrameRate === 24 || editor.mp4FrameRate === 30 || editor.mp4FrameRate === 60
396+
? editor.mp4FrameRate
397+
: 60,
387398
gifFrameRate:
388399
editor.gifFrameRate === 15 ||
389400
editor.gifFrameRate === 20 ||

src/i18n/locales/en/settings.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,9 @@
7070
"medium": "Medium",
7171
"high": "High"
7272
},
73+
"mp4Settings": {
74+
"frameRate": "Frame Rate"
75+
},
7376
"gifSettings": {
7477
"frameRate": "GIF Frame Rate",
7578
"size": "GIF Size",

src/i18n/locales/es/settings.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,9 @@
7070
"medium": "Media",
7171
"high": "Alta"
7272
},
73+
"mp4Settings": {
74+
"frameRate": "Frecuencia de fotogramas"
75+
},
7376
"gifSettings": {
7477
"frameRate": "Velocidad de cuadros del GIF",
7578
"size": "Tamaño del GIF",

src/i18n/locales/fr/settings.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,9 @@
6767
"medium": "Moyenne",
6868
"high": "Haute"
6969
},
70+
"mp4Settings": {
71+
"frameRate": "Fréquence d'images"
72+
},
7073
"gifSettings": {
7174
"frameRate": "Fréquence d'images GIF",
7275
"size": "Taille du GIF",

src/i18n/locales/tr/settings.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,9 @@
6767
"medium": "Orta",
6868
"high": "Yüksek"
6969
},
70+
"mp4Settings": {
71+
"frameRate": "Kare Hızı"
72+
},
7073
"gifSettings": {
7174
"frameRate": "GIF Kare Hızı",
7275
"size": "GIF Boyutu",

src/i18n/locales/zh-CN/settings.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,9 @@
7070
"medium": "",
7171
"high": ""
7272
},
73+
"mp4Settings": {
74+
"frameRate": "帧率"
75+
},
7376
"gifSettings": {
7477
"frameRate": "GIF 帧率",
7578
"size": "GIF 尺寸",

src/lib/exporter/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,17 @@ export type {
1212
GifExportConfig,
1313
GifFrameRate,
1414
GifSizePreset,
15+
Mp4FrameRate,
1516
VideoFrameData,
1617
} from "./types";
1718
export {
1819
GIF_FRAME_RATES,
1920
GIF_SIZE_PRESETS,
2021
isValidGifFrameRate,
22+
isValidMp4FrameRate,
23+
MP4_FRAME_RATES,
2124
VALID_GIF_FRAME_RATES,
25+
VALID_MP4_FRAME_RATES,
2226
} from "./types";
2327
export { VideoFileDecoder } from "./videoDecoder";
2428
export { VideoExporter } from "./videoExporter";

src/lib/exporter/types.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ export type ExportFormat = "mp4" | "gif";
3434

3535
export type GifFrameRate = 15 | 20 | 25 | 30;
3636

37+
export type Mp4FrameRate = 24 | 30 | 60;
38+
3739
export type GifSizePreset = "medium" | "large" | "original";
3840

3941
export interface GifExportConfig {
@@ -58,6 +60,26 @@ export const GIF_SIZE_PRESETS: Record<GifSizePreset, { maxHeight: number; label:
5860
original: { maxHeight: Infinity, label: "Original" },
5961
};
6062

63+
/** Display metadata for each supported MP4 frame rate option. */
64+
export const MP4_FRAME_RATES: { value: Mp4FrameRate; label: string }[] = [
65+
{ value: 24, label: "24 FPS" },
66+
{ value: 30, label: "30 FPS" },
67+
{ value: 60, label: "60 FPS" },
68+
];
69+
70+
/** Tuple of every valid MP4 frame rate value used for runtime validation. */
71+
export const VALID_MP4_FRAME_RATES: readonly Mp4FrameRate[] = [24, 30, 60] as const;
72+
73+
/**
74+
* Type guard that checks whether a number is a valid {@link Mp4FrameRate}.
75+
*
76+
* @param rate - The frame rate value to test.
77+
* @returns `true` if `rate` is 24, 30, or 60; `false` otherwise.
78+
*/
79+
export function isValidMp4FrameRate(rate: number): rate is Mp4FrameRate {
80+
return VALID_MP4_FRAME_RATES.includes(rate as Mp4FrameRate);
81+
}
82+
6183
export const GIF_FRAME_RATES: { value: GifFrameRate; label: string }[] = [
6284
{ value: 15, label: "15 FPS - Balanced" },
6385
{ value: 20, label: "20 FPS - Smooth" },

0 commit comments

Comments
 (0)