|
2 | 2 |
|
3 | 3 | export type ExecutionMode = "planning" | "design" | "execution" | null; |
4 | 4 |
|
5 | | -// ── Frame rate ────────────────────────────────────────────────────────────── |
6 | | - |
7 | | -/** |
8 | | - * Frame rate as an exact rational. Carrying `{num, den}` end-to-end (rather |
9 | | - * than collapsing to `29.97`) lets us pass NTSC / drop-frame rates straight |
10 | | - * through to FFmpeg via `-r 30000/1001` without any decimal round-trip. |
11 | | - * |
12 | | - * Integer fps is represented with `den: 1` (e.g. `{ num: 30, den: 1 }`). |
13 | | - * |
14 | | - * Use {@link fpsToNumber} when arithmetic forces a decimal (e.g. `setTimeout` |
15 | | - * intervals) and {@link fpsToFfmpegArg} when emitting FFmpeg `-r` / |
16 | | - * `-framerate` strings. |
17 | | - */ |
18 | | -export interface Fps { |
19 | | - num: number; |
20 | | - den: number; |
21 | | -} |
22 | | - |
23 | | -export type FpsInput = number | Fps; |
24 | | - |
25 | | -export function toFps(input: FpsInput): Fps { |
26 | | - if (typeof input === "number") { |
27 | | - return { num: input, den: 1 }; |
28 | | - } |
29 | | - return input; |
30 | | -} |
31 | | - |
32 | | -/** |
33 | | - * Decimal value of an {@link Fps} rational. Used at sites that need a |
34 | | - * `number` for arithmetic (frame-index → time, frame intervals, telemetry |
35 | | - * payloads) where the small precision loss of the decimal is acceptable. |
36 | | - */ |
37 | | -export function fpsToNumber(fps: Fps): number { |
38 | | - return fps.num / fps.den; |
39 | | -} |
40 | | - |
41 | | -/** |
42 | | - * FFmpeg-style fps argument. Returns `"30"` for integer fps and `"30000/1001"` |
43 | | - * for rationals — both forms are accepted verbatim by FFmpeg's `-r` and |
44 | | - * `-framerate` flags. We keep integer fps as a bare integer so existing |
45 | | - * snapshot tests / log output don't churn for the common case. |
46 | | - */ |
47 | | -export function fpsToFfmpegArg(fps: Fps): string { |
48 | | - return fps.den === 1 ? String(fps.num) : `${fps.num}/${fps.den}`; |
49 | | -} |
50 | | - |
51 | | -/** |
52 | | - * Discriminated parse result for {@link parseFps}. Lets the CLI / route |
53 | | - * validation own its own error UX without losing the structured failure |
54 | | - * reason. |
55 | | - */ |
56 | | -export type FpsParseResult = |
57 | | - | { ok: true; value: Fps } |
58 | | - | { |
59 | | - ok: false; |
60 | | - reason: |
61 | | - | "empty" |
62 | | - | "not-a-number" |
63 | | - | "non-positive" |
64 | | - | "out-of-range" |
65 | | - | "invalid-fraction" |
66 | | - | "ambiguous-decimal"; |
67 | | - }; |
68 | | - |
69 | | -/** |
70 | | - * Parse a user-supplied fps spec into an {@link Fps} rational. |
71 | | - * |
72 | | - * Accepted forms: |
73 | | - * - integer string `"30"` → `{ num: 30, den: 1 }` |
74 | | - * - integer number `30` → `{ num: 30, den: 1 }` |
75 | | - * - rational string `"30000/1001"` → `{ num: 30000, den: 1001 }` (exact NTSC) |
76 | | - * |
77 | | - * Rejected: |
78 | | - * - empty / non-numeric input |
79 | | - * - decimals like `"29.97"` — callers must spell rationals with `/` so the |
80 | | - * exact denominator is unambiguous (FFmpeg treats `29.97` as a slightly |
81 | | - * different framerate than `30000/1001`). |
82 | | - * - division by zero, negative or zero numerator |
83 | | - * - decimal value outside `[1, 240]` — defensive bounds for "human" fps |
84 | | - * ranges (24, 25, 30, 50, 60, 120, 240, plus the NTSC trio). |
85 | | - */ |
86 | | -export function parseFps(input: string | number): FpsParseResult { |
87 | | - if (typeof input === "number") { |
88 | | - if (!Number.isFinite(input)) return { ok: false, reason: "not-a-number" }; |
89 | | - if (!Number.isInteger(input)) return { ok: false, reason: "ambiguous-decimal" }; |
90 | | - if (input <= 0) return { ok: false, reason: "non-positive" }; |
91 | | - if (input > 240) return { ok: false, reason: "out-of-range" }; |
92 | | - return { ok: true, value: { num: input, den: 1 } }; |
93 | | - } |
94 | | - const raw = input.trim(); |
95 | | - if (raw === "") return { ok: false, reason: "empty" }; |
96 | | - |
97 | | - if (raw.includes("/")) { |
98 | | - const parts = raw.split("/"); |
99 | | - if (parts.length !== 2) return { ok: false, reason: "invalid-fraction" }; |
100 | | - const num = Number(parts[0]); |
101 | | - const den = Number(parts[1]); |
102 | | - if (!Number.isFinite(num) || !Number.isFinite(den)) { |
103 | | - return { ok: false, reason: "not-a-number" }; |
104 | | - } |
105 | | - if (!Number.isInteger(num) || !Number.isInteger(den)) { |
106 | | - return { ok: false, reason: "invalid-fraction" }; |
107 | | - } |
108 | | - if (den <= 0) return { ok: false, reason: "invalid-fraction" }; |
109 | | - if (num <= 0) return { ok: false, reason: "non-positive" }; |
110 | | - const decimal = num / den; |
111 | | - if (decimal < 1 || decimal > 240) return { ok: false, reason: "out-of-range" }; |
112 | | - return { ok: true, value: { num, den } }; |
113 | | - } |
114 | | - |
115 | | - // Integer-only path — reject `"29.97"` so users are explicit about the |
116 | | - // exact rational they want. |
117 | | - if (!/^-?\d+$/.test(raw)) { |
118 | | - // Allow caller to differentiate "29.97" from "abc" if they want; both |
119 | | - // are user errors but the message can be friendlier for decimals. |
120 | | - if (/^-?\d*\.\d+$/.test(raw)) return { ok: false, reason: "ambiguous-decimal" }; |
121 | | - return { ok: false, reason: "not-a-number" }; |
122 | | - } |
123 | | - const n = Number(raw); |
124 | | - if (n <= 0) return { ok: false, reason: "non-positive" }; |
125 | | - if (n > 240) return { ok: false, reason: "out-of-range" }; |
126 | | - return { ok: true, value: { num: n, den: 1 } }; |
127 | | -} |
128 | | - |
129 | | -/** |
130 | | - * Convenience wrapper around {@link parseFps} for callsites that want the |
131 | | - * default-30-fps fallback when input is `undefined`. Does NOT swallow parse |
132 | | - * errors — those still surface via the discriminated result. |
133 | | - */ |
134 | | -export function parseFpsWithDefault(input: string | number | undefined): FpsParseResult { |
135 | | - if (input === undefined || input === "") return { ok: true, value: { num: 30, den: 1 } }; |
136 | | - return parseFps(input); |
137 | | -} |
| 5 | +export type { Fps, FpsInput, FpsParseResult } from "./fps"; |
| 6 | +export { toFps, fpsToNumber, fpsToFfmpegArg, parseFps, parseFpsWithDefault } from "./fps"; |
138 | 7 |
|
139 | 8 | /** Video orientation / aspect ratio. */ |
140 | 9 | export type Orientation = "16:9" | "9:16"; |
|
0 commit comments