mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-05 09:28:08 +00:00
* ci: run fallow audit in lefthook pre-commit Mirrors the same `fallow audit --base ... --fail-on-issues` check that runs in CI, but locally against HEAD so issues surface at commit time instead of after the push round-trip. Scoped to `packages/**` source files via the glob — non-code edits (README, docs, top-level configs) skip the hook entirely. Measured locally: ~5s in parallel with the existing lint/format/typecheck checks. Doesn't extend wall-clock time because typecheck (~11s) is the long pole, and lefthook runs commands in parallel. The default `--gate new-only` means inherited findings don't block the commit — same gate behavior as CI, so local pre-commit and PR audit agree. * refactor: delete orphan declarations flagged by fallow After fallow's auto-fix de-exports unused symbols, oxlint surfaces them as no-unused-vars. This PR deletes those orphan declarations outright. Biggest cleanup: studio/src/icons/SystemIcons.tsx shrinks from 132 to 57 lines — 33 unused icon wrappers and their phosphor-icon imports deleted. Other deletions across 14 more files covering paired getter/setters, helper functions, dead env constants, internal components with no callers, and cascading unused imports. Cascade-causing files held back for follow-up PRs: renderOrchestrator barrel of captureCost re-exports, telemetry/portUtils/remote barrels, Button.tsx + ui/index.ts (would orphan whole file), studioMotion type re-exports. Test plan: typecheck clean across 8 packages, oxlint + oxfmt clean, fallow audit exit 0 (remaining findings inherited), cli + studio vitest suites pass.
315 lines
11 KiB
TypeScript
315 lines
11 KiB
TypeScript
/// <reference types="@webgpu/types" />
|
||
/**
|
||
* HDR Capture Service
|
||
*
|
||
* Captures HDR video frames via WebGPU float16 readback.
|
||
*
|
||
* The pipeline:
|
||
* 1. FFmpeg extracts raw HDR pixels (rgba64le) from video sources
|
||
* 2. Node converts HLG/PQ signal → linear light → float16
|
||
* 3. writeTexture uploads float16 data to WebGPU rgba16float texture
|
||
* 4. (Optional) WebGPU shader applies GSAP CSS transform
|
||
* 5. readback extracts float16 RGBA via base64 transfer
|
||
* 6. Node converts linear float16 → PQ signal → pipe to FFmpeg H.265
|
||
*
|
||
* Requirements:
|
||
* - Headed Chrome (not headless) — WebGPU unavailable in headless mode
|
||
* - GPU access (Metal on macOS, Vulkan+NVIDIA on Linux)
|
||
*
|
||
* Performance: ~6 fps at 1080x1920 via base64 transfer.
|
||
*/
|
||
|
||
import type { Page, Browser, PuppeteerNode } from "puppeteer-core";
|
||
import { existsSync, readdirSync } from "fs";
|
||
import { join } from "path";
|
||
import { homedir } from "os";
|
||
|
||
// ── PQ (SMPTE 2084) OETF ─────────────────────────────────────────────────────
|
||
|
||
const PQ_M1 = 0.1593017578125;
|
||
const PQ_M2 = 78.84375;
|
||
const PQ_C1 = 0.8359375;
|
||
const PQ_C2 = 18.8515625;
|
||
const PQ_C3 = 18.6875;
|
||
const PQ_MAX_NITS = 10000.0;
|
||
const SDR_NITS = 203.0;
|
||
|
||
function linearToPQ(L: number): number {
|
||
const Lp = Math.max(0, (L * SDR_NITS) / PQ_MAX_NITS);
|
||
const Lm1 = Math.pow(Lp, PQ_M1);
|
||
return Math.pow((PQ_C1 + PQ_C2 * Lm1) / (1.0 + PQ_C3 * Lm1), PQ_M2);
|
||
}
|
||
|
||
function float16Decode(h: number): number {
|
||
const sign = (h >> 15) & 1;
|
||
const exp = (h >> 10) & 0x1f;
|
||
const frac = h & 0x3ff;
|
||
if (exp === 0) return (sign ? -1 : 1) * Math.pow(2, -14) * (frac / 1024);
|
||
if (exp === 31) return frac ? NaN : sign ? -Infinity : Infinity;
|
||
return (sign ? -1 : 1) * Math.pow(2, exp - 15) * (1 + frac / 1024);
|
||
}
|
||
|
||
// ── Browser-side interface ────────────────────────────────────────────────────
|
||
|
||
interface HdrCaptureRuntime {
|
||
uploadAndReadback(float16Base64: string): Promise<{ base64: string; bytesPerRow: number }>;
|
||
}
|
||
|
||
// ── Initialization ────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Inject the WebGPU HDR readback runtime into the page.
|
||
*
|
||
* Creates an rgba16float render texture that accepts writeTexture uploads
|
||
* and provides readback via base64 transfer.
|
||
*/
|
||
export async function initHdrReadback(page: Page, width: number, height: number): Promise<boolean> {
|
||
return page.evaluate(
|
||
async (w: number, h: number): Promise<boolean> => {
|
||
if (!navigator.gpu) return false;
|
||
|
||
const adapter = await navigator.gpu.requestAdapter();
|
||
if (!adapter) return false;
|
||
|
||
const device = await adapter.requestDevice();
|
||
|
||
const bytesPerPixel = 8; // rgba16float = 4 channels × 2 bytes
|
||
const bytesPerRow = Math.ceil((w * bytesPerPixel) / 256) * 256;
|
||
|
||
// Render texture — includes COPY_DST for writeTexture uploads
|
||
const renderTexture = device.createTexture({
|
||
size: [w, h],
|
||
format: "rgba16float",
|
||
usage:
|
||
GPUTextureUsage.RENDER_ATTACHMENT |
|
||
GPUTextureUsage.COPY_SRC |
|
||
GPUTextureUsage.COPY_DST |
|
||
GPUTextureUsage.TEXTURE_BINDING,
|
||
});
|
||
|
||
const readBuffer = device.createBuffer({
|
||
size: bytesPerRow * h,
|
||
usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
|
||
});
|
||
|
||
const captureRuntime = {
|
||
device,
|
||
renderTexture,
|
||
readBuffer,
|
||
bytesPerRow,
|
||
width: w,
|
||
height: h,
|
||
|
||
/**
|
||
* Upload pre-converted float16 RGBA data and read it back.
|
||
* The float16 data must be row-aligned to bytesPerRow.
|
||
*
|
||
* Input: base64-encoded Uint16Array (float16 RGBA, row-padded)
|
||
* Output: base64-encoded readback of the same texture
|
||
*/
|
||
async uploadAndReadback(
|
||
float16Base64: string,
|
||
): Promise<{ base64: string; bytesPerRow: number }> {
|
||
// Decode base64 → Uint8Array
|
||
const binary = atob(float16Base64);
|
||
const bytes = new Uint8Array(binary.length);
|
||
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||
|
||
// Upload to texture
|
||
device.queue.writeTexture(
|
||
{ texture: renderTexture },
|
||
bytes.buffer,
|
||
{ bytesPerRow, rowsPerImage: h },
|
||
[w, h],
|
||
);
|
||
|
||
// Readback
|
||
const encoder = device.createCommandEncoder();
|
||
encoder.copyTextureToBuffer(
|
||
{ texture: renderTexture },
|
||
{ buffer: readBuffer, bytesPerRow },
|
||
[w, h],
|
||
);
|
||
device.queue.submit([encoder.finish()]);
|
||
|
||
await readBuffer.mapAsync(GPUMapMode.READ);
|
||
const readBytes = new Uint8Array(readBuffer.getMappedRange().slice(0));
|
||
readBuffer.unmap();
|
||
|
||
// Base64 encode in chunks
|
||
let b64 = "";
|
||
const chunkSize = 32768;
|
||
for (let i = 0; i < readBytes.length; i += chunkSize) {
|
||
const slice = readBytes.subarray(i, Math.min(i + chunkSize, readBytes.length));
|
||
b64 += String.fromCharCode(...slice);
|
||
}
|
||
|
||
return { base64: btoa(b64), bytesPerRow };
|
||
},
|
||
};
|
||
|
||
(window as unknown as Record<string, unknown>).__hfHdrCapture = captureRuntime;
|
||
return true;
|
||
},
|
||
width,
|
||
height,
|
||
);
|
||
}
|
||
|
||
// ── HDR frame conversion ──────────────────────────────────────────────────────
|
||
|
||
// ── Frame upload + readback ───────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Upload a float16 frame to WebGPU and read it back.
|
||
* Call after converting with convertHdrFrameToFloat16Base64.
|
||
*/
|
||
export async function uploadAndReadbackHdrFrame(
|
||
page: Page,
|
||
float16Base64: string,
|
||
): Promise<{ rawBuffer: Buffer; bytesPerRow: number }> {
|
||
const result = await page.evaluate(
|
||
async (b64: string): Promise<{ base64: string; bytesPerRow: number }> => {
|
||
const hdr = (window as unknown as Record<string, unknown>).__hfHdrCapture as
|
||
| HdrCaptureRuntime
|
||
| undefined;
|
||
if (!hdr) throw new Error("HDR capture not initialized");
|
||
return hdr.uploadAndReadback(b64);
|
||
},
|
||
float16Base64,
|
||
);
|
||
|
||
return {
|
||
rawBuffer: Buffer.from(result.base64, "base64"),
|
||
bytesPerRow: result.bytesPerRow,
|
||
};
|
||
}
|
||
|
||
// ── PQ conversion ─────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Convert float16 RGBA readback to PQ-encoded rgb48le for FFmpeg.
|
||
*/
|
||
export function float16ToPqRgb(
|
||
rawBuffer: Buffer,
|
||
bytesPerRow: number,
|
||
width: number,
|
||
height: number,
|
||
): Buffer {
|
||
const data = new Uint16Array(rawBuffer.buffer, rawBuffer.byteOffset, rawBuffer.byteLength / 2);
|
||
const channelsPerRow = bytesPerRow / 2;
|
||
const output = Buffer.alloc(width * height * 6);
|
||
|
||
for (let y = 0; y < height; y++) {
|
||
for (let x = 0; x < width; x++) {
|
||
const srcIdx = y * channelsPerRow + x * 4;
|
||
const r = float16Decode(data[srcIdx] ?? 0);
|
||
const g = float16Decode(data[srcIdx + 1] ?? 0);
|
||
const b = float16Decode(data[srcIdx + 2] ?? 0);
|
||
|
||
const dstIdx = (y * width + x) * 6;
|
||
output.writeUInt16LE(Math.round(Math.min(1.0, linearToPQ(r)) * 65535), dstIdx);
|
||
output.writeUInt16LE(Math.round(Math.min(1.0, linearToPQ(g)) * 65535), dstIdx + 2);
|
||
output.writeUInt16LE(Math.round(Math.min(1.0, linearToPQ(b)) * 65535), dstIdx + 4);
|
||
}
|
||
}
|
||
|
||
return output;
|
||
}
|
||
|
||
// ── Chrome launch ─────────────────────────────────────────────────────────────
|
||
|
||
function resolveHeadedChromePath(): string | undefined {
|
||
const baseDir = join(homedir(), ".cache", "puppeteer", "chrome");
|
||
if (!existsSync(baseDir)) return undefined;
|
||
const versions = readdirSync(baseDir).sort().reverse();
|
||
for (const version of versions) {
|
||
const candidates = [
|
||
join(
|
||
baseDir,
|
||
version,
|
||
"chrome-mac-arm64",
|
||
"Google Chrome for Testing.app",
|
||
"Contents",
|
||
"MacOS",
|
||
"Google Chrome for Testing",
|
||
),
|
||
join(
|
||
baseDir,
|
||
version,
|
||
"chrome-mac-x64",
|
||
"Google Chrome for Testing.app",
|
||
"Contents",
|
||
"MacOS",
|
||
"Google Chrome for Testing",
|
||
),
|
||
join(baseDir, version, "chrome-linux64", "chrome"),
|
||
join(baseDir, version, "chrome-win64", "chrome.exe"),
|
||
];
|
||
for (const binary of candidates) {
|
||
if (existsSync(binary)) return binary;
|
||
}
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
/**
|
||
* Launch a headed Chrome browser with WebGPU enabled.
|
||
*/
|
||
export async function launchHdrBrowser(
|
||
width: number,
|
||
height: number,
|
||
): Promise<{ browser: Browser; page: Page }> {
|
||
let ppt: PuppeteerNode | undefined;
|
||
try {
|
||
const mod = await import("puppeteer" as string);
|
||
ppt = mod.default;
|
||
} catch (err) {
|
||
const code = (err as NodeJS.ErrnoException | undefined)?.code;
|
||
if (code !== "ERR_MODULE_NOT_FOUND" && code !== "MODULE_NOT_FOUND") {
|
||
throw err;
|
||
}
|
||
const mod = await import("puppeteer-core");
|
||
ppt = mod.default;
|
||
}
|
||
if (!ppt) throw new Error("Neither puppeteer nor puppeteer-core found");
|
||
|
||
const chromePath = resolveHeadedChromePath();
|
||
if (!chromePath) {
|
||
throw new Error(
|
||
"[HDR] No Chrome binary found. Install: npx @puppeteer/browsers install chrome@stable",
|
||
);
|
||
}
|
||
|
||
const browser = await ppt.launch({
|
||
headless: false,
|
||
executablePath: chromePath,
|
||
args: buildHdrChromeArgs(width, height),
|
||
});
|
||
|
||
const page = await browser.newPage();
|
||
await page.setViewport({ width, height });
|
||
|
||
return { browser, page };
|
||
}
|
||
|
||
export function buildHdrChromeArgs(width: number, height: number): string[] {
|
||
return [
|
||
"--enable-unsafe-webgpu",
|
||
"--no-sandbox",
|
||
"--disable-setuid-sandbox",
|
||
"--window-position=-10000,-10000",
|
||
`--window-size=${width},${height}`,
|
||
"--disable-background-timer-throttling",
|
||
"--disable-backgrounding-occluded-windows",
|
||
"--disable-renderer-backgrounding",
|
||
"--disable-background-media-suspend",
|
||
"--disable-extensions",
|
||
"--disable-component-update",
|
||
"--disable-default-apps",
|
||
"--disable-sync",
|
||
"--no-zygote",
|
||
"--force-gpu-mem-available-mb=4096",
|
||
];
|
||
}
|