Merge pull request #627 from heygen-com/fix/sub-comp-video-path-resolution-and-render-fixes

fix: render robustness — sub-comp src paths, alpha tag case, encoder + matter improvements
This commit is contained in:
James Russo
2026-05-04 21:38:50 -07:00
committed by GitHub
19 changed files with 699 additions and 57 deletions
+97 -15
View File
@@ -25,7 +25,7 @@ The CLI ships a built-in `remove-background` command that runs locally — no AP
</Step>
<Step title="Remove the background from your video">
```bash Terminal
npx hyperframes remove-background avatar.mp4 -o transparent.webm
npx hyperframes remove-background subject.mp4 -o transparent.webm
```
On the first run, the CLI downloads ~168 MB of model weights to `~/.cache/hyperframes/background-removal/models/`. Subsequent runs reuse the cache.
@@ -44,7 +44,7 @@ The CLI ships a built-in `remove-background` command that runs locally — no AP
<!-- background layer -->
<img src="city.jpg" class="bg" />
<!-- transparent avatar floats on top -->
<!-- transparent subject floats on top -->
<video src="transparent.webm" autoplay muted loop playsinline></video>
</div>
```
@@ -75,8 +75,8 @@ The output is encoded with the exact ffmpeg flags Chrome's `<video>` element nee
| `.png` | PNG with alpha | Single-image cutout (only when the input is also a single image) | varies |
```bash Terminal
npx hyperframes remove-background avatar.mp4 -o transparent.webm # web playback
npx hyperframes remove-background avatar.mp4 -o transparent.mov # editing
npx hyperframes remove-background subject.mp4 -o transparent.webm # web playback
npx hyperframes remove-background subject.mp4 -o transparent.mov # editing
npx hyperframes remove-background portrait.jpg -o cutout.png # still image
```
@@ -91,7 +91,7 @@ Real-world numbers from the [matting eval](https://www.heygenverse.com/a/0dd5a43
| Linux x86 | CPU | ~1100 | ~16 min |
| macOS Intel | CPU | ~900 | ~13 min |
Matting is offline preprocessing — you run it once per asset and reuse the output. CPU-only is slow but always works; if you reuse the same avatar repeatedly, run it once on a faster machine and check the transparent output into your project.
Matting is offline preprocessing — you run it once per asset and reuse the output. CPU-only is slow but always works; if you reuse the same subject clip repeatedly, run it once on a faster machine and check the transparent output into your project.
## Picking a device explicitly
@@ -100,13 +100,13 @@ Matting is offline preprocessing — you run it once per asset and reuse the out
- **Force CPU on a GPU box** when you want to keep the GPU free for other work, or are debugging an EP-specific issue:
```bash Terminal
npx hyperframes remove-background avatar.mp4 -o transparent.webm --device cpu
npx hyperframes remove-background subject.mp4 -o transparent.webm --device cpu
```
- **Opt into CUDA** by setting `HYPERFRAMES_CUDA=1` and providing a GPU-enabled `onnxruntime-node` build (the bundled build is CPU + CoreML only, to keep the install small for the 99% of users who don't have a GPU):
```bash Terminal
HYPERFRAMES_CUDA=1 npx hyperframes remove-background avatar.mp4 -o transparent.webm --device cuda
HYPERFRAMES_CUDA=1 npx hyperframes remove-background subject.mp4 -o transparent.webm --device cuda
```
Run `npx hyperframes remove-background --info` to see what providers are detected on your machine and which one `auto` would pick.
@@ -115,7 +115,7 @@ Run `npx hyperframes remove-background --info` to see what providers are detecte
The transparent WebM behaves like any other video element. The two patterns you'll use most:
**Avatar over a background image:**
**Subject over a background image:**
```html
<div style="position: relative; width: 1920px; height: 1080px;">
@@ -131,22 +131,104 @@ The transparent WebM behaves like any other video element. The two patterns you'
</div>
```
**Avatar over a HyperFrames scene:**
**Subject over a HyperFrames scene:**
```html
<!-- scene contents (text, animations, etc.) -->
<div class="title-card">Welcome</div>
<!-- avatar layered on top -->
<video src="transparent.webm" autoplay muted loop playsinline class="avatar"></video>
<!-- subject layered on top -->
<video src="transparent.webm" autoplay muted loop playsinline class="subject"></video>
```
The avatar inherits the composition's frame rate and timeline — it plays through once during the scene's duration, so match the source clip length to the scene length when possible. If the scene is longer than the clip, `loop` handles it.
The cutout inherits the composition's frame rate and timeline — it plays through once during the scene's duration, so match the source clip length to the scene length when possible. If the scene is longer than the clip, `loop` handles it.
<Tip>
When rendering a composition that contains a `<video>` element, the renderer reads the source via ffmpeg internally. Transparent WebMs are decoded with the alpha plane preserved.
</Tip>
## Compositing patterns and pitfalls
The cutout webm is a **re-encoded copy** of the source mp4's RGB — the matter pipeline decodes the source to raw RGB, runs segmentation, and re-encodes to VP9 with alpha. That choice has consequences depending on what you put behind it.
### The three patterns
| Pattern | Behind the cutout | Result |
|---|---|---|
| **Cutout over a different scene** *(most common)* | Static image, gradient, animated bg, or unrelated footage | Clean. The cutout is the only source of the subject — no doubling, no edge halo. Use any `--quality`. |
| **Cutout over its own source mp4** *(text-behind-subject, talking-head with overlays)* | The same mp4 the cutout was generated from | Two RGB sources for the same person. At default `--quality balanced` (crf 18) the doubling is barely visible; at `--quality fast` (crf 30) you'll see a slight color shift / soft edge on the silhouette. Use `--quality best` (crf 12) for hero shots. |
| **Cutout over different footage of the same subject** | Another take of the same person | Looks like two overlapping people. Avoid — re-shoot or re-cut the source. |
### Text-behind-subject: the recommended layout
Putting a headline *behind* a presenter so their silhouette occludes the text:
```html
<!-- z=1 base mp4: full lobby + presenter, plays the whole scene -->
<video
id="cf-base"
data-start="0" data-duration="6" data-media-start="0" data-track-index="0"
src="presenter.mp4"
muted playsinline
></video>
<!-- z=2 headline -->
<h1 id="cf-headline" style="position:absolute;top:50%;left:50%;
transform:translate(-50%,-50%); z-index:2;
color:#fff; text-shadow:0 6px 32px rgba(0,0,0,.55);
clip-path:inset(0 0 100% 0); font-size:220px; font-weight:900;">
MAKE IT IN HYPERFRAMES
</h1>
<!-- z=3 cutout: same source, alpha around presenter, hidden until the cut.
The wrapper carries the opacity, NOT the <video> itself. -->
<div class="cutout-wrap" style="position:absolute;inset:0;z-index:3;opacity:0">
<video
id="cf-cutout"
data-start="0" data-duration="6" data-media-start="0" data-track-index="1"
src="presenter.webm"
muted playsinline
></video>
</div>
```
```js
const tl = gsap.timeline({ paused: true });
const CUT = 3.3;
// Reveal the headline early
tl.to("#cf-headline", { clipPath: "inset(0 0 0% 0)", duration: 0.6, ease: "expo.out" }, 0.25);
// At the cut, flip the cutout wrapper visible — silhouette punches through the headline
tl.set(".cutout-wrap", { opacity: 1 }, CUT);
// Sentinel: extend timeline to the composition's full duration so the renderer
// doesn't bail past the last meaningful tween.
tl.set({}, {}, 6);
```
### Two non-obvious rules
**1. Wrap the cutout video in a non-timed `<div>` and animate the wrapper, not the video.**
The framework forces `opacity: 1` on any element with `data-start`/`data-duration` while it's "active" — that's how it controls clip visibility. CSS `opacity: 0` on the video element is silently overwritten by the framework's clip lifecycle, so an opacity tween on the video element won't do anything. Wrap the video in a `<div>` that has no `data-*` attributes; the wrapper is owned entirely by your CSS/GSAP.
**2. Both videos start at `data-start="0"` and decode in sync from t=0.**
It's tempting to "late-mount" the cutout (`data-start="3.3"` to match the cut). Don't — Chrome does a seek + decoder warm-up at mount, which can land one frame off the base mp4 at the cut moment. With both videos mounted from t=0 and the cutout's wrapper opacity-animated, both decoders advance the same way and stay frame-accurate.
### Quality preset and color match
When the cutout is overlaid on its own source mp4, the encoder's CRF directly affects how visible the doubling is at edges:
| `--quality` | CRF | File size (12s @ 1080p) | When to use |
|---|---|---|---|
| `fast` | 30 | ~2 MB | Cutout sits over an unrelated background and file size matters |
| `balanced` *(default)* | 18 | ~6 MB | Recommended for text-behind-subject and any pattern that overlays on the source |
| `best` | 12 | ~12 MB | Hero shots, masters, or anything you'll re-encode downstream |
The encoder also writes BT.709 + limited-range color metadata so Chrome's YUV→RGB pipeline matches the source mp4's. Without those tags, the cutout would render slightly differently from the underlying mp4 even at lossless quality (visible red/skin shift).
## What u²-net_human_seg is and isn't good for
The model is purpose-built for **portrait / human matting**. It excels when:
@@ -166,7 +248,7 @@ If your use case hits one of these, see the alternatives below.
## Alternatives — when the built-in command isn't the right tool
The CLI ships **one model on purpose** — the one that's MIT-licensed, runs everywhere, and produces production-quality output for HeyGen-style avatar workflows. The list below leads with **free, open-source tools** that pair naturally with HyperFrames. Each entry calls out the actual catch — license, install effort, hardware needs — so you can pick the right one for your situation. Full benchmarks are in the [matting eval](https://www.heygenverse.com/a/0dd5a431-1832-4858-862d-de7fb7d02654).
The CLI ships **one model on purpose** — the one that's MIT-licensed, runs everywhere, and produces production-quality output for person/portrait video. The list below leads with **free, open-source tools** that pair naturally with HyperFrames. Each entry calls out the actual catch — license, install effort, hardware needs — so you can pick the right one for your situation. Full benchmarks are in the [matting eval](https://www.heygenverse.com/a/0dd5a431-1832-4858-862d-de7fb7d02654).
### Free, open-source CLIs and libraries
@@ -208,7 +290,7 @@ ffmpeg -i frames-%04d.png -c:v libvpx-vp9 \
### How to choose
- **Avatars / portraits, web playback, MIT-clean** → use the built-in `hyperframes remove-background` (this is what it's tuned for).
- **Person / portrait video, web playback, MIT-clean** → use the built-in `hyperframes remove-background` (this is what it's tuned for).
- **Non-human subject** (product, animal, object) → `rembg` with `isnet-general-use`.
- **Maximum portrait quality, especially hair** → `BiRefNet` via Python.
- **Long video where edge flicker would be visible**, GPL is OK → `RVM`.
@@ -259,7 +341,7 @@ The decoded `frame0.png` should be RGBA and have non-trivial alpha values.
The pipeline auto-falls-back to CPU if CoreML fails to bind, with a warning. If you want to skip the CoreML attempt entirely, force CPU:
```bash Terminal
npx hyperframes remove-background avatar.mp4 -o transparent.webm --device cpu
npx hyperframes remove-background subject.mp4 -o transparent.webm --device cpu
```
### The alpha mask has rough or jagged edges
+1
View File
@@ -367,6 +367,7 @@ This is suppressed in CI environments, non-TTY shells, and when `HYPERFRAMES_NO_
|------|-------------|
| `--output, -o` | Output path. Format inferred from extension: `.webm` (default), `.mov`, `.png` |
| `--device` | Execution provider: `auto` (default), `cpu`, `coreml`, `cuda` |
| `--quality` | WebM encoder preset: `fast` (crf 30, smallest), `balanced` (crf 18, default), `best` (crf 12, near-lossless). Higher quality keeps the cutout's RGB closer to the source mp4 — important when overlaying the cutout on its own source for text-behind-subject effects. Ignored for `.mov` / `.png`. |
| `--info` | Print detected execution providers and exit (no render) |
| `--json` | Output result as JSON |
@@ -159,10 +159,16 @@ async function postprocess(
}
// lanczos3 keeps soft edges; nearest leaves visible jaggies on hair.
// Sharp upcasts the single-channel raw input to a 3-channel buffer during
// resize, so the output is laid out as RGB-interleaved (R0,G0,B0,R1,G1,B1,...)
// even though all three channels carry the same grayscale value. Force the
// output back to single channel with toColourspace("b-w") so we can index
// it linearly as a mask.
const fullMask = await sharp(maskBuf, {
raw: { width: INPUT_SIZE, height: INPUT_SIZE, channels: 1 },
})
.resize(width, height, { kernel: "lanczos3", fit: "fill" })
.toColourspace("b-w")
.raw()
.toBuffer();
@@ -46,6 +46,35 @@ describe("background-removal/pipeline — buildEncoderArgs", () => {
expect(args[args.length - 1]).toBe("/tmp/out.webm");
});
it("webm preset tags BT.709 colorspace + limited range", () => {
// Without these tags, ffmpeg's RGB→YUV conversion uses the BT.601 default,
// and Chrome's YUV→RGB pass on the resulting webm produces a different
// RGB triple than the source mp4 (visible color shift on overlay). Pin
// BT.709 limited-range so the cutout matches modern Rec.709 sources.
const args = buildEncoderArgs("webm", 1920, 1080, 30, "/tmp/out.webm");
const csIdx = args.indexOf("-colorspace");
expect(csIdx).toBeGreaterThan(-1);
expect(args[csIdx + 1]).toBe("bt709");
const rangeIdx = args.indexOf("-color_range");
expect(rangeIdx).toBeGreaterThan(-1);
expect(args[rangeIdx + 1]).toBe("tv");
});
it("webm quality presets map to crf 30/18/12", () => {
const fast = buildEncoderArgs("webm", 1920, 1080, 30, "/tmp/o.webm", "fast");
const balanced = buildEncoderArgs("webm", 1920, 1080, 30, "/tmp/o.webm", "balanced");
const best = buildEncoderArgs("webm", 1920, 1080, 30, "/tmp/o.webm", "best");
const crf = (args: string[]) => args[args.indexOf("-crf") + 1];
expect(crf(fast)).toBe("30");
expect(crf(balanced)).toBe("18");
expect(crf(best)).toBe("12");
});
it("webm default quality is balanced (crf 18)", () => {
const args = buildEncoderArgs("webm", 1920, 1080, 30, "/tmp/o.webm");
expect(args[args.indexOf("-crf") + 1]).toBe("18");
});
it("mov preset emits ProRes 4444 + yuva444p10le", () => {
const args = buildEncoderArgs("mov", 1920, 1080, 30, "/tmp/out.mov");
expect(args).toContain("prores_ks");
@@ -20,11 +20,28 @@ import { type Device, type ModelId } from "./manager.js";
export type OutputFormat = "webm" | "mov" | "png";
export const QUALITY_CRF = {
fast: 30,
balanced: 18,
best: 12,
} as const;
export type Quality = keyof typeof QUALITY_CRF;
export const QUALITIES = Object.keys(QUALITY_CRF) as readonly Quality[];
export const DEFAULT_QUALITY: Quality = "balanced";
export const isQuality = (v: unknown): v is Quality =>
typeof v === "string" && (QUALITIES as readonly string[]).includes(v);
export interface RenderOptions {
inputPath: string;
outputPath: string;
device?: Device;
model?: ModelId;
/** Encoder CRF preset for `.webm`. See `QUALITY_CRF`. Ignored for `.mov`/`.png`. */
quality?: Quality;
onProgress?: (event: ProgressEvent) => void;
}
@@ -100,6 +117,7 @@ export function buildEncoderArgs(
height: number,
fps: number,
outputPath: string,
quality: Quality = DEFAULT_QUALITY,
): string[] {
const base = [
"-y",
@@ -123,7 +141,7 @@ export function buildEncoderArgs(
"-b:v",
"0",
"-crf",
"30",
String(QUALITY_CRF[quality]),
"-deadline",
"good",
"-row-mt",
@@ -132,6 +150,19 @@ export function buildEncoderArgs(
"0",
"-pix_fmt",
"yuva420p",
// Tag the output as BT.709 limited range so browsers use the same
// YUV→RGB matrix the source video was encoded with. Without these tags
// ffmpeg's default RGB→YUV conversion is BT.601, which causes a visible
// color shift (red/skin tones in particular) when the matted overlay is
// composited over the original mp4.
"-colorspace",
"bt709",
"-color_primaries",
"bt709",
"-color_trc",
"bt709",
"-color_range",
"tv",
"-metadata:s:v:0",
"alpha_mode=1",
"-an",
@@ -250,9 +281,20 @@ async function runPipeline(
});
const decoderExit = waitForExit(decoder, "ffmpeg decoder", () => decoderStderr);
const encoder = spawn("ffmpeg", buildEncoderArgs(format, width, height, fps || 30, outputPath), {
stdio: ["pipe", "ignore", "pipe"],
});
const encoder = spawn(
"ffmpeg",
buildEncoderArgs(
format,
width,
height,
fps || 30,
outputPath,
options.quality ?? DEFAULT_QUALITY,
),
{
stdio: ["pipe", "ignore", "pipe"],
},
);
let encoderStderr = "";
encoder.stderr?.on("data", (d: Buffer) => {
encoderStderr += d.toString();
@@ -4,6 +4,7 @@ import { existsSync } from "node:fs";
import * as clack from "@clack/prompts";
import { c } from "../ui/colors.js";
import { isDevice, DEVICES } from "../background-removal/manager.js";
import { DEFAULT_QUALITY, QUALITIES, isQuality } from "../background-removal/pipeline.js";
import type { Example } from "./_examples.js";
export const examples: Example[] = [
@@ -23,6 +24,14 @@ export const examples: Example[] = [
"Force CPU (skip CoreML/CUDA)",
"hyperframes remove-background avatar.mp4 -o transparent.webm --device cpu",
],
[
"Smaller file at the cost of color match (text-behind-subject won't blend as cleanly)",
"hyperframes remove-background avatar.mp4 -o transparent.webm --quality fast",
],
[
"Visually-lossless WebM (master / re-encode source)",
"hyperframes remove-background avatar.mp4 -o transparent.webm --quality best",
],
["Show detected providers without rendering", "hyperframes remove-background --info"],
];
@@ -48,6 +57,11 @@ export default defineCommand({
description: `Execution provider: ${DEVICES.join(", ")}`,
default: "auto",
},
quality: {
type: "string",
description: `Encoder quality preset for .webm output: ${QUALITIES.join(", ")} (default: ${DEFAULT_QUALITY}). Higher quality = closer color match when overlaying on the source mp4, larger file. Ignored for .mov / .png.`,
default: DEFAULT_QUALITY,
},
info: {
type: "boolean",
description: "Print detected execution providers and exit (no render)",
@@ -81,6 +95,12 @@ export default defineCommand({
);
process.exit(1);
}
if (!isQuality(args.quality)) {
console.error(
c.error(`Invalid --quality '${String(args.quality)}'. Use: ${QUALITIES.join(", ")}.`),
);
process.exit(1);
}
const inputPath = resolve(args.input);
const outputPath = resolve(args.output);
@@ -95,6 +115,7 @@ export default defineCommand({
inputPath,
outputPath,
device: args.device,
quality: args.quality,
onProgress: (event) => {
if (event.kind === "info") {
spin?.message(event.message);
+1
View File
@@ -115,6 +115,7 @@ export {
parseImageElements,
extractVideoFramesRange,
extractAllVideoFrames,
resolveProjectRelativeSrc,
getFrameAtTime,
createFrameLookupTable,
FrameLookupTable,
+4 -6
View File
@@ -12,6 +12,7 @@ import { downloadToTemp, isHttpUrl } from "../utils/urlDownloader.js";
import { DEFAULT_CONFIG, type EngineConfig } from "../config.js";
import { runFfmpeg } from "../utils/runFfmpeg.js";
import { unwrapTemplate } from "../utils/htmlTemplate.js";
import { resolveProjectRelativeSrc } from "./videoFrameExtractor.js";
import type { AudioElement, AudioTrack, MixResult } from "./audioMixer.types.js";
export type { AudioElement, AudioTrack, MixResult } from "./audioMixer.types.js";
@@ -325,13 +326,10 @@ export async function processCompositionAudio(
}
try {
let srcPath = element.src;
// Use isAbsolute() rather than startsWith("/"). On Windows, absolute paths
// like "C:\…" are not detected by the latter, so we'd re-join them under
// baseDir and produce duplicated, nonexistent paths.
if (!isAbsolute(srcPath) && !isHttpUrl(srcPath)) {
const fromCompiled = compiledDir ? join(compiledDir, srcPath) : null;
srcPath =
fromCompiled && existsSync(fromCompiled) ? fromCompiled : join(baseDir, srcPath);
// Same browser-vs-filesystem path semantics as videos — see
// resolveProjectRelativeSrc in videoFrameExtractor for the full why.
srcPath = resolveProjectRelativeSrc(element.src, baseDir, compiledDir);
}
if (isHttpUrl(srcPath)) {
@@ -139,12 +139,43 @@ export function buildEncoderArgs(
else args.push("-global_quality", String(quality));
break;
}
// Same B-frame story as the SW branch below — nvenc emits B-frames
// by default (qsv via b_strategy, vaapi too), and the negative-DTS
// freeze hits the same downstream players. The unconditional
// `-avoid_negative_ts make_zero` near the bottom of this function
// covers the mux level, but we belt-and-suspenders the encoder too
// so even tools that consume the chunk file directly (without going
// through our mux step) play correctly. videotoolbox doesn't accept
// `-bf` so it's skipped — videotoolbox h264 also doesn't emit
// negative DTS in practice on macOS Sonoma+.
if (
codec === "h264" &&
(gpuEncoder === "nvenc" || gpuEncoder === "qsv" || gpuEncoder === "vaapi")
) {
args.push("-bf", "0");
if (gpuEncoder === "qsv") {
args.push("-b_strategy", "0");
}
}
} else {
const encoderName = codec === "h264" ? "libx264" : "libx265";
args.push("-c:v", encoderName, "-preset", preset);
if (bitrate) args.push("-b:v", bitrate);
else args.push("-crf", String(quality));
// Disable B-frames. Standard h264 with B-frames produces negative DTS
// at the start of the stream (the first B-frame's decode order is
// "before" the first I-frame's presentation time). VS Code's video
// preview, several browser <video> pipelines, and some HW decoders
// freeze on the first frame when DTS is negative, so audio plays alone.
// -bf 0 makes PTS == DTS at every frame, eliminating the issue at the
// source. Quality cost is ~510% larger files at the same CRF — a
// worthwhile trade for "the file plays everywhere".
if (codec === "h264") {
args.push("-bf", "0");
}
// Encoder-specific params: anti-banding + color space tagging.
// aq-mode=3 redistributes bits to dark flat areas (gradients).
// For HDR x265 paths we additionally embed BT.2020 + transfer + HDR static
@@ -239,6 +270,8 @@ export function buildEncoderArgs(
args.push("-pix_fmt", pixelFormat);
}
args.push("-avoid_negative_ts", "make_zero");
args.push("-y", outputPath);
return args;
}
@@ -510,6 +543,9 @@ export async function muxVideoWithAudio(
} else {
args.push("-c:a", "aac", "-b:a", "192k", "-movflags", "+faststart");
}
// PTS bases can diverge during mux and reintroduce negative DTS. See
// buildEncoderArgs for the full reasoning on why that breaks playback.
args.push("-avoid_negative_ts", "make_zero");
args.push("-shortest", "-y", outputPath);
const processTimeout = config?.ffmpegProcessTimeout ?? DEFAULT_CONFIG.ffmpegProcessTimeout;
+12 -9
View File
@@ -388,8 +388,14 @@ export async function initializeSession(session: CaptureSession): Promise<void>
await applyVideoMetadataHints(page, session.options.videoMetadataHints);
// Wait for all video elements to have loaded metadata (dimensions + duration)
// Without this, frame 0 captures videos at their 300x150 default size.
// Wait for all video elements to have decoded their CURRENT frame, not
// just metadata. readyState >= 2 (HAVE_CURRENT_DATA) means a frame is
// actually rasterized and ready to paint — at >= 1 (HAVE_METADATA) we
// only know the dimensions, and the first <video> screenshot can come
// back as a black/blank rectangle. This bites compositions with two
// <video> elements of different codecs (h264 mp4 + VP9 webm) where the
// faster decoder lets the readiness check pass while the slower one
// hasn't painted, producing a black "first frame" for the slower clip.
// skipReadinessVideoIds excludes natively-extracted videos (e.g. HDR HEVC
// sources) whose frames come from ffmpeg out-of-band. videoMetadataHints
// supply intrinsic dimensions for skipped videos whose layout depends on
@@ -397,12 +403,12 @@ export async function initializeSession(session: CaptureSession): Promise<void>
const skipIdsLiteral = JSON.stringify(session.options.skipReadinessVideoIds ?? []);
const videosReady = await pollPageExpression(
page,
`(() => { const skip = new Set(${skipIdsLiteral}); const vids = Array.from(document.querySelectorAll("video")).filter(v => !skip.has(v.id)); return vids.length === 0 || vids.every(v => v.readyState >= 1); })()`,
`(() => { const skip = new Set(${skipIdsLiteral}); const vids = Array.from(document.querySelectorAll("video")).filter(v => !skip.has(v.id)); return vids.length === 0 || vids.every(v => v.readyState >= 2); })()`,
pageReadyTimeout,
);
if (!videosReady) {
throw new Error(
`[FrameCapture] video metadata not ready after ${pageReadyTimeout}ms. Video elements must load metadata before capture starts.`,
`[FrameCapture] video first frame not decoded after ${pageReadyTimeout}ms. Video elements must reach readyState >= 2 (HAVE_CURRENT_DATA) before capture starts.`,
);
}
@@ -484,16 +490,13 @@ export async function initializeSession(session: CaptureSession): Promise<void>
await applyVideoMetadataHints(page, session.options.videoMetadataHints);
// Wait for all video elements to have loaded metadata (dimensions + duration).
// Without this, frame 0 captures videos at their 300x150 default size.
// See screenshot-mode comment above for why skipReadinessVideoIds and
// videoMetadataHints are paired.
// Same readyState contract as the screenshot path above (>= 2 / HAVE_CURRENT_DATA).
const beginframeSkipIdsLiteral = JSON.stringify(session.options.skipReadinessVideoIds ?? []);
const videoDeadline =
Date.now() + (session.config?.playerReadyTimeout ?? DEFAULT_CONFIG.playerReadyTimeout);
while (Date.now() < videoDeadline) {
const videosReady = await page.evaluate(
`(() => { const skip = new Set(${beginframeSkipIdsLiteral}); const vids = Array.from(document.querySelectorAll("video")).filter(v => !skip.has(v.id)); return vids.length === 0 || vids.every(v => v.readyState >= 1); })()`,
`(() => { const skip = new Set(${beginframeSkipIdsLiteral}); const vids = Array.from(document.querySelectorAll("video")).filter(v => !skip.has(v.id)); return vids.length === 0 || vids.every(v => v.readyState >= 2); })()`,
);
if (videosReady) break;
await new Promise((r) => setTimeout(r, 100));
@@ -221,12 +221,33 @@ export function buildStreamingArgs(
else args.push("-global_quality", String(quality));
break;
}
// Mirror SW branch: GPU h264 paths emit B-frames by default (nvenc, qsv,
// vaapi) and produce the same negative-DTS freeze for downstream players.
// See chunkEncoder.buildEncoderArgs for the full explanation.
if (
codec === "h264" &&
(gpuEncoder === "nvenc" || gpuEncoder === "qsv" || gpuEncoder === "vaapi")
) {
args.push("-bf", "0");
if (gpuEncoder === "qsv") {
args.push("-b_strategy", "0");
}
}
} else {
const encoderName = codec === "h264" ? "libx264" : "libx265";
args.push("-c:v", encoderName, "-preset", preset);
if (bitrate) args.push("-b:v", bitrate);
else args.push("-crf", String(quality));
// Mirrors chunkEncoder: disable B-frames for h264 so PTS == DTS, no
// negative DTS at stream start. Without this, files freeze on the
// first frame in VS Code preview, several browsers, and some HW
// decoders. See chunkEncoder.buildEncoderArgs for the full reasoning.
if (codec === "h264") {
args.push("-bf", "0");
}
// Encoder-specific params: anti-banding + color space tagging.
// For HDR, getHdrEncoderColorParams also emits the SMPTE ST 2086
// mastering-display and CTA-861.3 MaxCLL/MaxFALL SEI messages —
@@ -313,6 +334,10 @@ export function buildStreamingArgs(
args.push("-pix_fmt", pixelFormat);
}
// Belt-and-suspenders against negative DTS at stream start. See chunkEncoder
// for the full explanation; same playback compatibility class.
args.push("-avoid_negative_ts", "make_zero");
args.push("-y", outputPath);
return args;
}
@@ -1,5 +1,13 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs";
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
readdirSync,
rmSync,
writeFileSync,
} from "node:fs";
import { createHash } from "node:crypto";
import { join } from "node:path";
import { tmpdir } from "node:os";
@@ -9,6 +17,9 @@ import {
parseImageElements,
extractAllVideoFrames,
createFrameLookupTable,
resolveProjectRelativeSrc,
codecMayHaveAlpha,
decoderForCodec,
type VideoElement,
type ExtractedFrames,
} from "./videoFrameExtractor.js";
@@ -23,6 +34,111 @@ import { runFfmpeg } from "../utils/runFfmpeg.js";
// synthesized VFR fixture.
const HAS_FFMPEG = spawnSync("ffmpeg", ["-version"]).status === 0;
// Codec-based alpha defaulting replaces tag-based detection (the
// alpha_mode/ALPHA_MODE case bug — see ffprobe.test.ts for the regression
// pin on that). The extractor uses these helpers for two decisions:
// 1. whether to force the alpha-aware decoder (libvpx-vp9 for VP9, libvpx
// for VP8)
// 2. whether to default the cached frame format to PNG (with alpha) vs JPG
// The "default to capable" trade is small file-size growth on opaque VP9
// content for correctness on alpha-having content even when the sidecar tag
// is missing or muxed with the wrong case.
describe("codec alpha capability", () => {
it("flags VP9, VP8, and ProRes as alpha-capable", () => {
expect(codecMayHaveAlpha("vp9")).toBe(true);
expect(codecMayHaveAlpha("VP9")).toBe(true);
expect(codecMayHaveAlpha("vp8")).toBe(true);
expect(codecMayHaveAlpha("prores")).toBe(true);
});
it("does not flag h264 / h265 / mpeg4 (no alpha in their bitstreams)", () => {
expect(codecMayHaveAlpha("h264")).toBe(false);
expect(codecMayHaveAlpha("h265")).toBe(false);
expect(codecMayHaveAlpha("hevc")).toBe(false);
expect(codecMayHaveAlpha("mpeg4")).toBe(false);
});
it("treats undefined / empty input as non-alpha", () => {
expect(codecMayHaveAlpha(undefined)).toBe(false);
expect(codecMayHaveAlpha("")).toBe(false);
});
it("returns the alpha-aware decoder name for VP9 and VP8", () => {
expect(decoderForCodec("vp9")).toBe("libvpx-vp9");
expect(decoderForCodec("VP9")).toBe("libvpx-vp9");
expect(decoderForCodec("vp8")).toBe("libvpx");
});
});
// Regression: a long-standing footgun where `<video src="../assets/foo">`
// inside a sub-composition silently dropped the video from extraction. The
// browser's URL resolver clamps `..` at the served origin's root (so the
// page renders fine in the studio), but `path.join(projectDir, "../assets/foo")`
// normalizes to <parentOfProjectDir>/assets/foo, which doesn't exist —
// extraction skipped, no frame injection, rendered output shows the video's
// first decoded frame for the whole clip duration. The resolver now mirrors
// browser semantics by clamping any traversal that escapes the project root.
describe("resolveProjectRelativeSrc — sub-composition path clamping", () => {
let tmp: string;
beforeAll(() => {
tmp = mkdtempSync(join(tmpdir(), "hf-resolver-"));
mkdirSync(join(tmp, "project", "assets"), { recursive: true });
writeFileSync(join(tmp, "project", "assets", "foo.mp4"), "");
});
afterAll(() => {
rmSync(tmp, { recursive: true, force: true });
});
it("returns the literal join when the file exists at projectDir/src", () => {
const projectDir = join(tmp, "project");
expect(resolveProjectRelativeSrc("assets/foo.mp4", projectDir)).toBe(
join(projectDir, "assets/foo.mp4"),
);
});
it("clamps a leading `../` so `../assets/foo.mp4` resolves to assets/foo.mp4", () => {
const projectDir = join(tmp, "project");
expect(resolveProjectRelativeSrc("../assets/foo.mp4", projectDir)).toBe(
join(projectDir, "assets/foo.mp4"),
);
});
it("clamps multiple leading `../../../` segments", () => {
const projectDir = join(tmp, "project");
expect(resolveProjectRelativeSrc("../../../assets/foo.mp4", projectDir)).toBe(
join(projectDir, "assets/foo.mp4"),
);
});
it("clamps mid-path traversal that escapes baseDir (not just leading `..`)", () => {
// `assets/../../foo.mp4` collapses past projectDir via path.join — this
// case used to silently escape; the resolver now strips embedded `..`
// segments and re-anchors at the project root.
const projectDir = join(tmp, "project");
expect(resolveProjectRelativeSrc("assets/../../assets/foo.mp4", projectDir)).toBe(
join(projectDir, "assets/foo.mp4"),
);
});
it("returns the (non-existent) base-dir path on miss so callers get a stable error message", () => {
const projectDir = join(tmp, "project");
expect(resolveProjectRelativeSrc("../assets/missing.mp4", projectDir)).toBe(
join(projectDir, "../assets/missing.mp4"),
);
});
it("prefers compiled-dir over base-dir when the file exists in both", () => {
const projectDir = join(tmp, "project");
const compiledDir = join(tmp, "compiled");
mkdirSync(join(compiledDir, "assets"), { recursive: true });
writeFileSync(join(compiledDir, "assets", "foo.mp4"), "");
expect(resolveProjectRelativeSrc("assets/foo.mp4", projectDir, compiledDir)).toBe(
join(compiledDir, "assets/foo.mp4"),
);
});
});
describe("parseVideoElements", () => {
it("parses videos without an id or data-start attribute", () => {
const videos = parseVideoElements('<video src="clip.mp4"></video>');
@@ -7,7 +7,7 @@
import { spawn } from "child_process";
import { existsSync, mkdirSync, readdirSync, rmSync } from "fs";
import { isAbsolute, join } from "path";
import { isAbsolute, join, posix, resolve, sep } from "path";
import { parseHTML } from "linkedom";
import { extractMediaMetadata, type VideoMetadata } from "../utils/ffprobe.js";
import {
@@ -230,8 +230,17 @@ export async function extractVideoFramesRange(
if (isHdr && isMacOS) {
args.push("-hwaccel", "videotoolbox");
}
if (metadata.hasAlpha && metadata.videoCodec === "vp9") {
args.push("-c:v", "libvpx-vp9");
// Always force the alpha-aware decoder on codecs that can carry alpha. The
// alternative — gating on `metadata.hasAlpha` — relies on tag detection that
// has at least three known failure modes: case-sensitivity across ffmpeg
// versions (`alpha_mode` vs `ALPHA_MODE`), missing tags from older muxers,
// and mp4-as-webm rewraps that drop the sidecar. A wrong negative there
// silently strips alpha during decode and the bug doesn't surface until
// the rendered video is missing layers. Codec-based default has no such
// ambiguity: libvpx-vp9 reads the alpha sidecar when present and decodes
// normally when it isn't.
if (codecMayHaveAlpha(metadata.videoCodec)) {
args.push("-c:v", decoderForCodec(metadata.videoCodec));
}
args.push("-ss", String(startTime), "-i", videoPath, "-t", String(duration));
@@ -398,9 +407,31 @@ function resolveSegmentDuration(
return sourceRemaining > 0 ? sourceRemaining : metadata.durationSeconds;
}
/**
* Codecs whose bitstream is allowed to carry an alpha channel. Default the
* extraction path to PNG output for these regardless of `metadata.hasAlpha`
* so a missed sidecar tag doesn't silently strip transparency. Opaque content
* encoded in one of these codecs pays a small file-size cost on the cached
* frames but stays correct on the rare case where alpha IS present and the
* tag was missed.
*/
const ALPHA_CAPABLE_CODECS = new Set(["vp9", "vp8", "prores"]);
export function codecMayHaveAlpha(codec: string | undefined): boolean {
return ALPHA_CAPABLE_CODECS.has((codec ?? "").toLowerCase());
}
export function decoderForCodec(codec: string | undefined): string {
const c = (codec ?? "").toLowerCase();
if (c === "vp9") return "libvpx-vp9";
if (c === "vp8") return "libvpx";
return c;
}
function resolveFrameFormat(metadata: VideoMetadata, requested?: "jpg" | "png"): CacheFrameFormat {
if (requested) return requested;
return metadata.hasAlpha ? "png" : "jpg";
if (metadata.hasAlpha || codecMayHaveAlpha(metadata.videoCodec)) return "png";
return "jpg";
}
/**
@@ -459,6 +490,54 @@ async function convertVfrToCfr(
}
}
/**
* Resolve a relative `<video src>` to a filesystem path the way the browser
* resolves it as a URL. Browsers clamp `..` segments at the served origin's
* root; `path.join(projectDir, "../assets/foo")` does not. So a sub-comp
* `<video src="../assets/foo">` loads in the page (browser clamps to
* `<projectDir>/assets/foo`) but the filesystem-side resolver lands at
* `<parentOfProjectDir>/assets/foo` file missing, extraction skipped,
* the rendered output shows the video's first frame for the whole clip.
*
* The clamp covers two escape patterns: leading `..` (`../assets/foo`) AND
* mid-path escapes (`assets/../../foo`) that `path.join` collapses past the
* project root silently. Both fall back to a project-rooted candidate that
* strips traversal from the resolved path.
*
* Returns the first existing candidate, or the base-dir join on miss so
* the caller's `existsSync` check produces a stable error path.
*/
export function resolveProjectRelativeSrc(
src: string,
baseDir: string,
compiledDir?: string,
): string {
const fromCompiled = compiledDir ? join(compiledDir, src) : null;
const fromBase = join(baseDir, src);
const candidates: string[] = [];
if (fromCompiled) candidates.push(fromCompiled);
candidates.push(fromBase);
// If the joined result escapes the project root (either via leading `..`
// or mid-path traversal that path.join collapsed past baseDir), retry
// with the basename re-anchored at the project root. This mirrors the
// browser URL clamp without relying on a particular `..` shape.
const baseAbs = resolve(baseDir);
const fromBaseAbs = resolve(fromBase);
if (!fromBaseAbs.startsWith(baseAbs + sep) && fromBaseAbs !== baseAbs) {
// Normalize first (`assets/../../assets/foo.mp4` → `../assets/foo.mp4`)
// then strip any remaining leading `..` segments. Stripping `..` from the
// raw input would leave dangling siblings (`assets/../../assets/foo`
// would become `assets/assets/foo` instead of `assets/foo`).
const normalized = posix.normalize(src.replace(/\\/g, "/"));
const stripped = normalized.replace(/^(\.\.\/)+/, "");
if (stripped && stripped !== src && !stripped.startsWith("..")) {
if (compiledDir) candidates.push(join(compiledDir, stripped));
candidates.push(join(baseDir, stripped));
}
}
return candidates.find(existsSync) ?? fromBase;
}
export async function extractAllVideoFrames(
videos: VideoElement[],
baseDir: string,
@@ -487,6 +566,9 @@ export async function extractAllVideoFrames(
// Phase 1: Resolve paths and download remote videos
const phase1Start = Date.now();
const resolvedVideos: Array<{ video: VideoElement; videoPath: string }> = [];
// Dedupe missing-src warnings: a composition with N <video> elements all
// pointing at the same broken src should only print one warning, not N.
const warnedSrcs = new Set<string>();
for (const video of videos) {
if (signal?.aborted) break;
try {
@@ -496,9 +578,7 @@ export async function extractAllVideoFrames(
// baseDir and produce duplicated, nonexistent paths
// (e.g. C:\tmp\hf-vfr-test-X\C:\tmp\hf-vfr-test-X\vfr_screen.mp4).
if (!isAbsolute(videoPath) && !isHttpUrl(videoPath)) {
const fromCompiled = compiledDir ? join(compiledDir, videoPath) : null;
videoPath =
fromCompiled && existsSync(fromCompiled) ? fromCompiled : join(baseDir, videoPath);
videoPath = resolveProjectRelativeSrc(video.src, baseDir, compiledDir);
}
if (isHttpUrl(videoPath)) {
@@ -508,6 +588,19 @@ export async function extractAllVideoFrames(
}
if (!existsSync(videoPath)) {
// Loud: silent miss leaves the rendered video frozen at frame 0 with
// no error in stdout — extremely confusing for authors. Dedupe by
// src so 50 broken videos pointing at the same path don't spam.
if (!warnedSrcs.has(video.src)) {
warnedSrcs.add(video.src);
process.stderr.write(
`[hyperframes:render] WARNING: video src="${video.src}" ` +
`could not be resolved on disk (looked for ${videoPath}). ` +
`The rendered output will show this video's first frame for the entire clip duration. ` +
`If your <video> lives inside a sub-composition, prefer project-root-relative paths ` +
`(e.g. src="assets/foo.mp4") over "../assets/foo.mp4".\n`,
);
}
errors.push({ videoId: video.id, error: `Video file not found: ${videoPath}` });
continue;
}
+40
View File
@@ -225,6 +225,46 @@ describe("ffprobe missing-binary fallback", () => {
expect(meta.hasAlpha).toBe(true);
});
// Regression: newer libavformat builds (and the output of `hyperframes
// remove-background` itself) write the VP9-alpha sidecar tag as
// `ALPHA_MODE` (uppercase). The lowercase-only check classified those
// files as having no alpha, the producer extracted them as JPGs, and
// the injected <img> overlays were fully opaque rectangles that hid
// every static element below them on the z-stack. The bug was silent —
// studio preview rendered correctly via native <video> playback while
// production renders covered headlines and captions with the avatar.
it("extractMediaMetadata detects ALPHA_MODE (uppercase) streams from newer ffmpeg builds", async () => {
const { spawn } = createSpawnSpy([
{
kind: "exit",
code: 0,
stdout: JSON.stringify({
streams: [
{
codec_type: "video",
codec_name: "vp9",
width: 320,
height: 180,
r_frame_rate: "30/1",
avg_frame_rate: "30/1",
pix_fmt: "yuv420p",
tags: { ALPHA_MODE: "1" },
},
],
format: { duration: "1.5" },
}),
},
]);
vi.resetModules();
vi.doMock("child_process", () => ({ spawn }));
const { extractMediaMetadata: extractMediaMetadataMocked } = await import("./ffprobe.js");
const meta = await extractMediaMetadataMocked("/tmp/alpha-uppercase.webm");
expect(meta.videoCodec).toBe("vp9");
expect(meta.hasAlpha).toBe(true);
});
it("extractMediaMetadata rethrows ffprobe-missing error for non-image files without fallback", async () => {
const { spawn } = createSpawnSpy([{ kind: "missing" }]);
vi.resetModules();
+16 -1
View File
@@ -203,6 +203,21 @@ function extractStillImageMetadata(filePath: string): StillImageMetadata | null
}
}
/**
* Read an ffprobe tag case-insensitively. ffmpeg/libavformat versions disagree
* on tag casing VP9 alpha is `alpha_mode` in older builds and `ALPHA_MODE`
* in newer ones; HDR tags vary similarly. Use this for any sidecar tag where
* you want to be resilient across muxer versions.
*/
function readTagCI(tags: Record<string, string | undefined> | undefined, name: string): string {
if (!tags) return "";
const target = name.toLowerCase();
for (const [key, value] of Object.entries(tags)) {
if (key.toLowerCase() === target && typeof value === "string") return value;
}
return "";
}
function parseFrameRate(frameRateStr: string | undefined): number {
if (!frameRateStr) return 0;
const parts = frameRateStr.split("/");
@@ -277,7 +292,7 @@ export async function extractMediaMetadata(filePath: string): Promise<VideoMetad
: null;
const colorSpace = ffprobeColorSpace ?? stillImageMeta?.colorSpace ?? null;
const pixelFormat = videoStream.pix_fmt || "";
const alphaMode = videoStream.tags?.alpha_mode || "";
const alphaMode = readTagCI(videoStream.tags, "alpha_mode");
const hasAlpha =
/(^|[^a-z])yuva|rgba|argb|bgra|gbrap|gray[a-z0-9]*a/i.test(pixelFormat) || alphaMode === "1";
+8 -1
View File
@@ -650,7 +650,14 @@ async function runTestSuite(
// Visual comparison (100 frames, 1 per 1% of video duration)
logPretty("Comparing visual quality (100 checkpoints)...", "🔍");
const videoMetadata = await extractMediaMetadata(renderedOutputPath);
const videoDuration = videoMetadata.durationSeconds;
const snapshotMetadata = await extractMediaMetadata(snapshotVideoPath);
// Sample at the common duration. Container duration can drift between
// rendered and snapshot when encoder/mux flags change (e.g. -avoid_negative_ts
// can shift the first audio sample, extending reported duration without
// changing video frame count). Using the rendered duration alone makes the
// last checkpoint land on a frame index that may not exist in the snapshot,
// which causes ffmpeg's PSNR filter to emit no `average:` line.
const videoDuration = Math.min(videoMetadata.durationSeconds, snapshotMetadata.durationSeconds);
const visualCheckpoints: Array<{ time: number; psnr: number; passed: boolean }> = [];
for (let i = 0; i < 100; i++) {
@@ -33,6 +33,7 @@ import {
type EngineConfig,
resolveConfig,
extractAllVideoFrames,
resolveProjectRelativeSrc,
type ExtractedFrames,
type ExtractionPhaseBreakdown,
createFrameLookupTable,
@@ -2312,13 +2313,14 @@ export async function executeRenderJob(
if (job.config.hdrMode !== "force-sdr" && composition.videos.length > 0) {
await Promise.all(
composition.videos.map(async (v) => {
let videoPath = v.src;
if (!videoPath.startsWith("/")) {
const fromCompiled = existsSync(join(compiledDir, videoPath))
? join(compiledDir, videoPath)
: join(projectDir, videoPath);
videoPath = fromCompiled;
}
// Use the shared resolver so a `<video src="../assets/foo">` in a
// sub-composition resolves the same way the browser would (see
// resolveProjectRelativeSrc in videoFrameExtractor for the full
// explanation). isAbsolute (not `startsWith("/")`) so Windows
// absolute paths like `C:\...` skip the join correctly.
const videoPath = isAbsolute(v.src)
? v.src
: resolveProjectRelativeSrc(v.src, projectDir, compiledDir);
if (!existsSync(videoPath)) return;
const meta = await extractMediaMetadata(videoPath);
if (isHdrColorSpace(meta.colorSpace)) {
+58 -6
View File
@@ -102,14 +102,14 @@ Compositions consume a flat array of word objects. The `id` field (`w0`, `w1`, .
## Background Removal (`remove-background`)
Remove the background from a video or image so it can sit as a transparent overlay in a composition (e.g. an avatar floating on a background plate).
Remove the background from a video or image so the subject (typically a person — avatar, presenter, talking head) sits as a transparent overlay in a composition.
```bash
npx hyperframes remove-background avatar.mp4 -o transparent.webm # default: VP9 alpha WebM
npx hyperframes remove-background avatar.mp4 -o transparent.mov # ProRes 4444 (editing)
npx hyperframes remove-background portrait.jpg -o cutout.png # single-image cutout
npx hyperframes remove-background avatar.mp4 -o transparent.webm --device cpu
npx hyperframes remove-background --info # detected providers
npx hyperframes remove-background subject.mp4 -o transparent.webm # default: VP9 alpha WebM
npx hyperframes remove-background subject.mp4 -o transparent.mov # ProRes 4444 (editing)
npx hyperframes remove-background portrait.jpg -o cutout.png # single-image cutout
npx hyperframes remove-background subject.mp4 -o transparent.webm --device cpu
npx hyperframes remove-background --info # detected providers
```
Uses `u2net_human_seg` (MIT). First run downloads ~168 MB of weights to `~/.cache/hyperframes/background-removal/models/`.
@@ -124,6 +124,58 @@ Uses `u2net_human_seg` (MIT). First run downloads ~168 MB of weights to `~/.cach
Chrome decodes VP9 alpha natively, so the `.webm` plugs into a composition like any other muted-autoplay video — see the `hyperframes` skill for the `<video>` track conventions.
### Quality presets
`--quality fast|balanced|best` controls only the VP9 encoder's CRF — segmentation quality is fixed.
| Preset | CRF | When |
| ---------- | --- | ----------------------------------------------------- |
| `fast` | 30 | Iterating, smaller file, looser color match |
| `balanced` | 18 | Default. Visually identical for most uses |
| `best` | 12 | Master / final delivery. Largest file, tightest match |
### Compositing patterns — pick the right one
The cutout webm is a **re-encoded copy** of the source mp4's RGB. That choice has consequences depending on what you put behind it:
| Pattern | What's behind the cutout | Result |
| -------------------------------------------------------- | ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Cutout over a different scene** (most common) | Static image, gradient, or unrelated video | Looks great. The cutout's RGB is the only source of the subject — no doubling, no edge halo. This is what `remove-background` is built for. |
| **Cutout over its own source mp4** (text-behind-subject) | Same mp4 the cutout was generated from | Two RGB sources for the same person. At default `--quality balanced` (crf 18) the doubling is barely visible; at `--quality fast` (crf 30) you'll see a faint color shift / edge halo. Use `--quality best` (crf 12) for masters. |
| **Cutout over a _different_ take of the same person** | Footage of the same subject | Will look like two separate people overlapping. Don't do this. |
**Text-behind-subject** (headline behind a presenter):
```html
<video
src="presenter.mp4"
id="bg"
data-start="0"
data-duration="6"
data-track-index="0"
muted
playsinline
></video>
<h1 id="headline" style="z-index:2; ...">MAKE IT IN HYPERFRAMES</h1>
<div class="cutout-wrap" style="position:absolute;inset:0;z-index:3;opacity:0">
<video
src="presenter.webm"
data-start="0"
data-duration="6"
data-track-index="1"
muted
playsinline
></video>
</div>
```
Two key rules:
1. **Wrap the cutout video in a non-timed `<div>`** and animate the wrapper's opacity, not the video element's. The framework forces opacity:1 on active clips (any element with `data-start`/`data-duration`), so animating the video's opacity directly is silently overridden. The wrapper has no `data-*` attributes, so it's owned by your CSS/GSAP.
2. **Both videos use `data-start="0"` and `data-media-start="0"`** so the framework decodes them in sync from t=0. Late-mounting the cutout (`data-start=3.3`) introduces a seek + warm-up that lands a frame off the base mp4 — visible as one frame of misalignment at the cut.
Then GSAP-flip the wrapper opacity at the cut: `tl.set(cutoutWrap, { opacity: 1 }, 3.3)`.
## TTS → Transcribe → Captions
When there's no pre-recorded voiceover, generate one and transcribe it back to get word-level timestamps for captions:
+73
View File
@@ -30,6 +30,79 @@ tl.to(
tl.to("#pip-frame", { left: 40, duration: 0.6 }, 30);
```
## Text Behind Subject (transparent webm overlay)
Put a headline _behind_ a presenter so their silhouette occludes the text. Requires a transparent cutout produced by `npx hyperframes remove-background presenter.mp4 -o presenter.webm`.
Three layers, plus one critical rule:
```html
<!-- z=1 base — full opaque mp4 (lobby + presenter), always visible -->
<video
id="cf-base"
data-start="0"
data-duration="6"
data-media-start="0"
data-track-index="0"
src="presenter.mp4"
muted
playsinline
></video>
<!-- z=2 headline — visible the whole time -->
<h1
id="cf-headline"
style="position:absolute;top:50%;left:50%;
transform:translate(-50%,-50%); z-index:2; font-size:220px; font-weight:900;
color:#fff; text-shadow:0 6px 32px rgba(0,0,0,.55); clip-path:inset(0 0 100% 0);"
>
MAKE IT IN HYPERFRAMES
</h1>
<!-- z=3 cutout — same source, alpha around presenter, hidden until the cut -->
<!-- WRAPPER has the opacity, NOT the video itself (see rule below). -->
<div class="cutout-wrap" style="position:absolute;inset:0;z-index:3;opacity:0">
<video
id="cf-cutout"
data-start="0"
data-duration="6"
data-media-start="0"
data-track-index="1"
src="presenter.webm"
muted
playsinline
></video>
</div>
```
```js
const tl = gsap.timeline({ paused: true });
const CUT = 3.3;
// Reveal headline early
tl.to("#cf-headline", { clipPath: "inset(0 0 0% 0)", duration: 0.6, ease: "expo.out" }, 0.25);
// At the cut, flip the cutout wrapper visible — the presenter's silhouette
// punches through the headline.
tl.set(".cutout-wrap", { opacity: 1 }, CUT);
// Sentinel: extend timeline to the composition's full duration so the
// renderer doesn't bail past the last meaningful tween.
tl.set({}, {}, 6);
window.__timelines["cover-flip"] = tl;
```
**Why a wrapper div, not opacity on the video itself?**
The framework forces `opacity: 1` on any element with `data-start`/`data-duration` while it's "active" — that's how it manages clip lifecycles. A CSS `opacity: 0` on the video element is silently overwritten. Wrap the video in a div with no `data-*` attributes; the wrapper is owned by your CSS/GSAP.
**Why both videos at `data-start="0"`?**
So both decode in sync from t=0. Late-mounting the cutout (`data-start=3.3`) makes Chrome do a seek + decoder warm-up at mount, which can land a frame off the base mp4 — visible as a one-frame jitter at the cut.
**Color match:** `remove-background` defaults to `--quality balanced` (crf 18) which keeps the cutout's RGB nearly identical to the source mp4 — minimal edge halo or color shift when overlaid. Use `--quality best` (crf 12) for hero shots; only drop to `--quality fast` (crf 30) when the cutout sits over a _different_ background and the size matters.
## Title Card with Fade
```html