Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/packages/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -2076,6 +2076,7 @@ Render parameters mirror local `hyperframes render` where they overlap:
| `--fps` | `30` | Integer, 1–240 |
| `--quality` | `standard` | `draft`, `standard`, or `high` |
| `--format` | `mp4` | `mp4`, `webm`, or `mov` |
| `--page-motion-blur` | current cloud behavior | Pass `off` to disable authored page motion blur. Omit to preserve current behavior. Sends the public API field `page_motion_blur: "off"`. |
| `--resolution` | `1080p` | `1080p` or `4k`. 4k bills at 1.5×. |
| `--aspect-ratio` | auto | `16:9`, `9:16`, or `1:1`. Auto-detected from a local project's `data-width` and `data-height`, so you rarely need it. The renderer cannot reshape a composition — a value that disagrees with the authored ratio is an error. With `--asset-id` or `--url` the server defaults to `16:9`. |
| `--composition` / `-c` | `index.html` | Entry HTML file inside the zip |
Expand Down
6 changes: 6 additions & 0 deletions packages/aws-lambda/src/sdk/validateConfig.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ describe("validateDistributedRenderConfig", () => {
codec: "h265",
quality: "high",
crf: 18,
pageMotionBlur: "off",
chunkSize: 240,
maxParallelChunks: 16,
runtimeCap: "lambda",
Expand All @@ -42,6 +43,11 @@ describe("validateDistributedRenderConfig", () => {

it.each([
["null config", null as unknown as SerializableDistributedRenderConfig, "config"],
[
"unsupported page motion blur",
{ ...VALID, pageMotionBlur: "on" as never } satisfies SerializableDistributedRenderConfig,
"config.pageMotionBlur",
],
[
"wrong fps",
{ ...VALID, fps: 50 as never } satisfies SerializableDistributedRenderConfig,
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/cloud/_gen/client.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

22 changes: 22 additions & 0 deletions packages/cli/src/cloud/_gen/types.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

52 changes: 52 additions & 0 deletions packages/cli/src/commands/cloud/render.pageMotionBlur.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { runCommand } from "citty";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import render from "./render.js";
import { CliUsageError, consumeCommandResult } from "../../utils/commandResult.js";

const bodies: unknown[] = [];
const baseArgs = ["--asset-id", "asst_fixture", "--no-wait", "--json"];

beforeEach(() => {
bodies.length = 0;
vi.stubEnv("HEYGEN_API_KEY", "fixture-key");
vi.stubEnv("HEYGEN_API_URL", "https://cloud.fixture.invalid");
vi.spyOn(console, "log").mockImplementation(() => undefined);
vi.spyOn(console, "error").mockImplementation(() => undefined);
vi.stubGlobal("fetch", async (url: unknown, init?: RequestInit) => {
expect(String(url)).toBe("https://cloud.fixture.invalid/v3/hyperframes/renders");
expect(init?.method).toBe("POST");
if (typeof init?.body !== "string") throw new Error("Expected a JSON submit body");
const body: unknown = JSON.parse(init.body);
bodies.push(body);
return Response.json({ data: { render_id: "hfr_fixture" } }, { status: 202 });
});
});

afterEach(() => {
vi.unstubAllGlobals();
vi.unstubAllEnvs();
vi.restoreAllMocks();
consumeCommandResult();
});

describe("cloud render page motion blur", () => {
it("writes explicit off into the public API request", async () => {
await runCommand(render, { rawArgs: [...baseArgs, "--page-motion-blur", "off"] });
expect(bodies).toHaveLength(1);
expect(bodies[0]).toHaveProperty("page_motion_blur", "off");
expect(bodies[0]).not.toHaveProperty("pageMotionBlur");
});

it("omits the override when the flag is absent", async () => {
await runCommand(render, { rawArgs: baseArgs });
expect(bodies).toHaveLength(1);
expect(bodies[0]).not.toHaveProperty("page_motion_blur");
});

it.each(["on", "false", "", "OFF"])("rejects %j before any cloud request", async (value) => {
await expect(
runCommand(render, { rawArgs: [...baseArgs, "--page-motion-blur", value] }),
).rejects.toThrow(CliUsageError);
expect(bodies).toHaveLength(0);
});
});
11 changes: 11 additions & 0 deletions packages/cli/src/commands/cloud/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,11 @@ export default defineCommand({
fps: { type: "string", description: "Frames per second (1-240). Default: 30." },
quality: { type: "string", description: "draft | standard | high (default: standard)" },
format: { type: "string", description: "mp4 | webm | mov (default: mp4)" },
"page-motion-blur": {
type: "string",
description:
"Set off to disable authored page motion blur. Omit to keep the current cloud behavior.",
},
resolution: {
type: "string",
description: "Resolution tier: 1080p | 4k (default: 1080p; 4k is billed at 1.5x)",
Expand Down Expand Up @@ -214,6 +219,9 @@ export default defineCommand({
const fps = parseIntFlag(args.fps, { flag: "--fps", min: 1, max: 240 });
const quality = parseEnumFlag(args.quality, VALID_QUALITY, { flag: "--quality" });
const format = parseEnumFlag(args.format, VALID_FORMAT, { flag: "--format" });
const pageMotionBlur = parseEnumFlag(args["page-motion-blur"], ["off"] as const, {
flag: "--page-motion-blur",
});
const resolution = parseEnumFlag(args.resolution, VALID_RESOLUTION, {
flag: "--resolution",
});
Expand Down Expand Up @@ -282,6 +290,7 @@ export default defineCommand({
quality,
format,
resolution,
pageMotionBlur,
aspectRatio,
composition: args.composition,
variables,
Expand Down Expand Up @@ -802,6 +811,7 @@ interface SubmitOptions {
fps: number | undefined;
quality: "draft" | "standard" | "high" | undefined;
format: "mp4" | "webm" | "mov" | undefined;
pageMotionBlur: NonNullable<CreateHyperframesRenderRequest["page_motion_blur"]> | undefined;
resolution: CreateHyperframesRenderRequest["resolution"] | undefined;
aspectRatio: CreateHyperframesRenderRequest["aspect_ratio"] | undefined;
composition: string | undefined;
Expand Down Expand Up @@ -830,6 +840,7 @@ function buildRenderBody(opts: SubmitOptions): CreateHyperframesRenderRequest {
if (opts.fps !== undefined) body.fps = opts.fps;
if (opts.quality !== undefined) body.quality = opts.quality;
if (opts.format !== undefined) body.format = opts.format;
if (opts.pageMotionBlur !== undefined) body.page_motion_blur = opts.pageMotionBlur;
if (opts.resolution !== undefined) body.resolution = opts.resolution;
if (opts.aspectRatio !== undefined) body.aspect_ratio = opts.aspectRatio;
if (opts.composition !== undefined) body.composition = opts.composition;
Expand Down
56 changes: 26 additions & 30 deletions packages/producer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,18 +18,13 @@ npm install @hyperframes/producer
import { createRenderJob, executeRenderJob } from "@hyperframes/producer";

const job = createRenderJob({
inputPath: "./my-composition.html",
outputPath: "./output.mp4",
width: 1920,
height: 1080,
fps: 30,
quality: "standard",
});

const result = await executeRenderJob(job, (progress) => {
console.log(`${Math.round(progress.percent * 100)}%`);
await executeRenderJob(job, "./my-project", "./output.mp4", (currentJob) => {
console.log(`${Math.round(currentJob.progress)}%`);
});

console.log(result.outputPath); // ./output.mp4
```

### Run as an HTTP server
Expand All @@ -40,24 +35,27 @@ The producer can also run as a render server, accepting render requests over HTT
import { startServer } from "@hyperframes/producer";

await startServer({ port: 8080 });
// POST /render with a RenderConfig body
// POST /render or /render/stream with projectDir, html, or previewUrl and render options
```

Both HTTP routes accept optional `pageMotionBlur: "off"` to disable authored page
motion blur. Omit it to preserve the composition's behavior. Other explicit values
are rejected before a render starts. This is separate from engine sub-frame
`motionBlur` sampling.

### Configuration

`RenderConfig` controls the render pipeline:

| Option | Default | Description |
| ------------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `inputPath` | — | Path to the HTML composition |
| `outputPath` | — | Output video file path (or directory, for `format: "png-sequence"` and `format: "hls"`) |
| `width` | 1920 | Frame width in pixels |
| `height` | 1080 | Frame height in pixels |
| `fps` | 30 | Frames per second (24, 30, or 60) |
| `quality` | `"standard"` | Encoder preset (`"draft"`, `"standard"`, `"high"`) |
| `format` | `"mp4"` | Output container — `"mp4"`, `"webm"`, `"mov"`, `"gif"`, `"png-sequence"`, or `"hls"`. See [Transparent Video Output](#transparent-video-output) and [HLS Output](#hls-output) below. |
| `hlsSegmentSeconds` | `4` | HLS target segment length in whole seconds. Ignored for every other format. |
| `videoFrameFormat` | `"auto"` | Source video frame extraction format — `"auto"`, `"jpg"`, or `"png"`. Use `"png"` for UI recordings, screen captures, and color-sensitive source videos. |
| Option | Default | Description |
| ------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `entryFile` | `index.html` | Entry composition inside the project directory. Its authored dimensions set the canvas size. |
| `fps` | required | Frame rate as an integer or exact `{ num, den }` rational. |
| `quality` | required | Encoder preset (`"draft"`, `"standard"`, `"high"`). |
| `pageMotionBlur` | authored setting | Set `"off"` to disable page motion blur; omit to preserve authored behavior. |
| `format` | `"mp4"` | Output container — `"mp4"`, `"webm"`, `"mov"`, `"gif"`, `"png-sequence"`, or `"hls"`. See [Transparent Video Output](#transparent-video-output) and [HLS Output](#hls-output) below. |
| `hlsSegmentSeconds` | `4` | HLS target segment length in whole seconds. Ignored for every other format. |
| `videoFrameFormat` | `"auto"` | Source video frame extraction format — `"auto"`, `"jpg"`, or `"png"`. Use `"png"` for UI recordings, screen captures, and color-sensitive source videos. |

## Transparent Video Output

Expand All @@ -68,7 +66,7 @@ The producer can render HTML compositions to formats that carry a **true alpha c
| `"mp4"` (default) | H.264 (yuv420p) or H.265 + HDR10 | No | AAC | Streaming, social, default deliverable |
| `"webm"` | VP9 + yuva420p | **True alpha** | Opus | Web playback as overlay (`<video>` over background); supported in Chrome, Edge, Firefox |
| `"mov"` | ProRes 4444 + yuva444p10le | **True alpha + 10-bit** | AAC | Editor ingest (Premiere, Final Cut Pro, DaVinci Resolve) |
| `"png-sequence"` | Numbered RGBA PNGs in a directory | **Lossless alpha** | Sidecar `audio.aac` | After Effects / Nuke / Fusion, or pipelines that post-process frames before encoding |
| `"png-sequence"` | Numbered RGBA PNGs in a directory | **Lossless alpha** | Sidecar `audio.m4a` | After Effects / Nuke / Fusion, or pipelines that post-process frames before encoding |
| `"hls"` | H.264 (yuv420p) in MPEG-TS | No | AAC rendition | VOD streaming — see [HLS Output](#hls-output) |

### Example
Expand All @@ -77,15 +75,12 @@ The producer can render HTML compositions to formats that carry a **true alpha c
import { createRenderJob, executeRenderJob } from "@hyperframes/producer";

const job = createRenderJob({
inputPath: "./my-composition.html",
outputPath: "./output.webm", // or a directory for "png-sequence"
width: 1080,
height: 1920,
fps: 30,
quality: "standard",
format: "webm", // "mp4" | "webm" | "mov" | "png-sequence"
});

await executeRenderJob(job);
await executeRenderJob(job, "./my-project", "./output.webm");
```

### What "transparent background" means here
Expand All @@ -102,7 +97,7 @@ This is not chroma keying. There is no green/blue background to remove and no "k

- **Linux + alpha forces screenshot capture.** Chrome's BeginFrame compositor (the default deterministic capture path on Linux headless-shell) does not preserve alpha; the orchestrator falls back to `Page.captureScreenshot`, which is slower per frame. macOS and Windows already use screenshot mode by default, so they are unaffected.
- **HDR + alpha is not supported.** Setting `hdr: true` together with an alpha-capable format logs a warning and falls back to SDR. Use `format: "mp4"` for HDR10 output.
- **`png-sequence` does not produce a single muxed file.** When the composition contains audio elements, an `audio.aac` sidecar is written alongside the PNGs in `outputPath`.
- **`png-sequence` does not produce a single muxed file.** When the composition contains audio elements, an `audio.m4a` sidecar is written alongside the PNGs in `outputPath`.
- **Safari + WebM alpha is incomplete.** For broad browser playback of an alpha video, ship `format: "mov"` to your editor and re-encode for the codec your distribution target supports.

### Authoring transparent compositions
Expand All @@ -124,11 +119,12 @@ audio_00000.ts

```typescript
const job = createRenderJob({
inputPath: "./my-composition.html",
outputPath: "./renders/stream",
fps: 30,
quality: "standard",
format: "hls",
hlsSegmentSeconds: 4,
});
await executeRenderJob(job, "./my-project", "./renders/stream");
```

Segments are exactly `hlsSegmentSeconds` long except the last: the encoder's GOP
Expand Down Expand Up @@ -181,7 +177,7 @@ The activity functions plus their result types are also re-exported from `@hyper
1. **Serve** — spins up a local file server for the HTML composition
2. **Capture** — opens the page in headless Chrome, seeks frame-by-frame via `HeadlessExperimental.beginFrame` (or `Page.captureScreenshot` for transparent / non-Linux renders), captures screenshots
3. **Encode** — pipes frames through FFmpeg (with GPU encoder detection and chunked concat). Skipped for `format: "png-sequence"`.
4. **Mix** — extracts `<audio>` elements and mixes them into the final video. For `png-sequence`, audio is written as an `audio.aac` sidecar.
4. **Mix** — extracts `<audio>` elements and mixes them into the final video. For `png-sequence`, audio is written as an `audio.m4a` sidecar.
5. **Finalize** — applies faststart for streaming-friendly MP4 (no-op for WebM, MOV, and `png-sequence`). For `hls`, stream-copies the encode into playlists and MPEG-TS segments instead.

### Where each step lives
Expand Down
14 changes: 14 additions & 0 deletions packages/producer/src/renderRequest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,20 @@ function request() {
}

describe("RenderRequest", () => {
it("preserves explicit motion blur off through the distributed request round trip", () => {
const value = request();
value.options.pageMotionBlur = "off";
const config = distributedConfigFromRequest(value);
expect(config.pageMotionBlur).toBe("off");
expect(
renderRequestFromDistributedConfig({
projectDir: value.projectDir,
outputPath: value.outputPath,
config,
}).options.pageMotionBlur,
).toBe("off");
});

it("round-trips through JSON without dropping nested options", () => {
const value = request();
expect(parseRenderRequest(serializeRenderRequest(value))).toEqual(value);
Expand Down
Loading
Loading