diff --git a/docs/guides/remove-background.mdx b/docs/guides/remove-background.mdx index a7cb06bc7..334bb1163 100644 --- a/docs/guides/remove-background.mdx +++ b/docs/guides/remove-background.mdx @@ -80,6 +80,71 @@ npx hyperframes remove-background subject.mp4 -o transparent.mov # editi npx hyperframes remove-background portrait.jpg -o cutout.png # still image ``` +## Layer separation: emit the cutout and the background plate together + +Pass `--background-output` (alias `-b`) to write a *second* transparent video alongside the cutout. Same source RGB, alpha is the *inverse* mask — opaque where the surroundings were, transparent where the subject is. The result is a clean two-layer separation in a single inference pass: + +```bash Terminal +npx hyperframes remove-background subject.mp4 \ + -o subject.webm \ + --background-output plate.webm +``` + +| Output | Alpha | Use it as | +| ------ | ----- | --------- | +| `subject.webm` | Mask — subject opaque | Foreground layer (top of stack) | +| `plate.webm` | `255 − mask` — subject region transparent | Background layer; place anything you want **under the subject's silhouette** between this and `subject.webm` | + +Both encoders share the source W/H/fps and your `--quality` preset, so the layers are pixel-aligned. Encode cost roughly doubles; segmentation cost is unchanged. + + +**This is a hole-cut plate, not an inpainted clean plate.** The subject region in `plate.webm` is fully transparent — you have to composite something opaque under it (a graphic, a blurred copy, a different scene) to fill the hole. If you need an actual filled background where the subject was, use a video inpainter (LaMa, ProPainter, RunwayML Inpaint) — `remove-background` is not the right tool for that. + + +### Hole-cut vs. clean plate — when does the difference matter? + +A **hole-cut plate** keeps the original surroundings and makes the subject region transparent. A **clean plate** fills the subject region with reconstructed background — produced by a separate inpainting model. Display each alone over black: + +| | Hole-cut plate (this command) | Clean plate (inpainted) | +| --- | --- | --- | +| Subject region | Transparent silhouette | Reconstructed background pixels | +| What you see alone | A person-shaped hole | An empty room | +| Cost | One inference pass, one extra ffmpeg encode | A second model (LaMa, ProPainter, E2FGVI) | +| Tool | `remove-background --background-output` | Outside this CLI | + +The line is: **does anything ever need to be visible *through* the subject's silhouette where the subject used to be?** + +| Use case | What you need | +| --- | --- | +| Text/graphics live *between* the cutout and the plate (the example above) | **Hole-cut** — the graphics fill the hole. | +| Composite the subject onto an unrelated scene | Neither. Just use `subject.webm`; the plate is irrelevant. | +| Show "the room without the person" as a real background | **Clean plate** — a hole-cut plate would show a transparent void. | +| Replace the person with a different subject (re-target) | **Clean plate** — the new subject needs real pixels under it. | +| VFX rotoscoping / "remove an extra from this take" | **Clean plate** — the canonical inpainting use case. | + +If something opaque always covers the silhouette, hole-cut is sufficient and ~1000× cheaper than running an inpainter. + +### The two-layer composition pattern + +The two-layer pattern is functionally a drop-in for [text-behind-subject](#text-behind-subject-the-recommended-layout) without needing the original `presenter.mp4` in the project — the plate replaces it as the bottom layer: + +```html + + + + +

+ MAKE IT IN HYPERFRAMES +

+ + +
+ +
+``` + +Constraints: the flag requires a video input and `.webm` or `.mov` for both outputs. It's not valid for image inputs (no temporal pairing to do) and won't accept `.png` for the plate. + ## Performance Real-world numbers from the [matting eval](https://www.heygenverse.com/a/0dd5a431-1832-4858-862d-de7fb7d02654), running u²-net_human_seg on a 4-second 1080p clip: diff --git a/docs/packages/cli.mdx b/docs/packages/cli.mdx index 57f8a0811..f9ae12f72 100644 --- a/docs/packages/cli.mdx +++ b/docs/packages/cli.mdx @@ -356,6 +356,10 @@ This is suppressed in CI environments, non-TTY shells, and when `HYPERFRAMES_NO_ # Single image → transparent PNG npx hyperframes remove-background portrait.jpg -o cutout.png + # Layer separation: cutout AND inverse-alpha background plate in one pass + npx hyperframes remove-background avatar.mp4 \ + -o subject.webm --background-output plate.webm + # Force CPU on a machine that has CoreML or CUDA npx hyperframes remove-background avatar.mp4 -o transparent.webm --device cpu @@ -366,8 +370,9 @@ This is suppressed in CI environments, non-TTY shells, and when `HYPERFRAMES_NO_ | Flag | Description | |------|-------------| | `--output, -o` | Output path. Format inferred from extension: `.webm` (default), `.mov`, `.png` | + | `--background-output, -b` | Optional second output: inverse-alpha background plate (subject region transparent, surroundings opaque). Same source RGB, complementary mask. Must be `.webm` or `.mov`. Hole-cut, not inpainted — composite something underneath to fill the hole. | | `--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`. | + | `--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. Applies to both `--output` and `--background-output`. Ignored for `.mov` / `.png`. | | `--info` | Print detected execution providers and exit (no render) | | `--json` | Output result as JSON | diff --git a/packages/cli/src/background-removal/inference.test.ts b/packages/cli/src/background-removal/inference.test.ts index ffc62cc0b..719bcab4d 100644 --- a/packages/cli/src/background-removal/inference.test.ts +++ b/packages/cli/src/background-removal/inference.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { MEAN, STD } from "./inference.js"; +import { MEAN, STD, applyMask } from "./inference.js"; // Regression: the u2net_human_seg model was trained with ImageNet // normalization. Drifting away from these exact values changes the input @@ -16,3 +16,112 @@ describe("background-removal/inference — rembg u2net_human_seg parity", () => expect(STD).toEqual([0.229, 0.224, 0.225]); }); }); + +// These tests pin the contract that `--background-output` is built on: +// fg.alpha + bg.alpha === 255 per pixel, and the RGB plane is byte-identical +// between fg and bg. A future change to the postprocess loop (different mask +// threshold, premultiplied alpha, gamma-corrected compositing) that breaks +// either invariant should fail here loudly. +describe("background-removal/inference — applyMask invariants", () => { + function makeRgb(pixels: number): Buffer { + // Deterministic but non-trivial RGB so byte equality is meaningful. + const buf = Buffer.allocUnsafe(pixels * 3); + for (let i = 0; i < pixels; i++) { + buf[i * 3] = (i * 7) & 0xff; + buf[i * 3 + 1] = (i * 13 + 31) & 0xff; + buf[i * 3 + 2] = (i * 19 + 61) & 0xff; + } + return buf; + } + + function makeMask(pixels: number): Buffer { + // Hit the saturation endpoints (0, 255) and a few mid-tone values so the + // 255-m inversion is exercised across the full byte range. + const buf = Buffer.allocUnsafe(pixels); + for (let i = 0; i < pixels; i++) buf[i] = (i * 37) & 0xff; + return buf; + } + + it("dual-output: fg.alpha + bg.alpha === 255 for every pixel", () => { + const pixels = 64; + const rgb = makeRgb(pixels); + const mask = makeMask(pixels); + const fg = Buffer.allocUnsafe(pixels * 4); + const bg = Buffer.allocUnsafe(pixels * 4); + + const result = applyMask(rgb, mask, fg, bg, pixels); + + expect(result.fg).toBe(fg); + expect(result.bg).toBe(bg); + for (let i = 0; i < pixels; i++) { + const sum = fg[i * 4 + 3]! + bg[i * 4 + 3]!; + expect(sum).toBe(255); + } + }); + + it("dual-output: RGB triples are byte-identical between fg and bg", () => { + const pixels = 64; + const rgb = makeRgb(pixels); + const mask = makeMask(pixels); + const fg = Buffer.allocUnsafe(pixels * 4); + const bg = Buffer.allocUnsafe(pixels * 4); + + applyMask(rgb, mask, fg, bg, pixels); + + for (let i = 0; i < pixels; i++) { + expect(fg[i * 4]).toBe(bg[i * 4]); + expect(fg[i * 4 + 1]).toBe(bg[i * 4 + 1]); + expect(fg[i * 4 + 2]).toBe(bg[i * 4 + 2]); + // And both match the source. + expect(fg[i * 4]).toBe(rgb[i * 3]); + expect(fg[i * 4 + 1]).toBe(rgb[i * 3 + 1]); + expect(fg[i * 4 + 2]).toBe(rgb[i * 3 + 2]); + } + }); + + it("dual-output: fg.alpha equals the input mask", () => { + const pixels = 32; + const rgb = makeRgb(pixels); + const mask = makeMask(pixels); + const fg = Buffer.allocUnsafe(pixels * 4); + const bg = Buffer.allocUnsafe(pixels * 4); + + applyMask(rgb, mask, fg, bg, pixels); + + for (let i = 0; i < pixels; i++) { + expect(fg[i * 4 + 3]).toBe(mask[i]); + } + }); + + it("single-output: bg=null returns bg=null and writes only fg", () => { + const pixels = 32; + const rgb = makeRgb(pixels); + const mask = makeMask(pixels); + const fg = Buffer.allocUnsafe(pixels * 4); + + const result = applyMask(rgb, mask, fg, null, pixels); + + expect(result.bg).toBeNull(); + expect(result.fg).toBe(fg); + for (let i = 0; i < pixels; i++) { + expect(fg[i * 4]).toBe(rgb[i * 3]); + expect(fg[i * 4 + 3]).toBe(mask[i]); + } + }); + + it("saturates correctly at mask=0 and mask=255", () => { + // mask=0 → fg.alpha=0 (transparent subject), bg.alpha=255 (fully opaque plate) + // mask=255 → fg.alpha=255 (fully opaque subject), bg.alpha=0 (transparent plate) + const rgb = Buffer.from([10, 20, 30, 40, 50, 60]); + const mask = Buffer.from([0, 255]); + const fg = Buffer.allocUnsafe(8); + const bg = Buffer.allocUnsafe(8); + + applyMask(rgb, mask, fg, bg, 2); + + expect(fg[3]).toBe(0); + expect(bg[3]).toBe(255); + expect(fg[7]).toBe(255); + expect(bg[7]).toBe(0); + }); +}); diff --git a/packages/cli/src/background-removal/inference.ts b/packages/cli/src/background-removal/inference.ts index d0b3d8adb..605257fc3 100644 --- a/packages/cli/src/background-removal/inference.ts +++ b/packages/cli/src/background-removal/inference.ts @@ -24,10 +24,24 @@ interface OrtModule { Tensor: typeof Tensor; } +export interface SessionResult { + /** Subject opaque, background fully transparent. */ + fg: Buffer; + /** Inverse-alpha plate: same RGB, alpha is `255 − mask`. Null unless `withBackground` was true. */ + bg: Buffer | null; +} + export interface Session { - /** Run inference on one RGB frame, return RGBA bytes (H*W*4). */ - process(rgb: Buffer, width: number, height: number): Promise; - /** ORT EP that was actually selected. */ + /** + * Both `fg` and `bg` (when requested) are session-owned buffers reused on the + * next call — drain the encoder's stdin before invoking `process` again. + */ + process( + rgb: Buffer, + width: number, + height: number, + withBackground?: boolean, + ): Promise; provider: string; close(): Promise; } @@ -73,16 +87,15 @@ export async function createSession(options: CreateSessionOptions = {}): Promise throw new Error("ONNX session is missing input or output bindings"); } - // Pre-allocated per-frame buffers reused across every process() call. - // At 1080p this saves ~9 MB of allocations per frame. rgbaBuf is sized - // lazily on the first call (we don't know W/H until then). + // Reused across calls; sized lazily on first frame. Saves ~9 MB/frame at 1080p. const inputData = new Float32Array(3 * INPUT_PLANE); const maskBuf = Buffer.allocUnsafe(INPUT_PLANE); let rgbaBuf: Buffer | null = null; + let rgbaBgBuf: Buffer | null = null; return { provider: providerUsed, - async process(rgb, width, height) { + async process(rgb, width, height, withBackground = false) { const tensor = await preprocess(sharp, ort, rgb, width, height, inputData); const outputs = await session.run({ [inputName]: tensor }); const output = outputs[outputName]; @@ -91,7 +104,21 @@ export async function createSession(options: CreateSessionOptions = {}): Promise if (!rgbaBuf || rgbaBuf.length !== expectedBytes) { rgbaBuf = Buffer.allocUnsafe(expectedBytes); } - return await postprocess(sharp, output, rgb, width, height, maskBuf, rgbaBuf); + if (withBackground) { + if (!rgbaBgBuf || rgbaBgBuf.length !== expectedBytes) { + rgbaBgBuf = Buffer.allocUnsafe(expectedBytes); + } + } + return await postprocess( + sharp, + output, + rgb, + width, + height, + maskBuf, + rgbaBuf, + withBackground ? rgbaBgBuf : null, + ); }, async close() { await session.release(); @@ -141,7 +168,8 @@ async function postprocess( height: number, maskBuf: Buffer, rgbaBuf: Buffer, -): Promise { + rgbaBgBuf: Buffer | null, +): Promise { const raw = output.data as Float32Array; let lo = Infinity; @@ -172,11 +200,50 @@ async function postprocess( .raw() .toBuffer(); - for (let i = 0; i < width * height; i++) { - rgbaBuf[i * 4] = rgb[i * 3]!; - rgbaBuf[i * 4 + 1] = rgb[i * 3 + 1]!; - rgbaBuf[i * 4 + 2] = rgb[i * 3 + 2]!; - rgbaBuf[i * 4 + 3] = fullMask[i]!; + return applyMask(rgb, fullMask, rgbaBuf, rgbaBgBuf, width * height); +} + +/** + * Composite the RGB source frame with the segmentation mask into one or two + * RGBA buffers. The contract this PR is built on: + * - `fg`'s alpha is the mask, `bg`'s alpha (when provided) is `255 − mask`, + * so `fg.alpha + bg.alpha === 255` for every pixel. + * - RGB triples are byte-identical between `fg` and `bg`. + * - When `bg` is null, only `fg` is touched. + * + * Exported for direct unit testing of the invariants above without spinning + * up an ONNX session. + */ +export function applyMask( + rgb: Buffer, + mask: Buffer, + fg: Buffer, + bg: Buffer | null, + pixels: number, +): SessionResult { + if (bg) { + for (let i = 0; i < pixels; i++) { + const r = rgb[i * 3]!; + const g = rgb[i * 3 + 1]!; + const b = rgb[i * 3 + 2]!; + const m = mask[i]!; + const o = i * 4; + fg[o] = r; + fg[o + 1] = g; + fg[o + 2] = b; + fg[o + 3] = m; + bg[o] = r; + bg[o + 1] = g; + bg[o + 2] = b; + bg[o + 3] = 255 - m; + } + return { fg, bg }; } - return rgbaBuf; + for (let i = 0; i < pixels; i++) { + fg[i * 4] = rgb[i * 3]!; + fg[i * 4 + 1] = rgb[i * 3 + 1]!; + fg[i * 4 + 2] = rgb[i * 3 + 2]!; + fg[i * 4 + 3] = mask[i]!; + } + return { fg, bg: null }; } diff --git a/packages/cli/src/background-removal/pipeline.test.ts b/packages/cli/src/background-removal/pipeline.test.ts index 7bc5488a9..5763efb06 100644 --- a/packages/cli/src/background-removal/pipeline.test.ts +++ b/packages/cli/src/background-removal/pipeline.test.ts @@ -1,7 +1,13 @@ import { describe, expect, it } from "vitest"; import { EventEmitter } from "node:events"; import type { spawn } from "node:child_process"; -import { inferOutputFormat, inferInputKind, buildEncoderArgs, waitForExit } from "./pipeline.js"; +import { + inferOutputFormat, + inferInputKind, + buildEncoderArgs, + resolveRenderTargets, + waitForExit, +} from "./pipeline.js"; describe("background-removal/pipeline — inferOutputFormat", () => { it("maps .webm → webm", () => { @@ -97,6 +103,54 @@ describe("background-removal/pipeline — buildEncoderArgs", () => { }); }); +describe("background-removal/pipeline — resolveRenderTargets", () => { + it("resolves a normal video → webm render", () => { + const t = resolveRenderTargets("/tmp/clip.mp4", "/tmp/cutout.webm"); + expect(t.format).toBe("webm"); + expect(t.inputKind).toBe("video"); + expect(t.bgFormat).toBeUndefined(); + }); + + it("resolves an image → png render", () => { + const t = resolveRenderTargets("/tmp/portrait.jpg", "/tmp/cutout.png"); + expect(t.format).toBe("png"); + expect(t.inputKind).toBe("image"); + }); + + it("rejects image input with a video output extension", () => { + expect(() => resolveRenderTargets("/tmp/portrait.jpg", "/tmp/cutout.webm")).toThrow( + /Image input requires a \.png output/, + ); + }); + + it("rejects video input with a .png output", () => { + expect(() => resolveRenderTargets("/tmp/clip.mp4", "/tmp/cutout.png")).toThrow( + /Video input requires a \.webm or \.mov output/, + ); + }); + + it("threads background-output format through when valid", () => { + const t = resolveRenderTargets("/tmp/clip.mp4", "/tmp/fg.webm", "/tmp/bg.webm"); + expect(t.bgFormat).toBe("webm"); + const tMov = resolveRenderTargets("/tmp/clip.mp4", "/tmp/fg.webm", "/tmp/bg.mov"); + expect(tMov.bgFormat).toBe("mov"); + }); + + it("rejects --background-output for image inputs (no temporal pairing to do)", () => { + expect(() => + resolveRenderTargets("/tmp/portrait.jpg", "/tmp/cutout.png", "/tmp/bg.png"), + ).toThrow(/--background-output is not supported for image inputs/); + }); + + it("rejects .png as the --background-output extension", () => { + // .png is only valid for single-image inputs, and image inputs themselves + // can't have a background-output anyway. So .png here is always a misuse. + expect(() => resolveRenderTargets("/tmp/clip.mp4", "/tmp/fg.webm", "/tmp/bg.png")).toThrow( + /--background-output must be \.webm or \.mov/, + ); + }); +}); + // Regression: a previous version of waitForExit treated `code === null` as // success. Per Node's child_process docs, that's the signal-killed case — // reporting it as success means a SIGTERM/SIGKILL'd ffmpeg encoder produces diff --git a/packages/cli/src/background-removal/pipeline.ts b/packages/cli/src/background-removal/pipeline.ts index 99b95386e..a0a0f3bfc 100644 --- a/packages/cli/src/background-removal/pipeline.ts +++ b/packages/cli/src/background-removal/pipeline.ts @@ -38,6 +38,16 @@ export const isQuality = (v: unknown): v is Quality => export interface RenderOptions { inputPath: string; outputPath: string; + /** + * Optional second output: an inverse-alpha background plate (same source + * RGB, transparent where the subject was). Only valid for video inputs and + * .webm/.mov outputs — not allowed alongside a .png output. The plate's + * format is inferred from this path independently of the foreground's. + * + * NOTE: this is a hole-cut plate, not an inpainted clean plate. Composite + * something opaque (graphics, blur, scene) under it to fill the hole. + */ + backgroundOutputPath?: string; device?: Device; model?: ModelId; /** Encoder CRF preset for `.webm`. See `QUALITY_CRF`. Ignored for `.mov`/`.png`. */ @@ -52,6 +62,8 @@ export type ProgressEvent = export interface RenderResult { outputPath: string; + /** Present only when `backgroundOutputPath` was set. */ + backgroundOutputPath?: string; framesProcessed: number; durationSeconds: number; avgMsPerFrame: number; @@ -203,17 +215,27 @@ async function* readFrames( } } -export async function render(options: RenderOptions): Promise { - if (!hasFFmpeg() || !hasFFprobe()) { - throw new Error("ffmpeg and ffprobe are required. Install: brew install ffmpeg"); - } +export interface RenderTargets { + format: OutputFormat; + inputKind: "video" | "image"; + bgFormat: OutputFormat | undefined; +} - const format = inferOutputFormat(options.outputPath); - const inputKind = inferInputKind(options.inputPath); +/** + * Resolve and validate the input/output combination before any I/O. Pure; + * exported so unit tests can pin the error messages without spawning ffmpeg. + */ +export function resolveRenderTargets( + inputPath: string, + outputPath: string, + backgroundOutputPath?: string, +): RenderTargets { + const format = inferOutputFormat(outputPath); + const inputKind = inferInputKind(inputPath); if (inputKind === "image" && format !== "png") { throw new Error( - `Image input requires a .png output (got ${extname(options.outputPath)}). Use a video input for .webm/.mov.`, + `Image input requires a .png output (got ${extname(outputPath)}). Use a video input for .webm/.mov.`, ); } if (inputKind === "video" && format === "png") { @@ -222,6 +244,35 @@ export async function render(options: RenderOptions): Promise { ); } + let bgFormat: OutputFormat | undefined; + if (backgroundOutputPath) { + if (inputKind === "image") { + throw new Error( + "--background-output is not supported for image inputs. Use a video input (mp4/mov/webm) to produce both a cutout and a background plate.", + ); + } + bgFormat = inferOutputFormat(backgroundOutputPath); + if (bgFormat === "png") { + throw new Error( + "--background-output must be .webm or .mov; .png is only valid for single-image inputs.", + ); + } + } + + return { format, inputKind, bgFormat }; +} + +export async function render(options: RenderOptions): Promise { + if (!hasFFmpeg() || !hasFFprobe()) { + throw new Error("ffmpeg and ffprobe are required. Install: brew install ffmpeg"); + } + + const { format, bgFormat } = resolveRenderTargets( + options.inputPath, + options.outputPath, + options.backgroundOutputPath, + ); + const media = await probeMedia(options.inputPath); options.onProgress?.({ @@ -240,12 +291,13 @@ export async function render(options: RenderOptions): Promise { try { const start = Date.now(); - const framesProcessed = await runPipeline(options, session, media, format); + const framesProcessed = await runPipeline(options, session, media, format, bgFormat); const durationSeconds = (Date.now() - start) / 1000; const avgMsPerFrame = framesProcessed ? (durationSeconds * 1000) / framesProcessed : 0; return { outputPath: options.outputPath, + backgroundOutputPath: options.backgroundOutputPath, framesProcessed, durationSeconds, avgMsPerFrame, @@ -259,61 +311,77 @@ export async function render(options: RenderOptions): Promise { const RECENT_WINDOW = 30; +interface FfmpegProc { + proc: ReturnType; + exit: Promise; + /** Tail of stderr, captured for inclusion in error messages. */ + getStderr: () => string; +} + +type StdioFd = "ignore" | "pipe"; +type StdioTuple = [StdioFd, StdioFd, StdioFd]; + +function spawnFfmpeg(args: string[], label: string, stdio: StdioTuple): FfmpegProc { + const proc = spawn("ffmpeg", args, { stdio }); + let stderrBuf = ""; + proc.stderr?.on("data", (d: Buffer) => { + stderrBuf += d.toString(); + }); + // If the encoder dies mid-render, the next .write() to its stdin emits an + // 'error' event on the writable. Without a listener, Node treats it as + // unhandled and crashes the CLI before waitForExit's reject path can + // surface the real cause (encoder stderr tail). Swallowing here is safe — + // the process exit is the source of truth. + proc.stdin?.on("error", () => {}); + const exit = waitForExit(proc, label, () => stderrBuf); + return { proc, exit, getStderr: () => stderrBuf }; +} + async function runPipeline( options: RenderOptions, session: Session, media: MediaInfo, format: OutputFormat, + bgFormat: OutputFormat | undefined, ): Promise { - const { inputPath, outputPath } = options; + const { inputPath, outputPath, backgroundOutputPath } = options; const { width, height, fps, frameCount } = media; const frameBytes = width * height * 3; + const quality = options.quality ?? DEFAULT_QUALITY; - const decoder = spawn( - "ffmpeg", + const decoder = spawnFfmpeg( ["-loglevel", "error", "-i", inputPath, "-f", "rawvideo", "-pix_fmt", "rgb24", "-an", "-"], - { stdio: ["ignore", "pipe", "pipe"] }, + "ffmpeg decoder", + ["ignore", "pipe", "pipe"], ); - let decoderStderr = ""; - decoder.stderr?.on("data", (d: Buffer) => { - decoderStderr += d.toString(); - }); - const decoderExit = waitForExit(decoder, "ffmpeg decoder", () => decoderStderr); - - const encoder = spawn( - "ffmpeg", - buildEncoderArgs( - format, - width, - height, - fps || 30, - outputPath, - options.quality ?? DEFAULT_QUALITY, - ), - { - stdio: ["pipe", "ignore", "pipe"], - }, + const fg = spawnFfmpeg( + buildEncoderArgs(format, width, height, fps || 30, outputPath, quality), + "ffmpeg encoder", + ["pipe", "ignore", "pipe"], ); - let encoderStderr = ""; - encoder.stderr?.on("data", (d: Buffer) => { - encoderStderr += d.toString(); - }); - const encoderExit = waitForExit(encoder, "ffmpeg encoder", () => encoderStderr); + + const bg = + backgroundOutputPath && bgFormat + ? spawnFfmpeg( + buildEncoderArgs(bgFormat, width, height, fps || 30, backgroundOutputPath, quality), + "ffmpeg background encoder", + ["pipe", "ignore", "pipe"], + ) + : null; let processed = 0; const total = frameCount; - // Running average over the last RECENT_WINDOW frames. const recentMs = new Array(RECENT_WINDOW).fill(0); let recentSum = 0; let recentSlot = 0; let recentCount = 0; try { - for await (const rgb of readFrames(decoder.stdout!, frameBytes)) { + for await (const rgb of readFrames(decoder.proc.stdout!, frameBytes)) { const t0 = Date.now(); - const rgba = await session.process(rgb, width, height); + const result = await session.process(rgb, width, height, bg !== null); const elapsed = Date.now() - t0; recentSum += elapsed - recentMs[recentSlot]!; @@ -321,8 +389,31 @@ async function runPipeline( recentSlot = (recentSlot + 1) % RECENT_WINDOW; if (recentCount < RECENT_WINDOW) recentCount++; - if (!encoder.stdin!.write(rgba)) { - await new Promise((resolve) => encoder.stdin!.once("drain", () => resolve())); + // Issue both writes before any await so a slow encoder doesn't block + // the other. Drain anything that returned false before the next + // session.process() — its output buffers are reused per frame. + // + // Subtlety: write() returning true means "highWaterMark not exceeded," + // NOT "libuv has flushed the chunk." The buffer reference is held by + // libuv until the underlying syscall completes. Reusing the session's + // output buffer is safe because the next session.process() call takes + // ~10–50ms (ORT inference) — plenty of event-loop turns for libuv to + // drain. If that ever stops being true, we'd need to copy here. + const fgWroteFully = fg.proc.stdin!.write(result.fg); + const bgWroteFully = bg && result.bg ? bg.proc.stdin!.write(result.bg) : true; + if (!fgWroteFully || !bgWroteFully) { + const drains: Promise[] = []; + if (!fgWroteFully) { + drains.push( + new Promise((resolve) => fg.proc.stdin!.once("drain", () => resolve())), + ); + } + if (!bgWroteFully && bg) { + drains.push( + new Promise((resolve) => bg.proc.stdin!.once("drain", () => resolve())), + ); + } + await Promise.all(drains); } processed++; @@ -334,17 +425,21 @@ async function runPipeline( }); } } catch (err) { - decoder.kill("SIGKILL"); - encoder.kill("SIGKILL"); + decoder.proc.kill("SIGKILL"); + fg.proc.kill("SIGKILL"); + bg?.proc.kill("SIGKILL"); throw err; } - encoder.stdin!.end(); - await Promise.all([decoderExit, encoderExit]); + fg.proc.stdin!.end(); + bg?.proc.stdin!.end(); + const exits: Promise[] = [decoder.exit, fg.exit]; + if (bg) exits.push(bg.exit); + await Promise.all(exits); if (processed === 0) { throw new Error( - `No frames produced from ${inputPath}. Decoder stderr:\n${decoderStderr.slice(-400)}`, + `No frames produced from ${inputPath}. Decoder stderr:\n${decoder.getStderr().slice(-400)}`, ); } diff --git a/packages/cli/src/commands/layout.ts b/packages/cli/src/commands/layout.ts index a8a713963..fa27dd70b 100644 --- a/packages/cli/src/commands/layout.ts +++ b/packages/cli/src/commands/layout.ts @@ -1,6 +1,6 @@ import { defineCommand } from "citty"; import { existsSync, readFileSync } from "node:fs"; -import { dirname, join, resolve } from "node:path"; +import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import type { Example } from "./_examples.js"; import { c } from "../ui/colors.js"; @@ -107,27 +107,10 @@ async function seekTo(page: import("puppeteer-core").Page, time: number): Promis } async function bundleProjectHtml(projectDir: string): Promise { + // `bundleToSingleHtml` now inlines the runtime IIFE by default, so the + // previous post-bundle runtime substitution is no longer needed. const { bundleToSingleHtml } = await import("@hyperframes/core/compiler"); - let html = await bundleToSingleHtml(projectDir); - - const runtimePath = resolve( - __dirname, - "..", - "..", - "..", - "core", - "dist", - "hyperframe.runtime.iife.js", - ); - if (existsSync(runtimePath)) { - const runtimeSource = readFileSync(runtimePath, "utf-8"); - html = html.replace( - /]*data-hyperframes-preview-runtime[^>]*src="[^"]*"[^>]*><\/script>/, - () => ``, - ); - } - - return html; + return bundleToSingleHtml(projectDir); } async function alignViewportToComposition( diff --git a/packages/cli/src/commands/remove-background.ts b/packages/cli/src/commands/remove-background.ts index cc942f67e..1116bef74 100644 --- a/packages/cli/src/commands/remove-background.ts +++ b/packages/cli/src/commands/remove-background.ts @@ -20,6 +20,10 @@ export const examples: Example[] = [ "Remove background from a single image, output transparent PNG", "hyperframes remove-background portrait.jpg -o cutout.png", ], + [ + "Separate the layers — emit both the cutout and an inverse-alpha background plate (subject region transparent)", + "hyperframes remove-background avatar.mp4 -o subject.webm --background-output plate.webm", + ], [ "Force CPU (skip CoreML/CUDA)", "hyperframes remove-background avatar.mp4 -o transparent.webm --device cpu", @@ -52,6 +56,12 @@ export default defineCommand({ description: "Output path. Format inferred from extension: .webm (default), .mov, .png", alias: "o", }, + "background-output": { + type: "string", + description: + "Optional second output path for the inverse-alpha background plate (subject region transparent, original surroundings opaque). Hole-cut, not inpainted — composite something underneath to fill the hole. Must be .webm or .mov; not allowed for image inputs.", + alias: "b", + }, device: { type: "string", description: `Execution provider: ${DEVICES.join(", ")}`, @@ -104,6 +114,8 @@ export default defineCommand({ const inputPath = resolve(args.input); const outputPath = resolve(args.output); + const backgroundOutputArg = args["background-output"]; + const backgroundOutputPath = backgroundOutputArg ? resolve(backgroundOutputArg) : undefined; const { render } = await import("../background-removal/pipeline.js"); @@ -114,6 +126,7 @@ export default defineCommand({ const result = await render({ inputPath, outputPath, + backgroundOutputPath, device: args.device, quality: args.quality, onProgress: (event) => { @@ -137,6 +150,9 @@ export default defineCommand({ JSON.stringify({ ok: true, outputPath: result.outputPath, + ...(result.backgroundOutputPath + ? { backgroundOutputPath: result.backgroundOutputPath } + : {}), framesProcessed: result.framesProcessed, durationSeconds: Number(result.durationSeconds.toFixed(2)), avgMsPerFrame: Number(result.avgMsPerFrame.toFixed(1)), @@ -148,9 +164,12 @@ export default defineCommand({ const fpsThroughput = result.durationSeconds ? (result.framesProcessed / result.durationSeconds).toFixed(1) : "n/a"; + const outputs = result.backgroundOutputPath + ? `${c.accent(result.outputPath)} + ${c.accent(result.backgroundOutputPath)}` + : c.accent(result.outputPath); spin?.stop( c.success( - `Removed background from ${c.accent(String(result.framesProcessed))} frames in ${result.durationSeconds.toFixed(1)}s (${fpsThroughput} fps, ${c.accent(result.provider)}) → ${c.accent(result.outputPath)}`, + `Removed background from ${c.accent(String(result.framesProcessed))} frames in ${result.durationSeconds.toFixed(1)}s (${fpsThroughput} fps, ${c.accent(result.provider)}) → ${outputs}`, ), ); } diff --git a/packages/cli/src/commands/snapshot.ts b/packages/cli/src/commands/snapshot.ts index 2c1d26cce..0fd2a81da 100644 --- a/packages/cli/src/commands/snapshot.ts +++ b/packages/cli/src/commands/snapshot.ts @@ -97,20 +97,9 @@ async function captureSnapshots( const numFrames = opts.frames ?? 5; - // 1. Bundle - let html = await bundleToSingleHtml(projectDir); - - // Inject local runtime if available. - // Uses the same multi-strategy resolver as the studio preview server - // (runtimeSource.ts) so snapshot works in dev (tsx), built CLI, and npx. - const { loadRuntimeSource } = await import("../server/runtimeSource.js"); - const runtimeSource = await loadRuntimeSource(); - if (runtimeSource) { - html = html.replace( - /]*data-hyperframes-preview-runtime[^>]*src="[^"]*"[^>]*><\/script>/, - () => ``, - ); - } + // 1. Bundle. `bundleToSingleHtml` now inlines the runtime IIFE by default, + // so the previous post-bundle runtime substitution is no longer needed. + const html = await bundleToSingleHtml(projectDir); const server = await serveStaticProjectHtml(projectDir, html); diff --git a/packages/cli/src/commands/validate.ts b/packages/cli/src/commands/validate.ts index 90eb99cdc..996d84465 100644 --- a/packages/cli/src/commands/validate.ts +++ b/packages/cli/src/commands/validate.ts @@ -1,6 +1,6 @@ import { defineCommand } from "citty"; import { existsSync, readFileSync } from "node:fs"; -import { resolve, join, dirname } from "node:path"; +import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { resolveProject } from "../utils/project.js"; import { resolveCompositionViewportFromHtml } from "../utils/compositionViewport.js"; @@ -113,24 +113,10 @@ async function validateInBrowser( const { bundleToSingleHtml } = await import("@hyperframes/core/compiler"); const { ensureBrowser } = await import("../browser/manager.js"); - let html = await bundleToSingleHtml(projectDir); - - const runtimePath = resolve( - __dirname, - "..", - "..", - "..", - "core", - "dist", - "hyperframe.runtime.iife.js", - ); - if (existsSync(runtimePath)) { - const runtimeSource = readFileSync(runtimePath, "utf-8"); - html = html.replace( - /]*data-hyperframes-preview-runtime[^>]*src="[^"]*"[^>]*><\/script>/, - () => ``, - ); - } + // `bundleToSingleHtml` now inlines the runtime IIFE by default, so the + // previous post-bundle regex substitution (which matched `src="..."` on the + // runtime tag) is no longer needed — there's no `src` attribute to match. + const html = await bundleToSingleHtml(projectDir); const { createServer } = await import("node:http"); const { getMimeType } = await import("@hyperframes/core/studio-api"); diff --git a/packages/cli/src/server/fileWatcher.test.ts b/packages/cli/src/server/fileWatcher.test.ts new file mode 100644 index 000000000..62412fcb9 --- /dev/null +++ b/packages/cli/src/server/fileWatcher.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; + +import { shouldWatchProjectFile } from "./fileWatcher.js"; + +describe("shouldWatchProjectFile", () => { + it("watches files that can affect the project signature", () => { + expect(shouldWatchProjectFile("index.html")).toBe(true); + expect(shouldWatchProjectFile("src/scene.tsx")).toBe(true); + expect(shouldWatchProjectFile("assets/hero.png")).toBe(true); + expect(shouldWatchProjectFile("Dockerfile")).toBe(true); + }); + + it("skips generated and dependency directories excluded from signatures", () => { + expect(shouldWatchProjectFile("node_modules/pkg/index.js")).toBe(false); + expect(shouldWatchProjectFile("renders/output.mp4")).toBe(false); + expect(shouldWatchProjectFile("dist/index.html")).toBe(false); + expect(shouldWatchProjectFile(".hyperframes/cache.json")).toBe(false); + }); +}); diff --git a/packages/cli/src/server/fileWatcher.ts b/packages/cli/src/server/fileWatcher.ts index ffe0620a9..050f009ea 100644 --- a/packages/cli/src/server/fileWatcher.ts +++ b/packages/cli/src/server/fileWatcher.ts @@ -8,9 +8,27 @@ export interface ProjectWatcher { close(): void; } -const WATCHED_EXTENSIONS = new Set([".html", ".css", ".js", ".json"]); +const WATCHER_EXCLUDED_DIRS = new Set([ + ".cache", + ".git", + ".hyperframes", + ".next", + ".vite", + "build", + "coverage", + "dist", + "node_modules", + "outputs", + "renders", +]); const DEBOUNCE_MS = 300; +export function shouldWatchProjectFile(filename: string): boolean { + if (!filename) return false; + const parts = filename.split(/[\\/]+/); + return !parts.some((part) => WATCHER_EXCLUDED_DIRS.has(part)); +} + export function createProjectWatcher(projectDir: string): ProjectWatcher { const listeners = new Set(); let debounceTimer: ReturnType | null = null; @@ -19,13 +37,13 @@ export function createProjectWatcher(projectDir: string): ProjectWatcher { try { watcher = watch(projectDir, { recursive: true }, (_event, filename) => { if (!filename) return; - const ext = "." + filename.split(".").pop()?.toLowerCase(); - if (!WATCHED_EXTENSIONS.has(ext)) return; + const relativePath = filename.toString(); + if (!shouldWatchProjectFile(relativePath)) return; if (debounceTimer) clearTimeout(debounceTimer); debounceTimer = setTimeout(() => { for (const fn of listeners) { - fn(filename); + fn(relativePath); } }, DEBOUNCE_MS); }); diff --git a/packages/cli/src/server/studioServer.ts b/packages/cli/src/server/studioServer.ts index ccceccffb..f61f6a62a 100644 --- a/packages/cli/src/server/studioServer.ts +++ b/packages/cli/src/server/studioServer.ts @@ -14,6 +14,7 @@ import { loadRuntimeSource } from "./runtimeSource.js"; import { VERSION as version } from "../version.js"; import { createStudioApi, + createProjectSignature, getMimeType, type StudioApiAdapter, type ResolvedProject, @@ -144,6 +145,10 @@ export function createStudioServer(options: StudioServerOptions): StudioServer { // ── CLI adapter for the shared studio API ────────────────────────────── const project: ResolvedProject = { id: projectId, dir: projectDir, title: projectId }; + let cachedProjectSignature: string | null = null; + watcher.addListener(() => { + cachedProjectSignature = null; + }); const adapter: StudioApiAdapter = { listProjects: () => [project], @@ -153,8 +158,11 @@ export function createStudioServer(options: StudioServerOptions): StudioServer { async bundle(dir: string): Promise { try { const { bundleToSingleHtml } = await import("@hyperframes/core/compiler"); - let html = await bundleToSingleHtml(dir); - // Fix empty runtime src from bundler — point to the local runtime endpoint + // Studio dev server: ask the bundler for an empty `src=""` placeholder so + // we can point it at our hot-reloadable local runtime endpoint. Inlining + // ~150 KB of runtime body on every preview render would defeat browser + // caching across composition edits. + let html = await bundleToSingleHtml(dir, { runtime: "placeholder" }); html = html.replace( 'data-hyperframes-preview-runtime="1" src=""', 'data-hyperframes-preview-runtime="1" src="/api/runtime.js"', @@ -166,6 +174,12 @@ export function createStudioServer(options: StudioServerOptions): StudioServer { } }, + getProjectSignature(dir: string): string { + if (resolve(dir) !== resolve(projectDir)) return createProjectSignature(dir); + cachedProjectSignature ??= createProjectSignature(projectDir); + return cachedProjectSignature; + }, + async lint(html: string, opts?: { filePath?: string }) { const { lintHyperframeHtml } = await import("@hyperframes/core/lint"); return lintHyperframeHtml(html, opts); diff --git a/packages/cli/src/utils/publishProject.test.ts b/packages/cli/src/utils/publishProject.test.ts index b5a3b8117..4764e0409 100644 --- a/packages/cli/src/utils/publishProject.test.ts +++ b/packages/cli/src/utils/publishProject.test.ts @@ -7,6 +7,7 @@ import { createPublishArchive, getPublishApiBaseUrl, publishProjectArchive, + uploadTimeoutMs, } from "./publishProject.js"; function makeProjectDir(): string { @@ -35,6 +36,22 @@ describe("createPublishArchive", () => { }); }); +describe("uploadTimeoutMs", () => { + it("returns the minimum timeout for small files", () => { + expect(uploadTimeoutMs(0)).toBe(120_000); + expect(uploadTimeoutMs(50 * 1024 * 1024)).toBe(120_000); + }); + + it("scales above the floor for large files", () => { + expect(uploadTimeoutMs(64 * 1024 * 1024)).toBeGreaterThan(120_000); + expect(uploadTimeoutMs(500 * 1024 * 1024)).toBeGreaterThan(900_000); + }); + + it("returns an integer", () => { + expect(Number.isInteger(uploadTimeoutMs(123_456))).toBe(true); + }); +}); + describe("publishProjectArchive", () => { beforeEach(() => { vi.stubEnv("HYPERFRAMES_PUBLISHED_PROJECTS_API_URL", ""); diff --git a/packages/cli/src/utils/publishProject.ts b/packages/cli/src/utils/publishProject.ts index 3b4d62855..9a317b619 100644 --- a/packages/cli/src/utils/publishProject.ts +++ b/packages/cli/src/utils/publishProject.ts @@ -5,7 +5,11 @@ import AdmZip from "adm-zip"; const IGNORED_DIRS = new Set([".git", "node_modules", "dist", ".next", "coverage"]); const IGNORED_FILES = new Set([".DS_Store", "Thumbs.db"]); const PUBLISH_CONTENT_TYPE = "application/zip"; -const PUBLISH_REQUEST_TIMEOUT_MS = 30_000; +const PUBLISH_METADATA_TIMEOUT_MS = 30_000; +const PUBLISH_UPLOAD_MIN_TIMEOUT_MS = 120_000; +// Conservative floor — most connections are faster, but this prevents +// premature aborts on slow/unstable networks (hotel wifi, tethering). +const PUBLISH_UPLOAD_BYTES_PER_SECOND = 500_000; export interface PublishArchiveResult { buffer: Buffer; @@ -25,6 +29,7 @@ interface StagedUploadResponse { uploadKey: string; contentType: string; uploadHeaders: Record; + expiresInSeconds: number; } type JsonRecord = Record; @@ -73,11 +78,14 @@ function parseStagedUploadResponse( const uploadKey = stringField(data, "upload_key"); const contentType = stringField(data, "content_type") || PUBLISH_CONTENT_TYPE; if (!uploadUrl || !uploadKey) return null; + const rawExpires = data["expires_in_seconds"]; + const expiresInSeconds = typeof rawExpires === "number" && rawExpires > 0 ? rawExpires : 1800; return { uploadUrl, uploadKey, contentType, uploadHeaders: getUploadHeaders(data, uploadUrl, contentType, archiveByteLength), + expiresInSeconds, }; } @@ -142,6 +150,13 @@ async function readErrorMessage(response: Response, fallback: string): Promise { )?.[0]; expect(runtimeBlock).toBeDefined(); - expect(runtimeBlock).not.toContain("getElementById"); + // The runtime block must contain the inlined HF runtime IIFE — bundled + // output is self-contained, so the bundle's runtime body is loaded inline, + // not referenced via src. + expect(runtimeBlock).toMatch(/data-hyperframes-preview-runtime="1">/); + expect(runtimeBlock).not.toMatch(/src=""/); + // The author's specific composition script must NOT be merged INTO the + // runtime tag — it stays as its own when no runtime URL was configured. An + // empty src resolves to the page URL itself, which Chrome flags as an + // infinite-fetch hazard. Verify that bundleToSingleHtml inlines the + // runtime body so the bundle is genuinely self-contained. + const dir = makeTempProject({ + "index.html": ` + +
+`, + }); + + const previousUrl = process.env.HYPERFRAME_RUNTIME_URL; + delete process.env.HYPERFRAME_RUNTIME_URL; + let bundled: string; + try { + bundled = await bundleToSingleHtml(dir); + } finally { + if (previousUrl !== undefined) process.env.HYPERFRAME_RUNTIME_URL = previousUrl; + } + + const runtimeBlock = bundled.match( + /]*data-hyperframes-preview-runtime[^>]*>[\s\S]*?<\/script>/i, + )?.[0]; + expect(runtimeBlock).toBeDefined(); + // Must NOT have an empty src attribute (would self-fetch). + expect(runtimeBlock).not.toMatch(/src=""/); + // Must have a non-trivial inlined body (the runtime IIFE is ~150KB). + const innerLength = (runtimeBlock!.match(/>([\s\S]*?)<\/script>/)?.[1] ?? "").length; + expect(innerLength).toBeGreaterThan(1000); + }); + + it("preserves chunk integrity when a chunk ends with a line comment (ASI hazard guard)", async () => { + // Regression guard for the joinJsChunks helper. If a chunk ends with `// ...` + // and we naively appended `;` on the same line, the appended semicolon would + // be eaten by the comment, leaving the next chunk's first statement attached + // to the previous chunk's last expression. Verify the helper appends `\n;` + // instead so the comment terminates and the semicolon stands alone. + const dir = makeTempProject({ + "index.html": ` + +
+ + + +`, + // Chunk A ends with a // line comment — without the \n separator before + // the appended ;, that ; would be eaten by the comment. + "local-a.js": "window.__a = 1 // trailing line comment", + "local-b.js": "window.__b = 2", + }); + + const bundled = await bundleToSingleHtml(dir); + // Run every inline script body through esbuild; if the line comment ate + // the separator, parse would fail with an unexpected-token error somewhere + // around the chunk boundary. Use a real HTML parser (CodeQL flags regex- + // based script extraction as bad-tag-filter). + const { transformSync } = await import("esbuild"); + const { document } = parseHTML(bundled); + for (const script of document.querySelectorAll("script")) { + const body = script.textContent; + if (!body || !body.trim()) continue; + expect(() => transformSync(body, { loader: "js", minify: false })).not.toThrow(); + } + }); + + it("does not produce stray bare-semicolon lines between concatenated JS chunks", async () => { + // Regression guard: hf#XXX. Earlier the bundler joined script chunks with + // `\n;\n`, which produces a lone `;` on its own line between chunks. Valid + // JS but reads as a code smell. Each chunk should end in `;` and chunks + // should join with `\n`. + const dir = makeTempProject({ + "index.html": ` + +
+
+
+ + + +`, + "local-a.js": "window.__a = 1", + "local-b.js": "window.__b = 2", + "compositions/child.html": ``, + }); + + const bundled = await bundleToSingleHtml(dir); + // No line is JUST a bare semicolon (with optional surrounding whitespace). + expect(bundled).not.toMatch(/\n\s*;\s*\n/); + }); + it("hoists external CDN scripts from sub-compositions into the bundle", async () => { const dir = makeTempProject({ "index.html": ` @@ -84,8 +190,14 @@ describe("bundleToSingleHtml", () => { // GSAP CDN from main doc should still be present expect(bundled).toContain("cdn.jsdelivr.net/npm/gsap"); - // data-composition-src should be stripped (composition was inlined) - expect(bundled).not.toContain("data-composition-src"); + // data-composition-src should be stripped from the host element (composition + // was inlined). The literal string may still appear inside the inlined + // runtime IIFE that knows how to look up that attribute — so check the DOM, + // not the raw text. + const { document: doc } = parseHTML(bundled); + const hostEl = doc.getElementById("rockets-host"); + expect(hostEl).toBeTruthy(); + expect(hostEl?.hasAttribute("data-composition-src")).toBe(false); }); it("does not duplicate CDN scripts already present in the main document", async () => { diff --git a/packages/core/src/compiler/htmlBundler.ts b/packages/core/src/compiler/htmlBundler.ts index 3a80d58c9..def7c8463 100644 --- a/packages/core/src/compiler/htmlBundler.ts +++ b/packages/core/src/compiler/htmlBundler.ts @@ -10,6 +10,7 @@ import { import { rewriteAssetPaths, rewriteCssAssetUrls } from "./rewriteSubCompPaths"; import { scopeCssToComposition, wrapScopedCompositionScript } from "./compositionScoping"; import { validateHyperframeHtmlContract } from "./staticGuard"; +import { getHyperframeRuntimeScript } from "../generated/runtime-inline"; /** Resolve a relative path within projectDir, rejecting traversal outside it. */ function safePath(projectDir: string, relativePath: string): string | null { @@ -26,12 +27,28 @@ function getRuntimeScriptUrl(): string { return configured || DEFAULT_RUNTIME_SCRIPT_URL; } -function injectInterceptor(html: string): string { +function injectInterceptor(html: string, runtimeMode: "inline" | "placeholder" = "inline"): string { const sanitized = stripEmbeddedRuntimeScripts(html); if (sanitized.includes(RUNTIME_BOOTSTRAP_ATTR)) return sanitized; - const runtimeScriptUrl = getRuntimeScriptUrl().replace(/"/g, """); - const tag = ``; + // Three modes for the runtime `; + } else if (runtimeMode === "placeholder") { + tag = ``; + } else { + const inlinedRuntime = getHyperframeRuntimeScript(); + tag = ``; + } if (sanitized.includes("")) { return sanitized.replace("", `${tag}\n`); } @@ -268,11 +285,7 @@ function coalesceHeadStylesAndBodyScripts(document: Document): void { return !type || type === "text/javascript" || type === "application/javascript"; }); if (bodyInlineScripts.length > 0) { - const mergedJs = bodyInlineScripts - .map((el) => (el.textContent || "").trim()) - .filter(Boolean) - .join("\n;\n") - .trim(); + const mergedJs = joinJsChunks(bodyInlineScripts.map((el) => el.textContent || "")); for (const el of bodyInlineScripts) el.remove(); if (mergedJs) { const stripped = stripJsCommentsParserSafe(mergedJs); @@ -283,6 +296,31 @@ function coalesceHeadStylesAndBodyScripts(document: Document): void { } } +/** + * Concatenate JS chunks safely. Goals: + * - Each chunk's last statement is terminated, so joining can't introduce ASI + * surprises (e.g. `a()` followed by `(b)()` — the second chunk would parse + * as a call on the first's return value). + * - In the common case (chunk already ends with `;` — typical of esbuild + * output and IIFE-wrapped composition scripts ending in `})();`), the join + * produces clean output: chunks separated by `\n` with no stray bare + * semicolon lines. + * - Defensive against trailing line comments. If a chunk ends with `// ...` + * and we appended `;` on the same line, the appended semicolon would be + * swallowed by the comment, leaving the next chunk's first statement + * attached to the previous chunk's last expression — exactly the ASI + * hazard this helper exists to prevent. So when a chunk doesn't already + * end in `;`, we append `\n;` instead — the newline closes any line + * comment, and the standalone `;` becomes the statement separator. + */ +function joinJsChunks(chunks: string[]): string { + return chunks + .map((chunk) => chunk.trim()) + .filter((chunk) => chunk.length > 0) + .map((chunk) => (chunk.endsWith(";") ? chunk : chunk + "\n;")) + .join("\n"); +} + function stripJsCommentsParserSafe(source: string): string { if (!source) return source; try { @@ -296,6 +334,22 @@ function stripJsCommentsParserSafe(source: string): string { export interface BundleOptions { /** Optional media duration prober (e.g., ffprobe). If omitted, media durations are not resolved. */ probeMediaDuration?: MediaDurationProber; + /** + * How to handle the HyperFrames runtime ` so the caller can + * substitute it with a real URL via string replace. Used by the dev studio + * server and vite preview to point at a local runtime endpoint, which keeps + * the runtime cacheable across hot-reloads instead of re-inlining ~150 KB + * on every change. + * + * The `HYPERFRAME_RUNTIME_URL` env var, when set, takes precedence over both + * modes and emits `', + ); + + const srcdoc = player.iframeElement.srcdoc; + expect(srcdoc).toContain('window.__HF_SHADER_CAPTURE_SCALE="0.5";'); + expect(srcdoc).toContain('window.__HF_SHADER_LOADING="player";'); + expect(srcdoc.indexOf("data-hyperframes-player-shader-options")).toBeLessThan( + srcdoc.indexOf("composition.js"), + ); + }); + + it("shows and hides the player-owned shader loader from transition state messages", () => { + vi.useFakeTimers(); + const player = document.createElement("hyperframes-player") as PlayerWithIframe; + player.setAttribute("shader-loading", "player"); + document.body.appendChild(player); + + const iframeWindow = player.iframeElement.contentWindow; + expect(iframeWindow).toBeTruthy(); + window.dispatchEvent( + new MessageEvent("message", { + source: iframeWindow, + data: { + source: "hf-preview", + type: "shader-transition-state", + compositionId: "main", + state: { + loading: true, + progress: 3, + total: 10, + currentTransition: 1, + transitionTotal: 2, + transitionFrame: 3, + transitionFrames: 5, + phase: "capturing", + }, + }, + }), + ); + + const loader = player.shadowRoot?.querySelector(".hfp-shader-loader"); + expect(loader?.classList.contains("hfp-visible")).toBe(true); + expect(loader?.textContent).toContain("1/2"); + expect(loader?.textContent).toContain("3/5"); + + const playEvents: Event[] = []; + player.addEventListener("play", (event) => playEvents.push(event)); + loader?.dispatchEvent(new MouseEvent("click", { bubbles: true, composed: true })); + expect(playEvents).toHaveLength(0); + + window.dispatchEvent( + new MessageEvent("message", { + source: iframeWindow, + data: { + source: "hf-preview", + type: "shader-transition-state", + compositionId: "main", + state: { loading: false, ready: true }, + }, + }), + ); + window.dispatchEvent( + new MessageEvent("message", { + source: iframeWindow, + data: { + source: "hf-preview", + type: "shader-transition-state", + compositionId: "main", + state: { loading: false, ready: true }, + }, + }), + ); + expect(loader?.classList.contains("hfp-visible")).toBe(false); + expect(loader?.classList.contains("hfp-hiding")).toBe(true); + vi.advanceTimersByTime(420); + expect(loader?.classList.contains("hfp-hiding")).toBe(false); + vi.useRealTimers(); + }); +}); + // ── Shared stylesheet (adoptedStyleSheets) ── // // Every player constructed in the same document should adopt the *same* diff --git a/packages/player/src/hyperframes-player.ts b/packages/player/src/hyperframes-player.ts index 0c180f731..a9cd174d0 100644 --- a/packages/player/src/hyperframes-player.ts +++ b/packages/player/src/hyperframes-player.ts @@ -20,6 +20,114 @@ function getSharedSheet(): CSSStyleSheet | null { const DEFAULT_FPS = 30; const RUNTIME_CDN_URL = "https://cdn.jsdelivr.net/npm/@hyperframes/core/dist/hyperframe.runtime.iife.js"; +const SHADER_CAPTURE_SCALE_ATTR = "shader-capture-scale"; +const SHADER_LOADING_ATTR = "shader-loading"; +const SHADER_CAPTURE_SCALE_PARAM = "__hf_shader_capture_scale"; +const SHADER_LOADING_PARAM = "__hf_shader_loading"; + +export type ShaderLoadingMode = "composition" | "player" | "none"; + +interface ShaderTransitionState { + ready?: boolean; + progress?: number; + total?: number; + currentTransition?: number; + transitionTotal?: number; + transitionFrame?: number; + transitionFrames?: number; + phase?: "cached" | "capturing" | "finalizing"; + loading?: boolean; +} + +interface ShaderLoaderElements { + root: HTMLDivElement; + fill: HTMLDivElement; + title: HTMLSpanElement; + detail: HTMLDivElement; + transitionValue: HTMLSpanElement; + frameLabel: HTMLSpanElement; + frameValue: HTMLSpanElement; + frameRow: HTMLDivElement; +} + +const SHADER_LOADING_PHRASES = [ + "Preparing scene transitions", + "Sampling outgoing scene motion", + "Sampling incoming scene motion", + "Caching transition frames", + "Finalizing transition preview", +]; + +function normalizeShaderCaptureScale(value: string | null): string | null { + if (value === null) return null; + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) return null; + return String(Math.min(1, Math.max(0.25, parsed))); +} + +function normalizeShaderLoadingMode(value: string | null): ShaderLoadingMode { + if (value === null || value.trim() === "") return "composition"; + const normalized = value.trim().toLowerCase(); + if ( + normalized === "none" || + normalized === "false" || + normalized === "0" || + normalized === "off" + ) { + return "none"; + } + if ( + normalized === "player" || + normalized === "true" || + normalized === "1" || + normalized === "on" + ) { + return "player"; + } + return "composition"; +} + +function setQueryParam(params: URLSearchParams, key: string, value: string | null): void { + if (value === null) params.delete(key); + else params.set(key, value); +} + +function withShaderQueryParams( + src: string, + scale: string | null, + loadingMode: ShaderLoadingMode, +): string { + const hashIndex = src.indexOf("#"); + const beforeHash = hashIndex >= 0 ? src.slice(0, hashIndex) : src; + const hash = hashIndex >= 0 ? src.slice(hashIndex) : ""; + const queryIndex = beforeHash.indexOf("?"); + const path = queryIndex >= 0 ? beforeHash.slice(0, queryIndex) : beforeHash; + const query = queryIndex >= 0 ? beforeHash.slice(queryIndex + 1) : ""; + const params = new URLSearchParams(query); + setQueryParam(params, SHADER_CAPTURE_SCALE_PARAM, scale); + setQueryParam(params, SHADER_LOADING_PARAM, loadingMode === "composition" ? null : loadingMode); + const nextQuery = params.toString(); + return `${path}${nextQuery ? `?${nextQuery}` : ""}${hash}`; +} + +function injectShaderOptionsIntoSrcdoc( + html: string, + scale: string | null, + loadingMode: ShaderLoadingMode, +): string { + if (scale === null && loadingMode === "composition") return html; + const lines: string[] = []; + if (scale !== null) lines.push(`window.__HF_SHADER_CAPTURE_SCALE=${JSON.stringify(scale)};`); + if (loadingMode !== "composition") { + lines.push(`window.__HF_SHADER_LOADING=${JSON.stringify(loadingMode)};`); + } + const script = ``; + if (/]*>/i.test(html)) + return html.replace(/]*>/i, (match) => `${match}${script}`); + if (/]*>/i.test(html)) + return html.replace(/]*>/i, (match) => `${match}${script}`); + return `${script}${html}`; +} class HyperframesPlayer extends HTMLElement { static get observedAttributes() { @@ -33,6 +141,8 @@ class HyperframesPlayer extends HTMLElement { "poster", "playback-rate", "audio-src", + SHADER_CAPTURE_SCALE_ATTR, + SHADER_LOADING_ATTR, ]; } @@ -42,6 +152,15 @@ class HyperframesPlayer extends HTMLElement { private posterEl: HTMLImageElement | null = null; private controlsApi: ReturnType | null = null; private resizeObserver: ResizeObserver; + private shaderLoaderEl: HTMLDivElement; + private shaderLoaderFillEl: HTMLDivElement; + private shaderLoaderTitleEl: HTMLSpanElement; + private shaderLoaderDetailEl: HTMLDivElement; + private shaderLoaderTransitionValueEl: HTMLSpanElement; + private shaderLoaderFrameLabelEl: HTMLSpanElement; + private shaderLoaderFrameValueEl: HTMLSpanElement; + private shaderLoaderFrameRowEl: HTMLDivElement; + private shaderLoaderHideTimeout: ReturnType | null = null; private _ready = false; private _duration = 0; @@ -141,6 +260,16 @@ class HyperframesPlayer extends HTMLElement { this.container.appendChild(this.iframe); this.shadow.appendChild(this.container); + const shaderLoader = this._createShaderLoader(); + this.shaderLoaderEl = shaderLoader.root; + this.shaderLoaderFillEl = shaderLoader.fill; + this.shaderLoaderTitleEl = shaderLoader.title; + this.shaderLoaderDetailEl = shaderLoader.detail; + this.shaderLoaderTransitionValueEl = shaderLoader.transitionValue; + this.shaderLoaderFrameLabelEl = shaderLoader.frameLabel; + this.shaderLoaderFrameValueEl = shaderLoader.frameValue; + this.shaderLoaderFrameRowEl = shaderLoader.frameRow; + this.shadow.appendChild(this.shaderLoaderEl); // Clicking the bare player surface toggles play/pause. // Ignore shadow-DOM control interactions so overlay clicks don't double-handle. @@ -167,8 +296,9 @@ class HyperframesPlayer extends HTMLElement { this._setupParentAudioFromUrl(this.getAttribute("audio-src")!); // srcdoc wins over src per HTML spec when both are set; mirror both attributes // so the browser applies the standard precedence rules. - if (this.hasAttribute("srcdoc")) this.iframe.srcdoc = this.getAttribute("srcdoc")!; - if (this.hasAttribute("src")) this.iframe.src = this.getAttribute("src")!; + if (this.hasAttribute("srcdoc")) + this.iframe.srcdoc = this._prepareSrcdoc(this.getAttribute("srcdoc")!); + if (this.hasAttribute("src")) this.iframe.src = this._prepareSrc(this.getAttribute("src")!); } disconnectedCallback() { @@ -176,6 +306,8 @@ class HyperframesPlayer extends HTMLElement { window.removeEventListener("message", this._onMessage); this.iframe.removeEventListener("load", this._onIframeLoad); if (this._probeInterval) clearInterval(this._probeInterval); + if (this.shaderLoaderHideTimeout) clearTimeout(this.shaderLoaderHideTimeout); + this.shaderLoaderHideTimeout = null; this._teardownMediaObserver(); this.controlsApi?.destroy(); for (const m of this._parentMedia) { @@ -190,7 +322,7 @@ class HyperframesPlayer extends HTMLElement { case "src": if (val) { this._ready = false; - this.iframe.src = val; + this.iframe.src = this._prepareSrc(val); } break; case "srcdoc": @@ -198,7 +330,7 @@ class HyperframesPlayer extends HTMLElement { // srcdoc and let src take over. Always reset readiness; the iframe will // load a new document either way. this._ready = false; - if (val !== null) this.iframe.srcdoc = val; + if (val !== null) this.iframe.srcdoc = this._prepareSrcdoc(val); else this.iframe.removeAttribute("srcdoc"); break; case "width": @@ -234,6 +366,10 @@ class HyperframesPlayer extends HTMLElement { case "audio-src": if (val) this._setupParentAudioFromUrl(val); break; + case SHADER_CAPTURE_SCALE_ATTR: + case SHADER_LOADING_ATTR: + this._reloadShaderOptions(); + break; } } @@ -355,6 +491,21 @@ class HyperframesPlayer extends HTMLElement { this.setAttribute("playback-rate", String(r)); } + get shaderCaptureScale() { + return Number(normalizeShaderCaptureScale(this.getAttribute(SHADER_CAPTURE_SCALE_ATTR)) ?? "1"); + } + set shaderCaptureScale(scale: number) { + this.setAttribute(SHADER_CAPTURE_SCALE_ATTR, String(scale)); + } + + get shaderLoading() { + return normalizeShaderLoadingMode(this.getAttribute(SHADER_LOADING_ATTR)); + } + set shaderLoading(mode: ShaderLoadingMode) { + if (mode === "composition") this.removeAttribute(SHADER_LOADING_ATTR); + else this.setAttribute(SHADER_LOADING_ATTR, mode); + } + get muted() { return this.hasAttribute("muted"); } @@ -384,6 +535,236 @@ class HyperframesPlayer extends HTMLElement { } } + private _shaderCaptureScaleParam(): string | null { + return normalizeShaderCaptureScale(this.getAttribute(SHADER_CAPTURE_SCALE_ATTR)); + } + + private _shaderLoadingMode(): ShaderLoadingMode { + return normalizeShaderLoadingMode(this.getAttribute(SHADER_LOADING_ATTR)); + } + + private _prepareSrc(src: string): string { + return withShaderQueryParams(src, this._shaderCaptureScaleParam(), this._shaderLoadingMode()); + } + + private _prepareSrcdoc(srcdoc: string): string { + return injectShaderOptionsIntoSrcdoc( + srcdoc, + this._shaderCaptureScaleParam(), + this._shaderLoadingMode(), + ); + } + + private _reloadShaderOptions(): void { + if (this._shaderLoadingMode() !== "player") { + this._resetShaderLoader(); + } + if (this.hasAttribute("srcdoc")) { + this.iframe.srcdoc = this._prepareSrcdoc(this.getAttribute("srcdoc") || ""); + return; + } + if (this.hasAttribute("src")) { + this.iframe.src = this._prepareSrc(this.getAttribute("src") || ""); + } + } + + private _createShaderLoader(): ShaderLoaderElements { + const root = document.createElement("div"); + root.className = "hfp-shader-loader"; + root.setAttribute("role", "status"); + root.setAttribute("aria-live", "polite"); + root.setAttribute("aria-label", "Preparing scene transitions"); + root.setAttribute("data-hyperframes-ignore", ""); + root.draggable = false; + + const blockOverlayInteraction = (event: Event) => { + event.preventDefault(); + event.stopPropagation(); + }; + for (const eventName of [ + "selectstart", + "dragstart", + "pointerdown", + "mousedown", + "click", + "dblclick", + "contextmenu", + "touchstart", + ]) { + root.addEventListener(eventName, blockOverlayInteraction, { capture: true }); + } + + const panel = document.createElement("div"); + panel.className = "hfp-shader-loader-panel"; + panel.draggable = false; + + const markFrame = document.createElement("div"); + markFrame.className = "hfp-shader-loader-mark"; + markFrame.draggable = false; + markFrame.innerHTML = [ + '", + ].join(""); + + const title = document.createElement("div"); + title.className = "hfp-shader-loader-title"; + const titleText = document.createElement("span"); + titleText.className = "hfp-shader-loader-title-text"; + titleText.textContent = SHADER_LOADING_PHRASES[0] || "Preparing scene transitions"; + title.appendChild(titleText); + + const detail = document.createElement("div"); + detail.className = "hfp-shader-loader-detail"; + detail.textContent = "Rendering animated scene samples for shader transitions."; + + const track = document.createElement("div"); + track.className = "hfp-shader-loader-track"; + track.setAttribute("aria-hidden", "true"); + const fill = document.createElement("div"); + fill.className = "hfp-shader-loader-fill"; + track.appendChild(fill); + + const progress = document.createElement("div"); + progress.className = "hfp-shader-loader-progress"; + const createProgressRow = (labelText: string) => { + const row = document.createElement("div"); + row.className = "hfp-shader-loader-row"; + const label = document.createElement("span"); + label.className = "hfp-shader-loader-label"; + label.textContent = labelText; + const value = document.createElement("span"); + value.className = "hfp-shader-loader-value"; + row.appendChild(label); + row.appendChild(value); + progress.appendChild(row); + return { row, label, value }; + }; + const transitionStatus = createProgressRow("transition"); + const frameStatus = createProgressRow("transition frame"); + + panel.appendChild(markFrame); + panel.appendChild(title); + panel.appendChild(detail); + panel.appendChild(track); + panel.appendChild(progress); + root.appendChild(panel); + + return { + root, + fill, + title: titleText, + detail, + transitionValue: transitionStatus.value, + frameLabel: frameStatus.label, + frameValue: frameStatus.value, + frameRow: frameStatus.row, + }; + } + + private _showShaderLoader(): void { + if (this.shaderLoaderHideTimeout) { + clearTimeout(this.shaderLoaderHideTimeout); + this.shaderLoaderHideTimeout = null; + } + this.shaderLoaderEl.classList.remove("hfp-hiding"); + this.shaderLoaderEl.classList.add("hfp-visible"); + } + + private _hideShaderLoader(): void { + if (this.shaderLoaderEl.classList.contains("hfp-hiding")) { + if (!this.shaderLoaderHideTimeout) this._scheduleShaderLoaderHideCleanup(); + return; + } + if (!this.shaderLoaderEl.classList.contains("hfp-visible")) return; + this.shaderLoaderEl.classList.add("hfp-hiding"); + this.shaderLoaderEl.classList.remove("hfp-visible"); + this._scheduleShaderLoaderHideCleanup(); + } + + private _scheduleShaderLoaderHideCleanup(): void { + if (this.shaderLoaderHideTimeout) clearTimeout(this.shaderLoaderHideTimeout); + this.shaderLoaderHideTimeout = setTimeout(() => { + this.shaderLoaderEl.classList.remove("hfp-hiding"); + this.shaderLoaderHideTimeout = null; + }, 420); + } + + private _resetShaderLoader(): void { + if (this.shaderLoaderHideTimeout) { + clearTimeout(this.shaderLoaderHideTimeout); + this.shaderLoaderHideTimeout = null; + } + this.shaderLoaderEl.classList.remove("hfp-visible", "hfp-hiding"); + this.shaderLoaderFillEl.style.transform = "scaleX(0)"; + this.shaderLoaderTransitionValueEl.textContent = ""; + this.shaderLoaderFrameValueEl.textContent = ""; + this.shaderLoaderFrameRowEl.style.visibility = "hidden"; + } + + private _updateShaderLoader(status: ShaderTransitionState): void { + if (this._shaderLoadingMode() !== "player") { + this._resetShaderLoader(); + return; + } + if (status.ready || !status.loading) { + this._hideShaderLoader(); + return; + } + + const progress = + typeof status.progress === "number" && Number.isFinite(status.progress) ? status.progress : 0; + const total = + typeof status.total === "number" && Number.isFinite(status.total) ? status.total : 0; + const ratio = total > 0 ? Math.min(1, Math.max(0, progress / total)) : 0; + const phraseIndex = Math.min( + SHADER_LOADING_PHRASES.length - 1, + Math.floor(ratio * SHADER_LOADING_PHRASES.length), + ); + this.shaderLoaderTitleEl.textContent = + SHADER_LOADING_PHRASES[phraseIndex] || "Preparing scene transitions"; + this.shaderLoaderDetailEl.textContent = + status.phase === "cached" + ? "Loading cached transition frames before playback." + : status.phase === "finalizing" + ? "Uploading transition textures for smooth playback." + : "Rendering animated scene samples for shader transitions."; + this.shaderLoaderFillEl.style.transform = `scaleX(${ratio})`; + + this.shaderLoaderTransitionValueEl.textContent = + status.currentTransition !== undefined && status.transitionTotal !== undefined + ? `${status.currentTransition}/${status.transitionTotal}` + : total > 0 + ? `${progress}/${total}` + : ""; + + const frameValue = + status.transitionFrame !== undefined && status.transitionFrames !== undefined + ? `${status.transitionFrame}/${status.transitionFrames}` + : ""; + this.shaderLoaderFrameLabelEl.textContent = + status.phase === "cached" + ? "cached transition frames" + : status.phase === "finalizing" + ? "finalizing transition frames" + : "rendering transition frames"; + this.shaderLoaderFrameValueEl.textContent = frameValue; + this.shaderLoaderFrameRowEl.style.visibility = frameValue ? "visible" : "hidden"; + this.shaderLoaderEl.setAttribute("aria-valuenow", String(Math.round(ratio * 100))); + this._showShaderLoader(); + } + /** * Reach into the runtime's `window.__player.seek` directly, skipping the * postMessage hop. Same-origin only — cross-origin embeds throw a @@ -426,6 +807,18 @@ class HyperframesPlayer extends HTMLElement { const data = e.data; if (!data || data.source !== "hf-preview") return; + if (data.type === "shader-transition-state") { + const state: ShaderTransitionState = + data.state && typeof data.state === "object" ? data.state : {}; + this._updateShaderLoader(state); + this.dispatchEvent( + new CustomEvent("shadertransitionstate", { + detail: { compositionId: data.compositionId, state }, + }), + ); + return; + } + if (data.type === "state") { this._currentTime = (data.frame ?? 0) / DEFAULT_FPS; const wasPlaying = !this._paused; @@ -501,6 +894,7 @@ class HyperframesPlayer extends HTMLElement { private _onIframeLoad() { let attempts = 0; this._runtimeInjected = false; + this._resetShaderLoader(); // A fresh iframe means a fresh runtime — `mediaOutputMuted` and the // autoplay-blocked latch are both reset inside it. The web component's // `_audioOwner` must reset to match, otherwise a composition switch on diff --git a/packages/player/src/styles.ts b/packages/player/src/styles.ts index 35a565b42..200269e77 100644 --- a/packages/player/src/styles.ts +++ b/packages/player/src/styles.ts @@ -31,6 +31,161 @@ export const PLAYER_STYLES = /* css */ ` pointer-events: none; } + .hfp-shader-loader { + position: absolute; + inset: 0; + z-index: 20; + display: grid; + place-items: center; + visibility: hidden; + opacity: 0; + pointer-events: none; + background: #030504; + color: #f4f7fb; + cursor: default; + user-select: none; + -webkit-user-select: none; + transition: opacity 420ms ease-out, visibility 420ms ease-out; + } + + .hfp-shader-loader.hfp-visible, + .hfp-shader-loader.hfp-hiding { + visibility: visible; + } + + .hfp-shader-loader.hfp-visible { + opacity: 1; + pointer-events: auto; + } + + .hfp-shader-loader.hfp-hiding { + opacity: 0; + pointer-events: none; + } + + .hfp-shader-loader-panel { + display: grid; + grid-template-rows: 86px 40px 26px 12px 44px; + justify-items: center; + align-items: center; + gap: 8px; + width: min(620px, 82%); + text-align: center; + font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + } + + .hfp-shader-loader-mark { + width: 86px; + height: 86px; + display: grid; + place-items: center; + overflow: visible; + } + + .hfp-shader-loader-mark svg { + display: block; + overflow: visible; + filter: drop-shadow(0 0 5px rgba(79, 219, 94, 0.16)); + pointer-events: none; + } + + .hfp-shader-loader-title { + width: 100%; + height: 40px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + font-size: 26px; + line-height: 40px; + font-weight: 700; + letter-spacing: 0; + } + + .hfp-shader-loader-title-text { + color: transparent; + background: linear-gradient( + 90deg, + rgba(244, 247, 251, 0.84) 0%, + #ffffff 42%, + #80efe4 52%, + #ffffff 62%, + rgba(244, 247, 251, 0.84) 100% + ); + background-size: 220% 100%; + -webkit-background-clip: text; + background-clip: text; + animation: hfp-shader-loader-sheen 1.9s linear infinite; + } + + .hfp-shader-loader-detail { + width: 100%; + height: 26px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + color: rgba(244, 247, 251, 0.62); + font-size: 15px; + line-height: 26px; + font-weight: 500; + } + + .hfp-shader-loader-track { + width: min(360px, 100%); + height: 8px; + overflow: hidden; + border-radius: 999px; + background: rgba(255, 255, 255, 0.1); + } + + .hfp-shader-loader-fill { + width: 100%; + height: 100%; + border-radius: inherit; + background: linear-gradient(90deg, #06e3fa, #4fdb5e); + transform: scaleX(0); + transform-origin: left center; + transition: transform 160ms ease; + } + + .hfp-shader-loader-progress { + width: min(420px, 100%); + height: 44px; + display: grid; + grid-template-rows: repeat(2, 22px); + color: rgba(244, 247, 251, 0.48); + font: 600 13px/22px "IBM Plex Mono", "SF Mono", "Fira Code", "Courier New", monospace; + font-variant-numeric: tabular-nums; + } + + .hfp-shader-loader-row { + display: grid; + grid-template-columns: minmax(0, 1fr) 74px; + align-items: center; + column-gap: 20px; + width: 100%; + white-space: nowrap; + } + + .hfp-shader-loader-label { + min-width: 0; + overflow: hidden; + text-align: left; + text-overflow: ellipsis; + } + + .hfp-shader-loader-value { + text-align: right; + } + + @keyframes hfp-shader-loader-sheen { + from { + background-position: 140% 0; + } + to { + background-position: -140% 0; + } + } + /* ── Theming via CSS custom properties ── * * Override from outside the shadow DOM: diff --git a/packages/producer/src/services/renderOrchestrator.test.ts b/packages/producer/src/services/renderOrchestrator.test.ts index 01b5617d2..a6c5bdc7e 100644 --- a/packages/producer/src/services/renderOrchestrator.test.ts +++ b/packages/producer/src/services/renderOrchestrator.test.ts @@ -21,8 +21,10 @@ import { materializeExtractedFramesForCompiledDir, projectBrowserEndToCompositionTimeline, resolveRenderWorkerCount, + resolveCompositeTransfer, selectCaptureCalibrationFrames, shouldFallbackToScreenshotAfterCalibrationError, + shouldUseLayeredComposite, shouldUseStreamingEncode, writeCompiledArtifacts, } from "./renderOrchestrator.js"; @@ -543,6 +545,48 @@ describe("estimateCaptureCostMultiplier", () => { }); }); +describe("shouldUseLayeredComposite", () => { + it("uses the layered compositor for SDR shader transition renders", () => { + expect( + shouldUseLayeredComposite({ + hasHdrContent: false, + hasShaderTransitions: true, + isPngSequence: false, + }), + ).toBe(true); + }); + + it("does not route PNG sequence shader renders through the streaming layered compositor", () => { + expect( + shouldUseLayeredComposite({ + hasHdrContent: false, + hasShaderTransitions: true, + isPngSequence: true, + }), + ).toBe(false); + }); + + it("keeps HDR content on the layered compositor even without shader transitions", () => { + expect( + shouldUseLayeredComposite({ + hasHdrContent: true, + hasShaderTransitions: false, + isPngSequence: false, + }), + ).toBe(true); + }); +}); + +describe("resolveCompositeTransfer", () => { + it("uses 16-bit-expanded sRGB for SDR layered shader transition renders", () => { + expect(resolveCompositeTransfer(false, undefined)).toBe("srgb"); + }); + + it("uses the active HDR transfer when HDR content is being preserved", () => { + expect(resolveCompositeTransfer(true, { transfer: "hlg" })).toBe("hlg"); + }); +}); + describe("estimateMeasuredCaptureCostMultiplier", () => { it("turns slow calibration samples into a capture cost multiplier", () => { const estimate = estimateMeasuredCaptureCostMultiplier([ diff --git a/packages/producer/src/services/renderOrchestrator.ts b/packages/producer/src/services/renderOrchestrator.ts index af7157cca..ed134be44 100644 --- a/packages/producer/src/services/renderOrchestrator.ts +++ b/packages/producer/src/services/renderOrchestrator.ts @@ -1534,6 +1534,23 @@ function blitHdrImageLayer( * extracting them into an explicit struct lets the helper live at module * scope (no closure-over-renderJob) and keeps the per-call signature small. */ +type CompositeTransfer = HdrTransfer | "srgb"; + +export function shouldUseLayeredComposite(options: { + hasHdrContent: boolean; + hasShaderTransitions: boolean; + isPngSequence: boolean; +}): boolean { + return options.hasHdrContent || (options.hasShaderTransitions && !options.isPngSequence); +} + +export function resolveCompositeTransfer( + hasHdrContent: boolean, + effectiveHdr: { transfer: HdrTransfer } | undefined, +): CompositeTransfer { + return hasHdrContent && effectiveHdr ? effectiveHdr.transfer : "srgb"; +} + interface HdrCompositeContext { log: ProducerLogger; domSession: CaptureSession; @@ -1541,7 +1558,7 @@ interface HdrCompositeContext { width: number; height: number; fps: number; - effectiveHdr: { transfer: HdrTransfer }; + compositeTransfer: CompositeTransfer; nativeHdrImageIds: Set; hdrImageBuffers: Map; hdrImageTransferCache: HdrImageTransferCache; @@ -1596,7 +1613,7 @@ async function compositeHdrFrame( width, height, fps, - effectiveHdr, + compositeTransfer, nativeHdrImageIds, hdrImageBuffers, hdrImageTransferCache, @@ -1648,6 +1665,7 @@ async function compositeHdrFrame( if (layer.element.opacity <= 0) continue; const before = shouldLog ? countNonZeroRgb48(canvas) : 0; const isHdrImage = nativeHdrImageIds.has(layer.element.id); + const hdrTargetTransfer = compositeTransfer === "srgb" ? undefined : compositeTransfer; if (isHdrImage) { blitHdrImageLayer( canvas, @@ -1658,7 +1676,7 @@ async function compositeHdrFrame( height, log, imageTransfers.get(layer.element.id), - effectiveHdr.transfer, + hdrTargetTransfer, hdrPerf, ); } else { @@ -1673,7 +1691,7 @@ async function compositeHdrFrame( height, log, videoTransfers.get(layer.element.id), - effectiveHdr.transfer, + hdrTargetTransfer, hdrPerf, ); } @@ -1774,7 +1792,7 @@ async function compositeHdrFrame( const before = shouldLog ? countNonZeroRgb48(canvas) : 0; const alphaPixels = shouldLog ? countNonZeroAlpha(domRgba) : 0; timingStart = Date.now(); - blitRgba8OverRgb48le(domRgba, canvas, width, height, effectiveHdr.transfer); + blitRgba8OverRgb48le(domRgba, canvas, width, height, compositeTransfer); addHdrTiming(hdrPerf, "domBlitMs", timingStart); if (shouldLog && debugDumpDir) { const after = countNonZeroRgb48(canvas); @@ -2699,7 +2717,12 @@ export async function executeRenderJob( // auto mode stays SDR since H.265 10-bit causes browser color management // issues (orange shift) with no quality benefit. const nativeHdrIds = new Set([...nativeHdrVideoIds, ...nativeHdrImageIds]); - const hasHdrContent = effectiveHdr && nativeHdrIds.size > 0; + const hasHdrContent = Boolean(effectiveHdr && nativeHdrIds.size > 0); + const useLayeredComposite = shouldUseLayeredComposite({ + hasHdrContent, + hasShaderTransitions: compiled.hasShaderTransitions, + isPngSequence, + }); const encoderHdr = hasHdrContent ? effectiveHdr : undefined; // png-sequence has no encoder, but the rest of the orchestrator still // reads `preset.quality` for `effectiveQuality` and `preset.codec` for @@ -2730,21 +2753,27 @@ export async function executeRenderJob( job.framesRendered = 0; - // ── HDR z-ordered multi-layer compositing ────────────────────────────── + // ── Z-ordered multi-layer compositing ───────────────────────────────── // Per frame: query all elements' z-order, group into layers (DOM or HDR), // composite bottom-to-top in Node.js memory. HDR layers use native - // pre-extracted HLG pixels; DOM layers use Chrome alpha screenshots - // with sRGB→HLG conversion. Video position/opacity applied via queried bounds. - if (hasHdrContent) { - log.info("[Render] HDR layered composite: z-ordered DOM + native HLG video layers"); + // pre-extracted pixels; DOM layers use Chrome alpha screenshots converted + // into the active rgb48le signal space. Shader transitions use this same + // path for SDR compositions so the engine can apply transition math to + // isolated scene buffers instead of recording plain DOM screenshots. + if (useLayeredComposite) { + log.info( + hasHdrContent + ? "[Render] HDR layered composite: z-ordered DOM + native HDR video/image layers" + : "[Render] Shader transition composite: z-ordered SDR DOM layers", + ); hdrPerf = createHdrPerfCollector(); - // HDR layered compositing relies on captureAlphaPng (Page.captureScreenshot - // with a transparent background) for the SDR DOM overlay layer. That CDP - // call hangs indefinitely when Chrome is launched with --enable-begin-frame-control + // Layered compositing relies on captureAlphaPng (Page.captureScreenshot + // with a transparent background) for DOM layers. That CDP call hangs + // indefinitely when Chrome is launched with --enable-begin-frame-control // (the default on Linux/headless-shell), because the compositor is paused // and never produces a frame to capture. Force screenshot mode for the - // entire HDR path — same constraint as alpha output formats above. + // entire layered path — same constraint as alpha output formats above. cfg.forceScreenshot = true; // Use NATIVE HDR IDs (probed before SDR→HDR conversion) so only originally-HDR @@ -2823,8 +2852,13 @@ export async function executeRenderJob( const scenes = document.querySelectorAll(".scene"); const map: Record = {}; for (const scene of scenes) { - const els = scene.querySelectorAll("[data-start]"); - map[scene.id] = Array.from(els).map((e) => e.id); + if (!scene.id) continue; + const ids = new Set([scene.id]); + const els = scene.querySelectorAll("[id]"); + for (const el of els) { + if (el.id) ids.add(el.id); + } + map[scene.id] = Array.from(ids); } return map; }); @@ -2836,7 +2870,7 @@ export async function executeRenderJob( })); if (transitionRanges.length > 0) { - log.info("[Render] Detected shader transitions for HDR compositing", { + log.info("[Render] Detected shader transitions for layered compositing", { count: transitionRanges.length, transitions: transitionRanges.map((t) => ({ shader: t.shader, @@ -3109,15 +3143,8 @@ export async function executeRenderJob( if (debugDumpDir && !existsSync(debugDumpDir)) { mkdirSync(debugDumpDir, { recursive: true }); } - // INVARIANT: this entire `try` block is reachable only when HDR - // output is enabled (`if (effectiveHdr) { ... try { ... } }`), so - // narrowing here is safe even though `effectiveHdr` is typed as - // `... | undefined` at the outer scope. - if (!effectiveHdr) { - throw new Error( - "Internal: HDR render path entered without effectiveHdr — this is a bug.", - ); - } + const compositeTransfer = resolveCompositeTransfer(hasHdrContent, effectiveHdr); + const hdrTargetTransfer = compositeTransfer === "srgb" ? undefined : compositeTransfer; // Per-job LRU cache for transfer-converted HDR image buffers. Static HDR // images that need PQ↔HLG conversion are converted exactly once per // (imageId, targetTransfer) and then reused for every subsequent frame @@ -3137,7 +3164,7 @@ export async function executeRenderJob( width, height, fps: job.config.fps, - effectiveHdr, + compositeTransfer, nativeHdrImageIds, hdrImageBuffers, hdrImageTransferCache, @@ -3281,7 +3308,7 @@ export async function executeRenderJob( height, log, imageTransfers.get(el.id), - effectiveHdr?.transfer, + hdrTargetTransfer, hdrPerf, ); } else { @@ -3296,7 +3323,7 @@ export async function executeRenderJob( height, log, videoTransfers.get(el.id), - effectiveHdr?.transfer, + hdrTargetTransfer, hdrPerf, ); } @@ -3328,19 +3355,13 @@ export async function executeRenderJob( timingStart = Date.now(); const { data: domRgba } = decodePng(domPng); addHdrTiming(hdrPerf, "domPngDecodeMs", timingStart); - // Invariant: `hasHdrVideo` requires `effectiveHdr` to be set (see line ~919). - if (!effectiveHdr) { - throw new Error( - "Invariant violation: effectiveHdr is undefined inside hasHdrVideo branch", - ); - } timingStart = Date.now(); blitRgba8OverRgb48le( domRgba, sceneBuf as Buffer, width, height, - effectiveHdr.transfer, + compositeTransfer, ); addHdrTiming(hdrPerf, "domBlitMs", timingStart); } catch (err) { @@ -3352,11 +3373,12 @@ export async function executeRenderJob( } } - // Apply shader transition blend directly in PQ/HLG signal space. - // Linearization was attempted but destroys dark PQ content — values below - // PQ ~5000 quantize to zero in 16-bit linear, wiping out the bottom portion - // of dark video content. PQ space is perceptual and works well enough - // for shader math since the shaders were designed for perceptual (sRGB) space. + // Apply shader transition blend directly in the active rgb48le + // signal space. Linearizing HDR was attempted but destroys dark + // PQ content — values below PQ ~5000 quantize to zero in 16-bit + // linear, wiping out the bottom portion of dark video content. + // SDR compositions use 16-bit-expanded sRGB, which matches the + // shader design space. const transitionFn: TransitionFn = TRANSITIONS[activeTransition.shader] ?? crossfade; transitionFn(transBufferA, transBufferB, transOutput, width, height, progress); addHdrTiming(hdrPerf, "transitionCompositeMs", transitionTimingStart); @@ -3431,7 +3453,7 @@ export async function executeRenderJob( updateJobStatus( job, "rendering", - `HDR composite frame ${i + 1}/${job.totalFrames}`, + `Layered composite frame ${i + 1}/${job.totalFrames}`, Math.round(25 + frameProgress * 55), onProgress, ); diff --git a/packages/shader-transitions/README.md b/packages/shader-transitions/README.md index 14073d727..d6222cf42 100644 --- a/packages/shader-transitions/README.md +++ b/packages/shader-transitions/README.md @@ -30,7 +30,7 @@ const tl = init({ }); ``` -The `init()` function captures each scene to a WebGL texture at transition time, crossfades between them using the selected shader, and returns a GSAP timeline. If WebGL is unavailable, it falls back to hard cuts. +The `init()` function pre-captures animated scene samples for every transition, composites cached samples with the selected shader during playback, and returns a GSAP timeline. Scene animations keep advancing through shader transitions without running DOM captures in the playback loop. If WebGL is unavailable, it falls back to normal timeline playback without shader compositing. When the browser exposes Chrome's experimental CanvasDrawElement API, scene capture uses native HTML-in-canvas via `drawElementImage()`. Other browsers keep @@ -79,14 +79,19 @@ init({ ### `init(config): GsapTimeline` -| Option | Type | Required | Description | -| --------------- | -------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `bgColor` | `string` | yes | Fallback background color (hex) for scene capture. Use the composition's body/canvas background — individual scenes set their own `background-color` via CSS. | -| `accentColor` | `string` | no | Accent color (hex) for shader glow effects | -| `scenes` | `string[]` | yes | Element IDs of each scene, in order | -| `transitions` | `TransitionConfig[]` | yes | Transition definitions (see below) | -| `timeline` | `GsapTimeline` | no | Existing timeline to attach transitions to | -| `compositionId` | `string` | no | Override the `data-composition-id` for timeline registration | +| Option | Type | Required | Description | +| ------------------- | -------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `bgColor` | `string` | yes | Fallback background color (hex) for scene capture. Use the composition's body/canvas background — individual scenes set their own `background-color` via CSS. | +| `accentColor` | `string` | no | Accent color (hex) for shader glow effects | +| `scenes` | `string[]` | yes | Element IDs of each scene, in order | +| `transitions` | `TransitionConfig[]` | yes | Transition definitions (see below) | +| `timeline` | `GsapTimeline` | no | Existing timeline to attach transitions to | +| `compositionId` | `string` | no | Override the `data-composition-id` for timeline registration | +| `previewCaptureFps` | `number` | no | Browser preview pre-capture samples per transition second. Defaults to `30`; rendering uses deterministic per-frame compositing instead. | + +Browser preview capture scale and transition-prep loading UI ownership are controlled by `` (`shader-capture-scale`, `shader-loading`) instead of composition code. Direct non-player previews keep the built-in full-fidelity loading fallback. + +Browser previews store captured transition snapshots in IndexedDB using a key derived from composition ID, scene DOM/style signatures, transition timing, capture FPS, scale, and dimensions. On refresh, matching snapshots are reloaded into WebGL textures instead of being captured again. Runtime scene or stylesheet edits mark only adjacent transition caches dirty; recapture is deferred until playback so editing stays responsive. ### `TransitionConfig` diff --git a/packages/shader-transitions/src/capture.ts b/packages/shader-transitions/src/capture.ts index e45862e79..7f97f9a45 100644 --- a/packages/shader-transitions/src/capture.ts +++ b/packages/shader-transitions/src/capture.ts @@ -1,22 +1,23 @@ import html2canvas from "html2canvas"; import { DEFAULT_WIDTH, DEFAULT_HEIGHT } from "./webgl.js"; -type CanvasWithLayoutSubtree = HTMLCanvasElement & { - layoutSubtree: boolean; - requestPaint: () => void; -}; - -type DrawElementImageContext = CanvasRenderingContext2D & { - drawElementImage: ( - element: Element, - dx: number, - dy: number, - dwidth: number, - dheight: number, - ) => DOMMatrix; -}; - let patched = false; +const VOID_ELEMENT_TAGS = new Set([ + "AREA", + "BASE", + "BR", + "COL", + "EMBED", + "HR", + "IMG", + "INPUT", + "LINK", + "META", + "PARAM", + "SOURCE", + "TRACK", + "WBR", +]); function patchCreatePattern(): void { if (patched) return; @@ -42,6 +43,64 @@ export function initCapture(): void { patchCreatePattern(); } +export interface CaptureSceneOptions { + forceVisible?: boolean; + preferBrowserPaint?: boolean; + scale?: number; +} + +function forceSceneVisibleInClone(source: HTMLElement, cloneDoc: Document): void { + if (!source.id) return; + const clone = cloneDoc.getElementById(source.id); + if (!(clone instanceof HTMLElement)) return; + + clone.style.opacity = "1"; + clone.style.visibility = "visible"; + clone.querySelectorAll("[data-start]").forEach((el) => { + el.style.visibility = "visible"; + }); +} + +function stabilizeTransformedBoxShadows(root: HTMLElement): void { + const view = root.ownerDocument.defaultView; + if (!view) return; + + [root, ...Array.from(root.querySelectorAll("*"))].forEach((el) => { + if (VOID_ELEMENT_TAGS.has(el.tagName)) return; + const styles = view.getComputedStyle(el); + if (styles.boxShadow === "none" || styles.transform === "none") return; + + const shadow = root.ownerDocument.createElement("div"); + shadow.setAttribute("data-hyper-shader-shadow-shim", ""); + shadow.style.cssText = [ + "position:absolute", + "inset:0", + "border-radius:inherit", + `box-shadow:${styles.boxShadow}`, + "background:transparent", + "pointer-events:none", + "z-index:0", + ].join(";"); + + if (styles.position === "static") { + el.style.position = "relative"; + } + el.style.boxShadow = "none"; + el.insertBefore(shadow, el.firstChild); + }); +} + +// ── HTML-in-Canvas (drawElementImage) native capture ────────────────────── + +interface CanvasWithLayoutSubtree extends HTMLCanvasElement { + layoutSubtree: boolean; + requestPaint: () => void; +} + +interface CanvasRenderingContext2DWithDrawElement extends CanvasRenderingContext2D { + drawElementImage: (element: Element, x: number, y: number, w: number, h: number) => void; +} + function hasLayoutSubtreeCanvas(canvas: HTMLCanvasElement): canvas is CanvasWithLayoutSubtree { const candidate = canvas as HTMLCanvasElement & { layoutSubtree?: unknown; @@ -50,47 +109,15 @@ function hasLayoutSubtreeCanvas(canvas: HTMLCanvasElement): canvas is CanvasWith return "layoutSubtree" in candidate && typeof candidate.requestPaint === "function"; } -function getDrawElementImageContext(canvas: HTMLCanvasElement): DrawElementImageContext | null { - const ctx = canvas.getContext("2d"); - const candidate = ctx as (CanvasRenderingContext2D & { drawElementImage?: unknown }) | null; - if (!candidate || typeof candidate.drawElementImage !== "function") { - return null; - } - return candidate as DrawElementImageContext; -} - export function isHtmlInCanvasCaptureSupported(): boolean { - if (typeof document === "undefined") { - return false; - } - - const canvas = document.createElement("canvas"); - return hasLayoutSubtreeCanvas(canvas) && getDrawElementImageContext(canvas) !== null; -} - -function waitForNextFrame(): Promise { - return new Promise((resolve) => { - requestAnimationFrame(() => { - requestAnimationFrame(() => resolve()); - }); - }); -} - -function waitForPaint(canvas: CanvasWithLayoutSubtree): Promise { - return new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - canvas.removeEventListener("paint", onPaint); - reject(new Error("Timed out waiting for canvas paint event")); - }, 1000); - - const onPaint = () => { - clearTimeout(timeout); - resolve(); - }; - - canvas.addEventListener("paint", onPaint, { once: true }); - canvas.requestPaint(); - }); + if (typeof document === "undefined") return false; + const probe = document.createElement("canvas") as HTMLCanvasElement & { + layoutSubtree?: boolean; + }; + probe.setAttribute("layoutsubtree", ""); + if (!("layoutSubtree" in probe)) return false; + const ctx = probe.getContext("2d") as CanvasRenderingContext2DWithDrawElement | null; + return ctx != null && typeof ctx.drawElementImage === "function"; } async function captureSceneWithHtmlInCanvas( @@ -99,165 +126,108 @@ async function captureSceneWithHtmlInCanvas( width: number, height: number, ): Promise { - const canvas = document.createElement("canvas"); - if (!hasLayoutSubtreeCanvas(canvas)) { - throw new Error("HTML-in-canvas layoutsubtree support is unavailable"); - } - - const ctx = getDrawElementImageContext(canvas); - if (!ctx) { - throw new Error("HTML-in-canvas drawElementImage support is unavailable"); - } - - const clone = sceneEl.cloneNode(true); - if (!(clone instanceof HTMLElement)) { - throw new Error("Scene clone is not an HTMLElement"); - } - + const canvas = document.createElement("canvas") as CanvasWithLayoutSubtree; canvas.width = width; canvas.height = height; - canvas.layoutSubtree = true; canvas.setAttribute("layoutsubtree", ""); - canvas.style.cssText = [ - "position:fixed", - "left:0", - "top:0", - `width:${width}px`, - `height:${height}px`, - "pointer-events:none", - "opacity:0.001", - "z-index:-2147483648", - ].join(";"); - - clone.style.position = "absolute"; - clone.style.left = "0"; - clone.style.top = "0"; - clone.style.width = `${width}px`; - clone.style.height = `${height}px`; - - canvas.appendChild(clone); + canvas.style.cssText = `position:fixed;top:0;left:0;width:${width}px;height:${height}px;z-index:-9999;pointer-events:none;opacity:0`; + canvas.appendChild(sceneEl.cloneNode(true)); document.body.appendChild(canvas); try { - await waitForNextFrame(); - await waitForPaint(canvas); - - ctx.setTransform(1, 0, 0, 1, 0, 0); - ctx.clearRect(0, 0, width, height); + await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(() => r()))); + const ctx = canvas.getContext("2d") as CanvasRenderingContext2DWithDrawElement; ctx.fillStyle = bgColor; ctx.fillRect(0, 0, width, height); - ctx.drawElementImage(clone, 0, 0, width, height); + const child = canvas.firstElementChild; + if (child) ctx.drawElementImage(child, 0, 0, width, height); + const result = document.createElement("canvas"); + result.width = width; + result.height = height; + result.getContext("2d")!.drawImage(canvas, 0, 0); canvas.remove(); - return canvas; + return result; } catch (err) { canvas.remove(); throw err; } } -function captureSceneWithHtml2Canvas( - sceneEl: HTMLElement, - bgColor: string, - width: number = DEFAULT_WIDTH, - height: number = DEFAULT_HEIGHT, -): Promise { - return html2canvas(sceneEl, { - width, - height, - scale: 1, - backgroundColor: bgColor, - logging: false, - // Safari applies stricter canvas-taint rules than Chrome. SVG data URLs - // with elements (e.g. feTurbulence grain backgrounds), certain - // cross-origin images, and mask/clip-path url() refs can taint the - // output canvas on WebKit. Without these flags, html2canvas throws - // `SecurityError: The operation is insecure` during its own read-back - // path and every shader transition falls through to the catch handler - // — observed in Safari + Claude Design's cross-origin iframe sandbox. - // - // useCORS: send CORS headers on image fetches so cross-origin images - // with proper `Access-Control-Allow-Origin` don't taint the - // canvas in the first place. Strict improvement. - // allowTaint: let html2canvas complete and return a canvas even when it - // becomes tainted (instead of throwing). Important caveat: - // a tainted canvas CANNOT be uploaded to WebGL via - // `gl.texImage2D` — WebGL spec requires SecurityError on - // non-origin-clean sources, with no opt-out. So this flag - // only moves the failure point from html2canvas to the - // texImage2D call in webgl.ts. In both cases `hyper-shader.ts` - // catches the rejected promise and runs the CSS crossfade - // fallback. Net effect: the end-user UX is the same (smooth - // CSS fade in either case), but we get a cleaner, more - // predictable error site and the flag is defensively - // correct for the non-taint branches where it genuinely - // helps (e.g., `crossOrigin="anonymous"` image fetches - // that already had CORS headers). - useCORS: true, - allowTaint: true, - ignoreElements: (el: Element) => el.tagName === "CANVAS" || el.hasAttribute("data-no-capture"), - }); -} - export function captureScene( sceneEl: HTMLElement, bgColor: string, width: number = DEFAULT_WIDTH, height: number = DEFAULT_HEIGHT, + options: CaptureSceneOptions = {}, ): Promise { - if (!isHtmlInCanvasCaptureSupported()) { - return captureSceneWithHtml2Canvas(sceneEl, bgColor, width, height); + if (isHtmlInCanvasCaptureSupported() && !options.preferBrowserPaint) { + return captureSceneWithHtmlInCanvas(sceneEl, bgColor, width, height).catch(() => + captureSceneWithHtml2Canvas(sceneEl, bgColor, width, height, options), + ); + } + return captureSceneWithHtml2Canvas(sceneEl, bgColor, width, height, options); +} + +function captureSceneWithHtml2Canvas( + sceneEl: HTMLElement, + bgColor: string, + width: number, + height: number, + options: CaptureSceneOptions = {}, +): Promise { + const captureWithRenderer = (foreignObjectRendering: boolean): Promise => { + return html2canvas(sceneEl, { + width, + height, + scale: options.scale ?? 1, + backgroundColor: bgColor, + logging: false, + foreignObjectRendering, + // Safari applies stricter canvas-taint rules than Chrome. SVG data URLs + // with elements (e.g. feTurbulence grain backgrounds), certain + // cross-origin images, and mask/clip-path url() refs can taint the + // output canvas on WebKit. Without these flags, html2canvas throws + // `SecurityError: The operation is insecure` during its own read-back + // path and every shader transition falls through to the catch handler + // — observed in Safari + Claude Design's cross-origin iframe sandbox. + // + // useCORS: send CORS headers on image fetches so cross-origin images + // with proper `Access-Control-Allow-Origin` don't taint the + // canvas in the first place. Strict improvement. + // allowTaint: let html2canvas complete and return a canvas even when it + // becomes tainted (instead of throwing). Important caveat: + // a tainted canvas CANNOT be uploaded to WebGL via + // `gl.texImage2D` — WebGL spec requires SecurityError on + // non-origin-clean sources, with no opt-out. So this flag + // only moves the failure point from html2canvas to the + // texImage2D call in webgl.ts. The caller catches the + // rejected promise and keeps the DOM fallback visible. Net + // effect: the end-user UX avoids blank frames either way, + // but we get a cleaner, more predictable error site and the + // flag is defensively correct for the non-taint branches + // where it genuinely helps (e.g., + // `crossOrigin="anonymous"` image fetches that already had + // CORS headers). + useCORS: true, + allowTaint: true, + onclone: (cloneDoc) => { + if (!sceneEl.id) return; + const clone = cloneDoc.getElementById(sceneEl.id); + if (clone instanceof HTMLElement) { + stabilizeTransformedBoxShadows(clone); + } + if (options.forceVisible) { + forceSceneVisibleInClone(sceneEl, cloneDoc); + } + }, + ignoreElements: (el: Element) => + el.tagName === "CANVAS" || el.hasAttribute("data-no-capture"), + }); + }; + + if (options.preferBrowserPaint === true) { + return captureWithRenderer(true).catch(() => captureWithRenderer(false)); } - return captureSceneWithHtmlInCanvas(sceneEl, bgColor, width, height).catch(() => - captureSceneWithHtml2Canvas(sceneEl, bgColor, width, height), - ); -} - -/** - * Capture the incoming scene with .scene-content hidden (background + decoratives only). - * Shows the scene behind the outgoing scene via z-index, waits 2 rAFs for font rendering, - * captures, then restores. - * - * IMPORTANT: We force `visibility: visible` during capture because the HyperFrames runtime's - * time-based visibility gate (in `packages/core/src/runtime/init.ts`) sets `style.visibility - * = "hidden"` on every `[data-start]` element that's outside its current playback window — - * every frame. When a shader transition fires *before* the incoming scene's `data-start` - * boundary (the recommended "transition.time = boundary - duration/2" centered placement), - * the runtime has `visibility: hidden` on the incoming scene. Without the visibility override - * here, `html2canvas` captures the element as blank → shader transitions from the real - * outgoing scene to a blank incoming texture → users see content fade/morph into the - * background color mid-transition (a visible "blink"). Forcing `visibility: visible` only - * for the duration of the capture fixes this without affecting what the user sees during - * normal playback. - */ -export function captureIncomingScene( - toScene: HTMLElement, - bgColor: string, - width: number = DEFAULT_WIDTH, - height: number = DEFAULT_HEIGHT, -): Promise { - return new Promise((resolve, reject) => { - const origZ = toScene.style.zIndex; - const origOpacity = toScene.style.opacity; - const origVisibility = toScene.style.visibility; - toScene.style.zIndex = "-1"; - toScene.style.opacity = "1"; - toScene.style.visibility = "visible"; - - const contentEl = toScene.querySelector(".scene-content"); - if (contentEl) contentEl.style.visibility = "hidden"; - - const restore = () => { - if (contentEl) contentEl.style.visibility = ""; - toScene.style.visibility = origVisibility; - toScene.style.opacity = origOpacity; - toScene.style.zIndex = origZ; - }; - - requestAnimationFrame(() => { - requestAnimationFrame(() => { - captureScene(toScene, bgColor, width, height).then(resolve, reject).finally(restore); - }); - }); - }); + return captureWithRenderer(false); } diff --git a/packages/shader-transitions/src/hyper-shader.ts b/packages/shader-transitions/src/hyper-shader.ts index 64685b083..8bb3171d8 100644 --- a/packages/shader-transitions/src/hyper-shader.ts +++ b/packages/shader-transitions/src/hyper-shader.ts @@ -2,15 +2,16 @@ import { createContext, setupQuad, createProgram, + createProgramWithVertex, createTexture, - uploadTexture, + uploadTextureSource, renderShader, DEFAULT_WIDTH, DEFAULT_HEIGHT, type AccentColors, } from "./webgl.js"; import { getFragSource, type ShaderName } from "./shaders/registry.js"; -import { initCapture, captureScene, captureIncomingScene } from "./capture.js"; +import { initCapture, captureScene } from "./capture.js"; declare const gsap: { timeline: (opts: Record) => GsapTimeline; @@ -24,9 +25,13 @@ declare const gsap: { interface GsapTimeline { paused: () => boolean; - play: () => GsapTimeline; - pause: () => GsapTimeline; - time: () => number; + play: (from?: number, suppressEvents?: boolean) => GsapTimeline; + pause: (atTime?: number, suppressEvents?: boolean) => GsapTimeline; + time: { + (): number; + (value: number, suppressEvents?: boolean): GsapTimeline; + }; + seek?: (position: number | string, suppressEvents?: boolean) => GsapTimeline; call: (fn: () => void, args: null, position: number) => GsapTimeline; to: ( target: Record, @@ -58,14 +63,84 @@ export interface HyperShaderConfig { transitions: TransitionConfig[]; timeline?: GsapTimeline; compositionId?: string; + previewCaptureFps?: number; } interface TransState { active: boolean; prog: WebGLProgram | null; + progress: number; + transitionIndex: number; +} + +interface CachedTransitionFrame { + sampleIndex: number; + fromBlob: Blob | null; + toBlob: Blob | null; + fromTex: WebGLTexture | null; + toTex: WebGLTexture | null; +} + +interface TexturedTransitionFrame extends CachedTransitionFrame { + fromTex: WebGLTexture; + toTex: WebGLTexture; +} + +interface CachedTransitionFrameBlend { + a: TexturedTransitionFrame; + b: TexturedTransitionFrame; + mix: number; +} + +interface CachedTransition { + index: number; + time: number; + duration: number; fromId: string; toId: string; + prog: WebGLProgram; + frames: CachedTransitionFrame[]; + cacheKey: string; + dirty: boolean; + ready: boolean; + fallback: boolean; + persisted: boolean; + textureReady: boolean; + texturePromise: Promise | null; + textureGeneration: number; + textureAccess: number; + lastError?: string; +} + +interface SnapshotLoadingOverlay { + show: () => void; + update: (status: SnapshotLoadingStatus) => void; + hide: () => void; +} + +interface SnapshotLoadingStatus { progress: number; + total: number; + currentTransition?: number; + transitionTotal?: number; + transitionFrame?: number; + transitionFrames?: number; + phase?: "cached" | "capturing" | "finalizing"; +} + +interface SnapshotCacheEntry { + key: string; + blob: Blob; + width: number; + height: number; + updatedAt: number; +} + +interface SceneStyleState { + scene: HTMLElement | null; + opacity: string; + visibility: string; + pointerEvents: string; } // Defaults for transition duration/ease. Used by every fallback site in this @@ -75,6 +150,23 @@ interface TransState { // producer reads to plan compositing. const DEFAULT_DURATION = 0.7; const DEFAULT_EASE = "power2.inOut"; +const NO_FLIP_VERT_SRC = + "attribute vec2 a_pos; varying vec2 v_uv; void main(){" + + "v_uv=a_pos*0.5+0.5; gl_Position=vec4(a_pos,0,1);}"; +const SNAPSHOT_LOADING_PHRASES = [ + "Preparing scene transitions", + "Sampling outgoing scene motion", + "Sampling incoming scene motion", + "Caching transition frames", + "Finalizing transition preview", +]; +const SNAPSHOT_CACHE_DB = "hyper-shader-preview-cache"; +const SNAPSHOT_CACHE_STORE = "frames"; +const SNAPSHOT_CACHE_VERSION = 1; +const SNAPSHOT_CACHE_SCHEMA = "v1"; +const MAX_TEXTURED_TRANSITIONS = 2; +const TEXTURE_PRELOAD_LOOKAHEAD_SECONDS = 1.25; +const MAX_SNAPSHOT_CACHE_ENTRIES = 1200; function parseHex(hex: string): [number, number, number] { const h = hex.replace("#", ""); @@ -95,6 +187,603 @@ function deriveAccentColors(hex: string): AccentColors { }; } +function clampNumber(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} + +function resolvePositiveNumber(value: number | undefined, fallback: number): number { + if (value === undefined) return fallback; + return Number.isFinite(value) && value > 0 ? value : fallback; +} + +const PLAYER_CAPTURE_SCALE_PARAM = "__hf_shader_capture_scale"; +const PLAYER_LOADING_PARAM = "__hf_shader_loading"; + +function readPlayerOption(globalName: string, queryName: string): string | null { + const globalValue = (window as unknown as Record)[globalName]; + if (typeof globalValue === "string") return globalValue; + if (typeof globalValue === "number" && Number.isFinite(globalValue)) return String(globalValue); + try { + return new URLSearchParams(window.location.search).get(queryName); + } catch { + return null; + } +} + +function resolvePlayerCaptureScale(): number { + const raw = readPlayerOption("__HF_SHADER_CAPTURE_SCALE", PLAYER_CAPTURE_SCALE_PARAM); + const parsed = raw === null ? NaN : Number(raw); + return clampNumber(Number.isFinite(parsed) && parsed > 0 ? parsed : 1, 0.25, 1); +} + +function resolvePlayerLoadingMode(): "internal" | "player" | "none" { + const raw = readPlayerOption("__HF_SHADER_LOADING", PLAYER_LOADING_PARAM)?.trim().toLowerCase(); + if (raw === "player" || raw === "true" || raw === "1") return "player"; + if (raw === "none" || raw === "false" || raw === "0" || raw === "off") return "none"; + return "internal"; +} + +function stableHash(input: string): string { + let hash = 2166136261; + for (let i = 0; i < input.length; i += 1) { + hash ^= input.charCodeAt(i); + hash = Math.imul(hash, 16777619); + } + return (hash >>> 0).toString(16).padStart(8, "0"); +} + +function getDocumentStyleSignature(doc: Document): string { + const styleText = Array.from(doc.querySelectorAll("style")) + .map((style) => style.textContent || "") + .join("\n"); + const linkedStyles = Array.from(doc.querySelectorAll('link[rel~="stylesheet"]')) + .map((link) => `${link.href}:${link.getAttribute("integrity") || ""}`) + .join("\n"); + return stableHash(`${styleText}\n${linkedStyles}`); +} + +function getDocumentScriptSignature(doc: Document): string { + const projectSignature = Array.from( + doc.querySelectorAll('meta[name="hyperframes-project-signature"]'), + ) + .map((meta) => meta.getAttribute("content") || "") + .join("\n"); + const scriptText = Array.from(doc.querySelectorAll("script")) + .map((script) => { + const attrs = [ + script.type, + script.src, + script.getAttribute("integrity") || "", + script.getAttribute("crossorigin") || "", + script.getAttribute("data-hyperframes-runtime") || "", + ].join(":"); + return `${attrs}\n${script.src ? "" : script.textContent || ""}`; + }) + .join("\n"); + return stableHash(`${projectSignature}\n${scriptText}`); +} + +function getSceneSignature(sceneId: string): string { + const scene = document.getElementById(sceneId); + if (!scene) return "missing"; + return stableHash( + `${getDocumentStyleSignature(document)}\n${getDocumentScriptSignature(document)}\n${getSceneSignatureHtml(scene)}`, + ); +} + +function removeStyleProperties(el: HTMLElement, properties: string[]): void { + for (const property of properties) { + el.style.removeProperty(property); + } + if (el.getAttribute("style")?.trim() === "") { + el.removeAttribute("style"); + } +} + +function getSceneSignatureHtml(scene: HTMLElement): string { + const clone = scene.cloneNode(true) as HTMLElement; + + // HyperShader and the core runtime mutate these inline styles during seek and + // playback. Cache identity should track authored content, not the last preview + // playhead state. + removeStyleProperties(clone, ["opacity", "visibility", "pointer-events"]); + clone.querySelectorAll("[data-start]").forEach((el) => { + removeStyleProperties(el, ["visibility"]); + }); + + return clone.outerHTML; +} + +function makeSnapshotKey(cacheKey: string, sampleIndex: number, side: "from" | "to"): string { + return `${cacheKey}:sample:${sampleIndex}:${side}`; +} + +function openSnapshotDb(): Promise { + if (typeof indexedDB === "undefined") return Promise.resolve(null); + return new Promise((resolve) => { + const request = indexedDB.open(SNAPSHOT_CACHE_DB, SNAPSHOT_CACHE_VERSION); + request.onupgradeneeded = () => { + const db = request.result; + if (!db.objectStoreNames.contains(SNAPSHOT_CACHE_STORE)) { + db.createObjectStore(SNAPSHOT_CACHE_STORE, { keyPath: "key" }); + } + }; + request.onerror = () => resolve(null); + request.onblocked = () => resolve(null); + request.onsuccess = () => { + const db = request.result; + db.onversionchange = () => { + db.close(); + snapshotDbPromise = null; + }; + resolve(db); + }; + }); +} + +let snapshotDbPromise: Promise | null = null; + +function resetSnapshotDb(db: IDBDatabase | null): void { + try { + db?.close(); + } catch { + // Ignore close failures; the next cache operation will reopen the DB. + } + snapshotDbPromise = null; +} + +function getSnapshotDb(): Promise { + snapshotDbPromise = snapshotDbPromise || openSnapshotDb(); + return snapshotDbPromise; +} + +async function getSnapshotEntry(key: string): Promise { + const db = await getSnapshotDb(); + if (!db) return null; + return new Promise((resolve) => { + try { + const tx = db.transaction(SNAPSHOT_CACHE_STORE, "readonly"); + const request = tx.objectStore(SNAPSHOT_CACHE_STORE).get(key); + request.onerror = () => resolve(null); + request.onsuccess = () => { + const result = request.result; + if ( + result && + typeof result === "object" && + "blob" in result && + result.blob instanceof Blob + ) { + resolve(result as SnapshotCacheEntry); + } else { + resolve(null); + } + }; + } catch { + resetSnapshotDb(db); + resolve(null); + } + }); +} + +async function putSnapshotEntry(entry: SnapshotCacheEntry): Promise { + const db = await getSnapshotDb(); + if (!db) return false; + return new Promise((resolve) => { + try { + const tx = db.transaction(SNAPSHOT_CACHE_STORE, "readwrite"); + tx.oncomplete = () => resolve(true); + tx.onerror = () => resolve(false); + tx.objectStore(SNAPSHOT_CACHE_STORE).put(entry); + } catch { + resetSnapshotDb(db); + resolve(false); + } + }); +} + +function canvasToPngBlob(canvas: HTMLCanvasElement): Promise { + return new Promise((resolve) => { + try { + canvas.toBlob((blob) => resolve(blob), "image/png"); + } catch { + resolve(null); + } + }); +} + +async function pruneSnapshotCache( + compId: string, + activeCacheKeys: Set, + maxEntries: number = MAX_SNAPSHOT_CACHE_ENTRIES, +): Promise { + const db = await getSnapshotDb(); + if (!db) return; + const entries = await new Promise>((resolve) => { + try { + const tx = db.transaction(SNAPSHOT_CACHE_STORE, "readonly"); + const request = tx.objectStore(SNAPSHOT_CACHE_STORE).getAll(); + request.onerror = () => resolve([]); + request.onsuccess = () => { + const rows: Array<{ key: string; updatedAt: number }> = []; + for (const result of request.result) { + if ( + result && + typeof result === "object" && + "key" in result && + typeof result.key === "string" + ) { + const updatedAt = + "updatedAt" in result && typeof result.updatedAt === "number" ? result.updatedAt : 0; + rows.push({ key: result.key, updatedAt }); + } + } + resolve(rows); + }; + } catch { + resetSnapshotDb(db); + resolve([]); + } + }); + const projectPrefix = `${compId}:`; + const isActiveSnapshot = (key: string): boolean => { + for (const cacheKey of activeCacheKeys) { + if (key.startsWith(`${cacheKey}:sample:`)) return true; + } + return false; + }; + const staleProjectKeys = entries + .filter((entry) => entry.key.startsWith(projectPrefix) && !isActiveSnapshot(entry.key)) + .map((entry) => entry.key); + const staleProjectKeySet = new Set(staleProjectKeys); + const remaining = entries.filter((entry) => !staleProjectKeySet.has(entry.key)); + const activeCount = remaining.filter((entry) => isActiveSnapshot(entry.key)).length; + const removable = remaining + .filter((entry) => !isActiveSnapshot(entry.key)) + .sort((a, b) => b.updatedAt - a.updatedAt); + const removableBudget = Math.max(0, maxEntries - activeCount); + const overflowKeys = + removable.length > removableBudget + ? removable.slice(removableBudget).map((entry) => entry.key) + : []; + const keysToDelete = Array.from(new Set([...staleProjectKeys, ...overflowKeys])); + if (keysToDelete.length === 0) return; + await new Promise((resolve) => { + try { + const tx = db.transaction(SNAPSHOT_CACHE_STORE, "readwrite"); + tx.oncomplete = () => resolve(); + tx.onerror = () => resolve(); + const store = tx.objectStore(SNAPSHOT_CACHE_STORE); + for (const key of keysToDelete) { + store.delete(key); + } + } catch { + resetSnapshotDb(db); + resolve(); + } + }); +} + +function blobToTextureSource(blob: Blob): Promise { + if (typeof createImageBitmap === "function") { + return createImageBitmap(blob); + } + + return new Promise((resolve, reject) => { + const url = URL.createObjectURL(blob); + const img = new Image(); + img.onload = () => { + URL.revokeObjectURL(url); + resolve(img); + }; + img.onerror = () => { + URL.revokeObjectURL(url); + reject(new Error("[HyperShader] Failed to decode cached snapshot")); + }; + img.src = url; + }); +} + +function closeTextureSource(source: TexImageSource): void { + if (typeof ImageBitmap !== "undefined" && source instanceof ImageBitmap) { + source.close(); + } +} + +function createRenderTexture( + gl: WebGLRenderingContext, + width: number, + height: number, +): WebGLTexture { + const tex = createTexture(gl); + gl.bindTexture(gl.TEXTURE_2D, tex); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, width, height, 0, gl.RGBA, gl.UNSIGNED_BYTE, null); + return tex; +} + +function createFramebuffer(gl: WebGLRenderingContext, tex: WebGLTexture): WebGLFramebuffer { + const fbo = gl.createFramebuffer(); + if (!fbo) throw new Error("[HyperShader] Failed to create framebuffer"); + gl.bindFramebuffer(gl.FRAMEBUFFER, fbo); + gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0); + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + return fbo; +} + +function createSnapshotLoadingOverlay( + root: HTMLElement | null, + width: number, + height: number, +): SnapshotLoadingOverlay | null { + const doc = root?.ownerDocument || document; + const host = root || doc.body; + if (!host) return null; + + host.querySelector("[data-hyper-shader-loading]")?.remove(); + + const overlay = doc.createElement("div"); + overlay.setAttribute("data-hyper-shader-loading", ""); + overlay.setAttribute("data-hyperframes-ignore", ""); + overlay.setAttribute("data-hyperframes-picker-block", ""); + overlay.setAttribute("data-hf-ignore", ""); + overlay.setAttribute("data-no-capture", ""); + overlay.setAttribute("data-no-inspect", ""); + overlay.setAttribute("data-no-pick", ""); + overlay.setAttribute("draggable", "false"); + overlay.setAttribute("role", "status"); + overlay.setAttribute("aria-label", "Preparing scene transitions"); + overlay.style.cssText = [ + "position:absolute", + "inset:0", + `width:${width}px`, + `height:${height}px`, + "z-index:2147483647", + "display:none", + "place-items:center", + "opacity:1", + "transition:opacity 240ms ease-out", + "background:#030504", + "pointer-events:auto", + "color:#f4f7fb", + "cursor:default", + "touch-action:none", + "user-select:none", + "-webkit-user-select:none", + "-webkit-user-drag:none", + ].join(";"); + const blockOverlayInteraction = (event: Event): void => { + event.preventDefault(); + event.stopPropagation(); + }; + for (const eventName of ["selectstart", "dragstart", "pointerdown", "mousedown", "touchstart"]) { + overlay.addEventListener(eventName, blockOverlayInteraction, { capture: true }); + } + + const panel = doc.createElement("div"); + panel.setAttribute("draggable", "false"); + panel.style.cssText = [ + "display:grid", + "grid-template-rows:172px 72px 44px 44px 54px", + "justify-items:center", + "align-items:center", + "gap:0", + "width:min(1040px,82%)", + "padding:42px", + "box-sizing:border-box", + "text-align:center", + "font-family:Inter,-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif", + "user-select:none", + "-webkit-user-select:none", + "-webkit-user-drag:none", + ].join(";"); + + const markFrame = doc.createElement("div"); + markFrame.setAttribute("data-hf-loader-mark-frame", ""); + markFrame.style.cssText = [ + "width:172px", + "height:172px", + "display:grid", + "place-items:center", + "overflow:visible", + "transform-origin:50% 50%", + "will-change:transform,opacity", + "user-select:none", + "-webkit-user-select:none", + "-webkit-user-drag:none", + ].join(";"); + markFrame.innerHTML = [ + '", + ].join(""); + const mark = markFrame.querySelector("svg"); + if (!mark) return null; + mark.setAttribute("draggable", "false"); + + const phrase = doc.createElement("div"); + phrase.style.cssText = [ + "width:100%", + "height:72px", + "display:flex", + "align-items:center", + "justify-content:center", + "overflow:hidden", + "white-space:nowrap", + "text-overflow:ellipsis", + "font:600 44px/1.15 Inter,-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif", + "letter-spacing:0", + "text-align:center", + "color:#f4f7fb", + ].join(";"); + + const phraseText = doc.createElement("span"); + phraseText.textContent = SNAPSHOT_LOADING_PHRASES[0] ?? "Preparing scene transitions"; + + const detail = doc.createElement("div"); + detail.textContent = "Sampling animated scene frames so shader transitions stay in motion."; + detail.style.cssText = [ + "width:min(760px,100%)", + "height:44px", + "overflow:hidden", + "white-space:nowrap", + "text-overflow:ellipsis", + "color:rgba(244,247,251,0.64)", + "font:400 24px/1.5 Inter,-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif", + ].join(";"); + + phrase.appendChild(phraseText); + + const track = doc.createElement("div"); + track.setAttribute("aria-hidden", "true"); + track.style.cssText = [ + "width:min(520px,100%)", + "height:10px", + "overflow:hidden", + "border-radius:999px", + "background:rgba(255,255,255,0.1)", + ].join(";"); + + const fill = doc.createElement("div"); + fill.style.cssText = [ + "width:100%", + "height:100%", + "transform:scaleX(0)", + "transform-origin:left center", + "border-radius:inherit", + "background:linear-gradient(90deg,#06e3fa,#4fdb5e)", + "transition:transform 160ms ease", + ].join(";"); + + const progressText = doc.createElement("div"); + progressText.style.cssText = [ + "width:min(560px,100%)", + "height:54px", + "overflow:hidden", + "display:grid", + "grid-template-rows:repeat(2,27px)", + "font:500 18px/27px 'IBM Plex Mono','SF Mono','Fira Code',monospace", + "font-variant-numeric:tabular-nums", + "color:rgba(244,247,251,0.48)", + ].join(";"); + const createProgressRow = (labelText: string) => { + const row = doc.createElement("div"); + row.style.cssText = [ + "display:grid", + "grid-template-columns:minmax(0,1fr) auto", + "align-items:center", + "column-gap:28px", + "width:100%", + "height:27px", + "white-space:nowrap", + ].join(";"); + + const label = doc.createElement("span"); + label.textContent = labelText; + label.style.cssText = [ + "overflow:hidden", + "text-overflow:ellipsis", + "text-align:left", + "min-width:0", + ].join(";"); + + const value = doc.createElement("span"); + value.style.cssText = ["min-width:76px", "text-align:right"].join(";"); + + row.appendChild(label); + row.appendChild(value); + progressText.appendChild(row); + return { row, label, value }; + }; + const transitionStatus = createProgressRow("transition"); + const frameStatus = createProgressRow("transition frame"); + + track.appendChild(fill); + panel.appendChild(markFrame); + panel.appendChild(phrase); + panel.appendChild(detail); + panel.appendChild(track); + panel.appendChild(progressText); + overlay.appendChild(panel); + host.appendChild(overlay); + + let hideTimeout: ReturnType | null = null; + + return { + show: () => { + if (hideTimeout) { + clearTimeout(hideTimeout); + hideTimeout = null; + } + overlay.style.display = "grid"; + overlay.style.opacity = "1"; + overlay.style.pointerEvents = "auto"; + }, + update: (status: SnapshotLoadingStatus) => { + const { progress, total } = status; + const ratio = total > 0 ? clampNumber(progress / total, 0, 1) : 0; + const phraseIndex = Math.min( + SNAPSHOT_LOADING_PHRASES.length - 1, + Math.floor(ratio * SNAPSHOT_LOADING_PHRASES.length), + ); + const nextPhrase = SNAPSHOT_LOADING_PHRASES[phraseIndex] ?? "Preparing scene transitions"; + phraseText.textContent = nextPhrase; + fill.style.transform = `scaleX(${ratio})`; + const transitionValue = + status.currentTransition !== undefined && status.transitionTotal !== undefined + ? `${status.currentTransition}/${status.transitionTotal}` + : total > 0 + ? `${progress}/${total}` + : ""; + const frameValue = + status.transitionFrame !== undefined && status.transitionFrames !== undefined + ? `${status.transitionFrame}/${status.transitionFrames}` + : ""; + const phaseLabel = + status.phase === "cached" + ? "loading cached transition frames" + : status.phase === "finalizing" + ? "finalizing" + : "rendering transition frames"; + + transitionStatus.label.textContent = + status.currentTransition !== undefined ? "transition" : "transition frames"; + transitionStatus.value.textContent = transitionValue; + frameStatus.label.textContent = phaseLabel; + frameStatus.value.textContent = frameValue; + frameStatus.row.style.visibility = frameValue ? "visible" : "hidden"; + overlay.setAttribute("aria-valuenow", String(Math.round(ratio * 100))); + }, + hide: () => { + if (hideTimeout) clearTimeout(hideTimeout); + if (overlay.style.display === "none") return; + overlay.style.opacity = "0"; + overlay.style.pointerEvents = "none"; + hideTimeout = setTimeout(() => { + overlay.style.display = "none"; + overlay.style.opacity = "1"; + overlay.style.pointerEvents = "auto"; + hideTimeout = null; + }, 240); + }, + }; +} + export function init(config: HyperShaderConfig): GsapTimeline { const { bgColor, scenes, transitions } = config; @@ -183,9 +872,8 @@ export function init(config: HyperShaderConfig): GsapTimeline { const state: TransState = { active: false, prog: null, - fromId: "", - toId: "", progress: 0, + transitionIndex: -1, }; let glCanvas = document.getElementById("gl-canvas") as HTMLCanvasElement | null; @@ -221,29 +909,267 @@ export function init(config: HyperShaderConfig): GsapTimeline { } } - const textures = new Map(); - for (const id of scenes) { - textures.set(id, createTexture(gl)); - } + const canvasEl = glCanvas; + const previewCaptureFps = clampNumber(resolvePositiveNumber(config.previewCaptureFps, 30), 1, 60); + const previewCaptureScale = resolvePlayerCaptureScale(); + const loadingMode = resolvePlayerLoadingMode(); + const previewTextureWidth = Math.max(1, Math.round(compWidth * previewCaptureScale)); + const previewTextureHeight = Math.max(1, Math.round(compHeight * previewCaptureScale)); + const cachedTransitions: CachedTransition[] = []; + const blendProg = createProgramWithVertex( + gl, + NO_FLIP_VERT_SRC, + [ + "precision mediump float;", + "varying vec2 v_uv;", + "uniform sampler2D u_a;", + "uniform sampler2D u_b;", + "uniform float u_mix;", + "void main(){", + "gl_FragColor=mix(texture2D(u_a,v_uv),texture2D(u_b,v_uv),u_mix);", + "}", + ].join(""), + ); + const blendLoc = { + a: gl.getUniformLocation(blendProg, "u_a"), + b: gl.getUniformLocation(blendProg, "u_b"), + mix: gl.getUniformLocation(blendProg, "u_mix"), + pos: gl.getAttribLocation(blendProg, "a_pos"), + }; + const interpolatedFromTex = createRenderTexture(gl, previewTextureWidth, previewTextureHeight); + const interpolatedToTex = createRenderTexture(gl, previewTextureWidth, previewTextureHeight); + const interpolatedFromFbo = createFramebuffer(gl, interpolatedFromTex); + const interpolatedToFbo = createFramebuffer(gl, interpolatedToTex); + let loadingOverlay: SnapshotLoadingOverlay | null = null; + const getLoadingOverlay = (): SnapshotLoadingOverlay | null => { + if (loadingMode !== "internal") return null; + loadingOverlay = loadingOverlay || createSnapshotLoadingOverlay(root, compWidth, compHeight); + return loadingOverlay; + }; + let shaderCacheReady = false; + let prewarming = false; + let sceneMutationSuppressionDepth = 0; + let ignoreSceneMutationsUntil = 0; + const scenePointerEvents = new WeakMap(); - const tickShader = () => { - if (state.active && state.prog) { - const fromTex = textures.get(state.fromId); - const toTex = textures.get(state.toId); - if (fromTex && toTex) { - renderShader( - gl, - quadBuf, - state.prog, - fromTex, - toTex, - state.progress, - accentColors, - compWidth, - compHeight, - ); + const markRuntimeSceneMutation = (): void => { + ignoreSceneMutationsUntil = performance.now() + 120; + }; + + const rememberScenePointerEvents = (scene: HTMLElement): void => { + if (!scenePointerEvents.has(scene)) { + scenePointerEvents.set(scene, scene.style.pointerEvents); + } + }; + + const setScenePlaybackState = (scene: HTMLElement, visible: boolean, opacity: string): void => { + rememberScenePointerEvents(scene); + markRuntimeSceneMutation(); + scene.style.opacity = opacity; + scene.style.visibility = visible ? "visible" : "hidden"; + scene.style.pointerEvents = visible ? (scenePointerEvents.get(scene) ?? "") : "none"; + }; + + const paintScenePairState = ( + fromId: string, + toId: string, + fromOpacity: string, + toOpacity: string, + ): void => { + scenes.forEach((sceneId) => { + const scene = document.getElementById(sceneId); + if (!scene) return; + if (sceneId === fromId) { + setScenePlaybackState(scene, true, fromOpacity); + } else if (sceneId === toId) { + setScenePlaybackState(scene, true, toOpacity); + } else { + setScenePlaybackState(scene, false, "0"); + } + }); + }; + + const disposeCaptureCanvas = (canvas: HTMLCanvasElement): void => { + canvas.width = 0; + canvas.height = 0; + }; + + const captureLiveScene = (scene: HTMLElement): Promise => { + return captureScene(scene, bgColor, compWidth, compHeight, { + forceVisible: true, + scale: previewCaptureScale, + }); + }; + + const waitForPaint = (): Promise => { + return new Promise((resolve) => { + requestAnimationFrame(() => { + requestAnimationFrame(() => resolve()); + }); + }); + }; + + const hasFrameTextures = ( + frame: CachedTransitionFrame | undefined, + ): frame is TexturedTransitionFrame => { + return Boolean(frame?.fromTex && frame.toTex); + }; + + const selectCachedFrameBlend = ( + cache: CachedTransition, + progress: number, + ): CachedTransitionFrameBlend | null => { + if (cache.frames.length === 0) return null; + const position = clampNumber(progress, 0, 1) * (cache.frames.length - 1); + const lowerIndex = Math.floor(position); + const upperIndex = Math.ceil(position); + const a = cache.frames[lowerIndex] ?? cache.frames[cache.frames.length - 1]; + const b = cache.frames[upperIndex] ?? a; + if (!hasFrameTextures(a) || !hasFrameTextures(b)) return null; + return { a, b, mix: position - lowerIndex }; + }; + + const renderTextureBlend = ( + target: WebGLFramebuffer, + texA: WebGLTexture, + texB: WebGLTexture, + mix: number, + ): void => { + gl.bindFramebuffer(gl.FRAMEBUFFER, target); + gl.viewport(0, 0, previewTextureWidth, previewTextureHeight); + gl.useProgram(blendProg); + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, texA); + gl.uniform1i(blendLoc.a, 0); + gl.activeTexture(gl.TEXTURE1); + gl.bindTexture(gl.TEXTURE_2D, texB); + gl.uniform1i(blendLoc.b, 1); + gl.uniform1f(blendLoc.mix, mix); + gl.bindBuffer(gl.ARRAY_BUFFER, quadBuf); + gl.enableVertexAttribArray(blendLoc.pos); + gl.vertexAttribPointer(blendLoc.pos, 2, gl.FLOAT, false, 0, 0); + gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4); + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + gl.viewport(0, 0, compWidth, compHeight); + }; + + const preloadTransitionTextures = (cache: CachedTransition): void => { + void ensureTransitionTextures(cache).then((loaded) => { + if (!loaded) return; + const now = tl.time(); + if (now >= cache.time && now < cache.time + cache.duration) { + tickShader(); + } + }); + }; + + const resolveSettledSceneIndex = (currentTime: number): number => { + let index = 0; + for (let i = 0; i < cachedTransitions.length; i += 1) { + const cache = cachedTransitions[i]; + if (cache && currentTime >= cache.time + cache.duration) { + index = i + 1; } } + return clampNumber(index, 0, scenes.length - 1); + }; + + const paintSettledSceneState = (currentTime: number): void => { + const visibleIndex = resolveSettledSceneIndex(currentTime); + scenes.forEach((sceneId, index) => { + const scene = document.getElementById(sceneId); + if (!scene) return; + const visible = index === visibleIndex; + setScenePlaybackState(scene, visible, visible ? "1" : "0"); + }); + }; + + const applyFallbackTransition = (cache: CachedTransition, progress: number): void => { + const fromScene = document.getElementById(cache.fromId); + const toScene = document.getElementById(cache.toId); + if (!fromScene || !toScene) return; + const eased = progress * progress * (3 - 2 * progress); + canvasEl.style.display = "none"; + paintScenePairState(cache.fromId, cache.toId, String(1 - eased), String(eased)); + }; + + const tickShader = () => { + if (prewarming) { + return; + } + + const currentTime = tl.time(); + const upcoming = cachedTransitions.find((cache) => { + return ( + !cache.fallback && + cache.ready && + !cache.dirty && + !cache.textureReady && + currentTime >= cache.time - TEXTURE_PRELOAD_LOOKAHEAD_SECONDS && + currentTime < cache.time + cache.duration + ); + }); + if (upcoming) { + preloadTransitionTextures(upcoming); + } + + const activeIndex = cachedTransitions.findIndex((cache) => { + return currentTime >= cache.time && currentTime < cache.time + cache.duration; + }); + if (activeIndex < 0) { + state.active = false; + state.transitionIndex = -1; + canvasEl.style.display = "none"; + paintSettledSceneState(currentTime); + return; + } + + const cache = cachedTransitions[activeIndex]; + if (!cache || cache.dirty || !cache.ready) { + canvasEl.style.display = "none"; + return; + } + if (cache.fallback) { + state.active = true; + state.transitionIndex = activeIndex; + state.prog = null; + state.progress = clampNumber((currentTime - cache.time) / cache.duration, 0, 1); + applyFallbackTransition(cache, state.progress); + return; + } + if (!cache.textureReady) { + preloadTransitionTextures(cache); + canvasEl.style.display = "none"; + return; + } + + state.active = true; + state.transitionIndex = activeIndex; + state.prog = cache.prog; + state.progress = clampNumber((currentTime - cache.time) / cache.duration, 0, 1); + markTextureAccess(cache); + + const frame = selectCachedFrameBlend(cache, state.progress); + if (!frame) { + canvasEl.style.display = "none"; + return; + } + paintScenePairState(cache.fromId, cache.toId, "1", "1"); + renderTextureBlend(interpolatedFromFbo, frame.a.fromTex, frame.b.fromTex, frame.mix); + renderTextureBlend(interpolatedToFbo, frame.a.toTex, frame.b.toTex, frame.mix); + + canvasEl.style.display = "block"; + renderShader( + gl, + quadBuf, + state.prog, + interpolatedFromTex, + interpolatedToTex, + state.progress, + accentColors, + compWidth, + compHeight, + ); }; let tl: GsapTimeline; @@ -255,11 +1181,110 @@ export function init(config: HyperShaderConfig): GsapTimeline { tl = gsap.timeline({ paused: true, onUpdate: tickShader }); } + const originalPlay = tl.play.bind(tl) as (...args: unknown[]) => GsapTimeline; + const originalPause = tl.pause.bind(tl) as (...args: unknown[]) => GsapTimeline; + const originalTime = tl.time.bind(tl) as (...args: unknown[]) => GsapTimeline | number; + const originalSeek = + typeof tl.seek === "function" + ? (tl.seek.bind(tl) as (...args: unknown[]) => GsapTimeline) + : null; + const readActualTimelineTime = (): number => { + const value = originalTime(); + return typeof value === "number" && Number.isFinite(value) ? value : 0; + }; + let publicTimelineTime = readActualTimelineTime(); + const updatePublicTimelineTime = (value: unknown): void => { + if (typeof value === "number" && Number.isFinite(value)) { + publicTimelineTime = value; + } + }; + const getPlaybackRequestTime = (args: unknown[], fallback: number): number => { + const requestedTime = args[0]; + return typeof requestedTime === "number" && Number.isFinite(requestedTime) + ? requestedTime + : fallback; + }; + const setActualTimelineTime = (time: number, suppressEvents: boolean): GsapTimeline => { + const result = originalTime(time, suppressEvents); + return typeof result === "number" ? tl : result; + }; + const suppressSceneMutationTracking = (fn: () => T): T => { + sceneMutationSuppressionDepth += 1; + try { + return fn(); + } finally { + sceneMutationSuppressionDepth -= 1; + ignoreSceneMutationsUntil = performance.now() + 120; + } + }; + let pendingPlay = false; + let pendingPlayArgs: unknown[] = []; + let cancelResumeAfterPrewarm = false; + tl.play = ((...args: unknown[]) => { + updatePublicTimelineTime(args[0]); + const requestedTime = getPlaybackRequestTime(args, publicTimelineTime); + if (!areAllCachesReady() || !arePlaybackTexturesReady(requestedTime)) { + cancelResumeAfterPrewarm = false; + pendingPlay = true; + pendingPlayArgs = args; + void ensureTransitionCachesReady(); + return tl; + } + const result = suppressSceneMutationTracking(() => originalPlay(...args)); + publicTimelineTime = readActualTimelineTime(); + return result; + }) as GsapTimeline["play"]; + tl.pause = ((...args: unknown[]) => { + pendingPlay = false; + pendingPlayArgs = []; + updatePublicTimelineTime(args[0]); + if (prewarming) { + cancelResumeAfterPrewarm = true; + return tl; + } + const result = suppressSceneMutationTracking(() => originalPause(...args)); + publicTimelineTime = readActualTimelineTime(); + if (args.length > 0) { + tickShader(); + } + return result; + }) as GsapTimeline["pause"]; + tl.time = ((...args: unknown[]) => { + if (args.length === 0) { + if (!prewarming) { + publicTimelineTime = readActualTimelineTime(); + } + return publicTimelineTime; + } + updatePublicTimelineTime(args[0]); + if (prewarming) { + return tl; + } + const result = suppressSceneMutationTracking(() => originalTime(...args)); + if (!prewarming) { + publicTimelineTime = readActualTimelineTime(); + } + tickShader(); + return result; + }) as GsapTimeline["time"]; + if (originalSeek) { + tl.seek = ((...args: unknown[]) => { + updatePublicTimelineTime(args[0]); + if (prewarming) { + return tl; + } + const result = suppressSceneMutationTracking(() => originalSeek(...args)); + if (!prewarming) { + publicTimelineTime = readActualTimelineTime(); + } + tickShader(); + return result; + }) as NonNullable; + } + initCapture(); glCanvas.style.display = "none"; - const canvasEl = glCanvas; - for (let i = 0; i < transitions.length; i++) { const t = transitions[i]; const fromId = scenes[i]; @@ -272,85 +1297,46 @@ export function init(config: HyperShaderConfig): GsapTimeline { const dur = t.duration ?? DEFAULT_DURATION; const ease = t.ease ?? DEFAULT_EASE; const T = t.time; + const cacheIndex = cachedTransitions.length; + cachedTransitions.push({ + index: cacheIndex, + time: T, + duration: dur, + fromId, + toId, + prog, + frames: [], + cacheKey: "", + dirty: true, + ready: false, + fallback: false, + persisted: false, + textureReady: false, + texturePromise: null, + textureGeneration: 0, + textureAccess: 0, + }); - // Pause timeline during async capture to prevent the progress tween - // from running ahead. Resume once textures are uploaded. tl.call( () => { - const fromScene = document.getElementById(fromId); - const toScene = document.getElementById(toId); - if (!fromScene || !toScene) return; + suppressSceneMutationTracking(() => { + const fromScene = document.getElementById(fromId); + const toScene = document.getElementById(toId); + if (!fromScene || !toScene) return; - const wasPlaying = !tl.paused(); - if (wasPlaying) tl.pause(); - - captureScene(fromScene, bgColor, compWidth, compHeight) - .then((fromCanvas) => { - const fromTex = textures.get(fromId); - if (fromTex) uploadTexture(gl, fromTex, fromCanvas); - return captureIncomingScene(toScene, bgColor, compWidth, compHeight); - }) - .then((toCanvas) => { - const toTex = textures.get(toId); - if (toTex) uploadTexture(gl, toTex, toCanvas); - - // Guard: only apply transition-state DOM changes if the playhead - // is STILL inside this transition's [T, T+dur] window. Without - // this, a seek that crosses multiple transitions launches several - // async captures in parallel; each resolves ~80-200ms later and - // unconditionally calls querySelectorAll(".scene").opacity = "0" - // + canvas.display = "block" + state.active = true. The last one - // to resolve wins, so after seeking past a transition, state gets - // stuck pointing at the wrong transition and every scene is - // hidden — manifesting as the "scrub blanks until the next scene - // begins" bug. Checking tl.time() against the transition window - // keeps async capture completions from corrupting state the - // end-callback (at T+dur) or the next transition's start-callback - // has already set correctly. - const nowTime = tl.time(); - const inWindow = nowTime >= T && nowTime < T + dur; - if (inWindow) { - document.querySelectorAll(".scene").forEach((s) => { - s.style.opacity = "0"; - }); - canvasEl.style.display = "block"; - state.prog = prog; - state.fromId = fromId; - state.toId = toId; - state.progress = 0; - state.active = true; - } - - if (wasPlaying) tl.play(); - }) - .catch((e) => { - // Graceful fallback for unavoidable capture failures. The most - // common cause is Safari's stricter canvas-taint rules combined - // with SVG-filter-based background images (e.g. inline - // `` grain data URLs): html2canvas returns a - // tainted canvas, then `gl.texImage2D` throws SecurityError - // with no framework opt-out (WebGL spec). In Chrome this path - // rarely fires, but when it does (CORS-less cross-origin - // images, iframe sandbox restrictions, etc.) the old hard-cut - // was jarring. A CSS crossfade is strictly better UX. - console.warn("[HyperShader] Capture failed, CSS crossfade fallback:", e); - const nowTime = tl.time(); - const inWindow = nowTime >= T && nowTime < T + dur; - if (inWindow) { - const fromEl = document.getElementById(fromId); - const toEl = document.getElementById(toId); - if (fromEl && toEl) { - gsap.to(fromEl, { opacity: 0, duration: dur, ease }); - gsap.fromTo(toEl, { opacity: 0 }, { opacity: 1, duration: dur, ease }); - } else { - document.querySelectorAll(".scene").forEach((s) => { - s.style.opacity = "0"; - }); - if (toEl) toEl.style.opacity = "1"; - } - } - if (wasPlaying) tl.play(); - }); + state.prog = prog; + state.transitionIndex = cacheIndex; + state.progress = 0; + state.active = true; + const cache = cachedTransitions[cacheIndex]; + if (cache?.fallback) { + applyFallbackTransition(cache, 0); + return; + } + canvasEl.style.display = + !prewarming && cache?.ready && !cache.dirty && cache.textureReady ? "block" : "none"; + paintScenePairState(fromId, toId, "1", "1"); + }); }, null, T, @@ -372,16 +1358,794 @@ export function init(config: HyperShaderConfig): GsapTimeline { tl.call( () => { - state.active = false; - canvasEl.style.display = "none"; - const scene = document.getElementById(toId); - if (scene) scene.style.opacity = "1"; + suppressSceneMutationTracking(() => { + state.active = false; + state.transitionIndex = -1; + canvasEl.style.display = "none"; + paintSettledSceneState(T + dur); + }); }, null, T + dur, ); } + type ShaderReadyState = { + ready: boolean; + progress: number; + total: number; + currentTransition?: number; + transitionTotal?: number; + transitionFrame?: number; + transitionFrames?: number; + phase?: SnapshotLoadingStatus["phase"]; + dirtyTransitions: number; + captureScale: number; + textureWidth: number; + textureHeight: number; + fps: number; + loading: boolean; + error?: string; + }; + + const sampleCountForCache = (cache: CachedTransition): number => { + return Math.max(2, Math.ceil(cache.duration * previewCaptureFps) + 1); + }; + + const areAllCachesReady = (): boolean => { + return cachedTransitions.every((cache) => cache.ready && !cache.dirty); + }; + + const getPlaybackTextureWindow = (currentTime: number): CachedTransition[] => { + const selected: CachedTransition[] = []; + const selectedIndexes = new Set(); + const addIfNeeded = (cache: CachedTransition): void => { + if (selectedIndexes.has(cache.index)) return; + if (cache.fallback || cache.dirty || !cache.ready) return; + selected.push(cache); + selectedIndexes.add(cache.index); + }; + + for (const cache of cachedTransitions) { + if (currentTime >= cache.time && currentTime < cache.time + cache.duration) { + addIfNeeded(cache); + } + } + + const nextUpcoming = cachedTransitions.find((cache) => cache.time >= currentTime); + if (nextUpcoming) { + addIfNeeded(nextUpcoming); + } + + for (const cache of cachedTransitions) { + if ( + cache.time >= currentTime && + cache.time - currentTime <= TEXTURE_PRELOAD_LOOKAHEAD_SECONDS + ) { + addIfNeeded(cache); + } + } + return selected.slice(0, MAX_TEXTURED_TRANSITIONS); + }; + + const arePlaybackTexturesReady = (currentTime: number): boolean => { + return getPlaybackTextureWindow(currentTime).every((cache) => cache.textureReady); + }; + + let textureAccessCounter = 0; + + const disposeTransitionTextures = (cache: CachedTransition): void => { + cache.textureGeneration += 1; + for (const frame of cache.frames) { + if (frame.fromTex) { + gl.deleteTexture(frame.fromTex); + frame.fromTex = null; + } + if (frame.toTex) { + gl.deleteTexture(frame.toTex); + frame.toTex = null; + } + } + cache.textureReady = false; + }; + + const disposeCachedTransition = (cache: CachedTransition): void => { + disposeTransitionTextures(cache); + cache.texturePromise = null; + cache.frames = []; + cache.ready = false; + cache.fallback = false; + cache.persisted = false; + cache.textureReady = false; + cache.lastError = undefined; + }; + + const markTextureAccess = (cache: CachedTransition): void => { + textureAccessCounter += 1; + cache.textureAccess = textureAccessCounter; + }; + + const enforceTextureBudget = (keep: CachedTransition): void => { + const loaded = cachedTransitions + .filter((cache) => cache !== keep && cache.textureReady) + .sort((a, b) => a.textureAccess - b.textureAccess); + while (loaded.length >= MAX_TEXTURED_TRANSITIONS) { + const evict = loaded.shift(); + if (!evict) break; + disposeTransitionTextures(evict); + } + }; + + const buildTransitionCacheKey = (cache: CachedTransition, sampleCount: number): string => { + const source = [ + SNAPSHOT_CACHE_SCHEMA, + compId, + cache.index, + cache.fromId, + getSceneSignature(cache.fromId), + cache.toId, + getSceneSignature(cache.toId), + transitions[cache.index]?.shader || "unknown", + cache.time, + cache.duration, + sampleCount, + previewCaptureFps, + previewCaptureScale, + previewTextureWidth, + previewTextureHeight, + compWidth, + compHeight, + ].join("|"); + return `${compId}:${cache.index}:${stableHash(source)}`; + }; + + const setShaderReadyState = (status: Partial) => { + const hfWin = window as unknown as { + __hf?: { + shaderTransitions?: Record; + }; + }; + hfWin.__hf = hfWin.__hf || {}; + hfWin.__hf.shaderTransitions = hfWin.__hf.shaderTransitions || {}; + const current = hfWin.__hf.shaderTransitions[compId] || { + ready: false, + progress: 0, + total: 0, + dirtyTransitions: cachedTransitions.length, + captureScale: previewCaptureScale, + textureWidth: previewTextureWidth, + textureHeight: previewTextureHeight, + fps: previewCaptureFps, + loading: true, + }; + const next = { ...current, ...status }; + next.dirtyTransitions = cachedTransitions.filter((cache) => cache.dirty || !cache.ready).length; + hfWin.__hf.shaderTransitions[compId] = next; + window.parent?.postMessage( + { + source: "hf-preview", + type: "shader-transition-state", + compositionId: compId, + state: next, + }, + "*", + ); + if (next.loading) { + const overlay = getLoadingOverlay(); + overlay?.show(); + overlay?.update({ + progress: next.progress, + total: next.total, + currentTransition: next.currentTransition, + transitionTotal: next.transitionTotal, + transitionFrame: next.transitionFrame, + transitionFrames: next.transitionFrames, + phase: next.phase, + }); + } + if (next.ready) { + loadingOverlay?.hide(); + } else if (!next.loading) { + loadingOverlay?.hide(); + } + }; + + const shouldIgnoreSceneMutation = (): boolean => { + return ( + prewarming || + sceneMutationSuppressionDepth > 0 || + performance.now() < ignoreSceneMutationsUntil || + !tl.paused() + ); + }; + + const markScenesDirty = (sceneIds: Set): void => { + if (sceneIds.size === 0) return; + let changed = false; + for (const cache of cachedTransitions) { + if (!sceneIds.has(cache.fromId) && !sceneIds.has(cache.toId)) continue; + disposeCachedTransition(cache); + cache.dirty = true; + cache.cacheKey = ""; + changed = true; + } + if (!changed) return; + shaderCacheReady = areAllCachesReady(); + setShaderReadyState({ + ready: shaderCacheReady, + progress: 0, + total: 0, + currentTransition: undefined, + transitionTotal: undefined, + transitionFrame: undefined, + transitionFrames: undefined, + phase: undefined, + loading: false, + }); + window.dispatchEvent( + new CustomEvent("hyperShader:dirty", { + detail: { compositionId: compId, scenes: Array.from(sceneIds) }, + }), + ); + }; + + const observeSceneEdits = (): MutationObserver[] => { + const sceneElements = scenes + .map((id) => document.getElementById(id)) + .filter((el): el is HTMLElement => el instanceof HTMLElement); + const observers: MutationObserver[] = []; + for (const scene of sceneElements) { + const observer = new MutationObserver((mutations) => { + if (shouldIgnoreSceneMutation()) return; + const affected = new Set(); + for (const mutation of mutations) { + const target = + mutation.target instanceof Element ? mutation.target : mutation.target.parentElement; + if (!target) continue; + for (const candidate of sceneElements) { + if (candidate === target || candidate.contains(target)) { + affected.add(candidate.id); + } + } + } + markScenesDirty(affected); + }); + observer.observe(scene, { + attributes: true, + characterData: true, + childList: true, + subtree: true, + }); + observers.push(observer); + } + + const styleObserver = new MutationObserver(() => { + if (shouldIgnoreSceneMutation()) return; + markScenesDirty(new Set(scenes)); + }); + styleObserver.observe(document.head, { + attributes: true, + characterData: true, + childList: true, + subtree: true, + }); + observers.push(styleObserver); + return observers; + }; + + const hydrateTransitionCache = async ( + cache: CachedTransition, + sampleCount: number, + onProgress: (transitionFrame: number) => void, + ): Promise => { + const hydratedFrames: CachedTransitionFrame[] = []; + for (let sampleIndex = 0; sampleIndex < sampleCount; sampleIndex += 1) { + const [fromEntry, toEntry] = await Promise.all([ + getSnapshotEntry(makeSnapshotKey(cache.cacheKey, sampleIndex, "from")), + getSnapshotEntry(makeSnapshotKey(cache.cacheKey, sampleIndex, "to")), + ]); + if (!fromEntry || !toEntry) { + for (const frame of hydratedFrames) { + gl.deleteTexture(frame.fromTex); + gl.deleteTexture(frame.toTex); + } + return false; + } + + hydratedFrames.push({ + sampleIndex, + fromBlob: fromEntry.blob, + toBlob: toEntry.blob, + fromTex: null, + toTex: null, + }); + onProgress(sampleIndex + 1); + } + cache.frames = hydratedFrames; + cache.ready = true; + cache.dirty = false; + cache.fallback = false; + cache.persisted = true; + cache.textureReady = false; + return true; + }; + + const ensureTransitionTextures = (cache: CachedTransition): Promise => { + if (cache.fallback || cache.dirty || !cache.ready) return Promise.resolve(false); + if (cache.textureReady) { + markTextureAccess(cache); + return Promise.resolve(true); + } + if (cache.texturePromise) return cache.texturePromise; + + const generation = cache.textureGeneration; + const frames = cache.frames; + const uploadedTextures: WebGLTexture[] = []; + const isStaleTextureJob = (): boolean => { + return cache.textureGeneration !== generation || cache.dirty || cache.frames !== frames; + }; + const disposeUploadedTextures = (): void => { + const deleted = new Set(); + for (const tex of uploadedTextures) { + if (!deleted.has(tex)) { + gl.deleteTexture(tex); + deleted.add(tex); + } + } + for (const frame of frames) { + if (frame.fromTex && !deleted.has(frame.fromTex)) { + gl.deleteTexture(frame.fromTex); + deleted.add(frame.fromTex); + } + if (frame.toTex && !deleted.has(frame.toTex)) { + gl.deleteTexture(frame.toTex); + deleted.add(frame.toTex); + } + frame.fromTex = null; + frame.toTex = null; + } + }; + const getFrameBlob = async ( + frame: CachedTransitionFrame, + side: "from" | "to", + ): Promise => { + const cached = side === "from" ? frame.fromBlob : frame.toBlob; + if (cached) return cached; + const entry = await getSnapshotEntry( + makeSnapshotKey(cache.cacheKey, frame.sampleIndex, side), + ); + if (entry?.blob) return entry.blob; + throw new Error("[HyperShader] Cached transition snapshot blob is unavailable"); + }; + + let texturePromise = Promise.resolve(false); + texturePromise = (async () => { + for (const frame of frames) { + if (isStaleTextureJob()) { + disposeUploadedTextures(); + return false; + } + if (!frame.fromTex) { + const fromBlob = await getFrameBlob(frame, "from"); + if (isStaleTextureJob()) { + disposeUploadedTextures(); + return false; + } + const source = await blobToTextureSource(fromBlob); + const tex = createTexture(gl); + uploadedTextures.push(tex); + try { + uploadTextureSource(gl, tex, source); + if (isStaleTextureJob()) { + disposeUploadedTextures(); + return false; + } + frame.fromTex = tex; + } finally { + closeTextureSource(source); + } + } + if (!frame.toTex) { + const toBlob = await getFrameBlob(frame, "to"); + if (isStaleTextureJob()) { + disposeUploadedTextures(); + return false; + } + const source = await blobToTextureSource(toBlob); + const tex = createTexture(gl); + uploadedTextures.push(tex); + try { + uploadTextureSource(gl, tex, source); + if (isStaleTextureJob()) { + disposeUploadedTextures(); + return false; + } + frame.toTex = tex; + } finally { + closeTextureSource(source); + } + } + if (cache.persisted) { + frame.fromBlob = null; + frame.toBlob = null; + } + } + if (isStaleTextureJob()) { + disposeUploadedTextures(); + return false; + } + cache.textureReady = frames.every((frame) => Boolean(frame.fromTex && frame.toTex)); + if (cache.textureReady) { + markTextureAccess(cache); + enforceTextureBudget(cache); + } + return cache.textureReady; + })() + .catch((e) => { + disposeUploadedTextures(); + if (isStaleTextureJob()) { + return false; + } + disposeTransitionTextures(cache); + cache.fallback = true; + cache.ready = true; + cache.dirty = false; + cache.lastError = e instanceof Error ? e.message : String(e); + setShaderReadyState({ + ready: areAllCachesReady(), + loading: false, + error: cache.lastError, + }); + return false; + }) + .finally(() => { + if (cache.texturePromise === texturePromise) { + cache.texturePromise = null; + } + }); + + cache.texturePromise = texturePromise; + return texturePromise; + }; + + const ensurePlaybackTextureWindow = async (currentTime: number): Promise => { + for (const cache of getPlaybackTextureWindow(currentTime)) { + await ensureTransitionTextures(cache); + } + }; + + const persistSnapshot = async ( + cache: CachedTransition, + sampleIndex: number, + side: "from" | "to", + blob: Blob | null, + ): Promise => { + if (!blob) return false; + return putSnapshotEntry({ + key: makeSnapshotKey(cache.cacheKey, sampleIndex, side), + blob, + width: previewTextureWidth, + height: previewTextureHeight, + updatedAt: cache.index * 1_000_000 + sampleIndex * 2 + (side === "to" ? 1 : 0), + }); + }; + + const captureTransitionCache = async ( + cache: CachedTransition, + sampleCount: number, + onProgress: (transitionFrame: number) => void, + ): Promise => { + disposeCachedTransition(cache); + let allPersisted = true; + for (let sampleIndex = 0; sampleIndex < sampleCount; sampleIndex += 1) { + const progress = sampleIndex / (sampleCount - 1); + suppressSceneMutationTracking(() => { + originalTime(cache.time + cache.duration * progress, false); + }); + await waitForPaint(); + + const fromScene = document.getElementById(cache.fromId); + const toScene = document.getElementById(cache.toId); + if (!fromScene || !toScene) continue; + + const [fromCanvas, toCanvas] = await Promise.all([ + captureLiveScene(fromScene), + captureLiveScene(toScene), + ]); + const [fromBlob, toBlob] = await Promise.all([ + canvasToPngBlob(fromCanvas), + canvasToPngBlob(toCanvas), + ]); + disposeCaptureCanvas(fromCanvas); + disposeCaptureCanvas(toCanvas); + if (!fromBlob || !toBlob) { + throw new Error("[HyperShader] Failed to encode transition snapshot"); + } + + cache.frames.push({ sampleIndex, fromBlob, toBlob, fromTex: null, toTex: null }); + const persisted = await Promise.all([ + persistSnapshot(cache, sampleIndex, "from", fromBlob), + persistSnapshot(cache, sampleIndex, "to", toBlob), + ]); + if (!persisted.every(Boolean)) { + allPersisted = false; + cache.lastError = "[HyperShader] Failed to persist one or more transition snapshots"; + } + onProgress(sampleIndex + 1); + } + cache.ready = true; + cache.dirty = false; + cache.fallback = false; + cache.persisted = allPersisted; + cache.textureReady = false; + }; + + let transitionCachePromise: Promise | null = null; + + const ensureTransitionCachesReady = (): Promise => { + if (transitionCachePromise) return transitionCachePromise; + + transitionCachePromise = (async () => { + const work = cachedTransitions.filter((cache) => cache.dirty || !cache.ready); + const workItems = work.map((cache) => ({ + cache, + sampleCount: sampleCountForCache(cache), + })); + for (const item of workItems) { + item.cache.cacheKey = buildTransitionCacheKey(item.cache, item.sampleCount); + } + const total = workItems.reduce((sum, item) => sum + item.sampleCount, 0); + const firstItem = workItems[0]; + let showLoadingOverlay = false; + setShaderReadyState({ + ready: false, + progress: 0, + total, + currentTransition: firstItem ? 1 : undefined, + transitionTotal: workItems.length || undefined, + transitionFrame: firstItem ? 0 : undefined, + transitionFrames: firstItem?.sampleCount, + phase: firstItem ? "cached" : undefined, + loading: showLoadingOverlay, + }); + + if (work.length === 0) { + shaderCacheReady = true; + setShaderReadyState({ + ready: true, + progress: 0, + total: 0, + currentTransition: undefined, + transitionTotal: undefined, + transitionFrame: undefined, + transitionFrames: undefined, + phase: undefined, + loading: false, + }); + if (pendingPlay) { + const resumeArgs = pendingPlayArgs; + const resumeTime = getPlaybackRequestTime(resumeArgs, publicTimelineTime); + await ensurePlaybackTextureWindow(resumeTime); + if (pendingPlay) { + pendingPlay = false; + pendingPlayArgs = []; + cancelResumeAfterPrewarm = false; + suppressSceneMutationTracking(() => originalPlay(...resumeArgs)); + publicTimelineTime = readActualTimelineTime(); + } + } + transitionCachePromise = null; + return; + } + + publicTimelineTime = readActualTimelineTime(); + const wasPaused = tl.paused(); + const originalSceneStyles: SceneStyleState[] = scenes.map((id) => { + const scene = document.getElementById(id); + return { + scene, + opacity: scene?.style.opacity ?? "", + visibility: scene?.style.visibility ?? "", + pointerEvents: scene?.style.pointerEvents ?? "", + }; + }); + + prewarming = true; + canvasEl.style.display = "none"; + originalPause(); + + let completed = 0; + try { + for (let workIndex = 0; workIndex < workItems.length; workIndex += 1) { + const item = workItems[workIndex]; + if (!item) continue; + const { cache, sampleCount } = item; + const currentTransition = workIndex + 1; + const completedBeforeTransition = completed; + disposeCachedTransition(cache); + try { + setShaderReadyState({ + ready: false, + progress: completedBeforeTransition, + total, + currentTransition, + transitionTotal: workItems.length, + transitionFrame: 0, + transitionFrames: sampleCount, + phase: "cached", + loading: showLoadingOverlay, + }); + const hydrated = await hydrateTransitionCache(cache, sampleCount, (transitionFrame) => { + completed = completedBeforeTransition + transitionFrame; + setShaderReadyState({ + ready: false, + progress: completed, + total, + currentTransition, + transitionTotal: workItems.length, + transitionFrame, + transitionFrames: sampleCount, + phase: "cached", + loading: showLoadingOverlay, + }); + }); + if (!hydrated) { + completed = completedBeforeTransition; + showLoadingOverlay = true; + setShaderReadyState({ + ready: false, + progress: completed, + total, + currentTransition, + transitionTotal: workItems.length, + transitionFrame: 0, + transitionFrames: sampleCount, + phase: "capturing", + loading: true, + }); + await captureTransitionCache(cache, sampleCount, (transitionFrame) => { + completed = completedBeforeTransition + transitionFrame; + setShaderReadyState({ + ready: false, + progress: completed, + total, + currentTransition, + transitionTotal: workItems.length, + transitionFrame, + transitionFrames: sampleCount, + phase: "capturing", + loading: true, + }); + }); + if (!cache.persisted && cache.lastError) { + setShaderReadyState({ + ready: false, + loading: showLoadingOverlay, + error: cache.lastError, + }); + } + } + } catch (e) { + completed = completedBeforeTransition + sampleCount; + cache.fallback = true; + cache.ready = true; + cache.dirty = false; + cache.lastError = e instanceof Error ? e.message : String(e); + console.warn("[HyperShader] Transition capture failed; using CSS fallback:", e); + setShaderReadyState({ + ready: false, + progress: completed, + total, + currentTransition, + transitionTotal: workItems.length, + transitionFrame: sampleCount, + transitionFrames: sampleCount, + phase: "finalizing", + loading: showLoadingOverlay, + error: cache.lastError, + }); + } + completed = completedBeforeTransition + sampleCount; + } + void pruneSnapshotCache( + compId, + new Set(cachedTransitions.map((cache) => cache.cacheKey).filter(Boolean)), + ); + shaderCacheReady = areAllCachesReady(); + const finalItem = workItems[workItems.length - 1]; + setShaderReadyState({ + ready: shaderCacheReady, + progress: completed, + total, + currentTransition: workItems.length, + transitionTotal: workItems.length, + transitionFrame: finalItem?.sampleCount, + transitionFrames: finalItem?.sampleCount, + phase: "finalizing", + loading: false, + }); + } catch (e) { + console.warn("[HyperShader] Pre-capture failed, keeping DOM fallback visible:", e); + shaderCacheReady = areAllCachesReady(); + setShaderReadyState({ + ready: shaderCacheReady, + progress: completed, + total, + phase: "finalizing", + loading: false, + error: e instanceof Error ? e.message : String(e), + }); + } finally { + const restoreTimelineTime = publicTimelineTime; + let shouldResume = (pendingPlay || !wasPaused) && !cancelResumeAfterPrewarm; + let resumeArgs = pendingPlay ? pendingPlayArgs : []; + let resumeTime = getPlaybackRequestTime(resumeArgs, restoreTimelineTime); + let hydratedTextureTime: number | null = null; + if (shouldResume) { + await ensurePlaybackTextureWindow(resumeTime); + hydratedTextureTime = resumeTime; + } + shouldResume = (pendingPlay || !wasPaused) && !cancelResumeAfterPrewarm; + resumeArgs = pendingPlay ? pendingPlayArgs : []; + resumeTime = getPlaybackRequestTime(resumeArgs, restoreTimelineTime); + if (shouldResume && resumeTime !== hydratedTextureTime) { + await ensurePlaybackTextureWindow(resumeTime); + } + prewarming = false; + state.active = false; + state.transitionIndex = -1; + canvasEl.style.display = "none"; + suppressSceneMutationTracking(() => { + setActualTimelineTime(restoreTimelineTime, false); + }); + publicTimelineTime = restoreTimelineTime; + for (const item of originalSceneStyles) { + if (!item.scene) continue; + item.scene.style.opacity = item.opacity; + item.scene.style.visibility = item.visibility; + item.scene.style.pointerEvents = item.pointerEvents; + } + tickShader(); + window.dispatchEvent( + new CustomEvent("hyperShader:ready", { + detail: { compositionId: compId, progress: completed, total }, + }), + ); + transitionCachePromise = null; + if (shouldResume) { + pendingPlay = false; + pendingPlayArgs = []; + cancelResumeAfterPrewarm = false; + suppressSceneMutationTracking(() => originalPlay(...resumeArgs)); + publicTimelineTime = readActualTimelineTime(); + } else { + pendingPlay = false; + pendingPlayArgs = []; + cancelResumeAfterPrewarm = false; + originalPause(); + } + } + })(); + + return transitionCachePromise; + }; + + const sceneEditObservers = observeSceneEdits(); + window.addEventListener( + "beforeunload", + () => { + for (const observer of sceneEditObservers) { + observer.disconnect(); + } + }, + { once: true }, + ); + + const prewarmPromise = Promise.resolve().then(() => ensureTransitionCachesReady()); + const hfWin = window as unknown as { __hf?: { shaderTransitionsReady?: Promise } }; + hfWin.__hf = hfWin.__hf || {}; + hfWin.__hf.shaderTransitionsReady = prewarmPromise; + registerTimeline(compId, tl, config.timeline); return tl; } diff --git a/packages/shader-transitions/src/webgl.ts b/packages/shader-transitions/src/webgl.ts index 3025229ae..a36ffe51f 100644 --- a/packages/shader-transitions/src/webgl.ts +++ b/packages/shader-transitions/src/webgl.ts @@ -36,13 +36,14 @@ function compileShader(gl: WebGLRenderingContext, src: string, type: number): We return s; } -export function createProgram(gl: WebGLRenderingContext, fragSrc: string): WebGLProgram { - if (!cachedVertexShader) { - cachedVertexShader = compileShader(gl, vertSrc, gl.VERTEX_SHADER); - } +function linkProgram( + gl: WebGLRenderingContext, + vertexShader: WebGLShader, + fragSrc: string, +): WebGLProgram { const p = gl.createProgram(); if (!p) throw new Error("[HyperShader] Failed to create program"); - gl.attachShader(p, cachedVertexShader); + gl.attachShader(p, vertexShader); gl.attachShader(p, compileShader(gl, fragSrc, gl.FRAGMENT_SHADER)); gl.linkProgram(p); if (!gl.getProgramParameter(p, gl.LINK_STATUS)) { @@ -51,6 +52,21 @@ export function createProgram(gl: WebGLRenderingContext, fragSrc: string): WebGL return p; } +export function createProgram(gl: WebGLRenderingContext, fragSrc: string): WebGLProgram { + if (!cachedVertexShader) { + cachedVertexShader = compileShader(gl, vertSrc, gl.VERTEX_SHADER); + } + return linkProgram(gl, cachedVertexShader, fragSrc); +} + +export function createProgramWithVertex( + gl: WebGLRenderingContext, + vertexSrc: string, + fragSrc: string, +): WebGLProgram { + return linkProgram(gl, compileShader(gl, vertexSrc, gl.VERTEX_SHADER), fragSrc); +} + export interface AccentColors { accent: [number, number, number]; dark: [number, number, number]; @@ -136,8 +152,16 @@ export function uploadTexture( tex: WebGLTexture, canvas: HTMLCanvasElement, ): void { - gl.bindTexture(gl.TEXTURE_2D, tex); - gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, canvas); + uploadTextureSource(gl, tex, canvas); canvas.width = 0; canvas.height = 0; } + +export function uploadTextureSource( + gl: WebGLRenderingContext, + tex: WebGLTexture, + source: TexImageSource, +): void { + gl.bindTexture(gl.TEXTURE_2D, tex); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source); +} diff --git a/packages/studio/src/components/ui/HyperframesLoader.tsx b/packages/studio/src/components/ui/HyperframesLoader.tsx new file mode 100644 index 000000000..5a31e1a48 --- /dev/null +++ b/packages/studio/src/components/ui/HyperframesLoader.tsx @@ -0,0 +1,104 @@ +export interface HyperframesLoaderProps { + /** Status text shown below the mark. */ + title: string; + /** Optional secondary detail line. */ + detail?: string; + /** Optional monospace third line for IDs, counts, or percentages. */ + mono?: string; + /** Pixel size of the mark itself; status text scales independently. */ + size?: number; + /** Optional normalized progress value from 0 to 1. */ + progress?: number; +} + +export function HyperframesLoader({ + title, + detail, + mono, + size = 64, + progress, +}: HyperframesLoaderProps) { + const boundedProgress = + typeof progress === "number" && Number.isFinite(progress) + ? Math.min(1, Math.max(0, progress)) + : undefined; + const markFrameSize = Math.round(size * 1.16); + + return ( +
+
+ +
+
{title}
+ {detail &&
{detail}
} + {boundedProgress !== undefined && ( + + ); +} + +export function StatusFrame(props: HyperframesLoaderProps) { + return ( +
+ +
+ ); +} diff --git a/packages/studio/src/components/ui/index.ts b/packages/studio/src/components/ui/index.ts index 3f5812e0c..9623c7362 100644 --- a/packages/studio/src/components/ui/index.ts +++ b/packages/studio/src/components/ui/index.ts @@ -1,2 +1,4 @@ // Minimal UI primitives for studio canvas components export { Button, IconButton } from "./Button"; +export { HyperframesLoader, StatusFrame } from "./HyperframesLoader"; +export type { HyperframesLoaderProps } from "./HyperframesLoader"; diff --git a/packages/studio/src/player/components/Player.tsx b/packages/studio/src/player/components/Player.tsx index 519536d31..cd90ee07c 100644 --- a/packages/studio/src/player/components/Player.tsx +++ b/packages/studio/src/player/components/Player.tsx @@ -1,6 +1,7 @@ -import { forwardRef, useRef, useState } from "react"; +import { forwardRef, useEffect, useRef, useState } from "react"; import { isLottieAnimationLoaded } from "@hyperframes/core/runtime/lottie-readiness"; import { useMountEffect } from "../../hooks/useMountEffect"; +import { HyperframesLoader } from "../../components/ui"; // NOTE: importing "@hyperframes/player" registers a class extending HTMLElement // at module load, which throws under SSR. Defer the import to the mount effect // so it only runs in the browser. @@ -16,6 +17,19 @@ interface HyperframesPlayerElement extends HTMLElement { iframeElement: HTMLIFrameElement; } +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function getShaderTransitionLoading(event: Event): boolean | null { + if (!(event instanceof CustomEvent)) return null; + const detail: unknown = event.detail; + if (!isRecord(detail)) return null; + const state = detail.state; + if (!isRecord(state)) return null; + return state.loading === true && state.ready !== true; +} + // Assets are considered ready when every `