@@ -51,12 +51,43 @@ import {
5151 validateVariables ,
5252 formatVariableValidationIssue ,
5353 normalizeResolutionFlag ,
54+ parseFps ,
55+ fpsToNumber ,
56+ fpsToFfmpegArg ,
5457 type VariableValidationIssue ,
5558 type CanvasResolution ,
59+ type Fps ,
60+ type FpsParseResult ,
5661} from "@hyperframes/core" ;
5762
58- const VALID_FPS = new Set ( [ 24 , 30 , 60 ] ) ;
5963const VALID_QUALITY = new Set ( [ "draft" , "standard" , "high" ] ) ;
64+
65+ /**
66+ * Map a {@link FpsParseResult} failure reason to a human-friendly
67+ * error-box message. The empty / undefined / default-fallthrough case
68+ * shouldn't be reachable from the CLI flag (citty supplies a default of
69+ * "30") but the branch exists so this helper can be reused by other
70+ * fps-accepting CLI surfaces in the future.
71+ */
72+ function formatFpsParseError (
73+ input : string ,
74+ reason : Exclude < FpsParseResult , { ok : true } > [ "reason" ] ,
75+ ) : string {
76+ switch ( reason ) {
77+ case "empty" :
78+ return "Frame rate must not be empty." ;
79+ case "not-a-number" :
80+ return `Got "${ input } ". Frame rate must be an integer (e.g. 30) or a rational (e.g. 30000/1001 for NTSC).` ;
81+ case "non-positive" :
82+ return `Got "${ input } ". Frame rate must be greater than zero.` ;
83+ case "out-of-range" :
84+ return `Got "${ input } ". Frame rate must be in the range 1–240.` ;
85+ case "invalid-fraction" :
86+ return `Got "${ input } ". Rational frame rates must be two positive integers separated by '/' (e.g. 30000/1001).` ;
87+ case "ambiguous-decimal" :
88+ return `Got "${ input } ". Decimal frame rates are ambiguous — use the exact rational form instead (e.g. 30000/1001 for 29.97).` ;
89+ }
90+ }
6091const VALID_FORMAT = new Set ( [ "mp4" , "webm" , "mov" , "png-sequence" ] ) ;
6192// `png-sequence` writes a directory of frames rather than a single muxed file,
6293// so its "extension" is empty — the auto-output path becomes a directory name.
@@ -95,7 +126,10 @@ export default defineCommand({
95126 fps : {
96127 type : "string" ,
97128 alias : "f" ,
98- description : "Frame rate: 24, 30, 60" ,
129+ description :
130+ "Frame rate. Accepts integer (24, 25, 30, 50, 60, 120, 240) or " +
131+ "ffmpeg-style rational (30000/1001 for NTSC 29.97, 24000/1001 for " +
132+ "23.976, 60000/1001 for 59.94). Range 1-240." ,
99133 default : "30" ,
100134 } ,
101135 quality : {
@@ -194,12 +228,17 @@ export default defineCommand({
194228 const project = resolveProject ( args . dir ) ;
195229
196230 // ── Validate fps ───────────────────────────────────────────────────────
197- const fpsRaw = parseInt ( args . fps ?? "30" , 10 ) ;
198- if ( ! VALID_FPS . has ( fpsRaw ) ) {
199- errorBox ( "Invalid fps" , `Got "${ args . fps ?? "30" } ". Must be 24, 30, or 60.` ) ;
231+ // Accept either integer (`30`) or ffmpeg-style rational (`30000/1001`).
232+ // The whitelist-based validator was replaced with a sane numeric range so
233+ // legitimate framerates (NTSC trio, PAL, 120/240 slow-mo) work without
234+ // CLI gymnastics. The exact rational survives end-to-end into FFmpeg's
235+ // `-r` / `-framerate` flags via `fpsToFfmpegArg`.
236+ const fpsParse = parseFps ( args . fps ?? "30" ) ;
237+ if ( ! fpsParse . ok ) {
238+ errorBox ( "Invalid fps" , formatFpsParseError ( args . fps ?? "30" , fpsParse . reason ) ) ;
200239 process . exit ( 1 ) ;
201240 }
202- const fps = fpsRaw as 24 | 30 | 60 ;
241+ const fps : Fps = fpsParse . value ;
203242
204243 // ── Validate quality ───────────────────────────────────────────────────
205244 const qualityRaw = args . quality ?? "standard" ;
@@ -354,7 +393,9 @@ export default defineCommand({
354393 console . log (
355394 c . accent ( "\u25C6" ) + " Rendering " + c . accent ( nameLabel ) + c . dim ( " \u2192 " + outputPath ) ,
356395 ) ;
357- console . log ( c . dim ( " " + fps + "fps \u00B7 " + quality + " \u00B7 " + workerLabel ) ) ;
396+ console . log (
397+ c . dim ( " " + fpsToFfmpegArg ( fps ) + "fps \u00B7 " + quality + " \u00B7 " + workerLabel ) ,
398+ ) ;
358399 if ( outputResolution ) {
359400 // Don't claim "supersampled" — when the composition is already at the
360401 // target dimensions, the DPR resolves to 1 and no supersampling
@@ -521,7 +562,7 @@ export default defineCommand({
521562} ) ;
522563
523564interface RenderOptions {
524- fps : 24 | 30 | 60 ;
565+ fps : Fps ;
525566 quality : "draft" | "standard" | "high" ;
526567 format : "mp4" | "webm" | "mov" | "png-sequence" ;
527568 workers ?: number ;
@@ -865,7 +906,7 @@ async function renderDocker(
865906 // Track metrics (no job object available from Docker — use a minimal stub)
866907 trackRenderComplete ( {
867908 durationMs : elapsed ,
868- fps : options . fps ,
909+ fps : fpsToNumber ( options . fps ) ,
869910 quality : options . quality ,
870911 workers : options . workers ,
871912 docker : true ,
@@ -964,7 +1005,7 @@ function handleRenderError(
9641005) : never {
9651006 const message = error instanceof Error ? error . message : String ( error ) ;
9661007 trackRenderError ( {
967- fps : options . fps ,
1008+ fps : fpsToNumber ( options . fps ) ,
9681009 quality : options . quality ,
9691010 docker,
9701011 workers : options . workers ,
@@ -1001,7 +1042,7 @@ function trackRenderMetrics(
10011042
10021043 trackRenderComplete ( {
10031044 durationMs : elapsedMs ,
1004- fps : options . fps ,
1045+ fps : fpsToNumber ( options . fps ) ,
10051046 quality : options . quality ,
10061047 workers : options . workers ?? perf ?. workers ,
10071048 docker,
0 commit comments