Files
wehub-resource-sync 85453da49f
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
Docs / Validate docs (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Sync skills to ClawHub / Publish changed skills (push) Waiting to run
regression / regression-shards (style-16-prod style-9-prod style-17-prod iframe-render-compat variables-prod mp4-h265-sdr, shard-4) (push) Has been cancelled
regression / regression-shards (style-4-prod style-11-prod style-2-prod animejs-adapter typegpu-adapter parallel-capture-regression, shard-5) (push) Has been cancelled
regression / regression-shards (style-7-prod style-8-prod style-10-prod css-spinner-render-compat webm-transparency mp4-h264-sdr webm-vp9, shard-3) (push) Has been cancelled
regression / regression-shards (sub-composition-video style-18-prod raf-ball-render-compat font-variant-numeric sub-comp-t0 sub-comp-id-selector, shard-7) (push) Has been cancelled
Windows render verification / Detect changes (push) Has been cancelled
Windows render verification / Preflight (lint + format) (push) Has been cancelled
Windows render verification / Render on windows-latest (push) Has been cancelled
Windows render verification / Tests on windows-latest (push) Has been cancelled
CI / Detect changes (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Fallow audit (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Producer: integration tests (push) Has been cancelled
CI / Producer: unit tests (push) Has been cancelled
CI / File size check (push) Has been cancelled
CI / Test: skills (push) Has been cancelled
CI / Skills: manifest in sync (push) Has been cancelled
CI / CLI: npx shim (macos-latest) (push) Has been cancelled
CI / CLI: npx shim (ubuntu-latest) (push) Has been cancelled
CI / CLI: npx shim (windows-latest) (push) Has been cancelled
CI / SDK: unit + contract + smoke (push) Has been cancelled
CI / Test: runtime contract (push) Has been cancelled
CI / Studio: load smoke (push) Has been cancelled
CI / Smoke: global install (push) Has been cancelled
CI / CLI smoke (required) (push) Has been cancelled
CI / Semantic PR title (push) Has been cancelled
Player perf / Detect changes (push) Has been cancelled
Player perf / Preflight (lint + format) (push) Has been cancelled
Player perf / player-perf (push) Has been cancelled
Player perf / Perf: drift (push) Has been cancelled
Player perf / Perf: fps (push) Has been cancelled
Player perf / Perf: parity (push) Has been cancelled
Player perf / Perf: scrub (push) Has been cancelled
Player perf / Perf: load (push) Has been cancelled
preview-regression / Detect changes (push) Has been cancelled
preview-regression / Preflight (lint + format) (push) Has been cancelled
preview-regression / Preview parity (push) Has been cancelled
preview-regression / preview-regression (push) Has been cancelled
regression / regression (push) Has been cancelled
regression / Detect changes (push) Has been cancelled
regression / Preflight (lint + format) (push) Has been cancelled
regression / regression-shards (hdr-regression style-5-prod style-3-prod mov-prores, shard-1) (push) Has been cancelled
regression / regression-shards (overlay-montage-prod style-12-prod chat missing-host-comp-id png-sequence portrait-edge-bleed, shard-6) (push) Has been cancelled
regression / regression-shards (style-13-prod style-6-prod vignelli-stacking gsap-letters-render-compat audio-mux-parity, shard-8) (push) Has been cancelled
regression / regression-shards (style-15-prod hdr-hlg-regression style-1-prod many-cuts vfr-screen-recording render-symlinked-assets, shard-2) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:58:35 +08:00

280 lines
9.4 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/*
* matte.cjs — subject matting via hyperframes' built-in `remove-background`
* (rembg-equivalent u2net_human_seg, Apache-2.0, 320×320 input, ~9 fps on
* CoreML). Replaces the previous bundled PP-MattingV2 ONNX (34 MB asset +
* an onnxruntime inference loop in this script): one engine, zero bundled
* weights — the model auto-downloads once (~168 MB) to ~/.cache/hyperframes/.
*
* Semantics note (validated 2026-06-12 on 6 scenes + a cold-start E2E): a
* HUMAN segmenter by intent, not surgically. Thin offset furniture (mic boom
* arms) is usually excluded — captions render over it, behind the person —
* but large salient objects near the subject (a telescope rig) can still
* leak into the matte and occlude captions. Objects HELD by the subject
* (products, phones) may drop out intermittently, letting captions pass in
* front. Never assume: sample frames_fg/ before placing the hero.
*
* Pipeline:
* source.mp4 → hyperframes remove-background → ProRes 4444 .mov (lossless
* alpha; temp, deleted) → ffmpeg fps=<matte.fps> → frames_fg/f_%04d.png.
* frames_bg/ is extracted at the same rate (preview tooling reads it).
*
* node matte.cjs <project-dir>
* Reads: <project>/source.mp4 (any video in the project dir is adopted)
* Writes: <project>/frames_fg/f_%04d.png (RGBA, subject opaque),
* <project>/frames_bg/f_%04d.png, <project>/matte.fps
* Env: HYPERFRAMES_ROOT — hyperframes checkout (default ~/Downloads/hyperframes)
*/
const path = require("path");
const fs = require("fs");
const os = require("os");
const cp = require("child_process");
function hfCli() {
const roots = [
process.env.HYPERFRAMES_ROOT,
path.resolve(__dirname, "..", "..", ".."), // skills/embedded-captions/scripts → repo root if in-repo
path.join(os.homedir(), "Downloads", "hyperframes"),
].filter(Boolean);
for (const root of roots) {
const cli = path.join(root, "packages", "cli", "dist", "cli.js");
if (fs.existsSync(cli)) return cli;
}
console.error("[matte] cannot find hyperframes cli — set HYPERFRAMES_ROOT to a built checkout");
process.exit(3);
}
function ensureSource(project) {
const src = path.join(project, "source.mp4");
if (!fs.existsSync(src)) {
const found = fs
.readdirSync(project)
.filter((f) => /\.(mp4|mov|webm|mkv)$/i.test(f) && !f.startsWith("_"))
.map((f) => path.join(project, f))[0];
if (!found) return src;
try {
fs.symlinkSync(path.basename(found), src);
} catch {
fs.copyFileSync(found, src);
}
console.log(`[matte] resolved source.mp4 -> ${path.basename(found)}`);
}
return src;
}
function rateOf(expr) {
const [n, d] = String(expr || "").split("/");
const f = parseFloat(n) / parseFloat(d || "1");
return Number.isFinite(f) && f > 0 ? f : 0;
}
// Prefer avg_frame_rate (frames/duration — the truth) over r_frame_rate (the
// container's nominal tick rate, which lies on VFR sources: a 24fps screen
// recording can carry r_frame_rate=60 and would 2.5x-desync the matte).
function probeRates(src) {
try {
const out = cp
.execFileSync("ffprobe", [
"-v",
"0",
"-select_streams",
"v:0",
"-show_entries",
"stream=r_frame_rate,avg_frame_rate",
"-of",
"default=nk=1:nw=1",
src,
])
.toString()
.trim()
.split("\n");
return { r: rateOf(out[0]), avg: rateOf(out[1]) };
} catch {
return { r: 0, avg: 0 };
}
}
function probeFps(src) {
const { r, avg } = probeRates(src);
const f = avg || r;
return f > 0 ? Math.max(1, Math.round(f)) : 24;
}
// VFR when nominal and actual disagree by >5% — the remove-background engine
// mishandles VFR timestamps (observed: 2251 fg frames vs 902 bg on one clip),
// so VFR sources get normalized to CFR before matting.
function isVfr(src) {
const { r, avg } = probeRates(src);
return r > 0 && avg > 0 && Math.abs(r - avg) / avg > 0.05;
}
function extractFrames(src, dst, fps, extra = []) {
fs.mkdirSync(dst, { recursive: true });
if (fs.readdirSync(dst).some((f) => f.endsWith(".png"))) return false;
cp.execFileSync(
"ffmpeg",
["-y", "-i", src, "-vf", `fps=${fps}`, ...extra, path.join(dst, "f_%04d.png")],
{ stdio: "ignore" },
);
return true;
}
function countPngs(dir) {
try {
return fs.readdirSync(dir).filter((f) => f.endsWith(".png")).length;
} catch {
return 0;
}
}
async function main() {
const project = path.resolve(process.argv[2] || "");
if (!process.argv[2]) {
console.error("usage: matte.cjs <project-dir>");
process.exit(1);
}
const src = ensureSource(project);
if (!fs.existsSync(src)) {
console.error(`[matte] no source video found in ${project}`);
process.exit(2);
}
const fpsFile = path.join(project, "matte.fps");
// read-with-catch (no exists-then-read TOCTOU): a missing/corrupt fps file
// simply falls through to probing the source.
let fps = 0;
try {
fps = parseInt(fs.readFileSync(fpsFile, "utf8").replace(/\D/g, ""), 10) || 0;
} catch {
/* no cached fps — probe below */
}
if (!fps) fps = probeFps(src);
fs.writeFileSync(fpsFile, String(fps));
const framesBg = path.join(project, "frames_bg");
const framesFg = path.join(project, "frames_fg");
if (extractFrames(src, framesBg, fps))
console.log(`[matte] source fps=${fps} → frames_bg extracted`);
const want = countPngs(framesBg);
if (want > 0 && countPngs(framesFg) >= want) {
console.log(`[matte] frames_fg already complete (${want} frames) — nothing to do`);
return;
}
// 1) subject matte via hyperframes (ProRes 4444 keeps the alpha lossless).
// VFR sources are normalized to CFR first — remove-background trusts
// timestamps and emits a desynced frame count on VFR input (the ghost
// double-subject bug: the pasted matte runs at the wrong speed).
let matteSrc = src;
if (isVfr(src)) {
const cfr = path.join(project, "_src_cfr.mp4");
if (!fs.existsSync(cfr)) {
console.log(
`[matte] VFR source detected (nominal != actual fps) → normalizing to ${fps}fps CFR for matting`,
);
cp.execFileSync(
"ffmpeg",
[
"-y",
"-i",
src,
"-fps_mode",
"cfr",
"-r",
String(fps),
"-c:v",
"libx264",
"-crf",
"16",
"-preset",
"fast",
"-an",
cfr,
],
{ stdio: "ignore" },
);
}
matteSrc = cfr;
}
const mov = path.join(project, "_matte_tmp.mov");
const t0 = Date.now();
const cached = fs.existsSync(
path.join(
os.homedir(),
".cache",
"hyperframes",
"background-removal",
"models",
"u2net_human_seg.onnx",
),
);
console.log(
`[matte] hyperframes remove-background (u2net_human_seg${cached ? "" : "; first run downloads ~168 MB"})… model load takes ~1-2 min with no output — not hung`,
);
const r = cp.spawnSync("node", [hfCli(), "remove-background", matteSrc, "-o", mov], {
stdio: ["ignore", "pipe", "pipe"],
encoding: "utf8",
});
if (r.status !== 0 || !fs.existsSync(mov)) {
console.error("[matte] remove-background FAILED:");
console.error((r.stderr || r.stdout || "").split("\n").slice(-8).join("\n"));
process.exit(4);
}
// 2) burst to RGBA pngs at the project rate
fs.mkdirSync(framesFg, { recursive: true });
cp.execFileSync(
"ffmpeg",
["-y", "-i", mov, "-vf", `fps=${fps}`, "-pix_fmt", "rgba", path.join(framesFg, "f_%04d.png")],
{ stdio: "ignore" },
);
fs.rmSync(mov, { force: true });
// 3) count parity with frames_bg. Composite overlays fg by INDEX at matte.fps,
// so any fg/bg count mismatch is a time desync (subject ghosting). Handle
// BOTH directions: pad when short, linearly remap when long — and shout
// when the mismatch is big enough to mean broken timestamps upstream.
let got = countPngs(framesFg);
const tol = Math.max(2, Math.round(want * 0.01));
if (Math.abs(got - want) > tol) {
console.error(
`[matte] WARN frame-count desync: fg=${got} vs bg=${want} (tolerance ${tol}). ` +
`Reconciling by remap — if the source is VFR this run predates the CFR fix; ` +
`delete frames_fg/ frames_bg/ matte.fps and re-run matte.cjs.`,
);
}
if (got > want && want > 0) {
// keep want frames sampled evenly across got (re-times fg onto the bg timeline)
const keep = [];
for (let j = 1; j <= want; j++)
keep.push(Math.min(got, Math.max(1, Math.round((j - 0.5) * (got / want)))));
const tmp = path.join(project, "_fg_remap");
fs.mkdirSync(tmp, { recursive: true });
keep.forEach((srcIdx, k) => {
fs.copyFileSync(
path.join(framesFg, `f_${String(srcIdx).padStart(4, "0")}.png`),
path.join(tmp, `f_${String(k + 1).padStart(4, "0")}.png`),
);
});
fs.rmSync(framesFg, { recursive: true, force: true });
fs.renameSync(tmp, framesFg);
got = countPngs(framesFg);
}
while (got < want && got > 0) {
fs.copyFileSync(
path.join(framesFg, `f_${String(got).padStart(4, "0")}.png`),
path.join(framesFg, `f_${String(got + 1).padStart(4, "0")}.png`),
);
got++;
}
console.log(
`[matte] done in ${((Date.now() - t0) / 1000).toFixed(1)}s → fg=${got} bg=${want} @ ${fps}fps${got === want ? " (parity ok)" : ""}`,
);
}
main().catch((e) => {
console.error("[matte]", e.message);
process.exit(1);
});