mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-14 13:27:13 +00:00
Replaces the rigid `--fps 24|30|60` whitelist with a numeric range and
adds support for ffmpeg-style fractional framerates so NTSC stays exact
end-to-end.
- `--fps 30` keeps working (integer fps)
- `--fps 30000/1001` now means exact NTSC 29.97 (not the lossy decimal)
- `--fps 24000/1001`, `--fps 60000/1001`, `--fps 25/50/120/240` all work
- Decimals like `--fps 29.97` are rejected with a friendly error pointing
the user at the rational form, since `29.97` and `30000/1001` round
to different framerates inside ffmpeg
Carries an `Fps = { num: number; den: number }` rational end-to-end:
RenderConfig, EncoderOptions, StreamingEncoderOptions, CaptureOptions,
DockerRenderOptions, Studio API request body, regression-harness
meta.json. The `-r` and `-framerate` ffmpeg args emit the rational form
verbatim (`30000/1001`) so no decimal round-trip happens at the encoder
boundary. Frame-interval math uses `1000 * den / num` ms (33.366… for
NTSC, 33.333… for integer 30).
Helpers live in @hyperframes/core:
- `parseFps(input: string | number): FpsParseResult` — discriminated
parser used by both the CLI and the Studio API route
- `fpsToFfmpegArg(fps: Fps): string` — emits "30" or "30000/1001"
- `fpsToNumber(fps: Fps): number` — for arithmetic (telemetry, frame
count, frame-index → time)
Studio API wire format accepts polymorphic `fps: number | string`:
- number → integer fps (`30`)
- string → rational (`"30000/1001"`)
Decimals are rejected; matches the same rule as the CLI.
Existing meta.json fixtures with integer `"fps": 30` continue to load
unchanged — the regression-harness validator now normalizes both number
and string inputs through `parseFps`.
85 lines
3.0 KiB
TypeScript
85 lines
3.0 KiB
TypeScript
/**
|
|
* Build the argument array for `docker run` that invokes the Hyperframes
|
|
* renderer inside a container.
|
|
*
|
|
* Pure function with no I/O so it can be snapshot-tested. Any new render
|
|
* flag added to the CLI must also be threaded through here AND covered by
|
|
* a test in `dockerRunArgs.test.ts` — that combination is what catches
|
|
* silent-drop regressions like the one that lost `--hdr` historically.
|
|
*/
|
|
import { fpsToFfmpegArg, type Fps } from "@hyperframes/core";
|
|
|
|
export interface DockerRunArgsInput {
|
|
imageTag: string;
|
|
/** Absolute host path to the project directory (mounted read-only at /project). */
|
|
projectDir: string;
|
|
/** Absolute host path to the output directory (mounted read-write at /output). */
|
|
outputDir: string;
|
|
/** Filename within `outputDir` (joined to /output inside the container). */
|
|
outputFilename: string;
|
|
options: DockerRenderOptions;
|
|
}
|
|
|
|
export interface DockerRenderOptions {
|
|
/**
|
|
* Frame rate as an exact rational; see `Fps` in @hyperframes/core. The
|
|
* docker-run arg builder serializes this back to a `--fps` string
|
|
* (`"30"` or `"30000/1001"`) which the in-container CLI re-parses with
|
|
* `parseFps`, so the rational survives the host → container hop.
|
|
*/
|
|
fps: Fps;
|
|
quality: "draft" | "standard" | "high";
|
|
format: "mp4" | "webm" | "mov" | "png-sequence";
|
|
workers?: number;
|
|
gpu: boolean;
|
|
browserGpu: boolean;
|
|
hdrMode: "auto" | "force-hdr" | "force-sdr";
|
|
crf?: number;
|
|
videoBitrate?: string;
|
|
quiet: boolean;
|
|
variables?: Record<string, unknown>;
|
|
entryFile?: string;
|
|
/** Output resolution preset (e.g. "landscape-4k"). Forwarded as `--resolution`. */
|
|
outputResolution?: string;
|
|
}
|
|
|
|
export function buildDockerRunArgs(input: DockerRunArgsInput): string[] {
|
|
const { imageTag, projectDir, outputDir, outputFilename, options } = input;
|
|
return [
|
|
"run",
|
|
"--rm",
|
|
"--platform",
|
|
"linux/amd64",
|
|
"--shm-size=2g",
|
|
// GPU encoding requires host GPU passthrough.
|
|
...(options.gpu ? ["--gpus", "all"] : []),
|
|
"-v",
|
|
`${projectDir}:/project:ro`,
|
|
"-v",
|
|
`${outputDir}:/output`,
|
|
imageTag,
|
|
"/project",
|
|
"--output",
|
|
`/output/${outputFilename}`,
|
|
"--fps",
|
|
fpsToFfmpegArg(options.fps),
|
|
"--quality",
|
|
options.quality,
|
|
"--format",
|
|
options.format,
|
|
...(options.workers != null ? ["--workers", String(options.workers)] : []),
|
|
...(options.crf != null ? ["--crf", String(options.crf)] : []),
|
|
...(options.videoBitrate ? ["--video-bitrate", options.videoBitrate] : []),
|
|
...(options.quiet ? ["--quiet"] : []),
|
|
...(options.gpu ? ["--gpu"] : []),
|
|
...(options.browserGpu ? [] : ["--no-browser-gpu"]),
|
|
...(options.hdrMode === "force-hdr" ? ["--hdr"] : []),
|
|
...(options.hdrMode === "force-sdr" ? ["--sdr"] : []),
|
|
...(options.variables && Object.keys(options.variables).length > 0
|
|
? ["--variables", JSON.stringify(options.variables)]
|
|
: []),
|
|
...(options.entryFile ? ["--composition", options.entryFile] : []),
|
|
...(options.outputResolution ? ["--resolution", options.outputResolution] : []),
|
|
];
|
|
}
|