mirror of
https://github.com/hanzoai/frames.git
synced 2026-09-24 00:08:03 +00:00
feat(engine): add HDR two-pass compositing — DOM layer + native HLG video (#288)
## Summary Compositions with HDR video AND DOM overlays (text, graphics, SDR video) couldn't render both correctly — either HDR data was lost (Chrome captures sRGB only) or DOM overlays were missing (FFmpeg pass-through skips Chrome). This PR adds in-memory alpha compositing that combines both. ## What it does **Per-frame two-pass capture:** 1. **DOM pass** — Chrome screenshots the page with a transparent background (CDP alpha). HDR videos are hidden, leaving transparent holes where they go. 2. **HDR pass** — Pre-extracted native HLG/PQ frames (16-bit PNG from FFmpeg) are read from disk. 3. **Composite** — DOM pixels (sRGB RGBA8) are alpha-composited over HDR pixels (rgb48le) in Node.js memory, with sRGB→HLG/PQ conversion via a 256-entry lookup table. **Key components:** - `decodePng()` / `decodePngToRgb48le()` — Pure Node.js PNG decoders (no native dependencies). Support all 5 PNG filter types. - `blitRgba8OverRgb48le()` — Alpha composite with per-pixel sRGB→HDR LUT conversion. Fast paths for alpha=0 (skip) and alpha=255 (overwrite). - `initTransparentBackground()` + `captureAlphaPng()` — Split CDP transparent background setup (once) from per-frame screenshot capture (eliminates 2 CDP round-trips per frame). - Single-pass FFmpeg extraction — All HDR frames extracted in one sequential FFmpeg run (avoids duplicate frames from per-frame `-ss` fast seek). ## Key design decisions | Decision | Why | |----------|-----| | In-memory compositing (not FFmpeg overlay) | Eliminates ~2400 process spawns + temp files per render. Pure pixel math is 10x faster. | | 16-bit PNG intermediate | Raw `-f rawvideo` loses color metadata, causing moiré artifacts. PNG is self-describing. | | sRGB→HLG LUT (256 entries) | DOM content is sRGB. Without conversion, it appears orange-shifted in HLG stream. | | Native HDR detection before extraction | `extractAllVideoFrames` converts SDR→HDR. Pre-extraction probe identifies original HDR sources so only truly-HDR videos get native extraction. | ## Files changed | File | What changed | |------|-------------| | `packages/engine/src/utils/alphaBlit.ts` | **NEW** — PNG decode, sRGB→HDR LUT, alpha compositing (14 tests) | | `packages/engine/src/services/screenshotService.ts` | Transparent background CDP, `captureAlphaPng()` | | `packages/engine/src/services/videoFrameInjector.ts` | `hideVideoElements()` / `showVideoElements()` | | `packages/engine/src/services/streamingEncoder.ts` | Input color space tags for rgb48le | | `packages/producer/src/services/renderOrchestrator.ts` | Two-pass HDR capture loop, native HDR detection | ## How to test Render a composition with an HDR video background and text overlays. Both should be visible — HDR video at full quality, text crisp with correct colors (not orange-shifted). ## Stack position **3 of 6** — Stacked on #265 (HDR output pipeline). This is the foundation for all layered compositing that follows. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
@@ -35,8 +35,14 @@ packages/producer/tests/*/failures/
|
||||
|
||||
# Rendered output (not test fixtures — those use git LFS)
|
||||
output/
|
||||
renders/
|
||||
!packages/producer/tests/*/output/
|
||||
|
||||
# Composition source media (large binaries)
|
||||
compositions/**/*.mp4
|
||||
compositions/**/*.mov
|
||||
compositions/**/*.MOV
|
||||
|
||||
# npm pack artifacts
|
||||
*.tgz
|
||||
|
||||
|
||||
@@ -77,6 +77,8 @@ export {
|
||||
injectVideoFramesBatch,
|
||||
syncVideoFrameVisibility,
|
||||
cdpSessionCache,
|
||||
initTransparentBackground,
|
||||
captureAlphaPng,
|
||||
type BeginFrameResult,
|
||||
} from "./services/screenshotService.js";
|
||||
|
||||
@@ -154,15 +156,25 @@ export {
|
||||
|
||||
export { downloadToTemp, isHttpUrl } from "./utils/urlDownloader.js";
|
||||
|
||||
export { decodePng, decodePngToRgb48le, blitRgba8OverRgb48le } from "./utils/alphaBlit.js";
|
||||
|
||||
export {
|
||||
initHdrReadback,
|
||||
uploadAndReadbackHdrFrame,
|
||||
convertHdrFrameToRgb48le,
|
||||
float16ToPqRgb,
|
||||
buildHdrChromeArgs,
|
||||
launchHdrBrowser,
|
||||
} from "./services/hdrCapture.js";
|
||||
|
||||
export { captureScreenshotWithAlpha } from "./services/screenshotService.js";
|
||||
|
||||
export {
|
||||
hideVideoElements,
|
||||
showVideoElements,
|
||||
queryVideoElementBounds,
|
||||
type VideoElementBounds,
|
||||
} from "./services/videoFrameInjector.js";
|
||||
|
||||
export {
|
||||
isHdrColorSpace,
|
||||
detectTransfer,
|
||||
|
||||
@@ -133,6 +133,82 @@ export async function pageScreenshotCapture(page: Page, options: CaptureOptions)
|
||||
return Buffer.from(result.data, "base64");
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture a screenshot with transparent background (PNG + alpha channel).
|
||||
*
|
||||
* Used in the two-pass HDR compositing pipeline — captures DOM content
|
||||
* (text, graphics, SDR overlays) with transparency where the background shows,
|
||||
* so it can be overlaid on top of native HDR video frames in FFmpeg.
|
||||
*
|
||||
* Sets and restores the background color override on every call. For sessions
|
||||
* that capture many frames, prefer calling initTransparentBackground() once
|
||||
* at session init, then captureAlphaPng() per frame to avoid the 2× CDP
|
||||
* round-trip overhead.
|
||||
*/
|
||||
export async function captureScreenshotWithAlpha(
|
||||
page: Page,
|
||||
width: number,
|
||||
height: number,
|
||||
): Promise<Buffer> {
|
||||
const client = await getCdpSession(page);
|
||||
// Force transparent background so the screenshot has a real alpha channel
|
||||
await client.send("Emulation.setDefaultBackgroundColorOverride", {
|
||||
color: { r: 0, g: 0, b: 0, a: 0 },
|
||||
});
|
||||
try {
|
||||
const result = await client.send("Page.captureScreenshot", {
|
||||
format: "png",
|
||||
fromSurface: true,
|
||||
captureBeyondViewport: false,
|
||||
optimizeForSpeed: false, // `true` uses a zero-alpha-aware fast path that crushes real alpha values — observed empirically, CDP docs don't spell it out
|
||||
clip: { x: 0, y: 0, width, height, scale: 1 },
|
||||
});
|
||||
return Buffer.from(result.data, "base64");
|
||||
} finally {
|
||||
// Restore opaque background even if captureScreenshot throws, otherwise
|
||||
// subsequent opaque captures keep a transparent background.
|
||||
await client.send("Emulation.setDefaultBackgroundColorOverride", {}).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the page background to transparent once for a dedicated HDR DOM session.
|
||||
*
|
||||
* Call this once after session initialization. Then use captureAlphaPng() per
|
||||
* frame instead of captureScreenshotWithAlpha() to skip the per-frame CDP
|
||||
* background override round-trips.
|
||||
*
|
||||
* Only use on sessions that are exclusively dedicated to transparent capture
|
||||
* (e.g., the HDR two-pass DOM layer session) — the background will stay
|
||||
* transparent for the lifetime of the session.
|
||||
*/
|
||||
export async function initTransparentBackground(page: Page): Promise<void> {
|
||||
const client = await getCdpSession(page);
|
||||
await client.send("Emulation.setDefaultBackgroundColorOverride", {
|
||||
color: { r: 0, g: 0, b: 0, a: 0 },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture a transparent-background PNG screenshot without setting the
|
||||
* background color override. Requires initTransparentBackground() to have
|
||||
* been called once on this session.
|
||||
*
|
||||
* Faster than captureScreenshotWithAlpha() for per-frame use in the HDR
|
||||
* two-pass compositing loop.
|
||||
*/
|
||||
export async function captureAlphaPng(page: Page, width: number, height: number): Promise<Buffer> {
|
||||
const client = await getCdpSession(page);
|
||||
const result = await client.send("Page.captureScreenshot", {
|
||||
format: "png",
|
||||
fromSurface: true,
|
||||
captureBeyondViewport: false,
|
||||
optimizeForSpeed: false, // must be false to preserve alpha
|
||||
clip: { x: 0, y: 0, width, height, scale: 1 },
|
||||
});
|
||||
return Buffer.from(result.data, "base64");
|
||||
}
|
||||
|
||||
export async function injectVideoFramesBatch(
|
||||
page: Page,
|
||||
updates: Array<{ videoId: string; dataUri: string }>,
|
||||
@@ -160,16 +236,11 @@ export async function injectVideoFramesBatch(
|
||||
}
|
||||
if (!img) continue;
|
||||
|
||||
if (!sourceIsStatic) {
|
||||
img.style.position = computedStyle.position;
|
||||
img.style.width = computedStyle.width;
|
||||
img.style.height = computedStyle.height;
|
||||
img.style.top = computedStyle.top;
|
||||
img.style.left = computedStyle.left;
|
||||
img.style.right = computedStyle.right;
|
||||
img.style.bottom = computedStyle.bottom;
|
||||
img.style.inset = computedStyle.inset;
|
||||
} else {
|
||||
// Always use absolute positioning so the <img> overlays the <video>
|
||||
// instead of flowing below it. With position:relative, both elements
|
||||
// stack vertically — the <img> lands below the video and gets clipped
|
||||
// by any overflow:hidden ancestor (e.g., border-radius wrappers).
|
||||
{
|
||||
const videoRect = video.getBoundingClientRect();
|
||||
const offsetLeft = Number.isFinite(video.offsetLeft) ? video.offsetLeft : 0;
|
||||
const offsetTop = Number.isFinite(video.offsetTop) ? video.offsetTop : 0;
|
||||
@@ -235,14 +306,28 @@ export async function syncVideoFrameVisibility(
|
||||
const active = new Set(ids);
|
||||
const videos = Array.from(document.querySelectorAll("video[data-start]")) as HTMLVideoElement[];
|
||||
for (const video of videos) {
|
||||
if (active.has(video.id)) continue;
|
||||
video.style.removeProperty("display");
|
||||
video.style.setProperty("visibility", "hidden", "important");
|
||||
video.style.setProperty("opacity", "0", "important");
|
||||
video.style.setProperty("pointer-events", "none", "important");
|
||||
const img = video.nextElementSibling as HTMLElement | null;
|
||||
if (img && img.classList.contains("__render_frame__")) {
|
||||
img.style.visibility = "hidden";
|
||||
const hasImg = img && img.classList.contains("__render_frame__");
|
||||
if (active.has(video.id)) {
|
||||
// Active video: show injected <img>, hide native <video>.
|
||||
// Do NOT clobber inline opacity here — GSAP-controlled opacity must
|
||||
// survive until injectVideoFramesBatch reads it via getComputedStyle.
|
||||
// visibility:hidden alone hides the native element without affecting
|
||||
// its computed opacity.
|
||||
video.style.setProperty("visibility", "hidden", "important");
|
||||
video.style.setProperty("pointer-events", "none", "important");
|
||||
if (hasImg) {
|
||||
img.style.visibility = "visible";
|
||||
}
|
||||
} else {
|
||||
// Inactive video: hide both
|
||||
video.style.removeProperty("display");
|
||||
video.style.setProperty("visibility", "hidden", "important");
|
||||
video.style.setProperty("opacity", "0", "important");
|
||||
video.style.setProperty("pointer-events", "none", "important");
|
||||
if (hasImg) {
|
||||
img.style.visibility = "hidden";
|
||||
}
|
||||
}
|
||||
}
|
||||
}, activeVideoIds);
|
||||
|
||||
@@ -117,3 +117,111 @@ export function createVideoFrameInjector(
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ── HDR compositing utilities ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Bounds and transform of a video element, queried from Chrome each frame.
|
||||
* Used by the two-pass HDR compositing pipeline to position native HDR frames.
|
||||
*/
|
||||
export interface VideoElementBounds {
|
||||
videoId: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
opacity: number;
|
||||
/** CSS transform matrix as a DOMMatrix-compatible string, e.g. "matrix(1,0,0,1,0,0)" */
|
||||
transform: string;
|
||||
zIndex: number;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide specific video elements by ID. Used in Pass 1 of the HDR pipeline so
|
||||
* Chrome screenshots only contain DOM content (text, overlays) with transparent
|
||||
* holes where the HDR videos go.
|
||||
*/
|
||||
export async function hideVideoElements(page: Page, videoIds: string[]): Promise<void> {
|
||||
if (videoIds.length === 0) return;
|
||||
await page.evaluate((ids: string[]) => {
|
||||
for (const id of ids) {
|
||||
const el = document.getElementById(id) as HTMLVideoElement | null;
|
||||
if (el) {
|
||||
el.style.setProperty("visibility", "hidden", "important");
|
||||
el.style.setProperty("opacity", "0", "important");
|
||||
// Also hide the injected render frame image if present
|
||||
const img = document.getElementById(`__render_frame_${id}__`);
|
||||
if (img) img.style.setProperty("visibility", "hidden", "important");
|
||||
}
|
||||
}
|
||||
}, videoIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore visibility of video elements after a DOM screenshot.
|
||||
*/
|
||||
export async function showVideoElements(page: Page, videoIds: string[]): Promise<void> {
|
||||
if (videoIds.length === 0) return;
|
||||
await page.evaluate((ids: string[]) => {
|
||||
for (const id of ids) {
|
||||
const el = document.getElementById(id) as HTMLVideoElement | null;
|
||||
if (el) {
|
||||
el.style.removeProperty("visibility");
|
||||
el.style.removeProperty("opacity");
|
||||
const img = document.getElementById(`__render_frame_${id}__`);
|
||||
if (img) img.style.removeProperty("visibility");
|
||||
}
|
||||
}
|
||||
}, videoIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Query the current bounds, transform, and visibility of video elements.
|
||||
* Called after seeking (so GSAP has moved things) but before the screenshot.
|
||||
*/
|
||||
export async function queryVideoElementBounds(
|
||||
page: Page,
|
||||
videoIds: string[],
|
||||
): Promise<VideoElementBounds[]> {
|
||||
if (videoIds.length === 0) return [];
|
||||
return page.evaluate((ids: string[]): VideoElementBounds[] => {
|
||||
return ids.map((id) => {
|
||||
const el = document.getElementById(id) as HTMLVideoElement | null;
|
||||
if (!el) {
|
||||
return {
|
||||
videoId: id,
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
opacity: 0,
|
||||
transform: "none",
|
||||
zIndex: 0,
|
||||
visible: false,
|
||||
};
|
||||
}
|
||||
const rect = el.getBoundingClientRect();
|
||||
const style = window.getComputedStyle(el);
|
||||
const zIndex = parseInt(style.zIndex) || 0;
|
||||
const opacity = parseFloat(style.opacity) || 1;
|
||||
const transform = style.transform || "none";
|
||||
const visible =
|
||||
style.visibility !== "hidden" &&
|
||||
style.display !== "none" &&
|
||||
rect.width > 0 &&
|
||||
rect.height > 0;
|
||||
return {
|
||||
videoId: id,
|
||||
x: Math.round(rect.x),
|
||||
y: Math.round(rect.y),
|
||||
width: Math.round(rect.width),
|
||||
height: Math.round(rect.height),
|
||||
opacity,
|
||||
transform,
|
||||
zIndex,
|
||||
visible,
|
||||
};
|
||||
});
|
||||
}, videoIds);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,993 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { deflateSync } from "zlib";
|
||||
import {
|
||||
decodePng,
|
||||
decodePngToRgb48le,
|
||||
blitRgba8OverRgb48le,
|
||||
blitRgb48leRegion,
|
||||
blitRgb48leAffine,
|
||||
parseTransformMatrix,
|
||||
roundedRectAlpha,
|
||||
} from "./alphaBlit.js";
|
||||
|
||||
// ── PNG construction helpers ─────────────────────────────────────────────────
|
||||
|
||||
function uint32BE(n: number): Buffer {
|
||||
const b = Buffer.allocUnsafe(4);
|
||||
b.writeUInt32BE(n, 0);
|
||||
return b;
|
||||
}
|
||||
|
||||
function crc32(data: Buffer): number {
|
||||
let crc = 0xffffffff;
|
||||
const table = crc32Table();
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
crc = (table[(crc ^ (data[i] ?? 0)) & 0xff] ?? 0) ^ (crc >>> 8);
|
||||
}
|
||||
return (crc ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
let _crcTable: Uint32Array | undefined;
|
||||
function crc32Table(): Uint32Array {
|
||||
if (_crcTable) return _crcTable;
|
||||
const t = new Uint32Array(256);
|
||||
for (let i = 0; i < 256; i++) {
|
||||
let c = i;
|
||||
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||
t[i] = c;
|
||||
}
|
||||
_crcTable = t;
|
||||
return t;
|
||||
}
|
||||
|
||||
function makeChunk(type: string, data: Buffer): Buffer {
|
||||
const typeBuffer = Buffer.from(type, "ascii");
|
||||
const crcInput = Buffer.concat([typeBuffer, data]);
|
||||
const crcBuf = uint32BE(crc32(crcInput));
|
||||
return Buffer.concat([uint32BE(data.length), typeBuffer, data, crcBuf]);
|
||||
}
|
||||
|
||||
const PNG_SIG = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
||||
|
||||
/**
|
||||
* Build a minimal RGBA PNG for testing.
|
||||
* pixels: flat RGBA array (row-major, 8-bit per channel)
|
||||
*/
|
||||
function makePng(width: number, height: number, pixels: number[]): Buffer {
|
||||
// IHDR
|
||||
const ihdr = Buffer.allocUnsafe(13);
|
||||
ihdr.writeUInt32BE(width, 0);
|
||||
ihdr.writeUInt32BE(height, 4);
|
||||
ihdr[8] = 8; // bit depth
|
||||
ihdr[9] = 6; // color type RGBA
|
||||
ihdr[10] = 0; // compression
|
||||
ihdr[11] = 0; // filter method
|
||||
ihdr[12] = 0; // interlace none
|
||||
|
||||
// Raw scanlines with filter byte 0 (None)
|
||||
const scanlines: number[] = [];
|
||||
for (let y = 0; y < height; y++) {
|
||||
scanlines.push(0); // filter type None
|
||||
for (let x = 0; x < width; x++) {
|
||||
const i = (y * width + x) * 4;
|
||||
scanlines.push(pixels[i] ?? 0, pixels[i + 1] ?? 0, pixels[i + 2] ?? 0, pixels[i + 3] ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
const idatData = deflateSync(Buffer.from(scanlines));
|
||||
|
||||
return Buffer.concat([
|
||||
PNG_SIG,
|
||||
makeChunk("IHDR", ihdr),
|
||||
makeChunk("IDAT", idatData),
|
||||
makeChunk("IEND", Buffer.alloc(0)),
|
||||
]);
|
||||
}
|
||||
|
||||
// ── decodePng tests ──────────────────────────────────────────────────────────
|
||||
|
||||
describe("decodePng", () => {
|
||||
it("decodes a 1x1 RGBA PNG correctly", () => {
|
||||
// RGBA: red pixel, full opacity
|
||||
const png = makePng(1, 1, [255, 0, 0, 255]);
|
||||
const { width, height, data } = decodePng(png);
|
||||
expect(width).toBe(1);
|
||||
expect(height).toBe(1);
|
||||
expect(data[0]).toBe(255); // R
|
||||
expect(data[1]).toBe(0); // G
|
||||
expect(data[2]).toBe(0); // B
|
||||
expect(data[3]).toBe(255); // A
|
||||
});
|
||||
|
||||
it("decodes a 2x2 RGBA PNG with multiple pixels", () => {
|
||||
// TL=red, TR=green, BL=blue, BR=white (all full opacity)
|
||||
const pixels = [
|
||||
255,
|
||||
0,
|
||||
0,
|
||||
255, // TL red
|
||||
0,
|
||||
255,
|
||||
0,
|
||||
255, // TR green
|
||||
0,
|
||||
0,
|
||||
255,
|
||||
255, // BL blue
|
||||
255,
|
||||
255,
|
||||
255,
|
||||
255, // BR white
|
||||
];
|
||||
const png = makePng(2, 2, pixels);
|
||||
const { width, height, data } = decodePng(png);
|
||||
expect(width).toBe(2);
|
||||
expect(height).toBe(2);
|
||||
|
||||
// Top-left: red
|
||||
expect(data[0]).toBe(255);
|
||||
expect(data[1]).toBe(0);
|
||||
expect(data[2]).toBe(0);
|
||||
expect(data[3]).toBe(255);
|
||||
|
||||
// Bottom-right: white
|
||||
expect(data[12]).toBe(255);
|
||||
expect(data[13]).toBe(255);
|
||||
expect(data[14]).toBe(255);
|
||||
expect(data[15]).toBe(255);
|
||||
});
|
||||
|
||||
it("decodes a transparent pixel correctly", () => {
|
||||
const png = makePng(1, 1, [128, 64, 32, 0]);
|
||||
const { data } = decodePng(png);
|
||||
expect(data[3]).toBe(0); // alpha = 0
|
||||
});
|
||||
|
||||
it("decodes a semi-transparent pixel correctly", () => {
|
||||
const png = makePng(1, 1, [100, 150, 200, 128]);
|
||||
const { data } = decodePng(png);
|
||||
expect(data[0]).toBe(100);
|
||||
expect(data[1]).toBe(150);
|
||||
expect(data[2]).toBe(200);
|
||||
expect(data[3]).toBe(128);
|
||||
});
|
||||
|
||||
it("throws on invalid PNG signature", () => {
|
||||
const buf = Buffer.from([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]);
|
||||
expect(() => decodePng(buf)).toThrow("not a PNG file");
|
||||
});
|
||||
});
|
||||
|
||||
// ── PNG filter coverage ─────────────────────────────────────────────────────
|
||||
//
|
||||
// `makePng` only exercises filter type 0 (None). libpng (and Chrome) pick
|
||||
// other filter types heuristically; these tests build raw IDAT bytes with each
|
||||
// filter type so the defilter logic gets actual coverage.
|
||||
|
||||
const paethRef = (a: number, b: number, c: number): number => {
|
||||
const p = a + b - c;
|
||||
const pa = Math.abs(p - a);
|
||||
const pb = Math.abs(p - b);
|
||||
const pc = Math.abs(p - c);
|
||||
if (pa <= pb && pa <= pc) return a;
|
||||
if (pb <= pc) return b;
|
||||
return c;
|
||||
};
|
||||
|
||||
/**
|
||||
* Build a PNG with a specific filter type applied to every row. Encodes a
|
||||
* 3×2 RGBA image with unique per-channel values so any cross-channel mistake
|
||||
* in the defilter loop shows up as an assertion failure.
|
||||
*
|
||||
* @param filterType 0=None, 1=Sub, 2=Up, 3=Average, 4=Paeth
|
||||
*/
|
||||
function makePngWithFilter(filterType: 0 | 1 | 2 | 3 | 4): {
|
||||
png: Buffer;
|
||||
expectedPixels: number[];
|
||||
} {
|
||||
const width = 3;
|
||||
const height = 2;
|
||||
const bpp = 4; // RGBA, 8-bit
|
||||
const stride = width * bpp;
|
||||
|
||||
// Unique pixels so any defilter bug is observable
|
||||
const expectedPixels = [
|
||||
10, 20, 30, 255, 50, 60, 70, 255, 90, 100, 110, 255, 130, 140, 150, 255, 170, 180, 190, 255,
|
||||
210, 220, 230, 255,
|
||||
];
|
||||
|
||||
const filtered: number[] = [];
|
||||
const prev = new Uint8Array(stride);
|
||||
for (let y = 0; y < height; y++) {
|
||||
filtered.push(filterType);
|
||||
const rowStart = y * stride;
|
||||
const curr = new Uint8Array(stride);
|
||||
for (let x = 0; x < stride; x++) curr[x] = expectedPixels[rowStart + x] ?? 0;
|
||||
|
||||
const out = new Uint8Array(stride);
|
||||
for (let x = 0; x < stride; x++) {
|
||||
const a = x >= bpp ? (curr[x - bpp] ?? 0) : 0;
|
||||
const b = prev[x] ?? 0;
|
||||
const c = x >= bpp ? (prev[x - bpp] ?? 0) : 0;
|
||||
const cv = curr[x] ?? 0;
|
||||
switch (filterType) {
|
||||
case 0:
|
||||
out[x] = cv;
|
||||
break;
|
||||
case 1:
|
||||
out[x] = (cv - a) & 0xff;
|
||||
break;
|
||||
case 2:
|
||||
out[x] = (cv - b) & 0xff;
|
||||
break;
|
||||
case 3:
|
||||
out[x] = (cv - Math.floor((a + b) / 2)) & 0xff;
|
||||
break;
|
||||
case 4:
|
||||
out[x] = (cv - paethRef(a, b, c)) & 0xff;
|
||||
break;
|
||||
}
|
||||
}
|
||||
for (let x = 0; x < stride; x++) filtered.push(out[x] ?? 0);
|
||||
prev.set(curr);
|
||||
}
|
||||
|
||||
const ihdr = Buffer.allocUnsafe(13);
|
||||
ihdr.writeUInt32BE(width, 0);
|
||||
ihdr.writeUInt32BE(height, 4);
|
||||
ihdr[8] = 8;
|
||||
ihdr[9] = 6;
|
||||
ihdr[10] = 0;
|
||||
ihdr[11] = 0;
|
||||
ihdr[12] = 0;
|
||||
|
||||
const idat = deflateSync(Buffer.from(filtered));
|
||||
|
||||
return {
|
||||
png: Buffer.concat([
|
||||
PNG_SIG,
|
||||
makeChunk("IHDR", ihdr),
|
||||
makeChunk("IDAT", idat),
|
||||
makeChunk("IEND", Buffer.alloc(0)),
|
||||
]),
|
||||
expectedPixels,
|
||||
};
|
||||
}
|
||||
|
||||
describe("decodePng filter coverage", () => {
|
||||
it.each([
|
||||
[0, "None"],
|
||||
[1, "Sub"],
|
||||
[2, "Up"],
|
||||
[3, "Average"],
|
||||
[4, "Paeth"],
|
||||
] as const)("round-trips a 3×2 PNG with filter type %d (%s)", (filterType) => {
|
||||
const { png, expectedPixels } = makePngWithFilter(filterType);
|
||||
const { width, height, data } = decodePng(png);
|
||||
expect(width).toBe(3);
|
||||
expect(height).toBe(2);
|
||||
for (let i = 0; i < expectedPixels.length; i++) {
|
||||
expect(data[i]).toBe(expectedPixels[i]);
|
||||
}
|
||||
});
|
||||
|
||||
it("decodes a PNG split across multiple IDAT chunks", () => {
|
||||
// Build a normal single-IDAT PNG, then split its IDAT payload in half.
|
||||
// Chrome routinely emits multi-chunk IDATs (default ~8KB segment size).
|
||||
const { png: singleIdatPng, expectedPixels } = makePngWithFilter(0);
|
||||
|
||||
// Walk chunks to find IDAT
|
||||
let pos = 8;
|
||||
let ihdrChunk: Buffer | null = null;
|
||||
let idatPayload: Buffer | null = null;
|
||||
while (pos + 12 <= singleIdatPng.length) {
|
||||
const len = singleIdatPng.readUInt32BE(pos);
|
||||
const type = singleIdatPng.toString("ascii", pos + 4, pos + 8);
|
||||
const data = singleIdatPng.subarray(pos + 8, pos + 8 + len);
|
||||
const fullChunk = singleIdatPng.subarray(pos, pos + 12 + len);
|
||||
if (type === "IHDR") ihdrChunk = Buffer.from(fullChunk);
|
||||
if (type === "IDAT") idatPayload = Buffer.from(data);
|
||||
if (type === "IEND") break;
|
||||
pos += 12 + len;
|
||||
}
|
||||
expect(ihdrChunk).not.toBeNull();
|
||||
expect(idatPayload).not.toBeNull();
|
||||
if (!ihdrChunk || !idatPayload) return;
|
||||
|
||||
// Split the IDAT payload roughly in half across two IDAT chunks
|
||||
const split = Math.floor(idatPayload.length / 2);
|
||||
const part1 = idatPayload.subarray(0, split);
|
||||
const part2 = idatPayload.subarray(split);
|
||||
|
||||
const multiIdatPng = Buffer.concat([
|
||||
PNG_SIG,
|
||||
ihdrChunk,
|
||||
makeChunk("IDAT", Buffer.from(part1)),
|
||||
makeChunk("IDAT", Buffer.from(part2)),
|
||||
makeChunk("IEND", Buffer.alloc(0)),
|
||||
]);
|
||||
|
||||
const { data } = decodePng(multiIdatPng);
|
||||
for (let i = 0; i < expectedPixels.length; i++) {
|
||||
expect(data[i]).toBe(expectedPixels[i]);
|
||||
}
|
||||
});
|
||||
|
||||
it("throws on Adam7-interlaced PNGs", () => {
|
||||
const ihdr = Buffer.allocUnsafe(13);
|
||||
ihdr.writeUInt32BE(1, 0);
|
||||
ihdr.writeUInt32BE(1, 4);
|
||||
ihdr[8] = 8;
|
||||
ihdr[9] = 6;
|
||||
ihdr[10] = 0;
|
||||
ihdr[11] = 0;
|
||||
ihdr[12] = 1; // Adam7 interlace
|
||||
const idat = deflateSync(Buffer.from([0, 0, 0, 0, 255]));
|
||||
const png = Buffer.concat([
|
||||
PNG_SIG,
|
||||
makeChunk("IHDR", ihdr),
|
||||
makeChunk("IDAT", idat),
|
||||
makeChunk("IEND", Buffer.alloc(0)),
|
||||
]);
|
||||
expect(() => decodePng(png)).toThrow("interlace");
|
||||
});
|
||||
|
||||
it("throws on PNGs missing the IHDR chunk", () => {
|
||||
const idat = deflateSync(Buffer.from([0, 0, 0, 0, 255]));
|
||||
const png = Buffer.concat([
|
||||
PNG_SIG,
|
||||
makeChunk("IDAT", idat),
|
||||
makeChunk("IEND", Buffer.alloc(0)),
|
||||
]);
|
||||
expect(() => decodePng(png)).toThrow("IHDR");
|
||||
});
|
||||
});
|
||||
|
||||
// ── decodePngToRgb48le tests ────────────────────────────────────────────────
|
||||
//
|
||||
// FFmpeg emits 16-bit RGB PNGs (big-endian on the wire). The decoder swaps to
|
||||
// little-endian for the streaming HDR encoder. These tests cover the byte-order
|
||||
// swap, precision preservation, and multi-pixel row-major layout that the
|
||||
// 8-bit suite cannot exercise.
|
||||
|
||||
/**
|
||||
* Build a 16-bit RGB PNG (colorType 2, bitDepth 16). PNG stores each 16-bit
|
||||
* sample as two big-endian bytes; the decoder must swap them to LE.
|
||||
*
|
||||
* @param pixels Flat array of [r16, g16, b16, r16, g16, b16, ...] values
|
||||
* (one entry per channel sample, 0–65535).
|
||||
*/
|
||||
function makePng16(width: number, height: number, pixels: number[]): Buffer {
|
||||
const ihdr = Buffer.allocUnsafe(13);
|
||||
ihdr.writeUInt32BE(width, 0);
|
||||
ihdr.writeUInt32BE(height, 4);
|
||||
ihdr[8] = 16; // bit depth
|
||||
ihdr[9] = 2; // color type RGB
|
||||
ihdr[10] = 0;
|
||||
ihdr[11] = 0;
|
||||
ihdr[12] = 0;
|
||||
|
||||
const stride = width * 6; // 3 channels × 2 bytes
|
||||
const filtered: number[] = [];
|
||||
for (let y = 0; y < height; y++) {
|
||||
filtered.push(0); // filter type None
|
||||
for (let x = 0; x < width; x++) {
|
||||
const baseSample = (y * width + x) * 3;
|
||||
for (let ch = 0; ch < 3; ch++) {
|
||||
const v = pixels[baseSample + ch] ?? 0;
|
||||
filtered.push((v >> 8) & 0xff); // high byte (BE on wire)
|
||||
filtered.push(v & 0xff); // low byte
|
||||
}
|
||||
}
|
||||
void stride;
|
||||
}
|
||||
|
||||
const idat = deflateSync(Buffer.from(filtered));
|
||||
return Buffer.concat([
|
||||
PNG_SIG,
|
||||
makeChunk("IHDR", ihdr),
|
||||
makeChunk("IDAT", idat),
|
||||
makeChunk("IEND", Buffer.alloc(0)),
|
||||
]);
|
||||
}
|
||||
|
||||
describe("decodePngToRgb48le", () => {
|
||||
it("swaps PNG big-endian samples to little-endian rgb48le", () => {
|
||||
// Pick a value where high and low bytes differ so a missed swap is observable
|
||||
const v = 0x1234;
|
||||
const png = makePng16(1, 1, [v, v, v]);
|
||||
const { width, height, data } = decodePngToRgb48le(png);
|
||||
expect(width).toBe(1);
|
||||
expect(height).toBe(1);
|
||||
expect(data.length).toBe(6);
|
||||
expect(data.readUInt16LE(0)).toBe(v);
|
||||
expect(data.readUInt16LE(2)).toBe(v);
|
||||
expect(data.readUInt16LE(4)).toBe(v);
|
||||
// Spot-check raw byte order: low byte first, then high
|
||||
expect(data[0]).toBe(0x34);
|
||||
expect(data[1]).toBe(0x12);
|
||||
});
|
||||
|
||||
it("preserves full 16-bit precision (no 8-bit truncation)", () => {
|
||||
// A value whose low byte alone would be misleading — proves both bytes survive
|
||||
const r = 0xabcd;
|
||||
const g = 0xfedc;
|
||||
const b = 0x0102;
|
||||
const png = makePng16(1, 1, [r, g, b]);
|
||||
const { data } = decodePngToRgb48le(png);
|
||||
expect(data.readUInt16LE(0)).toBe(r);
|
||||
expect(data.readUInt16LE(2)).toBe(g);
|
||||
expect(data.readUInt16LE(4)).toBe(b);
|
||||
});
|
||||
|
||||
it("decodes a 2×2 image with row-major layout", () => {
|
||||
const pixels = [
|
||||
// row 0
|
||||
1000, 2000, 3000, 4000, 5000, 6000,
|
||||
// row 1
|
||||
7000, 8000, 9000, 10000, 11000, 12000,
|
||||
];
|
||||
const png = makePng16(2, 2, pixels);
|
||||
const { width, height, data } = decodePngToRgb48le(png);
|
||||
expect(width).toBe(2);
|
||||
expect(height).toBe(2);
|
||||
expect(data.length).toBe(2 * 2 * 6);
|
||||
|
||||
for (let i = 0; i < 4; i++) {
|
||||
expect(data.readUInt16LE(i * 6 + 0)).toBe(pixels[i * 3 + 0]);
|
||||
expect(data.readUInt16LE(i * 6 + 2)).toBe(pixels[i * 3 + 1]);
|
||||
expect(data.readUInt16LE(i * 6 + 4)).toBe(pixels[i * 3 + 2]);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects 8-bit PNGs with a clear error", () => {
|
||||
const png = makePng(1, 1, [255, 0, 0, 255]); // 8-bit RGBA
|
||||
expect(() => decodePngToRgb48le(png)).toThrow(/bit depth/);
|
||||
});
|
||||
});
|
||||
|
||||
// ── blitRgba8OverRgb48le tests ───────────────────────────────────────────────
|
||||
|
||||
/** Build an rgb48le buffer with a single solid color (16-bit per channel) */
|
||||
function makeHdrFrame(
|
||||
width: number,
|
||||
height: number,
|
||||
r16: number,
|
||||
g16: number,
|
||||
b16: number,
|
||||
): Buffer {
|
||||
const buf = Buffer.allocUnsafe(width * height * 6);
|
||||
for (let i = 0; i < width * height; i++) {
|
||||
buf.writeUInt16LE(r16, i * 6);
|
||||
buf.writeUInt16LE(g16, i * 6 + 2);
|
||||
buf.writeUInt16LE(b16, i * 6 + 4);
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
|
||||
/** Build a raw RGBA array (Uint8Array) with a single solid color */
|
||||
function makeDomRgba(
|
||||
width: number,
|
||||
height: number,
|
||||
r: number,
|
||||
g: number,
|
||||
b: number,
|
||||
a: number,
|
||||
): Uint8Array {
|
||||
const arr = new Uint8Array(width * height * 4);
|
||||
for (let i = 0; i < width * height; i++) {
|
||||
arr[i * 4 + 0] = r;
|
||||
arr[i * 4 + 1] = g;
|
||||
arr[i * 4 + 2] = b;
|
||||
arr[i * 4 + 3] = a;
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
describe("blitRgba8OverRgb48le", () => {
|
||||
it("fully transparent DOM: canvas unchanged", () => {
|
||||
const canvas = makeHdrFrame(1, 1, 32000, 40000, 50000);
|
||||
const dom = makeDomRgba(1, 1, 255, 0, 0, 0); // red but alpha=0
|
||||
blitRgba8OverRgb48le(dom, canvas, 1, 1);
|
||||
|
||||
expect(canvas.readUInt16LE(0)).toBe(32000);
|
||||
expect(canvas.readUInt16LE(2)).toBe(40000);
|
||||
expect(canvas.readUInt16LE(4)).toBe(50000);
|
||||
});
|
||||
|
||||
it("fully opaque DOM: sRGB→HLG converted values overwrite canvas", () => {
|
||||
const canvas = makeHdrFrame(1, 1, 10000, 20000, 30000);
|
||||
const dom = makeDomRgba(1, 1, 255, 128, 0, 255); // R=255, G=128, B=0, full opaque
|
||||
blitRgba8OverRgb48le(dom, canvas, 1, 1);
|
||||
|
||||
// sRGB 255 → HLG 65535 (white maps to white)
|
||||
// sRGB 128 → HLG ~46484 (mid-gray maps higher due to HLG OETF)
|
||||
// sRGB 0 → HLG 0
|
||||
expect(canvas.readUInt16LE(0)).toBe(65535);
|
||||
expect(canvas.readUInt16LE(2)).toBeGreaterThan(40000); // HLG mid-gray > sRGB mid-gray
|
||||
expect(canvas.readUInt16LE(2)).toBeLessThan(50000);
|
||||
expect(canvas.readUInt16LE(4)).toBe(0);
|
||||
});
|
||||
|
||||
it("sRGB→HLG: black stays black, white stays white", () => {
|
||||
const canvasBlack = makeHdrFrame(1, 1, 0, 0, 0);
|
||||
const domBlack = makeDomRgba(1, 1, 0, 0, 0, 255);
|
||||
blitRgba8OverRgb48le(domBlack, canvasBlack, 1, 1);
|
||||
expect(canvasBlack.readUInt16LE(0)).toBe(0);
|
||||
|
||||
const canvasWhite = makeHdrFrame(1, 1, 0, 0, 0);
|
||||
const domWhite = makeDomRgba(1, 1, 255, 255, 255, 255);
|
||||
blitRgba8OverRgb48le(domWhite, canvasWhite, 1, 1);
|
||||
expect(canvasWhite.readUInt16LE(0)).toBe(65535);
|
||||
});
|
||||
|
||||
it("50% alpha: HLG-converted DOM blended with canvas", () => {
|
||||
// DOM: white (255, 255, 255) at alpha=128 (~50%)
|
||||
// Canvas: black (0, 0, 0)
|
||||
const canvas = makeHdrFrame(1, 1, 0, 0, 0);
|
||||
const dom = makeDomRgba(1, 1, 255, 255, 255, 128);
|
||||
blitRgba8OverRgb48le(dom, canvas, 1, 1);
|
||||
|
||||
// sRGB 255 → HLG 65535, blended 50/50 with black
|
||||
const alpha = 128 / 255;
|
||||
const expectedR = Math.round(65535 * alpha);
|
||||
expect(canvas.readUInt16LE(0)).toBeCloseTo(expectedR, -1);
|
||||
});
|
||||
|
||||
it("50% alpha blends with non-zero canvas", () => {
|
||||
// DOM: 8-bit red=200, canvas: 16-bit red=32000, alpha=128
|
||||
const canvas = makeHdrFrame(1, 1, 32000, 0, 0);
|
||||
const dom = makeDomRgba(1, 1, 200, 0, 0, 128);
|
||||
blitRgba8OverRgb48le(dom, canvas, 1, 1);
|
||||
|
||||
// sRGB 200 → HLG value, blended ~50/50 with canvas red=32000
|
||||
// Result should be higher than 32000 (pulled up by the HLG-converted DOM value)
|
||||
expect(canvas.readUInt16LE(0)).toBeGreaterThan(32000);
|
||||
});
|
||||
|
||||
it("α=254 still blends (no fast-path overwrite at the opaque boundary)", () => {
|
||||
// Reviewer feedback: confirm the alpha branch is taken for any α < 255.
|
||||
// α=254 should *almost* match α=255 but still leave a sliver of the canvas
|
||||
// value visible — proving we didn't accidentally fast-path α >= 254.
|
||||
const canvasOpaque = makeHdrFrame(1, 1, 0, 0, 0);
|
||||
const domOpaque = makeDomRgba(1, 1, 255, 255, 255, 255);
|
||||
blitRgba8OverRgb48le(domOpaque, canvasOpaque, 1, 1);
|
||||
const opaqueR = canvasOpaque.readUInt16LE(0);
|
||||
|
||||
const canvasNear = makeHdrFrame(1, 1, 1000, 1000, 1000);
|
||||
const domNear = makeDomRgba(1, 1, 255, 255, 255, 254);
|
||||
blitRgba8OverRgb48le(domNear, canvasNear, 1, 1);
|
||||
const nearR = canvasNear.readUInt16LE(0);
|
||||
|
||||
// α=255 over black gave us the pure HLG-of-white value
|
||||
expect(opaqueR).toBe(65535);
|
||||
// α=254 over (1000, 1000, 1000) must be *strictly less* than α=255 over black —
|
||||
// if the implementation short-circuits at α >= 254 it would also return 65535.
|
||||
expect(nearR).toBeLessThan(opaqueR);
|
||||
// …but it should still be very close (within ~1% of full white)
|
||||
expect(nearR).toBeGreaterThan(64000);
|
||||
});
|
||||
|
||||
it("handles a 2x2 frame correctly pixel-by-pixel", () => {
|
||||
const canvas = makeHdrFrame(2, 2, 0, 0, 0);
|
||||
// First pixel: fully opaque white. Others: fully transparent.
|
||||
const dom = new Uint8Array(2 * 2 * 4);
|
||||
dom[0] = 255;
|
||||
dom[1] = 255;
|
||||
dom[2] = 255;
|
||||
dom[3] = 255; // pixel 0: opaque white
|
||||
// pixels 1-3: alpha=0 (transparent)
|
||||
|
||||
blitRgba8OverRgb48le(dom, canvas, 2, 2);
|
||||
|
||||
// Pixel 0: sRGB white → HLG white (65535)
|
||||
expect(canvas.readUInt16LE(0)).toBe(65535);
|
||||
expect(canvas.readUInt16LE(2)).toBe(65535);
|
||||
expect(canvas.readUInt16LE(4)).toBe(65535);
|
||||
|
||||
// Pixel 1: transparent DOM → canvas black (0, 0, 0) unchanged
|
||||
expect(canvas.readUInt16LE(6)).toBe(0);
|
||||
expect(canvas.readUInt16LE(8)).toBe(0);
|
||||
expect(canvas.readUInt16LE(10)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("blitRgba8OverRgb48le with PQ transfer", () => {
|
||||
it("PQ: black stays black, white maps to PQ white", () => {
|
||||
const canvasBlack = makeHdrFrame(1, 1, 0, 0, 0);
|
||||
const domBlack = makeDomRgba(1, 1, 0, 0, 0, 255);
|
||||
blitRgba8OverRgb48le(domBlack, canvasBlack, 1, 1, "pq");
|
||||
expect(canvasBlack.readUInt16LE(0)).toBe(0);
|
||||
|
||||
const canvasWhite = makeHdrFrame(1, 1, 0, 0, 0);
|
||||
const domWhite = makeDomRgba(1, 1, 255, 255, 255, 255);
|
||||
blitRgba8OverRgb48le(domWhite, canvasWhite, 1, 1, "pq");
|
||||
// PQ white at SDR 203 nits is NOT 65535 (that's 10000 nits)
|
||||
// SDR white in PQ ≈ 58% signal → ~38000
|
||||
const pqWhite = canvasWhite.readUInt16LE(0);
|
||||
expect(pqWhite).toBeGreaterThan(30000);
|
||||
expect(pqWhite).toBeLessThan(45000);
|
||||
});
|
||||
|
||||
it("PQ mid-gray differs from HLG mid-gray", () => {
|
||||
const canvasHlg = makeHdrFrame(1, 1, 0, 0, 0);
|
||||
const canvasPq = makeHdrFrame(1, 1, 0, 0, 0);
|
||||
const dom = makeDomRgba(1, 1, 128, 128, 128, 255);
|
||||
|
||||
blitRgba8OverRgb48le(dom, canvasHlg, 1, 1, "hlg");
|
||||
blitRgba8OverRgb48le(dom, canvasPq, 1, 1, "pq");
|
||||
|
||||
const hlgVal = canvasHlg.readUInt16LE(0);
|
||||
const pqVal = canvasPq.readUInt16LE(0);
|
||||
// PQ and HLG encode mid-gray differently
|
||||
expect(hlgVal).not.toBe(pqVal);
|
||||
// Both should be non-zero
|
||||
expect(hlgVal).toBeGreaterThan(0);
|
||||
expect(pqVal).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── blitRgb48leRegion tests ──────────────────────────────────────────────────
|
||||
|
||||
describe("blitRgb48leRegion", () => {
|
||||
it("copies a region at position (0,0) — full overlap", () => {
|
||||
const canvas = Buffer.alloc(4 * 4 * 6); // 4x4 black
|
||||
const source = makeHdrFrame(2, 2, 10000, 20000, 30000);
|
||||
blitRgb48leRegion(canvas, source, 0, 0, 2, 2, 4, 4);
|
||||
expect(canvas.readUInt16LE(0)).toBe(10000);
|
||||
expect(canvas.readUInt16LE(2)).toBe(20000);
|
||||
expect(canvas.readUInt16LE(4)).toBe(30000);
|
||||
expect(canvas.readUInt16LE(2 * 6)).toBe(0);
|
||||
});
|
||||
|
||||
it("copies a region at offset position", () => {
|
||||
const canvas = Buffer.alloc(4 * 4 * 6);
|
||||
const source = makeHdrFrame(2, 2, 50000, 40000, 30000);
|
||||
blitRgb48leRegion(canvas, source, 1, 1, 2, 2, 4, 4);
|
||||
expect(canvas.readUInt16LE(0)).toBe(0);
|
||||
const off = (1 * 4 + 1) * 6;
|
||||
expect(canvas.readUInt16LE(off)).toBe(50000);
|
||||
});
|
||||
|
||||
it("clips when region extends beyond canvas edge", () => {
|
||||
const canvas = Buffer.alloc(4 * 4 * 6);
|
||||
const source = makeHdrFrame(3, 3, 10000, 20000, 30000);
|
||||
blitRgb48leRegion(canvas, source, 2, 2, 3, 3, 4, 4);
|
||||
const off = (2 * 4 + 2) * 6;
|
||||
expect(canvas.readUInt16LE(off)).toBe(10000);
|
||||
const off2 = (3 * 4 + 3) * 6;
|
||||
expect(canvas.readUInt16LE(off2)).toBe(10000);
|
||||
expect(canvas.length).toBe(4 * 4 * 6);
|
||||
});
|
||||
|
||||
it("applies opacity when provided", () => {
|
||||
const canvas = Buffer.alloc(1 * 1 * 6);
|
||||
const source = makeHdrFrame(1, 1, 40000, 40000, 40000);
|
||||
blitRgb48leRegion(canvas, source, 0, 0, 1, 1, 1, 1, 0.5);
|
||||
expect(canvas.readUInt16LE(0)).toBe(20000);
|
||||
});
|
||||
|
||||
it("no-op for zero-size region", () => {
|
||||
const canvas = Buffer.alloc(4 * 4 * 6);
|
||||
const source = makeHdrFrame(2, 2, 10000, 20000, 30000);
|
||||
blitRgb48leRegion(canvas, source, 0, 0, 0, 0, 4, 4);
|
||||
expect(canvas.readUInt16LE(0)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── parseTransformMatrix tests ───────────────────────────────────────────────
|
||||
|
||||
describe("parseTransformMatrix", () => {
|
||||
it("returns null for 'none'", () => {
|
||||
expect(parseTransformMatrix("none")).toBeNull();
|
||||
});
|
||||
|
||||
it("parses identity matrix", () => {
|
||||
const m = parseTransformMatrix("matrix(1, 0, 0, 1, 0, 0)");
|
||||
expect(m).toEqual([1, 0, 0, 1, 0, 0]);
|
||||
});
|
||||
|
||||
it("parses scale + translate", () => {
|
||||
const m = parseTransformMatrix("matrix(0.85, 0, 0, 0.85, 100, 50)");
|
||||
expect(m).toEqual([0.85, 0, 0, 0.85, 100, 50]);
|
||||
});
|
||||
|
||||
it("parses rotation (45 degrees)", () => {
|
||||
const cos = Math.cos(Math.PI / 4);
|
||||
const sin = Math.sin(Math.PI / 4);
|
||||
const m = parseTransformMatrix(`matrix(${cos}, ${sin}, ${-sin}, ${cos}, 0, 0)`);
|
||||
expect(m).not.toBeNull();
|
||||
if (!m) return;
|
||||
expect(m[0]).toBeCloseTo(cos, 10);
|
||||
expect(m[1]).toBeCloseTo(sin, 10);
|
||||
});
|
||||
|
||||
it("parses negative values", () => {
|
||||
const m = parseTransformMatrix("matrix(-1, 0, 0, -1, -50, -100)");
|
||||
expect(m).toEqual([-1, 0, 0, -1, -50, -100]);
|
||||
});
|
||||
|
||||
it("returns null for empty string", () => {
|
||||
expect(parseTransformMatrix("")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for unsupported 3d matrix", () => {
|
||||
expect(parseTransformMatrix("matrix3d(1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1)")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── blitRgb48leAffine tests ─────────────────────────────────────────────────
|
||||
|
||||
describe("blitRgb48leAffine", () => {
|
||||
it("identity matrix produces same result as blitRgb48leRegion", () => {
|
||||
const canvas1 = Buffer.alloc(4 * 4 * 6);
|
||||
const canvas2 = Buffer.alloc(4 * 4 * 6);
|
||||
const source = makeHdrFrame(2, 2, 10000, 20000, 30000);
|
||||
const identity = [1, 0, 0, 1, 0, 0];
|
||||
|
||||
blitRgb48leRegion(canvas1, source, 0, 0, 2, 2, 4, 4);
|
||||
blitRgb48leAffine(canvas2, source, identity, 2, 2, 4, 4);
|
||||
|
||||
expect(Buffer.compare(canvas1, canvas2)).toBe(0);
|
||||
});
|
||||
|
||||
it("translation moves pixels", () => {
|
||||
const canvas = Buffer.alloc(4 * 4 * 6);
|
||||
const source = makeHdrFrame(1, 1, 50000, 40000, 30000);
|
||||
const translate = [1, 0, 0, 1, 2, 1];
|
||||
blitRgb48leAffine(canvas, source, translate, 1, 1, 4, 4);
|
||||
|
||||
expect(canvas.readUInt16LE(0)).toBe(0);
|
||||
const off = (1 * 4 + 2) * 6;
|
||||
expect(canvas.readUInt16LE(off)).toBe(50000);
|
||||
});
|
||||
|
||||
it("scale down by 0.5 shrinks the output", () => {
|
||||
const canvas = Buffer.alloc(4 * 4 * 6);
|
||||
const source = makeHdrFrame(4, 4, 40000, 30000, 20000);
|
||||
const scale = [0.5, 0, 0, 0.5, 0, 0];
|
||||
blitRgb48leAffine(canvas, source, scale, 4, 4, 4, 4);
|
||||
|
||||
expect(canvas.readUInt16LE(0)).toBeGreaterThan(0);
|
||||
expect(canvas.readUInt16LE((1 * 4 + 1) * 6)).toBeGreaterThan(0);
|
||||
expect(canvas.readUInt16LE(2 * 6)).toBe(0);
|
||||
});
|
||||
|
||||
it("scale up by 2 enlarges the output", () => {
|
||||
const canvas = Buffer.alloc(4 * 4 * 6);
|
||||
const source = makeHdrFrame(2, 2, 40000, 30000, 20000);
|
||||
const scale = [2, 0, 0, 2, 0, 0];
|
||||
blitRgb48leAffine(canvas, source, scale, 2, 2, 4, 4);
|
||||
|
||||
for (let i = 0; i < 16; i++) {
|
||||
expect(canvas.readUInt16LE(i * 6)).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("opacity blends with canvas", () => {
|
||||
const canvas = makeHdrFrame(1, 1, 20000, 20000, 20000);
|
||||
const source = makeHdrFrame(1, 1, 60000, 60000, 60000);
|
||||
const identity = [1, 0, 0, 1, 0, 0];
|
||||
blitRgb48leAffine(canvas, source, identity, 1, 1, 1, 1, 0.5);
|
||||
|
||||
expect(canvas.readUInt16LE(0)).toBe(40000);
|
||||
});
|
||||
|
||||
it("out-of-bounds source coordinates are clipped", () => {
|
||||
const canvas = Buffer.alloc(2 * 2 * 6);
|
||||
const source = makeHdrFrame(1, 1, 50000, 40000, 30000);
|
||||
const translate = [1, 0, 0, 1, 10, 10];
|
||||
blitRgb48leAffine(canvas, source, translate, 1, 1, 2, 2);
|
||||
|
||||
expect(canvas.readUInt16LE(0)).toBe(0);
|
||||
expect(canvas.readUInt16LE(6)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Round-trip test: decodePng → blitRgba8OverRgb48le ────────────────────────
|
||||
|
||||
describe("decodePng + blitRgba8OverRgb48le integration", () => {
|
||||
it("transparent PNG overlay leaves canvas untouched", () => {
|
||||
const width = 2;
|
||||
const height = 2;
|
||||
|
||||
// Build a fully transparent PNG
|
||||
const pixels = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; // all alpha=0
|
||||
const png = makePng(width, height, pixels);
|
||||
const { data: domRgba } = decodePng(png);
|
||||
|
||||
// Canvas pre-filled with known HDR values
|
||||
const canvas = makeHdrFrame(width, height, 10000, 20000, 30000);
|
||||
blitRgba8OverRgb48le(domRgba, canvas, width, height);
|
||||
|
||||
// All pixels should be unchanged
|
||||
for (let i = 0; i < width * height; i++) {
|
||||
expect(canvas.readUInt16LE(i * 6 + 0)).toBe(10000);
|
||||
expect(canvas.readUInt16LE(i * 6 + 2)).toBe(20000);
|
||||
expect(canvas.readUInt16LE(i * 6 + 4)).toBe(30000);
|
||||
}
|
||||
});
|
||||
|
||||
it("fully opaque PNG overlay overwrites all canvas pixels (sRGB→HLG)", () => {
|
||||
const width = 2;
|
||||
const height = 2;
|
||||
|
||||
// Build a fully opaque blue PNG (sRGB blue = 0,0,255)
|
||||
const pixels = Array(width * height)
|
||||
.fill(null)
|
||||
.flatMap(() => [0, 0, 255, 255]);
|
||||
const png = makePng(width, height, pixels);
|
||||
const { data: domRgba } = decodePng(png);
|
||||
|
||||
const canvas = makeHdrFrame(width, height, 50000, 40000, 30000);
|
||||
blitRgba8OverRgb48le(domRgba, canvas, width, height);
|
||||
|
||||
// sRGB blue (0,0,255) → HLG (0, 0, 65535) — black/white map identically
|
||||
for (let i = 0; i < width * height; i++) {
|
||||
expect(canvas.readUInt16LE(i * 6 + 0)).toBe(0);
|
||||
expect(canvas.readUInt16LE(i * 6 + 2)).toBe(0);
|
||||
expect(canvas.readUInt16LE(i * 6 + 4)).toBe(65535);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── roundedRectAlpha tests ──────────────────────────────────────────────────
|
||||
|
||||
describe("roundedRectAlpha", () => {
|
||||
const uniform20: [number, number, number, number] = [20, 20, 20, 20];
|
||||
|
||||
it("returns 1 for center pixel", () => {
|
||||
expect(roundedRectAlpha(50, 50, 100, 100, uniform20)).toBe(1);
|
||||
});
|
||||
|
||||
it("returns 1 for pixel well inside edge (not in corner zone)", () => {
|
||||
// On top edge but past the corner zone (x >= radius)
|
||||
expect(roundedRectAlpha(50, 5, 100, 100, uniform20)).toBe(1);
|
||||
});
|
||||
|
||||
it("returns 0 for pixel at the extreme corner (outside rounded area)", () => {
|
||||
// Top-left corner: (0, 0) is far from circle center at (20, 20)
|
||||
// dist = sqrt(400 + 400) = 28.28, well beyond radius 20
|
||||
expect(roundedRectAlpha(0, 0, 100, 100, uniform20)).toBe(0);
|
||||
});
|
||||
|
||||
it("returns 1 for pixel well inside corner circle", () => {
|
||||
// Pixel at (15, 15): dist from center (20, 20) = sqrt(25+25) = 7.07 << 20
|
||||
expect(roundedRectAlpha(15, 15, 100, 100, uniform20)).toBe(1);
|
||||
});
|
||||
|
||||
it("returns fractional alpha at corner edge (anti-aliasing)", () => {
|
||||
// Find a point near the circle edge. radius = 20, center at (20, 20).
|
||||
// Point on the circle: (20 - 20*cos(45°), 20 - 20*sin(45°)) ≈ (5.86, 5.86)
|
||||
// Shift slightly inward for fractional alpha
|
||||
const edgePx = 20 - 20 * Math.cos(Math.PI / 4); // ~5.86
|
||||
const alpha = roundedRectAlpha(edgePx, edgePx, 100, 100, uniform20);
|
||||
expect(alpha).toBeGreaterThan(0);
|
||||
expect(alpha).toBeLessThan(1);
|
||||
});
|
||||
|
||||
it("handles all four corners symmetrically", () => {
|
||||
// Test top-right corner (x near w, y near 0)
|
||||
expect(roundedRectAlpha(100, 0, 100, 100, uniform20)).toBe(0);
|
||||
// Test bottom-right corner
|
||||
expect(roundedRectAlpha(100, 100, 100, 100, uniform20)).toBe(0);
|
||||
// Test bottom-left corner
|
||||
expect(roundedRectAlpha(0, 100, 100, 100, uniform20)).toBe(0);
|
||||
});
|
||||
|
||||
it("returns 1 everywhere for zero radii", () => {
|
||||
const zero: [number, number, number, number] = [0, 0, 0, 0];
|
||||
expect(roundedRectAlpha(0, 0, 100, 100, zero)).toBe(1);
|
||||
expect(roundedRectAlpha(99, 0, 100, 100, zero)).toBe(1);
|
||||
expect(roundedRectAlpha(0, 99, 100, 100, zero)).toBe(1);
|
||||
expect(roundedRectAlpha(99, 99, 100, 100, zero)).toBe(1);
|
||||
});
|
||||
|
||||
it("supports per-corner radii", () => {
|
||||
const mixed: [number, number, number, number] = [20, 0, 10, 0];
|
||||
// Top-left has radius 20 — corner pixel outside
|
||||
expect(roundedRectAlpha(0, 0, 100, 100, mixed)).toBe(0);
|
||||
// Top-right has radius 0 — corner pixel inside
|
||||
expect(roundedRectAlpha(99, 0, 100, 100, mixed)).toBe(1);
|
||||
// Bottom-right has radius 10 — extreme corner outside
|
||||
expect(roundedRectAlpha(100, 100, 100, 100, mixed)).toBe(0);
|
||||
// Bottom-left has radius 0 — corner pixel inside
|
||||
expect(roundedRectAlpha(0, 99, 100, 100, mixed)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── blitRgb48leRegion with borderRadius ─────────────────────────────────────
|
||||
|
||||
describe("blitRgb48leRegion with borderRadius", () => {
|
||||
it("clips corner pixels when borderRadius is set", () => {
|
||||
// 10x10 source placed at origin on a 10x10 canvas, radius 5
|
||||
const canvas = Buffer.alloc(10 * 10 * 6);
|
||||
const source = makeHdrFrame(10, 10, 40000, 30000, 20000);
|
||||
const br: [number, number, number, number] = [5, 5, 5, 5];
|
||||
blitRgb48leRegion(canvas, source, 0, 0, 10, 10, 10, 10, undefined, br);
|
||||
|
||||
// Center pixel should be written
|
||||
const centerOff = (5 * 10 + 5) * 6;
|
||||
expect(canvas.readUInt16LE(centerOff)).toBe(40000);
|
||||
|
||||
// Corner pixel (0,0) should be clipped (remain 0)
|
||||
expect(canvas.readUInt16LE(0)).toBe(0);
|
||||
});
|
||||
|
||||
it("no effect when borderRadius is all zeros", () => {
|
||||
const canvas1 = Buffer.alloc(4 * 4 * 6);
|
||||
const canvas2 = Buffer.alloc(4 * 4 * 6);
|
||||
const source = makeHdrFrame(4, 4, 40000, 30000, 20000);
|
||||
|
||||
blitRgb48leRegion(canvas1, source, 0, 0, 4, 4, 4, 4);
|
||||
blitRgb48leRegion(canvas2, source, 0, 0, 4, 4, 4, 4, undefined, [0, 0, 0, 0]);
|
||||
|
||||
expect(Buffer.compare(canvas1, canvas2)).toBe(0);
|
||||
});
|
||||
|
||||
it("combines opacity and borderRadius", () => {
|
||||
// Canvas with known background, source with known values
|
||||
const canvas = makeHdrFrame(10, 10, 20000, 20000, 20000);
|
||||
const source = makeHdrFrame(10, 10, 60000, 60000, 60000);
|
||||
const br: [number, number, number, number] = [3, 3, 3, 3];
|
||||
|
||||
blitRgb48leRegion(canvas, source, 0, 0, 10, 10, 10, 10, 0.5, br);
|
||||
|
||||
// Center pixel: opacity 0.5, mask 1.0 → effective 0.5
|
||||
// Result: 60000 * 0.5 + 20000 * 0.5 = 40000
|
||||
const centerOff = (5 * 10 + 5) * 6;
|
||||
expect(canvas.readUInt16LE(centerOff)).toBe(40000);
|
||||
|
||||
// Corner pixel (0,0): mask 0.0 → skipped, canvas unchanged
|
||||
expect(canvas.readUInt16LE(0)).toBe(20000);
|
||||
});
|
||||
});
|
||||
|
||||
// ── blitRgb48leAffine with borderRadius ─────────────────────────────────────
|
||||
|
||||
describe("blitRgb48leAffine with borderRadius", () => {
|
||||
it("clips corner pixels with identity transform", () => {
|
||||
const canvas = Buffer.alloc(10 * 10 * 6);
|
||||
const source = makeHdrFrame(10, 10, 40000, 30000, 20000);
|
||||
const identity = [1, 0, 0, 1, 0, 0];
|
||||
const br: [number, number, number, number] = [5, 5, 5, 5];
|
||||
|
||||
blitRgb48leAffine(canvas, source, identity, 10, 10, 10, 10, undefined, br);
|
||||
|
||||
// Center pixel should be written
|
||||
const centerOff = (5 * 10 + 5) * 6;
|
||||
expect(canvas.readUInt16LE(centerOff)).toBe(40000);
|
||||
|
||||
// Corner pixel (0,0) should be clipped
|
||||
expect(canvas.readUInt16LE(0)).toBe(0);
|
||||
});
|
||||
|
||||
it("mask follows transform (scaled output has rounded corners)", () => {
|
||||
// 4x4 source scaled up 2× on an 8×8 canvas, radius 2 in source space
|
||||
const canvas = Buffer.alloc(8 * 8 * 6);
|
||||
const source = makeHdrFrame(4, 4, 50000, 40000, 30000);
|
||||
const scale2x = [2, 0, 0, 2, 0, 0];
|
||||
const br: [number, number, number, number] = [2, 2, 2, 2];
|
||||
|
||||
blitRgb48leAffine(canvas, source, scale2x, 4, 4, 8, 8, undefined, br);
|
||||
|
||||
// Canvas center (4,4) maps to source (2,2) — inside, should be written
|
||||
const centerOff = (4 * 8 + 4) * 6;
|
||||
expect(canvas.readUInt16LE(centerOff)).toBeGreaterThan(0);
|
||||
|
||||
// Canvas corner (0,0) maps to source (0,0) — outside radius, should be clipped
|
||||
expect(canvas.readUInt16LE(0)).toBe(0);
|
||||
});
|
||||
|
||||
it("no effect when borderRadius is undefined", () => {
|
||||
const canvas1 = Buffer.alloc(4 * 4 * 6);
|
||||
const canvas2 = Buffer.alloc(4 * 4 * 6);
|
||||
const source = makeHdrFrame(4, 4, 40000, 30000, 20000);
|
||||
const identity = [1, 0, 0, 1, 0, 0];
|
||||
|
||||
blitRgb48leAffine(canvas1, source, identity, 4, 4, 4, 4);
|
||||
blitRgb48leAffine(canvas2, source, identity, 4, 4, 4, 4, undefined, undefined);
|
||||
|
||||
expect(Buffer.compare(canvas1, canvas2)).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,643 @@
|
||||
/**
|
||||
* Alpha Blit — in-memory PNG decode + alpha compositing over rgb48le HDR frames.
|
||||
*
|
||||
* Replaces per-frame FFmpeg spawns for the two-pass HDR compositing path.
|
||||
* Uses only Node.js built-ins (zlib) — no additional dependencies.
|
||||
*/
|
||||
|
||||
import { inflateSync } from "zlib";
|
||||
|
||||
// ── PNG decoder ───────────────────────────────────────────────────────────────
|
||||
|
||||
function paeth(a: number, b: number, c: number): number {
|
||||
const p = a + b - c;
|
||||
const pa = Math.abs(p - a);
|
||||
const pb = Math.abs(p - b);
|
||||
const pc = Math.abs(p - c);
|
||||
if (pa <= pb && pa <= pc) return a;
|
||||
if (pb <= pc) return b;
|
||||
return c;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared PNG chunk parsing + filter reconstruction.
|
||||
*
|
||||
* Verifies the PNG signature, iterates chunks to collect IHDR metadata and IDAT
|
||||
* payloads, decompresses with zlib, and reconstructs all 5 PNG filter types.
|
||||
*
|
||||
* Returns the defiltered pixel bytes (no filter-type prefix bytes) along with
|
||||
* IHDR fields so callers can convert to their target pixel format.
|
||||
*/
|
||||
function decodePngRaw(
|
||||
buf: Buffer,
|
||||
caller: string,
|
||||
): { width: number; height: number; bitDepth: number; colorType: number; rawPixels: Buffer } {
|
||||
// Verify PNG signature
|
||||
if (
|
||||
buf[0] !== 137 ||
|
||||
buf[1] !== 80 ||
|
||||
buf[2] !== 78 ||
|
||||
buf[3] !== 71 ||
|
||||
buf[4] !== 13 ||
|
||||
buf[5] !== 10 ||
|
||||
buf[6] !== 26 ||
|
||||
buf[7] !== 10
|
||||
) {
|
||||
throw new Error(`${caller}: not a PNG file`);
|
||||
}
|
||||
|
||||
let pos = 8;
|
||||
let width = 0;
|
||||
let height = 0;
|
||||
let bitDepth = 0;
|
||||
let colorType = 0;
|
||||
let interlace = 0;
|
||||
let sawIhdr = false;
|
||||
const idatChunks: Buffer[] = [];
|
||||
|
||||
while (pos + 12 <= buf.length) {
|
||||
const chunkLen = buf.readUInt32BE(pos);
|
||||
const chunkType = buf.toString("ascii", pos + 4, pos + 8);
|
||||
const chunkData = buf.subarray(pos + 8, pos + 8 + chunkLen);
|
||||
|
||||
if (chunkType === "IHDR") {
|
||||
width = chunkData.readUInt32BE(0);
|
||||
height = chunkData.readUInt32BE(4);
|
||||
bitDepth = chunkData[8] ?? 0;
|
||||
colorType = chunkData[9] ?? 0;
|
||||
interlace = chunkData[12] ?? 0;
|
||||
sawIhdr = true;
|
||||
} else if (chunkType === "IDAT") {
|
||||
idatChunks.push(Buffer.from(chunkData));
|
||||
} else if (chunkType === "IEND") {
|
||||
break;
|
||||
}
|
||||
|
||||
pos += 12 + chunkLen; // length(4) + type(4) + data(chunkLen) + crc(4)
|
||||
}
|
||||
|
||||
if (!sawIhdr) {
|
||||
throw new Error(`${caller}: PNG missing IHDR chunk`);
|
||||
}
|
||||
if (colorType !== 2 && colorType !== 6) {
|
||||
throw new Error(`${caller}: unsupported color type ${colorType} (expected 2=RGB or 6=RGBA)`);
|
||||
}
|
||||
if (interlace !== 0) {
|
||||
throw new Error(
|
||||
`${caller}: Adam7-interlaced PNGs are not supported (interlace method ${interlace})`,
|
||||
);
|
||||
}
|
||||
|
||||
// Bytes per pixel: channels x bytes-per-channel
|
||||
const channels = colorType === 6 ? 4 : 3;
|
||||
const bpp = channels * (bitDepth / 8);
|
||||
const stride = width * bpp;
|
||||
|
||||
const compressed = Buffer.concat(idatChunks);
|
||||
const decompressed = inflateSync(compressed);
|
||||
|
||||
// Reconstruct filtered rows into a flat pixel buffer (no filter bytes)
|
||||
const rawPixels = Buffer.allocUnsafe(height * stride);
|
||||
const prevRow = new Uint8Array(stride);
|
||||
const currRow = new Uint8Array(stride);
|
||||
|
||||
let srcPos = 0;
|
||||
|
||||
for (let y = 0; y < height; y++) {
|
||||
const filterType = decompressed[srcPos++] ?? 0;
|
||||
const rawRow = decompressed.subarray(srcPos, srcPos + stride);
|
||||
srcPos += stride;
|
||||
|
||||
switch (filterType) {
|
||||
case 0: // None
|
||||
currRow.set(rawRow);
|
||||
break;
|
||||
case 1: // Sub
|
||||
for (let x = 0; x < stride; x++) {
|
||||
currRow[x] = ((rawRow[x] ?? 0) + (x >= bpp ? (currRow[x - bpp] ?? 0) : 0)) & 0xff;
|
||||
}
|
||||
break;
|
||||
case 2: // Up
|
||||
for (let x = 0; x < stride; x++) {
|
||||
currRow[x] = ((rawRow[x] ?? 0) + (prevRow[x] ?? 0)) & 0xff;
|
||||
}
|
||||
break;
|
||||
case 3: // Average
|
||||
for (let x = 0; x < stride; x++) {
|
||||
const left = x >= bpp ? (currRow[x - bpp] ?? 0) : 0;
|
||||
const up = prevRow[x] ?? 0;
|
||||
currRow[x] = ((rawRow[x] ?? 0) + Math.floor((left + up) / 2)) & 0xff;
|
||||
}
|
||||
break;
|
||||
case 4: // Paeth
|
||||
for (let x = 0; x < stride; x++) {
|
||||
const left = x >= bpp ? (currRow[x - bpp] ?? 0) : 0;
|
||||
const up = prevRow[x] ?? 0;
|
||||
const upLeft = x >= bpp ? (prevRow[x - bpp] ?? 0) : 0;
|
||||
currRow[x] = ((rawRow[x] ?? 0) + paeth(left, up, upLeft)) & 0xff;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
throw new Error(`${caller}: unknown filter type ${filterType} at row ${y}`);
|
||||
}
|
||||
|
||||
rawPixels.set(currRow, y * stride);
|
||||
prevRow.set(currRow);
|
||||
}
|
||||
|
||||
return { width, height, bitDepth, colorType, rawPixels };
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a PNG buffer to raw RGBA pixel data (8-bit per channel).
|
||||
*
|
||||
* Supports color type 6 (RGBA) and color type 2 (RGB) at 8-bit depth,
|
||||
* non-interlaced. Chrome's Page.captureScreenshot always emits this format.
|
||||
*
|
||||
* Returns a Uint8Array of width*height*4 bytes in RGBA order.
|
||||
*/
|
||||
export function decodePng(buf: Buffer): { width: number; height: number; data: Uint8Array } {
|
||||
const { width, height, bitDepth, colorType, rawPixels } = decodePngRaw(buf, "decodePng");
|
||||
|
||||
if (bitDepth !== 8) {
|
||||
throw new Error(`decodePng: unsupported bit depth ${bitDepth} (expected 8)`);
|
||||
}
|
||||
|
||||
const output = new Uint8Array(width * height * 4);
|
||||
|
||||
if (colorType === 6) {
|
||||
// RGBA — copy directly
|
||||
output.set(rawPixels);
|
||||
} else {
|
||||
// RGB → RGBA: set alpha to 255
|
||||
for (let i = 0; i < width * height; i++) {
|
||||
output[i * 4 + 0] = rawPixels[i * 3 + 0] ?? 0;
|
||||
output[i * 4 + 1] = rawPixels[i * 3 + 1] ?? 0;
|
||||
output[i * 4 + 2] = rawPixels[i * 3 + 2] ?? 0;
|
||||
output[i * 4 + 3] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
return { width, height, data: output };
|
||||
}
|
||||
|
||||
// ── 16-bit PNG decoder ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Decode a 16-bit RGB PNG (from FFmpeg) to an rgb48le Buffer.
|
||||
*
|
||||
* FFmpeg's `-pix_fmt rgb48le -c:v png` produces 16-bit RGB PNGs.
|
||||
* PNG stores 16-bit values in big-endian; this function swaps to little-endian
|
||||
* for the streaming encoder's rgb48le input format.
|
||||
*
|
||||
* Supports colorType 2 (RGB) and 6 (RGBA) at 16-bit depth, non-interlaced.
|
||||
*/
|
||||
export function decodePngToRgb48le(buf: Buffer): { width: number; height: number; data: Buffer } {
|
||||
const { width, height, bitDepth, colorType, rawPixels } = decodePngRaw(buf, "decodePngToRgb48le");
|
||||
|
||||
if (bitDepth !== 16) {
|
||||
throw new Error(`decodePngToRgb48le: unsupported bit depth ${bitDepth} (expected 16)`);
|
||||
}
|
||||
|
||||
// 16-bit: 2 bytes per channel. RGB=6 bytes/pixel, RGBA=8 bytes/pixel
|
||||
const bpp = colorType === 6 ? 8 : 6;
|
||||
|
||||
// Output: rgb48le = 3 channels x 2 bytes (LE) = 6 bytes/pixel
|
||||
const output = Buffer.allocUnsafe(width * height * 6);
|
||||
|
||||
for (let y = 0; y < height; y++) {
|
||||
const dstBase = y * width * 6;
|
||||
const srcRowBase = y * width * bpp;
|
||||
for (let x = 0; x < width; x++) {
|
||||
const srcBase = srcRowBase + x * bpp;
|
||||
// PNG stores 16-bit as big-endian: [high, low]. Swap to little-endian: [low, high].
|
||||
output[dstBase + x * 6 + 0] = rawPixels[srcBase + 1] ?? 0; // R low
|
||||
output[dstBase + x * 6 + 1] = rawPixels[srcBase + 0] ?? 0; // R high
|
||||
output[dstBase + x * 6 + 2] = rawPixels[srcBase + 3] ?? 0; // G low
|
||||
output[dstBase + x * 6 + 3] = rawPixels[srcBase + 2] ?? 0; // G high
|
||||
output[dstBase + x * 6 + 4] = rawPixels[srcBase + 5] ?? 0; // B low
|
||||
output[dstBase + x * 6 + 5] = rawPixels[srcBase + 4] ?? 0; // B high
|
||||
}
|
||||
}
|
||||
|
||||
return { width, height, data: output };
|
||||
}
|
||||
|
||||
// ── sRGB → HDR color conversion ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build a 256-entry LUT: sRGB 8-bit value → HDR 16-bit signal value.
|
||||
*
|
||||
* Pipeline per channel: sRGB EOTF (decode gamma) → linear → HDR OETF → 16-bit.
|
||||
*
|
||||
* ## Convention
|
||||
*
|
||||
* "Linear" here means **scene light in [0, 1] relative to SDR reference white**
|
||||
* (not absolute nits). The HLG branch applies the OETF directly — no OOTF (no
|
||||
* gamma 1.2 scene→display conversion). This is the right choice for DOM
|
||||
* overlays that will be composited ON TOP of HLG video pixels (which are
|
||||
* already in HLG signal space); we need the overlay to sit in the same space
|
||||
* as what it’s blending onto. Applying the OOTF here would double-apply it
|
||||
* when the HDR video already carries scene-light semantics.
|
||||
*
|
||||
* For PQ, SDR white is placed at 203 nits per ITU-R BT.2408 ("SDR white"
|
||||
* reference level) and normalized against 10,000-nit peak. This lets SDR
|
||||
* content (text, UI) sit at the conventional SDR-white brightness within a
|
||||
* PQ frame rather than at peak brightness.
|
||||
*
|
||||
* Note: converts the transfer function but not the color primaries (bt709 →
|
||||
* bt2020). For neutral/near-neutral content (text, UI) the gamut difference
|
||||
* is negligible.
|
||||
*/
|
||||
function buildSrgbToHdrLut(transfer: "hlg" | "pq"): Uint16Array {
|
||||
const lut = new Uint16Array(256);
|
||||
|
||||
// HLG OETF constants (Rec. 2100)
|
||||
const hlgA = 0.17883277;
|
||||
const hlgB = 1 - 4 * hlgA;
|
||||
const hlgC = 0.5 - hlgA * Math.log(4 * hlgA);
|
||||
|
||||
// PQ (SMPTE 2084) OETF constants
|
||||
const pqM1 = 0.1593017578125;
|
||||
const pqM2 = 78.84375;
|
||||
const pqC1 = 0.8359375;
|
||||
const pqC2 = 18.8515625;
|
||||
const pqC3 = 18.6875;
|
||||
const pqMaxNits = 10000.0;
|
||||
const sdrNits = 203.0;
|
||||
|
||||
for (let i = 0; i < 256; i++) {
|
||||
// sRGB EOTF: signal → linear (range 0–1, relative to SDR white)
|
||||
const v = i / 255;
|
||||
const linear = v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
|
||||
|
||||
let signal: number;
|
||||
if (transfer === "hlg") {
|
||||
signal =
|
||||
linear <= 1 / 12 ? Math.sqrt(3 * linear) : hlgA * Math.log(12 * linear - hlgB) + hlgC;
|
||||
} else {
|
||||
// PQ OETF: linear light (in SDR nits) → PQ signal
|
||||
const Lp = Math.max(0, (linear * sdrNits) / pqMaxNits);
|
||||
const Lm1 = Math.pow(Lp, pqM1);
|
||||
signal = Math.pow((pqC1 + pqC2 * Lm1) / (1.0 + pqC3 * Lm1), pqM2);
|
||||
}
|
||||
|
||||
lut[i] = Math.min(65535, Math.round(signal * 65535));
|
||||
}
|
||||
|
||||
return lut;
|
||||
}
|
||||
|
||||
const SRGB_TO_HLG = buildSrgbToHdrLut("hlg");
|
||||
const SRGB_TO_PQ = buildSrgbToHdrLut("pq");
|
||||
|
||||
/** Select the correct sRGB→HDR LUT for the given transfer function. */
|
||||
export function getSrgbToHdrLut(transfer: "hlg" | "pq"): Uint16Array {
|
||||
return transfer === "pq" ? SRGB_TO_PQ : SRGB_TO_HLG;
|
||||
}
|
||||
|
||||
// ── Alpha compositing ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Alpha-composite a DOM RGBA overlay (8-bit sRGB) onto an HDR canvas
|
||||
* (rgb48le) in-place.
|
||||
*
|
||||
* DOM pixels are converted from sRGB to the target HDR signal space (HLG or PQ)
|
||||
* before blending so the composited output is uniformly encoded. Without this
|
||||
* conversion, sRGB content appears orange/washed in HDR playback.
|
||||
*
|
||||
* @param domRgba Raw RGBA pixel data from decodePng() — width*height*4 bytes
|
||||
* @param canvas HDR canvas in rgb48le format — width*height*6 bytes, mutated in-place
|
||||
* @param width Canvas width in pixels
|
||||
* @param height Canvas height in pixels
|
||||
* @param transfer HDR transfer function — selects the correct sRGB→HDR LUT
|
||||
*/
|
||||
export function blitRgba8OverRgb48le(
|
||||
domRgba: Uint8Array,
|
||||
canvas: Buffer,
|
||||
width: number,
|
||||
height: number,
|
||||
transfer: "hlg" | "pq" = "hlg",
|
||||
): void {
|
||||
const pixelCount = width * height;
|
||||
const lut = getSrgbToHdrLut(transfer);
|
||||
|
||||
for (let i = 0; i < pixelCount; i++) {
|
||||
const da = domRgba[i * 4 + 3] ?? 0;
|
||||
|
||||
if (da === 0) {
|
||||
continue;
|
||||
} else if (da === 255) {
|
||||
const r16 = lut[domRgba[i * 4 + 0] ?? 0] ?? 0;
|
||||
const g16 = lut[domRgba[i * 4 + 1] ?? 0] ?? 0;
|
||||
const b16 = lut[domRgba[i * 4 + 2] ?? 0] ?? 0;
|
||||
canvas.writeUInt16LE(r16, i * 6);
|
||||
canvas.writeUInt16LE(g16, i * 6 + 2);
|
||||
canvas.writeUInt16LE(b16, i * 6 + 4);
|
||||
} else {
|
||||
const alpha = da / 255;
|
||||
const invAlpha = 1 - alpha;
|
||||
|
||||
const hdrR = (canvas[i * 6 + 0] ?? 0) | ((canvas[i * 6 + 1] ?? 0) << 8);
|
||||
const hdrG = (canvas[i * 6 + 2] ?? 0) | ((canvas[i * 6 + 3] ?? 0) << 8);
|
||||
const hdrB = (canvas[i * 6 + 4] ?? 0) | ((canvas[i * 6 + 5] ?? 0) << 8);
|
||||
|
||||
const domR = lut[domRgba[i * 4 + 0] ?? 0] ?? 0;
|
||||
const domG = lut[domRgba[i * 4 + 1] ?? 0] ?? 0;
|
||||
const domB = lut[domRgba[i * 4 + 2] ?? 0] ?? 0;
|
||||
|
||||
canvas.writeUInt16LE(Math.round(domR * alpha + hdrR * invAlpha), i * 6);
|
||||
canvas.writeUInt16LE(Math.round(domG * alpha + hdrG * invAlpha), i * 6 + 2);
|
||||
canvas.writeUInt16LE(Math.round(domB * alpha + hdrB * invAlpha), i * 6 + 4);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Rounded-rectangle mask ───────────────────────────────────────────────────
|
||||
|
||||
/** Anti-aliased alpha for a point at distance `dist` from a corner circle of radius `r`. */
|
||||
function cornerAlpha(px: number, py: number, cx: number, cy: number, r: number): number {
|
||||
const dx = px - cx;
|
||||
const dy = py - cy;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
if (dist > r + 0.5) return 0;
|
||||
if (dist > r - 0.5) return r + 0.5 - dist;
|
||||
return 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the alpha (0.0–1.0) for a point inside a rounded rectangle.
|
||||
* Returns 1.0 for interior pixels, 0.0 for exterior, and a smooth
|
||||
* transition at the corner edges (1px anti-aliasing).
|
||||
*
|
||||
* @param px X coordinate (continuous, e.g. pixel center or subpixel)
|
||||
* @param py Y coordinate
|
||||
* @param w Rectangle width
|
||||
* @param h Rectangle height
|
||||
* @param radii Corner radii [topLeft, topRight, bottomRight, bottomLeft]
|
||||
*/
|
||||
export function roundedRectAlpha(
|
||||
px: number,
|
||||
py: number,
|
||||
w: number,
|
||||
h: number,
|
||||
radii: [number, number, number, number],
|
||||
): number {
|
||||
const [tl, tr, br, bl] = radii;
|
||||
if (px < tl && py < tl) return cornerAlpha(px, py, tl, tl, tl);
|
||||
if (px >= w - tr && py < tr) return cornerAlpha(px, py, w - tr, tr, tr);
|
||||
if (px >= w - br && py >= h - br) return cornerAlpha(px, py, w - br, h - br, br);
|
||||
if (px < bl && py >= h - bl) return cornerAlpha(px, py, bl, h - bl, bl);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// ── Positioned HDR region copy ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Copy a rectangular region of an rgb48le source onto an rgb48le canvas
|
||||
* at position (dx, dy). Clips to canvas bounds. Optional opacity blending
|
||||
* (0.0–1.0) over existing canvas content.
|
||||
*
|
||||
* @param canvas Destination rgb48le buffer (canvasWidth * canvasHeight * 6 bytes)
|
||||
* @param source Source rgb48le buffer (sw * sh * 6 bytes)
|
||||
* @param dx Destination X offset on canvas
|
||||
* @param dy Destination Y offset on canvas
|
||||
* @param sw Source width in pixels
|
||||
* @param sh Source height in pixels
|
||||
* @param canvasWidth Canvas width in pixels (needed for stride calculation)
|
||||
* @param canvasHeight Canvas height in pixels (used to clip the destination region)
|
||||
* @param opacity Optional opacity 0.0–1.0 (default 1.0 = fully opaque copy)
|
||||
*/
|
||||
export function blitRgb48leRegion(
|
||||
canvas: Buffer,
|
||||
source: Buffer,
|
||||
dx: number,
|
||||
dy: number,
|
||||
sw: number,
|
||||
sh: number,
|
||||
canvasWidth: number,
|
||||
canvasHeight: number,
|
||||
opacity?: number,
|
||||
borderRadius?: [number, number, number, number],
|
||||
): void {
|
||||
if (sw <= 0 || sh <= 0) return;
|
||||
|
||||
const op = opacity ?? 1.0;
|
||||
|
||||
const x0 = Math.max(0, dx);
|
||||
const y0 = Math.max(0, dy);
|
||||
const x1 = Math.min(canvasWidth, dx + sw);
|
||||
const y1 = Math.min(canvasHeight, dy + sh);
|
||||
if (x0 >= x1 || y0 >= y1) return;
|
||||
|
||||
const clippedW = x1 - x0;
|
||||
const srcOffsetX = x0 - dx;
|
||||
const srcOffsetY = y0 - dy;
|
||||
|
||||
const hasMask = borderRadius !== undefined;
|
||||
|
||||
if (op >= 0.999 && !hasMask) {
|
||||
for (let y = 0; y < y1 - y0; y++) {
|
||||
const srcRowOff = ((srcOffsetY + y) * sw + srcOffsetX) * 6;
|
||||
const dstRowOff = ((y0 + y) * canvasWidth + x0) * 6;
|
||||
source.copy(canvas, dstRowOff, srcRowOff, srcRowOff + clippedW * 6);
|
||||
}
|
||||
} else {
|
||||
for (let y = 0; y < y1 - y0; y++) {
|
||||
for (let x = 0; x < clippedW; x++) {
|
||||
let effectiveOp = op;
|
||||
if (hasMask) {
|
||||
const ma = roundedRectAlpha(srcOffsetX + x, srcOffsetY + y, sw, sh, borderRadius);
|
||||
if (ma <= 0) continue;
|
||||
effectiveOp *= ma;
|
||||
}
|
||||
|
||||
const srcOff = ((srcOffsetY + y) * sw + srcOffsetX + x) * 6;
|
||||
const dstOff = ((y0 + y) * canvasWidth + x0 + x) * 6;
|
||||
|
||||
if (effectiveOp >= 0.999) {
|
||||
source.copy(canvas, dstOff, srcOff, srcOff + 6);
|
||||
} else {
|
||||
const invEff = 1 - effectiveOp;
|
||||
const sr = source.readUInt16LE(srcOff);
|
||||
const sg = source.readUInt16LE(srcOff + 2);
|
||||
const sb = source.readUInt16LE(srcOff + 4);
|
||||
const dr = canvas.readUInt16LE(dstOff);
|
||||
const dg = canvas.readUInt16LE(dstOff + 2);
|
||||
const db = canvas.readUInt16LE(dstOff + 4);
|
||||
canvas.writeUInt16LE(Math.round(sr * effectiveOp + dr * invEff), dstOff);
|
||||
canvas.writeUInt16LE(Math.round(sg * effectiveOp + dg * invEff), dstOff + 2);
|
||||
canvas.writeUInt16LE(Math.round(sb * effectiveOp + db * invEff), dstOff + 4);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a 2D affine transform to an rgb48le source and composite onto a canvas.
|
||||
*
|
||||
* For each destination pixel, the inverse transform maps back to source coordinates.
|
||||
* Bilinear interpolation samples the 4 nearest source pixels for smooth scaling/rotation.
|
||||
*
|
||||
* @param canvas Destination rgb48le buffer, mutated in-place
|
||||
* @param source Source rgb48le buffer (srcW * srcH * 6 bytes)
|
||||
* @param matrix CSS transform matrix [a, b, c, d, tx, ty]
|
||||
* @param srcW Source width in pixels
|
||||
* @param srcH Source height in pixels
|
||||
* @param canvasW Canvas width in pixels
|
||||
* @param canvasH Canvas height in pixels
|
||||
* @param opacity Optional opacity 0.0–1.0 (default 1.0)
|
||||
*/
|
||||
export function blitRgb48leAffine(
|
||||
canvas: Buffer,
|
||||
source: Buffer,
|
||||
matrix: number[],
|
||||
srcW: number,
|
||||
srcH: number,
|
||||
canvasW: number,
|
||||
canvasH: number,
|
||||
opacity?: number,
|
||||
borderRadius?: [number, number, number, number],
|
||||
): void {
|
||||
const a = matrix[0];
|
||||
const b = matrix[1];
|
||||
const c = matrix[2];
|
||||
const d = matrix[3];
|
||||
const tx = matrix[4];
|
||||
const ty = matrix[5];
|
||||
if (
|
||||
a === undefined ||
|
||||
b === undefined ||
|
||||
c === undefined ||
|
||||
d === undefined ||
|
||||
tx === undefined ||
|
||||
ty === undefined
|
||||
)
|
||||
return;
|
||||
|
||||
// Invert the 2x2 part of the affine matrix
|
||||
const det = a * d - b * c;
|
||||
if (Math.abs(det) < 1e-10) return; // degenerate matrix
|
||||
|
||||
const invA = d / det;
|
||||
const invB = -b / det;
|
||||
const invC = -c / det;
|
||||
const invD = a / det;
|
||||
const invTx = -(invA * tx + invC * ty);
|
||||
const invTy = -(invB * tx + invD * ty);
|
||||
|
||||
const op = opacity ?? 1.0;
|
||||
|
||||
const hasMask = borderRadius !== undefined;
|
||||
|
||||
// Compute bounding box of transformed source on canvas
|
||||
const corners = [
|
||||
[tx, ty],
|
||||
[a * srcW + tx, b * srcW + ty],
|
||||
[c * srcH + tx, d * srcH + ty],
|
||||
[a * srcW + c * srcH + tx, b * srcW + d * srcH + ty],
|
||||
];
|
||||
let minX = canvasW,
|
||||
maxX = 0,
|
||||
minY = canvasH,
|
||||
maxY = 0;
|
||||
for (const corner of corners) {
|
||||
const cx = corner[0] ?? 0;
|
||||
const cy = corner[1] ?? 0;
|
||||
if (cx < minX) minX = cx;
|
||||
if (cx > maxX) maxX = cx;
|
||||
if (cy < minY) minY = cy;
|
||||
if (cy > maxY) maxY = cy;
|
||||
}
|
||||
const startX = Math.max(0, Math.floor(minX));
|
||||
const endX = Math.min(canvasW, Math.ceil(maxX));
|
||||
const startY = Math.max(0, Math.floor(minY));
|
||||
const endY = Math.min(canvasH, Math.ceil(maxY));
|
||||
|
||||
for (let dy = startY; dy < endY; dy++) {
|
||||
for (let dx = startX; dx < endX; dx++) {
|
||||
const sx = invA * dx + invC * dy + invTx;
|
||||
const sy = invB * dx + invD * dy + invTy;
|
||||
|
||||
if (sx < 0 || sy < 0 || sx >= srcW || sy >= srcH) continue;
|
||||
|
||||
// Apply rounded-rect mask in source coordinates
|
||||
let effectiveOp = op;
|
||||
if (hasMask) {
|
||||
const ma = roundedRectAlpha(sx, sy, srcW, srcH, borderRadius);
|
||||
if (ma <= 0) continue;
|
||||
effectiveOp *= ma;
|
||||
}
|
||||
|
||||
const x0 = Math.floor(sx);
|
||||
const y0 = Math.floor(sy);
|
||||
const fx = sx - x0;
|
||||
const fy = sy - y0;
|
||||
const x1 = Math.min(x0 + 1, srcW - 1);
|
||||
const y1 = Math.min(y0 + 1, srcH - 1);
|
||||
|
||||
const off00 = (y0 * srcW + x0) * 6;
|
||||
const off10 = (y0 * srcW + x1) * 6;
|
||||
const off01 = (y1 * srcW + x0) * 6;
|
||||
const off11 = (y1 * srcW + x1) * 6;
|
||||
|
||||
const w00 = (1 - fx) * (1 - fy);
|
||||
const w10 = fx * (1 - fy);
|
||||
const w01 = (1 - fx) * fy;
|
||||
const w11 = fx * fy;
|
||||
|
||||
const sr =
|
||||
source.readUInt16LE(off00) * w00 +
|
||||
source.readUInt16LE(off10) * w10 +
|
||||
source.readUInt16LE(off01) * w01 +
|
||||
source.readUInt16LE(off11) * w11;
|
||||
const sg =
|
||||
source.readUInt16LE(off00 + 2) * w00 +
|
||||
source.readUInt16LE(off10 + 2) * w10 +
|
||||
source.readUInt16LE(off01 + 2) * w01 +
|
||||
source.readUInt16LE(off11 + 2) * w11;
|
||||
const sb =
|
||||
source.readUInt16LE(off00 + 4) * w00 +
|
||||
source.readUInt16LE(off10 + 4) * w10 +
|
||||
source.readUInt16LE(off01 + 4) * w01 +
|
||||
source.readUInt16LE(off11 + 4) * w11;
|
||||
|
||||
const dstOff = (dy * canvasW + dx) * 6;
|
||||
|
||||
if (effectiveOp >= 0.999) {
|
||||
canvas.writeUInt16LE(Math.round(sr), dstOff);
|
||||
canvas.writeUInt16LE(Math.round(sg), dstOff + 2);
|
||||
canvas.writeUInt16LE(Math.round(sb), dstOff + 4);
|
||||
} else {
|
||||
const invEff = 1 - effectiveOp;
|
||||
const dr = canvas.readUInt16LE(dstOff);
|
||||
const dg = canvas.readUInt16LE(dstOff + 2);
|
||||
const db = canvas.readUInt16LE(dstOff + 4);
|
||||
canvas.writeUInt16LE(Math.round(sr * effectiveOp + dr * invEff), dstOff);
|
||||
canvas.writeUInt16LE(Math.round(sg * effectiveOp + dg * invEff), dstOff + 2);
|
||||
canvas.writeUInt16LE(Math.round(sb * effectiveOp + db * invEff), dstOff + 4);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a CSS `matrix(a,b,c,d,e,f)` string into a 6-element array.
|
||||
* Returns null for "none", empty, or unsupported formats (matrix3d).
|
||||
*
|
||||
* The array maps to the CSS matrix: [a, b, c, d, tx, ty] where:
|
||||
* | a c tx | (a=scaleX, b=skewY, c=skewX, d=scaleY, tx/ty=translate)
|
||||
* | b d ty |
|
||||
* | 0 0 1 |
|
||||
*/
|
||||
export function parseTransformMatrix(css: string): number[] | null {
|
||||
if (!css || css === "none") return null;
|
||||
const match = css.match(
|
||||
/^matrix\(\s*([^,]+),\s*([^,]+),\s*([^,]+),\s*([^,]+),\s*([^,]+),\s*([^,)]+)\s*\)$/,
|
||||
);
|
||||
if (!match) return null;
|
||||
const values = match.slice(1, 7).map(Number);
|
||||
if (!values.every(Number.isFinite)) return null;
|
||||
return values;
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { HF_BRIDGE_SCRIPT, injectScriptsAtHeadStart, VIRTUAL_TIME_SHIM } from "./fileServer.js";
|
||||
import {
|
||||
HF_BRIDGE_SCRIPT,
|
||||
HF_EARLY_STUB,
|
||||
injectScriptsAtHeadStart,
|
||||
VIRTUAL_TIME_SHIM,
|
||||
} from "./fileServer.js";
|
||||
|
||||
describe("injectScriptsIntoHtml", () => {
|
||||
it("injects the virtual time shim into head content before authored scripts", () => {
|
||||
@@ -34,3 +39,67 @@ describe("injectScriptsIntoHtml", () => {
|
||||
expect(HF_BRIDGE_SCRIPT).toContain("seekSameOriginChildFrames(window, nextTimeMs)");
|
||||
});
|
||||
});
|
||||
|
||||
describe("HF_EARLY_STUB + HF_BRIDGE_SCRIPT integration", () => {
|
||||
/**
|
||||
* Simulates the real injection order in a Puppeteer page:
|
||||
* 1. HF_EARLY_STUB (start of <head>, before everything)
|
||||
* 2. authored page scripts that write to window.__hf.transitions
|
||||
* (e.g. @hyperframes/shader-transitions in <body>)
|
||||
* 3. HF_BRIDGE_SCRIPT (end of <body>, upgrades __hf with seek/duration)
|
||||
*
|
||||
* Regression test for the race condition where the bridge used to overwrite
|
||||
* window.__hf with a fresh object, dropping any fields user libraries
|
||||
* (notably `transitions`) had populated during page-script execution.
|
||||
* Without the early stub + patch-not-replace bridge, the engine never
|
||||
* detects shader transitions and HDR compositing falls back to plain DOM.
|
||||
*/
|
||||
it("preserves __hf.transitions written by page scripts through bridge upgrade", () => {
|
||||
const sandbox: {
|
||||
window: Record<string, unknown> & {
|
||||
__hf?: { transitions?: unknown[]; seek?: (t: number) => void; duration?: number };
|
||||
__player?: { renderSeek: (t: number) => void; getDuration: () => number };
|
||||
setInterval: typeof setInterval;
|
||||
clearInterval: typeof clearInterval;
|
||||
};
|
||||
document: { querySelector: () => null };
|
||||
} = {
|
||||
window: {
|
||||
setInterval: globalThis.setInterval,
|
||||
clearInterval: globalThis.clearInterval,
|
||||
},
|
||||
document: { querySelector: () => null },
|
||||
};
|
||||
sandbox.window.window = sandbox.window;
|
||||
sandbox.window.document = sandbox.document;
|
||||
|
||||
const run = (src: string): void => {
|
||||
new Function("window", "document", `with (window) {\n${src}\n}`)(
|
||||
sandbox.window,
|
||||
sandbox.document,
|
||||
);
|
||||
};
|
||||
|
||||
run(HF_EARLY_STUB);
|
||||
expect(sandbox.window.__hf).toBeDefined();
|
||||
expect(sandbox.window.__hf?.transitions).toBeUndefined();
|
||||
|
||||
sandbox.window.__hf!.transitions = [
|
||||
{ time: 5, duration: 0.5, shader: "domain-warp", fromScene: "a", toScene: "b" },
|
||||
];
|
||||
|
||||
sandbox.window.__player = {
|
||||
renderSeek: () => {},
|
||||
getDuration: () => 30,
|
||||
};
|
||||
|
||||
run(HF_BRIDGE_SCRIPT);
|
||||
|
||||
expect(sandbox.window.__hf).toBeDefined();
|
||||
expect(sandbox.window.__hf?.transitions).toEqual([
|
||||
{ time: 5, duration: 0.5, shader: "domain-warp", fromScene: "a", toScene: "b" },
|
||||
]);
|
||||
expect(typeof sandbox.window.__hf?.seek).toBe("function");
|
||||
expect(sandbox.window.__hf?.duration).toBe(30);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -261,9 +261,28 @@ const RENDER_MODE_SCRIPT = `(function() {
|
||||
waitForPlayer();
|
||||
})();`;
|
||||
|
||||
/**
|
||||
* Early stub: ensures `window.__hf` exists *before* any user `<script>` in
|
||||
* `<body>` executes. Without this, libraries that opportunistically write to
|
||||
* `__hf` during page-script execution (notably `@hyperframes/shader-transitions`,
|
||||
* which writes the active transition map to `__hf.transitions` inside its
|
||||
* `init()` call) silently no-op because `__hf` hasn't been created yet — the
|
||||
* full bridge script is injected at end-of-body and runs *after* user scripts.
|
||||
*
|
||||
* Injected at the very start of `<head>` so it runs before all other scripts.
|
||||
*/
|
||||
const HF_EARLY_STUB = `(function() {
|
||||
if (typeof window === "undefined") return;
|
||||
if (!window.__hf) window.__hf = {};
|
||||
})();`;
|
||||
|
||||
/**
|
||||
* Bridge script: maps window.__player (Hyperframe runtime) → window.__hf (engine protocol).
|
||||
* Injected after RENDER_MODE_SCRIPT so the engine's frameCapture can find window.__hf.
|
||||
*
|
||||
* This script *patches* the existing __hf object rather than replacing it, so
|
||||
* fields written during page-script execution (e.g. transitions metadata from
|
||||
* @hyperframes/shader-transitions) are preserved through to engine query time.
|
||||
*/
|
||||
const HF_BRIDGE_SCRIPT = `(function() {
|
||||
var __realSetInterval =
|
||||
@@ -310,20 +329,24 @@ const HF_BRIDGE_SCRIPT = `(function() {
|
||||
if (!p || typeof p.renderSeek !== "function" || typeof p.getDuration !== "function") {
|
||||
return false;
|
||||
}
|
||||
window.__hf = {
|
||||
get duration() {
|
||||
var hf = window.__hf || {};
|
||||
Object.defineProperty(hf, "duration", {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
get: function() {
|
||||
var d = p.getDuration();
|
||||
return d > 0 ? d : getDeclaredDuration();
|
||||
},
|
||||
seek: function(t) {
|
||||
p.renderSeek(t);
|
||||
var nextTimeMs = (Math.max(0, Number(t) || 0)) * 1000;
|
||||
if (window.__HF_VIRTUAL_TIME__ && typeof window.__HF_VIRTUAL_TIME__.seekToTime === "function") {
|
||||
window.__HF_VIRTUAL_TIME__.seekToTime(nextTimeMs);
|
||||
}
|
||||
seekSameOriginChildFrames(window, nextTimeMs);
|
||||
},
|
||||
});
|
||||
hf.seek = function(t) {
|
||||
p.renderSeek(t);
|
||||
var nextTimeMs = (Math.max(0, Number(t) || 0)) * 1000;
|
||||
if (window.__HF_VIRTUAL_TIME__ && typeof window.__HF_VIRTUAL_TIME__.seekToTime === "function") {
|
||||
window.__HF_VIRTUAL_TIME__.seekToTime(nextTimeMs);
|
||||
}
|
||||
seekSameOriginChildFrames(window, nextTimeMs);
|
||||
};
|
||||
window.__hf = hf;
|
||||
return true;
|
||||
}
|
||||
if (bridge()) return;
|
||||
@@ -437,7 +460,13 @@ export interface FileServerHandle {
|
||||
export function createFileServer(options: FileServerOptions): Promise<FileServerHandle> {
|
||||
const { projectDir, compiledDir, port = 0, stripEmbeddedRuntime = true } = options;
|
||||
|
||||
const preHeadScripts = options.preHeadScripts ?? [];
|
||||
// HF_EARLY_STUB must run before *any* page script so libraries that write
|
||||
// to window.__hf during page-script execution (e.g. shader-transitions
|
||||
// populating __hf.transitions) find it already defined. The full bridge in
|
||||
// bodyScripts later upgrades this stub with `seek` / `duration` once the
|
||||
// Hyperframe runtime's __player is ready, while preserving any fields
|
||||
// already written.
|
||||
const preHeadScripts = [HF_EARLY_STUB, ...(options.preHeadScripts ?? [])];
|
||||
// Default scripts: Hyperframe runtime in <head>, render mode in </body>
|
||||
const headScripts = options.headScripts ?? [getVerifiedHyperframeRuntimeSource()];
|
||||
const bodyScripts = options.bodyScripts ?? [RENDER_MODE_SCRIPT, HF_BRIDGE_SCRIPT];
|
||||
@@ -514,4 +543,4 @@ export function createFileServer(options: FileServerOptions): Promise<FileServer
|
||||
});
|
||||
}
|
||||
|
||||
export { HF_BRIDGE_SCRIPT, VIRTUAL_TIME_SHIM };
|
||||
export { HF_BRIDGE_SCRIPT, HF_EARLY_STUB, VIRTUAL_TIME_SHIM };
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
mkdirSync,
|
||||
rmSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
writeFileSync,
|
||||
copyFileSync,
|
||||
appendFileSync,
|
||||
@@ -55,8 +56,17 @@ import {
|
||||
spawnStreamingEncoder,
|
||||
createFrameReorderBuffer,
|
||||
type StreamingEncoder,
|
||||
convertHdrFrameToRgb48le,
|
||||
analyzeCompositionHdr,
|
||||
isHdrColorSpace,
|
||||
extractVideoMetadata,
|
||||
initTransparentBackground,
|
||||
captureAlphaPng,
|
||||
decodePng,
|
||||
decodePngToRgb48le,
|
||||
blitRgba8OverRgb48le,
|
||||
hideVideoElements,
|
||||
showVideoElements,
|
||||
queryVideoElementBounds,
|
||||
} from "@hyperframes/engine";
|
||||
import { join, dirname, resolve } from "path";
|
||||
import { randomUUID } from "crypto";
|
||||
@@ -90,6 +100,39 @@ async function safeCleanup(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache of the maximum 1-based frame index present in each pre-extracted frame
|
||||
* directory (e.g. `frame_0001.png … frame_0150.png` → 150). The directory is
|
||||
* read once on first access and the max is computed by parsing filenames.
|
||||
*
|
||||
* Used to bounds-check `videoFrameIndex` against the directory size before
|
||||
* calling `existsSync` per frame, which avoids redundant filesystem syscalls
|
||||
* when the requested time falls past the last extracted frame (e.g. a clip
|
||||
* shorter than the composition's effective video range).
|
||||
*/
|
||||
const frameDirMaxIndexCache = new Map<string, number>();
|
||||
|
||||
const FRAME_FILENAME_RE = /^frame_(\d+)\.png$/;
|
||||
|
||||
function getMaxFrameIndex(frameDir: string): number {
|
||||
const cached = frameDirMaxIndexCache.get(frameDir);
|
||||
if (cached !== undefined) return cached;
|
||||
let max = 0;
|
||||
try {
|
||||
for (const name of readdirSync(frameDir)) {
|
||||
const m = FRAME_FILENAME_RE.exec(name);
|
||||
if (!m) continue;
|
||||
const n = Number(m[1]);
|
||||
if (Number.isFinite(n) && n > max) max = n;
|
||||
}
|
||||
} catch {
|
||||
// Directory missing or unreadable → max stays 0; downstream existsSync
|
||||
// check will still produce the right "no frame" outcome.
|
||||
}
|
||||
frameDirMaxIndexCache.set(frameDir, max);
|
||||
return max;
|
||||
}
|
||||
|
||||
export type RenderStatus =
|
||||
| "queued"
|
||||
| "preprocessing"
|
||||
@@ -689,7 +732,10 @@ export async function executeRenderJob(
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
} catch (err) {
|
||||
log.warn("Failed to gather browser diagnostics for zero-duration composition", {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
diagnostics.push("(Could not gather browser diagnostics — page may have crashed)");
|
||||
}
|
||||
const hint =
|
||||
@@ -726,6 +772,29 @@ export async function executeRenderJob(
|
||||
const compiledDir = join(workDir, "compiled");
|
||||
let extractionResult: Awaited<ReturnType<typeof extractAllVideoFrames>> | null = null;
|
||||
|
||||
// Probe ORIGINAL color spaces before extraction (which may convert SDR→HDR).
|
||||
// This is needed to identify which videos are natively HDR vs converted-SDR
|
||||
// for the two-pass compositing path.
|
||||
const nativeHdrVideoIds = new Set<string>();
|
||||
if (composition.videos.length > 0) {
|
||||
await Promise.all(
|
||||
composition.videos.map(async (v) => {
|
||||
let videoPath = v.src;
|
||||
if (!videoPath.startsWith("/")) {
|
||||
const fromCompiled = existsSync(join(compiledDir, videoPath))
|
||||
? join(compiledDir, videoPath)
|
||||
: join(projectDir, videoPath);
|
||||
videoPath = fromCompiled;
|
||||
}
|
||||
if (!existsSync(videoPath)) return;
|
||||
const meta = await extractVideoMetadata(videoPath);
|
||||
if (isHdrColorSpace(meta.colorSpace)) {
|
||||
nativeHdrVideoIds.add(v.id);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (composition.videos.length > 0) {
|
||||
extractionResult = await extractAllVideoFrames(
|
||||
composition.videos,
|
||||
@@ -740,7 +809,6 @@ export async function executeRenderJob(
|
||||
if (extractionResult.extracted.length > 0) {
|
||||
frameLookup = createFrameLookupTable(composition.videos, extractionResult.extracted);
|
||||
}
|
||||
|
||||
perfStages.videoExtractMs = Date.now() - stage2Start;
|
||||
|
||||
// Auto-detect audio from video files via ffprobe metadata
|
||||
@@ -855,17 +923,56 @@ export async function executeRenderJob(
|
||||
|
||||
job.framesRendered = 0;
|
||||
|
||||
// ── HDR pass-through path ────────────────────────────────────────────
|
||||
// When HDR output is requested AND the composition has HDR video sources,
|
||||
// extract raw HLG frames and pass them directly to FFmpeg — no conversion.
|
||||
// The HLG pixel values ARE the correct encoding. The output is tagged as
|
||||
// HLG/BT.2020 so HDR displays render it correctly.
|
||||
// ── HDR two-pass compositing path ────────────────────────────────────
|
||||
// Pass 1: Capture DOM layer with alpha (Chrome, video elements hidden)
|
||||
// Pass 2: Extract native HLG frames from video sources (FFmpeg)
|
||||
// Composite: overlay DOM on top of HDR video in FFmpeg per-frame
|
||||
//
|
||||
// No WebGPU or Chrome needed for this path — it's a pure FFmpeg pipeline.
|
||||
// GSAP transforms are NOT applied (future work: WebGPU shader transforms).
|
||||
// This preserves HDR luminance from video sources while correctly
|
||||
// compositing DOM content (text, graphics, SDR overlays) on top.
|
||||
// Video position is applied via queried bounds; transform/opacity lands in a later PR.
|
||||
if (hasHdrVideo) {
|
||||
log.info("[Render] HDR pass-through: extracting native HLG frames from video sources");
|
||||
log.info("[Render] HDR two-pass: DOM layer + native HLG video compositing");
|
||||
|
||||
// Use NATIVE HDR IDs (probed before SDR→HDR conversion) so only originally-HDR
|
||||
// videos are hidden + extracted natively. SDR videos stay in the DOM screenshot
|
||||
// (injected via the frame injector) and get sRGB→HLG conversion in the blit.
|
||||
const hdrVideoIds = composition.videos
|
||||
.filter((v) => nativeHdrVideoIds.has(v.id))
|
||||
.map((v) => v.id);
|
||||
|
||||
// Resolve HDR video source paths
|
||||
const hdrVideoSrcPaths = new Map<string, string>();
|
||||
for (const v of composition.videos) {
|
||||
if (!hdrVideoIds.includes(v.id)) continue;
|
||||
let srcPath = v.src;
|
||||
if (!srcPath.startsWith("/")) {
|
||||
const fromCompiled = join(compiledDir, srcPath);
|
||||
srcPath = existsSync(fromCompiled) ? fromCompiled : join(projectDir, srcPath);
|
||||
}
|
||||
hdrVideoSrcPaths.set(v.id, srcPath);
|
||||
}
|
||||
|
||||
// Launch headless Chrome for DOM capture.
|
||||
// Pass the video frame injector so SDR videos are rendered correctly in Chrome.
|
||||
// HDR videos get injected too but are hidden via hideVideoElements before the
|
||||
// DOM screenshot — only the native FFmpeg-extracted HLG frames are used for HDR.
|
||||
const domSession = await createCaptureSession(
|
||||
fileServer!.url,
|
||||
framesDir,
|
||||
captureOptions,
|
||||
createVideoFrameInjector(frameLookup),
|
||||
cfg,
|
||||
);
|
||||
await initializeSession(domSession);
|
||||
assertNotAborted();
|
||||
lastBrowserConsole = domSession.browserConsoleBuffer;
|
||||
|
||||
// Set transparent background once for this dedicated DOM session.
|
||||
// captureAlphaPng() per frame skips the per-frame CDP set/reset overhead.
|
||||
await initTransparentBackground(domSession.page);
|
||||
|
||||
// Spawn HDR streaming encoder accepting raw rgb48le composited frames
|
||||
const hdrEncoder = await spawnStreamingEncoder(
|
||||
videoOnlyPath,
|
||||
{
|
||||
@@ -886,60 +993,130 @@ export async function executeRenderJob(
|
||||
|
||||
const { execSync } = await import("child_process");
|
||||
|
||||
// ── Pre-extract all HDR video frames in a single FFmpeg pass ──────
|
||||
// Per-frame `-ss` fast seek causes duplicate frames at keyframe boundaries.
|
||||
// A single extraction pass decodes sequentially — every frame is unique.
|
||||
const hdrFrameDirs = new Map<string, string>();
|
||||
for (const [videoId, srcPath] of hdrVideoSrcPaths) {
|
||||
const video = composition.videos.find((v) => v.id === videoId);
|
||||
if (!video) continue;
|
||||
const frameDir = join(framesDir, `hdr_${videoId}`);
|
||||
mkdirSync(frameDir, { recursive: true });
|
||||
const duration = video.end - video.start;
|
||||
try {
|
||||
execSync(
|
||||
`ffmpeg -ss ${video.mediaStart} -i "${srcPath}" -t ${duration} -r ${job.config.fps} ` +
|
||||
`-vf "scale=${width}:${height}:force_original_aspect_ratio=increase,crop=${width}:${height}" ` +
|
||||
`-pix_fmt rgb48le -c:v png "${join(frameDir, "frame_%04d.png")}"`,
|
||||
{ maxBuffer: 1024 * 1024, stdio: ["pipe", "pipe", "pipe"] },
|
||||
);
|
||||
} catch (err) {
|
||||
log.warn("HDR frame pre-extraction failed; loop will fill with black", {
|
||||
videoId,
|
||||
srcPath,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
hdrFrameDirs.set(videoId, frameDir);
|
||||
}
|
||||
assertNotAborted();
|
||||
|
||||
try {
|
||||
// The beforeCaptureHook injects SDR video frames into the DOM.
|
||||
// We call it manually since the HDR loop doesn't use captureFrame().
|
||||
const beforeCaptureHook = domSession.onBeforeCapture;
|
||||
|
||||
for (let i = 0; i < job.totalFrames!; i++) {
|
||||
assertNotAborted();
|
||||
const time = i / job.config.fps;
|
||||
|
||||
const activeFrames = frameLookup!.getActiveFramePayloads(time);
|
||||
// Seek timeline
|
||||
await domSession.page.evaluate((t: number) => {
|
||||
if (window.__hf && typeof window.__hf.seek === "function") window.__hf.seek(t);
|
||||
}, time);
|
||||
|
||||
if (activeFrames.size > 0) {
|
||||
const [videoId] = activeFrames.keys();
|
||||
const video = composition.videos.find((v) => v.id === videoId);
|
||||
if (video) {
|
||||
const localTime = time - video.start + video.mediaStart;
|
||||
|
||||
let srcPath = video.src;
|
||||
const compiledDir = join(workDir, "compiled");
|
||||
if (!srcPath.startsWith("/")) {
|
||||
const fromCompiled = join(compiledDir, srcPath);
|
||||
srcPath = existsSync(fromCompiled) ? fromCompiled : join(projectDir, srcPath);
|
||||
}
|
||||
|
||||
let rawFrame: Buffer;
|
||||
try {
|
||||
rawFrame = execSync(
|
||||
`ffmpeg -ss ${localTime} -i "${srcPath}" -vframes 1 -f rawvideo -pix_fmt rgba64le -`,
|
||||
{ maxBuffer: width * height * 8 + 1024 * 1024, stdio: ["pipe", "pipe", "pipe"] },
|
||||
);
|
||||
} catch {
|
||||
rawFrame = Buffer.alloc(width * height * 8);
|
||||
}
|
||||
|
||||
// Pass through HLG pixels as-is (RGBA → RGB, no color conversion)
|
||||
const rgb48Frame = convertHdrFrameToRgb48le(rawFrame, width, height);
|
||||
hdrEncoder.writeFrame(rgb48Frame);
|
||||
} else {
|
||||
hdrEncoder.writeFrame(Buffer.alloc(width * height * 6));
|
||||
}
|
||||
} else {
|
||||
hdrEncoder.writeFrame(Buffer.alloc(width * height * 6));
|
||||
// Inject SDR video frames into the DOM (the hook handles all videos,
|
||||
// but hideVideoElements below will hide the HDR ones before screenshot)
|
||||
if (beforeCaptureHook) {
|
||||
await beforeCaptureHook(domSession.page, time);
|
||||
}
|
||||
|
||||
// Query video element positions BEFORE hiding (so GSAP has already moved them)
|
||||
const bounds = await queryVideoElementBounds(domSession.page, hdrVideoIds);
|
||||
const activeBounds = bounds.filter((b) => b.visible);
|
||||
|
||||
// Pass 1: Hide HDR videos (and their injected frames), capture DOM with alpha.
|
||||
// SDR video frames remain visible in the screenshot.
|
||||
await hideVideoElements(domSession.page, hdrVideoIds);
|
||||
const domPng = await captureAlphaPng(domSession.page, width, height);
|
||||
await showVideoElements(domSession.page, hdrVideoIds);
|
||||
|
||||
// Pass 2: Read pre-extracted HDR frame and composite with DOM layer
|
||||
const activeVideoId = activeBounds[0]?.videoId ?? hdrVideoIds[0];
|
||||
const video = composition.videos.find((v) => v.id === activeVideoId);
|
||||
const frameDir = activeVideoId ? hdrFrameDirs.get(activeVideoId) : undefined;
|
||||
|
||||
let composited: Buffer;
|
||||
if (video && frameDir) {
|
||||
// Frame index within the video (1-based for FFmpeg image2 output).
|
||||
// Clamp against the highest extracted frame in the directory to
|
||||
// avoid issuing an existsSync per requested time when the
|
||||
// composition outlives the source clip.
|
||||
const rawIndex = Math.round((time - video.start) * job.config.fps) + 1;
|
||||
const maxIndex = getMaxFrameIndex(frameDir);
|
||||
const inBounds = rawIndex >= 1 && (maxIndex === 0 || rawIndex <= maxIndex);
|
||||
const framePath = inBounds
|
||||
? join(frameDir, `frame_${String(rawIndex).padStart(4, "0")}.png`)
|
||||
: null;
|
||||
|
||||
let hdrRgb: Buffer;
|
||||
if (framePath !== null && existsSync(framePath)) {
|
||||
try {
|
||||
hdrRgb = decodePngToRgb48le(readFileSync(framePath)).data;
|
||||
} catch (err) {
|
||||
log.warn("Failed to decode pre-extracted HDR frame; using black", {
|
||||
framePath,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
hdrRgb = Buffer.alloc(width * height * 6);
|
||||
}
|
||||
} else {
|
||||
hdrRgb = Buffer.alloc(width * height * 6);
|
||||
}
|
||||
|
||||
// In-memory alpha composite: DOM PNG over HDR rgb48le (in-place)
|
||||
try {
|
||||
const { data: domRgba } = decodePng(domPng);
|
||||
const hdrTransfer = effectiveHdr ? effectiveHdr.transfer : ("hlg" as HdrTransfer);
|
||||
blitRgba8OverRgb48le(domRgba, hdrRgb, width, height, hdrTransfer);
|
||||
} catch (err) {
|
||||
log.warn("DOM layer decode/blit failed; skipping overlay for frame", {
|
||||
frameIndex: i,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
composited = hdrRgb;
|
||||
} else {
|
||||
composited = Buffer.alloc(width * height * 6);
|
||||
}
|
||||
|
||||
hdrEncoder.writeFrame(composited);
|
||||
|
||||
job.framesRendered = i + 1;
|
||||
if ((i + 1) % 10 === 0 || i + 1 === job.totalFrames!) {
|
||||
const frameProgress = (i + 1) / job.totalFrames!;
|
||||
updateJobStatus(
|
||||
job,
|
||||
"rendering",
|
||||
`HDR frame ${i + 1}/${job.totalFrames}`,
|
||||
`HDR composite frame ${i + 1}/${job.totalFrames}`,
|
||||
Math.round(25 + frameProgress * 55),
|
||||
onProgress,
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
// No browser to close — pure FFmpeg path
|
||||
lastBrowserConsole = domSession.browserConsoleBuffer;
|
||||
await closeCaptureSession(domSession);
|
||||
}
|
||||
|
||||
const hdrEncodeResult = await hdrEncoder.close();
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 3.6 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 200 KiB |
@@ -0,0 +1,142 @@
|
||||
[
|
||||
{
|
||||
"text": "the",
|
||||
"start": 0.1,
|
||||
"end": 0.31
|
||||
},
|
||||
{
|
||||
"text": "makers",
|
||||
"start": 0.31,
|
||||
"end": 0.86
|
||||
},
|
||||
{
|
||||
"text": "of",
|
||||
"start": 0.94,
|
||||
"end": 1.13
|
||||
},
|
||||
{
|
||||
"text": "Subnautica,",
|
||||
"start": 1.13,
|
||||
"end": 2.28
|
||||
},
|
||||
{
|
||||
"text": "and",
|
||||
"start": 2.28,
|
||||
"end": 2.44
|
||||
},
|
||||
{
|
||||
"text": "he",
|
||||
"start": 2.44,
|
||||
"end": 2.54
|
||||
},
|
||||
{
|
||||
"text": "promised",
|
||||
"start": 2.54,
|
||||
"end": 2.99
|
||||
},
|
||||
{
|
||||
"text": "the",
|
||||
"start": 2.99,
|
||||
"end": 3.12
|
||||
},
|
||||
{
|
||||
"text": "founders",
|
||||
"start": 3.12,
|
||||
"end": 3.65
|
||||
},
|
||||
{
|
||||
"text": "$250",
|
||||
"start": 3.75,
|
||||
"end": 4.71
|
||||
},
|
||||
{
|
||||
"text": "million",
|
||||
"start": 4.71,
|
||||
"end": 5.4
|
||||
},
|
||||
{
|
||||
"text": "if",
|
||||
"start": 5.44,
|
||||
"end": 5.6
|
||||
},
|
||||
{
|
||||
"text": "their",
|
||||
"start": 5.6,
|
||||
"end": 5.95
|
||||
},
|
||||
{
|
||||
"text": "next",
|
||||
"start": 5.95,
|
||||
"end": 6.23
|
||||
},
|
||||
{
|
||||
"text": "game",
|
||||
"start": 6.24,
|
||||
"end": 6.64
|
||||
},
|
||||
{
|
||||
"text": "hits",
|
||||
"start": 6.64,
|
||||
"end": 6.91
|
||||
},
|
||||
{
|
||||
"text": "certain",
|
||||
"start": 6.98,
|
||||
"end": 7.36
|
||||
},
|
||||
{
|
||||
"text": "targets.",
|
||||
"start": 7.36,
|
||||
"end": 7.98
|
||||
},
|
||||
{
|
||||
"text": "And",
|
||||
"start": 8.06,
|
||||
"end": 8.14
|
||||
},
|
||||
{
|
||||
"text": "at",
|
||||
"start": 8.14,
|
||||
"end": 8.25
|
||||
},
|
||||
{
|
||||
"text": "the",
|
||||
"start": 8.25,
|
||||
"end": 8.41
|
||||
},
|
||||
{
|
||||
"text": "time,",
|
||||
"start": 8.41,
|
||||
"end": 8.72
|
||||
},
|
||||
{
|
||||
"text": "he",
|
||||
"start": 8.72,
|
||||
"end": 8.82
|
||||
},
|
||||
{
|
||||
"text": "thought",
|
||||
"start": 8.83,
|
||||
"end": 9.19
|
||||
},
|
||||
{
|
||||
"text": "there",
|
||||
"start": 9.24,
|
||||
"end": 9.48
|
||||
},
|
||||
{
|
||||
"text": "was",
|
||||
"start": 9.48,
|
||||
"end": 9.63
|
||||
},
|
||||
{
|
||||
"text": "no",
|
||||
"start": 9.64,
|
||||
"end": 9.75
|
||||
},
|
||||
{
|
||||
"text": "way-",
|
||||
"start": 9.75,
|
||||
"end": 9.98
|
||||
}
|
||||
]
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "HDR Regression — HDR PQ (Data Drift)",
|
||||
"description": "1920x1080 native HDR composition with HDR video (BT.2020 PQ 10-bit), 16-bit HDR PNG photo, audio, captions, the Refik-Anadol-inspired Data Drift visual identity, and the @hyperframes/shader-transitions package driving Domain Warp / Gravitational Lens / Cross-Warp Morph handoffs. Renders with `--hdr` to exercise the rgb48le shader transition compositor, the PQ encode path, and the HDR side-data writer.",
|
||||
"tags": [
|
||||
"hdr-regression",
|
||||
"hdr",
|
||||
"pq",
|
||||
"bt2020",
|
||||
"10bit",
|
||||
"video",
|
||||
"audio",
|
||||
"image",
|
||||
"captions",
|
||||
"shader-transitions",
|
||||
"data-drift"
|
||||
],
|
||||
|
||||
"minPsnr": 26,
|
||||
"maxFrameFailures": 0,
|
||||
|
||||
"minAudioCorrelation": 0.85,
|
||||
"maxAudioLagWindows": 120,
|
||||
|
||||
"renderConfig": {
|
||||
"fps": 30
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
<template id="captions-template">
|
||||
<div
|
||||
data-composition-id="captions"
|
||||
data-start="0"
|
||||
data-duration="10.0"
|
||||
data-width="1920"
|
||||
data-height="1080"
|
||||
>
|
||||
<div id="captions-container"></div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
|
||||
<style>
|
||||
[data-composition-id="captions"] {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 1920px;
|
||||
height: 1080px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-end;
|
||||
padding-bottom: 130px;
|
||||
pointer-events: none;
|
||||
font-family: "Inter", system-ui, sans-serif;
|
||||
}
|
||||
|
||||
#captions-container {
|
||||
position: relative;
|
||||
width: 1280px;
|
||||
min-height: 110px;
|
||||
text-align: center;
|
||||
font-weight: 300;
|
||||
font-size: 44px;
|
||||
letter-spacing: 0.01em;
|
||||
color: rgba(244, 244, 245, 0.95);
|
||||
text-shadow:
|
||||
0 2px 18px rgba(0, 0, 0, 0.85),
|
||||
0 0 24px rgba(124, 58, 237, 0.18);
|
||||
}
|
||||
|
||||
.dd-chunk {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
opacity: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 4px 14px;
|
||||
}
|
||||
|
||||
.dd-word {
|
||||
opacity: 0;
|
||||
display: inline-block;
|
||||
transition: none;
|
||||
color: rgba(244, 244, 245, 0.95);
|
||||
will-change: opacity, color, transform;
|
||||
}
|
||||
|
||||
.dd-word.is-active {
|
||||
background: linear-gradient(120deg, #b794f4 0%, #06b6d4 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
(function () {
|
||||
const TRANSCRIPT = [
|
||||
{ "text": "the", "start": 0.1, "end": 0.31 },
|
||||
{ "text": "makers", "start": 0.31, "end": 0.86 },
|
||||
{ "text": "of", "start": 0.94, "end": 1.13 },
|
||||
{ "text": "Subnautica,", "start": 1.13, "end": 2.28 },
|
||||
{ "text": "and", "start": 2.28, "end": 2.44 },
|
||||
{ "text": "he", "start": 2.44, "end": 2.54 },
|
||||
{ "text": "promised", "start": 2.54, "end": 2.99 },
|
||||
{ "text": "the", "start": 2.99, "end": 3.12 },
|
||||
{ "text": "founders", "start": 3.12, "end": 3.65 },
|
||||
{ "text": "$250", "start": 3.75, "end": 4.71 },
|
||||
{ "text": "million", "start": 4.71, "end": 5.4 },
|
||||
{ "text": "if", "start": 5.44, "end": 5.6 },
|
||||
{ "text": "their", "start": 5.6, "end": 5.95 },
|
||||
{ "text": "next", "start": 5.95, "end": 6.23 },
|
||||
{ "text": "game", "start": 6.24, "end": 6.64 },
|
||||
{ "text": "hits", "start": 6.64, "end": 6.91 },
|
||||
{ "text": "certain", "start": 6.98, "end": 7.36 },
|
||||
{ "text": "targets.", "start": 7.36, "end": 7.98 },
|
||||
{ "text": "And", "start": 8.06, "end": 8.14 },
|
||||
{ "text": "at", "start": 8.14, "end": 8.25 },
|
||||
{ "text": "the", "start": 8.25, "end": 8.41 },
|
||||
{ "text": "time,", "start": 8.41, "end": 8.72 },
|
||||
{ "text": "he", "start": 8.72, "end": 8.82 },
|
||||
{ "text": "thought", "start": 8.83, "end": 9.19 },
|
||||
{ "text": "there", "start": 9.24, "end": 9.48 },
|
||||
{ "text": "was", "start": 9.48, "end": 9.63 },
|
||||
{ "text": "no", "start": 9.64, "end": 9.75 },
|
||||
{ "text": "way-", "start": 9.75, "end": 9.98 }
|
||||
];
|
||||
|
||||
const container = document.getElementById("captions-container");
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
|
||||
const chunks = [];
|
||||
let current = [];
|
||||
for (let i = 0; i < TRANSCRIPT.length; i++) {
|
||||
current.push(TRANSCRIPT[i]);
|
||||
if (current.length >= 4 || i === TRANSCRIPT.length - 1) {
|
||||
chunks.push(current);
|
||||
current = [];
|
||||
}
|
||||
}
|
||||
|
||||
chunks.forEach((chunk) => {
|
||||
const start = chunk[0].start;
|
||||
const end = chunk[chunk.length - 1].end;
|
||||
|
||||
const div = document.createElement("div");
|
||||
div.className = "dd-chunk";
|
||||
container.appendChild(div);
|
||||
|
||||
tl.fromTo(
|
||||
div,
|
||||
{ opacity: 0, y: 18, filter: "blur(8px)" },
|
||||
{ opacity: 1, y: 0, filter: "blur(0px)", duration: 0.4, ease: "power2.out" },
|
||||
start - 0.12,
|
||||
);
|
||||
|
||||
chunk.forEach((wordData, wordIndex) => {
|
||||
const w = document.createElement("span");
|
||||
w.className = "dd-word";
|
||||
w.innerText = wordData.text;
|
||||
div.appendChild(w);
|
||||
|
||||
tl.to(
|
||||
w,
|
||||
{ opacity: 1, duration: 0.18, ease: "sine.out" },
|
||||
wordData.start,
|
||||
);
|
||||
|
||||
tl.add(() => w.classList.add("is-active"), wordData.start);
|
||||
|
||||
const offTime =
|
||||
wordIndex < chunk.length - 1 ? chunk[wordIndex + 1].start : end;
|
||||
tl.add(() => w.classList.remove("is-active"), offTime);
|
||||
});
|
||||
|
||||
tl.to(
|
||||
div,
|
||||
{ opacity: 0, y: -12, filter: "blur(8px)", duration: 0.35, ease: "sine.in" },
|
||||
end + 0.05,
|
||||
);
|
||||
tl.set(div, { opacity: 0, visibility: "hidden" }, end + 0.4);
|
||||
});
|
||||
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__timelines["captions"] = tl;
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,160 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>HDR Regression — HDR PQ (Data Drift)</title>
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@hyperframes/[email protected]/dist/index.global.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div
|
||||
id="main"
|
||||
data-composition-id="main-comp"
|
||||
data-start="0"
|
||||
data-duration="10.0"
|
||||
data-width="1920"
|
||||
data-height="1080"
|
||||
>
|
||||
<!-- ============================================================
|
||||
Data Drift — five scenes, ~10s, GPU shader handoffs.
|
||||
Scene timings (after each shader transition completes):
|
||||
0.0 – 1.6 intro card
|
||||
2.1 – 4.0 HDR video A (transition 1.6, 0.5s)
|
||||
4.5 – 6.2 HDR still photograph (transition 4.0, 0.5s)
|
||||
6.7 – 8.4 HDR video B (transition 6.2, 0.5s)
|
||||
8.9 – 10.0 outro card (transition 8.4, 0.5s)
|
||||
Shader picks (Data Drift): Domain Warp, Gravitational
|
||||
Lens, Cross-Warp Morph, Domain Warp.
|
||||
============================================================ -->
|
||||
|
||||
<!-- Persistent ambient layers — sit behind the .scene stack so
|
||||
every shader capture inherits the Data Drift atmosphere -->
|
||||
<div class="drift-field"></div>
|
||||
<div class="scan-line top"></div>
|
||||
<div class="scan-line bottom"></div>
|
||||
|
||||
<!-- Scene 1: cold open title card -->
|
||||
<section id="scene-intro" class="scene title-card">
|
||||
<div class="eyebrow">Hyperframes · HDR Regression Suite</div>
|
||||
<h1 class="display">
|
||||
A study in <span class="accent">light</span>,<br />unbounded.
|
||||
</h1>
|
||||
<div class="colophon">
|
||||
<span>Volume II</span>
|
||||
<span>HDR · BT.2020 · PQ</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Scene 2: HDR video A -->
|
||||
<section id="scene-video-a" class="scene media-frame">
|
||||
<video
|
||||
id="vid-hdr-1"
|
||||
class="clip"
|
||||
src="../assets/hdr-source-1.mp4"
|
||||
data-start="2.1"
|
||||
data-duration="1.9"
|
||||
muted
|
||||
playsinline
|
||||
></video>
|
||||
<div class="iridescent-frame"></div>
|
||||
<div class="hud top-left">
|
||||
<div class="accent-cyan">Plate I</div>
|
||||
<div>HDR · 10-bit</div>
|
||||
</div>
|
||||
<div class="hud top-right">
|
||||
<div class="accent-purple">SMPTE 2084</div>
|
||||
<div>BT.2020 / PQ</div>
|
||||
</div>
|
||||
<div class="hud-readout">
|
||||
1000<span class="unit">Peak nits</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Scene 3: HDR still photograph (16-bit PNG) -->
|
||||
<section id="scene-still" class="scene media-frame">
|
||||
<img
|
||||
id="img-hdr"
|
||||
src="../assets/photo-hdr.png"
|
||||
alt="HDR reference photograph"
|
||||
/>
|
||||
<div class="iridescent-frame"></div>
|
||||
<div class="hud top-left">
|
||||
<div class="accent-cyan">Plate II</div>
|
||||
<div>16-bit RGB</div>
|
||||
</div>
|
||||
<div class="hud top-right">
|
||||
<div class="accent-purple">PNG · linear-light</div>
|
||||
<div>HDR reference</div>
|
||||
</div>
|
||||
<div class="hud-readout">
|
||||
16<span class="unit">bits per channel</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Scene 4: HDR video B -->
|
||||
<section id="scene-video-b" class="scene media-frame">
|
||||
<video
|
||||
id="vid-hdr-2"
|
||||
class="clip"
|
||||
src="../assets/hdr-source-2.mp4"
|
||||
data-start="6.7"
|
||||
data-duration="1.7"
|
||||
muted
|
||||
playsinline
|
||||
></video>
|
||||
<div class="iridescent-frame"></div>
|
||||
<div class="hud top-left">
|
||||
<div class="accent-cyan">Plate III</div>
|
||||
<div>HDR · 10-bit</div>
|
||||
</div>
|
||||
<div class="hud top-right">
|
||||
<div class="accent-purple">Studio cut</div>
|
||||
<div>BT.2020 / PQ</div>
|
||||
</div>
|
||||
<div class="hud-readout">
|
||||
∞<span class="unit">specular highlights</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Scene 5: closing card -->
|
||||
<section id="scene-outro" class="scene title-card">
|
||||
<div class="eyebrow">Composition · Data Drift</div>
|
||||
<h1 class="display">
|
||||
<span class="accent">HDR</span> ·<br />the wider gamut.
|
||||
</h1>
|
||||
<div class="colophon">
|
||||
<span>BT.2020 · PQ · 10-bit</span>
|
||||
<span>End of Volume II</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================
|
||||
Voiceover — single audio track for the entire composition
|
||||
============================================================ -->
|
||||
<audio
|
||||
id="vo"
|
||||
src="../assets/voiceover.mp3"
|
||||
data-start="0"
|
||||
data-duration="10.0"
|
||||
></audio>
|
||||
|
||||
<!-- ============================================================
|
||||
Captions — sub-composition, lower-third, Data Drift styling
|
||||
============================================================ -->
|
||||
<div
|
||||
id="captions-comp"
|
||||
data-composition-id="captions"
|
||||
data-composition-src="compositions/captions.html"
|
||||
data-start="0"
|
||||
data-duration="10.0"
|
||||
></div>
|
||||
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
</script>
|
||||
<script src="script.js"></script>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,158 @@
|
||||
/* Data Drift — five-scene HDR PQ composition.
|
||||
|
||||
Scene visibility / handoff is owned entirely by the
|
||||
@hyperframes/shader-transitions package. We build a paused
|
||||
timeline of *internal* scene motion only (text reveals, video
|
||||
Ken-Burns, HUD drifts) and hand it to HyperShader.init() —
|
||||
the library appends the GL transitions and exposes the
|
||||
shader metadata on window.__hf.transitions so the HDR engine
|
||||
can reproduce each transition in rgb48le pixel space.
|
||||
|
||||
Motion rules: sine.inOut and power2.out only. Nothing snaps,
|
||||
everything drifts. */
|
||||
|
||||
(function () {
|
||||
const tl = gsap.timeline({ paused: true, defaults: { ease: "sine.inOut" } });
|
||||
|
||||
/* ---------- Scene 1 · Intro (0.00 – 1.60) ---------- */
|
||||
const introBrow = document.querySelector("#scene-intro .eyebrow");
|
||||
const introDisplay = document.querySelector("#scene-intro .display");
|
||||
const introFoot = document.querySelector("#scene-intro .colophon");
|
||||
if (introBrow && introDisplay && introFoot) {
|
||||
tl.from(
|
||||
introBrow,
|
||||
{ y: 18, opacity: 0, duration: 0.85, ease: "power2.out" },
|
||||
0.05,
|
||||
);
|
||||
tl.from(
|
||||
introDisplay,
|
||||
{ y: 36, opacity: 0, filter: "blur(12px)", duration: 1.05, ease: "power2.out" },
|
||||
0.22,
|
||||
);
|
||||
tl.from(
|
||||
introFoot,
|
||||
{ y: 12, opacity: 0, duration: 0.7, ease: "power2.out" },
|
||||
0.55,
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- Scene 2 · HDR video A (2.10 – 4.00) ----------
|
||||
The video starts playing 0.5s after the shader cuts in
|
||||
(the lib transitions 1.6 → 2.1) so the scene becomes visible
|
||||
just as the first frame plays. Subtle Ken Burns push-in. */
|
||||
const vidA = document.getElementById("vid-hdr-1");
|
||||
if (vidA) {
|
||||
tl.fromTo(
|
||||
vidA,
|
||||
{ scale: 1.07 },
|
||||
{ scale: 1.0, duration: 1.9, ease: "sine.out" },
|
||||
2.1,
|
||||
);
|
||||
}
|
||||
const hudA = document.querySelectorAll("#scene-video-a .hud, #scene-video-a .hud-readout");
|
||||
if (hudA.length) {
|
||||
tl.from(
|
||||
hudA,
|
||||
{ opacity: 0, y: 8, duration: 0.6, ease: "power2.out", stagger: 0.08 },
|
||||
2.25,
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- Scene 3 · HDR still (4.50 – 6.20) ----------
|
||||
16-bit PNG. Slow horizontal drift to give the still motion. */
|
||||
const still = document.getElementById("img-hdr");
|
||||
if (still) {
|
||||
tl.fromTo(
|
||||
still,
|
||||
{ scale: 1.1, x: -22 },
|
||||
{ scale: 1.02, x: 16, duration: 1.7, ease: "sine.inOut" },
|
||||
4.5,
|
||||
);
|
||||
}
|
||||
const hudB = document.querySelectorAll("#scene-still .hud, #scene-still .hud-readout");
|
||||
if (hudB.length) {
|
||||
tl.from(
|
||||
hudB,
|
||||
{ opacity: 0, y: 8, duration: 0.55, ease: "power2.out", stagger: 0.08 },
|
||||
4.65,
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- Scene 4 · HDR video B (6.70 – 8.40) ----------
|
||||
Pull-back, opposite Ken Burns. */
|
||||
const vidB = document.getElementById("vid-hdr-2");
|
||||
if (vidB) {
|
||||
tl.fromTo(
|
||||
vidB,
|
||||
{ scale: 1.0 },
|
||||
{ scale: 1.07, duration: 1.7, ease: "sine.inOut" },
|
||||
6.7,
|
||||
);
|
||||
}
|
||||
const hudC = document.querySelectorAll("#scene-video-b .hud, #scene-video-b .hud-readout");
|
||||
if (hudC.length) {
|
||||
tl.from(
|
||||
hudC,
|
||||
{ opacity: 0, y: 8, duration: 0.55, ease: "power2.out", stagger: 0.08 },
|
||||
6.85,
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- Scene 5 · Outro (8.90 – 10.00) ---------- */
|
||||
const outroBrow = document.querySelector("#scene-outro .eyebrow");
|
||||
const outroDisplay = document.querySelector("#scene-outro .display");
|
||||
const outroFoot = document.querySelector("#scene-outro .colophon");
|
||||
if (outroBrow && outroDisplay && outroFoot) {
|
||||
tl.from(
|
||||
outroBrow,
|
||||
{ y: 14, opacity: 0, duration: 0.65, ease: "power2.out" },
|
||||
8.95,
|
||||
);
|
||||
tl.from(
|
||||
outroDisplay,
|
||||
{ y: 28, opacity: 0, filter: "blur(10px)", duration: 0.85, ease: "power2.out" },
|
||||
9.05,
|
||||
);
|
||||
tl.from(
|
||||
outroFoot,
|
||||
{ y: 10, opacity: 0, duration: 0.55, ease: "power2.out" },
|
||||
9.45,
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- Shader transitions ----------
|
||||
init() reads __hf if present (set by the HDR engine), wires
|
||||
transitions into the timeline at the requested times, and
|
||||
registers the timeline on window.__timelines under the
|
||||
compositionId we pass. */
|
||||
if (typeof HyperShader !== "undefined" && HyperShader.init) {
|
||||
HyperShader.init({
|
||||
bgColor: "#0a0a0a",
|
||||
accentColor: "#7c3aed",
|
||||
compositionId: "main-comp",
|
||||
scenes: [
|
||||
"scene-intro",
|
||||
"scene-video-a",
|
||||
"scene-still",
|
||||
"scene-video-b",
|
||||
"scene-outro",
|
||||
],
|
||||
transitions: [
|
||||
{ time: 1.6, shader: "domain-warp", duration: 0.5, ease: "sine.inOut" },
|
||||
{ time: 4.0, shader: "gravitational-lens", duration: 0.5, ease: "sine.inOut" },
|
||||
{ time: 6.2, shader: "cross-warp-morph", duration: 0.5, ease: "sine.inOut" },
|
||||
{ time: 8.4, shader: "domain-warp", duration: 0.5, ease: "sine.inOut" },
|
||||
],
|
||||
timeline: tl,
|
||||
});
|
||||
}
|
||||
|
||||
/* When init() runs above it registers the timeline for us. If
|
||||
HyperShader is unavailable (e.g. CDN miss), fall back to a
|
||||
manual registration so the scene-internal motion still
|
||||
plays — the engine will then composite scenes as hard cuts. */
|
||||
window.__timelines = window.__timelines || {};
|
||||
if (!window.__timelines["main-comp"]) {
|
||||
window.__timelines["main-comp"] = tl;
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,277 @@
|
||||
/* ============================================================
|
||||
Data Drift — Refik Anadol-inspired
|
||||
Deep black backdrop, electric purple + cyan gradients,
|
||||
floating weightless typography. The composition is built
|
||||
for shader transitions so each scene handoff is owned by
|
||||
the GL canvas, not by CSS animation.
|
||||
============================================================ */
|
||||
|
||||
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@200;300;400;500&family=JetBrains+Mono:wght@300;500&display=swap");
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #0a0a0a;
|
||||
font-family: "Inter", system-ui, sans-serif;
|
||||
font-weight: 300;
|
||||
color: #f4f4f5;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#main {
|
||||
position: relative;
|
||||
width: 1920px;
|
||||
height: 1080px;
|
||||
background: #0a0a0a;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Persistent ambient backdrop visible through every scene's gaps.
|
||||
Two soft radial gradients sit behind everything — purple in the
|
||||
upper-left and cyan in the lower-right — establishing the Data
|
||||
Drift palette before any text appears. */
|
||||
#main::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at 22% 28%,
|
||||
rgba(124, 58, 237, 0.45) 0%,
|
||||
rgba(124, 58, 237, 0.0) 55%
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 78% 76%,
|
||||
rgba(6, 182, 212, 0.40) 0%,
|
||||
rgba(6, 182, 212, 0.0) 55%
|
||||
);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ---------- Scene container baseline ---------- */
|
||||
|
||||
.scene {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1;
|
||||
/* All scenes start hidden; the shader-transitions library will
|
||||
temporarily reveal them for capture, then promote each to
|
||||
opacity:1 as its transition completes. The intro overrides
|
||||
this below so frame 0 is non-empty. */
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
#scene-intro {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ---------- Title cards (intro + outro) ---------- */
|
||||
|
||||
.title-card {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
padding: 0 200px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.42em;
|
||||
text-transform: uppercase;
|
||||
color: #06b6d4;
|
||||
margin-bottom: 56px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.eyebrow::before,
|
||||
.eyebrow::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: 90px;
|
||||
height: 1px;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
transparent,
|
||||
rgba(6, 182, 212, 0.7),
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
.eyebrow::before {
|
||||
right: calc(100% + 28px);
|
||||
}
|
||||
|
||||
.eyebrow::after {
|
||||
left: calc(100% + 28px);
|
||||
}
|
||||
|
||||
.display {
|
||||
font-family: "Inter", sans-serif;
|
||||
font-size: 132px;
|
||||
font-weight: 200;
|
||||
line-height: 1.04;
|
||||
letter-spacing: -0.02em;
|
||||
color: #fafafa;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.display .accent {
|
||||
background: linear-gradient(120deg, #7c3aed 0%, #06b6d4 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.colophon {
|
||||
margin-top: 64px;
|
||||
display: flex;
|
||||
gap: 64px;
|
||||
justify-content: center;
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
font-size: 12px;
|
||||
font-weight: 300;
|
||||
letter-spacing: 0.22em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(244, 244, 245, 0.55);
|
||||
}
|
||||
|
||||
/* Soft outer scan lines (decorative HUD) */
|
||||
.scan-line {
|
||||
position: absolute;
|
||||
left: 80px;
|
||||
right: 80px;
|
||||
height: 1px;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
transparent,
|
||||
rgba(124, 58, 237, 0.35),
|
||||
rgba(6, 182, 212, 0.35),
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
.scan-line.top {
|
||||
top: 80px;
|
||||
}
|
||||
|
||||
.scan-line.bottom {
|
||||
bottom: 80px;
|
||||
}
|
||||
|
||||
/* ---------- Media-frame scenes ---------- */
|
||||
|
||||
.media-frame {
|
||||
background: #050505;
|
||||
}
|
||||
|
||||
.media-frame video,
|
||||
.media-frame img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
}
|
||||
|
||||
/* Subtle iridescent edge vignette so the HDR media reads as a
|
||||
"framed" plate inside the composition, not a fullscreen cut. */
|
||||
.iridescent-frame {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(124, 58, 237, 0.18),
|
||||
inset 0 0 220px rgba(0, 0, 0, 0.55),
|
||||
inset 0 0 60px rgba(6, 182, 212, 0.10);
|
||||
}
|
||||
|
||||
/* HUD overlays — minimal, weightless, JetBrains Mono. */
|
||||
.hud {
|
||||
position: absolute;
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
font-size: 11px;
|
||||
font-weight: 300;
|
||||
letter-spacing: 0.22em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(244, 244, 245, 0.78);
|
||||
}
|
||||
|
||||
.hud.top-left {
|
||||
top: 56px;
|
||||
left: 56px;
|
||||
}
|
||||
|
||||
.hud.top-right {
|
||||
top: 56px;
|
||||
right: 56px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.hud.bottom-left {
|
||||
bottom: 56px;
|
||||
left: 56px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.hud .accent-cyan {
|
||||
color: #06b6d4;
|
||||
}
|
||||
|
||||
.hud .accent-purple {
|
||||
color: #b794f4;
|
||||
}
|
||||
|
||||
.hud-readout {
|
||||
position: absolute;
|
||||
bottom: 56px;
|
||||
right: 56px;
|
||||
text-align: right;
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
font-size: 56px;
|
||||
font-weight: 300;
|
||||
letter-spacing: 0.04em;
|
||||
color: #fafafa;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.hud-readout .unit {
|
||||
display: block;
|
||||
margin-top: 14px;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.32em;
|
||||
color: rgba(6, 182, 212, 0.85);
|
||||
}
|
||||
|
||||
/* ---------- Particle field (CSS only, deterministic) ---------- */
|
||||
/* A drifting dot pattern on top of the ambient gradient. We keep
|
||||
this as an inline SVG background so it serializes cleanly into
|
||||
html2canvas captures the shader transition will sample. */
|
||||
|
||||
.drift-field {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
opacity: 0.45;
|
||||
background-image: radial-gradient(
|
||||
rgba(124, 58, 237, 0.55) 1px,
|
||||
transparent 1.5px
|
||||
),
|
||||
radial-gradient(rgba(6, 182, 212, 0.45) 1px, transparent 1.5px);
|
||||
background-size: 96px 96px, 168px 168px;
|
||||
background-position: 0 0, 40px 40px;
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
../../hdr-pq/assets/hdr-source-2.mp4
|
||||
@@ -0,0 +1 @@
|
||||
../../hdr-pq/assets/hdr-source-1.mp4
|
||||
@@ -0,0 +1 @@
|
||||
../../hdr-pq/assets/photo-hdr.png
|
||||
@@ -0,0 +1 @@
|
||||
../../sdr-baseline/assets/photo-sdr.jpg
|
||||
@@ -0,0 +1 @@
|
||||
../../sdr-baseline/assets/sdr-source-2.mp4
|
||||
@@ -0,0 +1 @@
|
||||
../../sdr-baseline/assets/sdr-source-1.mp4
|
||||
@@ -0,0 +1,142 @@
|
||||
[
|
||||
{
|
||||
"text": "the",
|
||||
"start": 0.1,
|
||||
"end": 0.31
|
||||
},
|
||||
{
|
||||
"text": "makers",
|
||||
"start": 0.31,
|
||||
"end": 0.86
|
||||
},
|
||||
{
|
||||
"text": "of",
|
||||
"start": 0.94,
|
||||
"end": 1.13
|
||||
},
|
||||
{
|
||||
"text": "Subnautica,",
|
||||
"start": 1.13,
|
||||
"end": 2.28
|
||||
},
|
||||
{
|
||||
"text": "and",
|
||||
"start": 2.28,
|
||||
"end": 2.44
|
||||
},
|
||||
{
|
||||
"text": "he",
|
||||
"start": 2.44,
|
||||
"end": 2.54
|
||||
},
|
||||
{
|
||||
"text": "promised",
|
||||
"start": 2.54,
|
||||
"end": 2.99
|
||||
},
|
||||
{
|
||||
"text": "the",
|
||||
"start": 2.99,
|
||||
"end": 3.12
|
||||
},
|
||||
{
|
||||
"text": "founders",
|
||||
"start": 3.12,
|
||||
"end": 3.65
|
||||
},
|
||||
{
|
||||
"text": "$250",
|
||||
"start": 3.75,
|
||||
"end": 4.71
|
||||
},
|
||||
{
|
||||
"text": "million",
|
||||
"start": 4.71,
|
||||
"end": 5.4
|
||||
},
|
||||
{
|
||||
"text": "if",
|
||||
"start": 5.44,
|
||||
"end": 5.6
|
||||
},
|
||||
{
|
||||
"text": "their",
|
||||
"start": 5.6,
|
||||
"end": 5.95
|
||||
},
|
||||
{
|
||||
"text": "next",
|
||||
"start": 5.95,
|
||||
"end": 6.23
|
||||
},
|
||||
{
|
||||
"text": "game",
|
||||
"start": 6.24,
|
||||
"end": 6.64
|
||||
},
|
||||
{
|
||||
"text": "hits",
|
||||
"start": 6.64,
|
||||
"end": 6.91
|
||||
},
|
||||
{
|
||||
"text": "certain",
|
||||
"start": 6.98,
|
||||
"end": 7.36
|
||||
},
|
||||
{
|
||||
"text": "targets.",
|
||||
"start": 7.36,
|
||||
"end": 7.98
|
||||
},
|
||||
{
|
||||
"text": "And",
|
||||
"start": 8.06,
|
||||
"end": 8.14
|
||||
},
|
||||
{
|
||||
"text": "at",
|
||||
"start": 8.14,
|
||||
"end": 8.25
|
||||
},
|
||||
{
|
||||
"text": "the",
|
||||
"start": 8.25,
|
||||
"end": 8.41
|
||||
},
|
||||
{
|
||||
"text": "time,",
|
||||
"start": 8.41,
|
||||
"end": 8.72
|
||||
},
|
||||
{
|
||||
"text": "he",
|
||||
"start": 8.72,
|
||||
"end": 8.82
|
||||
},
|
||||
{
|
||||
"text": "thought",
|
||||
"start": 8.83,
|
||||
"end": 9.19
|
||||
},
|
||||
{
|
||||
"text": "there",
|
||||
"start": 9.24,
|
||||
"end": 9.48
|
||||
},
|
||||
{
|
||||
"text": "was",
|
||||
"start": 9.48,
|
||||
"end": 9.63
|
||||
},
|
||||
{
|
||||
"text": "no",
|
||||
"start": 9.64,
|
||||
"end": 9.75
|
||||
},
|
||||
{
|
||||
"text": "way-",
|
||||
"start": 9.75,
|
||||
"end": 9.98
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
../../sdr-baseline/assets/voiceover.mp3
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "HDR Regression — Mixed SDR + HDR (Shadow Cut)",
|
||||
"description": "1920x1080 composition that mixes SDR (BT.709 8-bit) and HDR (BT.2020 PQ 10-bit) sources in the same scene stack — two SDR videos, two HDR videos, an SDR JPEG and a 16-bit HDR PNG, plus voiceover and captions. Renders twice through the regression harness: once SDR (BT.709 output, every HDR source must be tone-mapped down) and once with `--hdr` (BT.2020 PQ output, every SDR source must be promoted up). Exercises the engine's per-asset transfer auto-detection, the rgb48le cross-transfer compositor, and the Domain Warp shader transition in both SDR and HDR pixel spaces. Visual identity is Hans-Hillmann-inspired Shadow Cut.",
|
||||
"tags": [
|
||||
"hdr-regression",
|
||||
"mixed",
|
||||
"sdr",
|
||||
"hdr",
|
||||
"pq",
|
||||
"bt2020",
|
||||
"bt709",
|
||||
"auto-detect",
|
||||
"cross-transfer",
|
||||
"video",
|
||||
"audio",
|
||||
"image",
|
||||
"captions",
|
||||
"shader-transitions",
|
||||
"shadow-cut"
|
||||
],
|
||||
|
||||
"minPsnr": 26,
|
||||
"maxFrameFailures": 0,
|
||||
|
||||
"minAudioCorrelation": 0.85,
|
||||
"maxAudioLagWindows": 120,
|
||||
|
||||
"renderConfig": {
|
||||
"fps": 30
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
<template id="captions-template">
|
||||
<div
|
||||
data-composition-id="captions"
|
||||
data-start="0"
|
||||
data-duration="10.0"
|
||||
data-width="1920"
|
||||
data-height="1080"
|
||||
>
|
||||
<div id="captions-container"></div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
|
||||
<style>
|
||||
[data-composition-id="captions"] {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 1920px;
|
||||
height: 1080px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-end;
|
||||
padding-bottom: 150px;
|
||||
pointer-events: none;
|
||||
font-family: "Inter", system-ui, sans-serif;
|
||||
}
|
||||
|
||||
#captions-container {
|
||||
position: relative;
|
||||
width: 1320px;
|
||||
min-height: 130px;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
font-size: 46px;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
color: #fafafa;
|
||||
text-shadow:
|
||||
0 4px 22px rgba(0, 0, 0, 0.95),
|
||||
0 0 36px rgba(0, 0, 0, 0.85),
|
||||
0 0 1px rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
|
||||
.sc-chunk {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
opacity: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 6px 18px;
|
||||
}
|
||||
|
||||
.sc-word {
|
||||
opacity: 0;
|
||||
display: inline-block;
|
||||
transition: none;
|
||||
color: rgba(244, 244, 244, 0.92);
|
||||
will-change: opacity, color, transform;
|
||||
}
|
||||
|
||||
.sc-word.is-active {
|
||||
color: #C1121F;
|
||||
text-shadow:
|
||||
0 4px 22px rgba(0, 0, 0, 0.95),
|
||||
0 0 24px rgba(193, 18, 31, 0.55),
|
||||
0 0 1px rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
(function () {
|
||||
const TRANSCRIPT = [
|
||||
{ "text": "the", "start": 0.1, "end": 0.31 },
|
||||
{ "text": "makers", "start": 0.31, "end": 0.86 },
|
||||
{ "text": "of", "start": 0.94, "end": 1.13 },
|
||||
{ "text": "Subnautica,", "start": 1.13, "end": 2.28 },
|
||||
{ "text": "and", "start": 2.28, "end": 2.44 },
|
||||
{ "text": "he", "start": 2.44, "end": 2.54 },
|
||||
{ "text": "promised", "start": 2.54, "end": 2.99 },
|
||||
{ "text": "the", "start": 2.99, "end": 3.12 },
|
||||
{ "text": "founders", "start": 3.12, "end": 3.65 },
|
||||
{ "text": "$250", "start": 3.75, "end": 4.71 },
|
||||
{ "text": "million", "start": 4.71, "end": 5.4 },
|
||||
{ "text": "if", "start": 5.44, "end": 5.6 },
|
||||
{ "text": "their", "start": 5.6, "end": 5.95 },
|
||||
{ "text": "next", "start": 5.95, "end": 6.23 },
|
||||
{ "text": "game", "start": 6.24, "end": 6.64 },
|
||||
{ "text": "hits", "start": 6.64, "end": 6.91 },
|
||||
{ "text": "certain", "start": 6.98, "end": 7.36 },
|
||||
{ "text": "targets.", "start": 7.36, "end": 7.98 },
|
||||
{ "text": "And", "start": 8.06, "end": 8.14 },
|
||||
{ "text": "at", "start": 8.14, "end": 8.25 },
|
||||
{ "text": "the", "start": 8.25, "end": 8.41 },
|
||||
{ "text": "time,", "start": 8.41, "end": 8.72 },
|
||||
{ "text": "he", "start": 8.72, "end": 8.82 },
|
||||
{ "text": "thought", "start": 8.83, "end": 9.19 },
|
||||
{ "text": "there", "start": 9.24, "end": 9.48 },
|
||||
{ "text": "was", "start": 9.48, "end": 9.63 },
|
||||
{ "text": "no", "start": 9.64, "end": 9.75 },
|
||||
{ "text": "way-", "start": 9.75, "end": 9.98 }
|
||||
];
|
||||
|
||||
const container = document.getElementById("captions-container");
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
|
||||
const chunks = [];
|
||||
let current = [];
|
||||
for (let i = 0; i < TRANSCRIPT.length; i++) {
|
||||
current.push(TRANSCRIPT[i]);
|
||||
if (current.length >= 4 || i === TRANSCRIPT.length - 1) {
|
||||
chunks.push(current);
|
||||
current = [];
|
||||
}
|
||||
}
|
||||
|
||||
chunks.forEach((chunk) => {
|
||||
const start = chunk[0].start;
|
||||
const end = chunk[chunk.length - 1].end;
|
||||
|
||||
const div = document.createElement("div");
|
||||
div.className = "sc-chunk";
|
||||
container.appendChild(div);
|
||||
|
||||
tl.fromTo(
|
||||
div,
|
||||
{ opacity: 0, y: 16, filter: "blur(6px)" },
|
||||
{ opacity: 1, y: 0, filter: "blur(0px)", duration: 0.32, ease: "power3.out" },
|
||||
start - 0.1,
|
||||
);
|
||||
|
||||
chunk.forEach((wordData, wordIndex) => {
|
||||
const w = document.createElement("span");
|
||||
w.className = "sc-word";
|
||||
w.innerText = wordData.text;
|
||||
div.appendChild(w);
|
||||
|
||||
tl.to(
|
||||
w,
|
||||
{ opacity: 1, duration: 0.16, ease: "power2.out" },
|
||||
wordData.start,
|
||||
);
|
||||
|
||||
tl.add(() => w.classList.add("is-active"), wordData.start);
|
||||
|
||||
const offTime =
|
||||
wordIndex < chunk.length - 1 ? chunk[wordIndex + 1].start : end;
|
||||
tl.add(() => w.classList.remove("is-active"), offTime);
|
||||
});
|
||||
|
||||
tl.to(
|
||||
div,
|
||||
{ opacity: 0, y: -10, filter: "blur(6px)", duration: 0.3, ease: "power4.in" },
|
||||
end + 0.05,
|
||||
);
|
||||
tl.set(div, { opacity: 0, visibility: "hidden" }, end + 0.36);
|
||||
});
|
||||
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__timelines["captions"] = tl;
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,206 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>HDR Regression — Mixed SDR + HDR (Shadow Cut)</title>
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@hyperframes/[email protected]/dist/index.global.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div
|
||||
id="main"
|
||||
data-composition-id="main-comp"
|
||||
data-start="0"
|
||||
data-duration="10.0"
|
||||
data-width="1920"
|
||||
data-height="1080"
|
||||
>
|
||||
<!-- ============================================================
|
||||
Shadow Cut — six scenes, ~10s, every handoff is Domain Warp.
|
||||
This composition deliberately interleaves SDR and HDR sources
|
||||
so the engine has to auto-detect each plate's transfer fn
|
||||
and cross-convert into the working space:
|
||||
|
||||
SDR render → all HDR plates tonemap PQ → BT.709
|
||||
--hdr render → all SDR plates promote BT.709 → PQ
|
||||
|
||||
Scene timings (after each shader transition completes):
|
||||
0.00 – 1.40 intro card
|
||||
1.85 – 3.20 SDR video A (cross-warp: into PQ space)
|
||||
3.65 – 5.00 HDR video A (native PQ)
|
||||
5.45 – 6.50 SDR still (BT.709 promoted)
|
||||
6.95 – 8.00 HDR still (16-bit PNG, native HDR)
|
||||
8.45 – 10.00 outro card
|
||||
|
||||
Shader picks (Shadow Cut): Domain Warp on every handoff.
|
||||
============================================================ -->
|
||||
|
||||
<!-- Persistent darkness layers — sit behind the .scene stack so
|
||||
every shader capture inherits the noir atmosphere. -->
|
||||
<div class="bar top"></div>
|
||||
<div class="bar bottom"></div>
|
||||
|
||||
<!-- Scene 1: cold open title card -->
|
||||
<section id="scene-intro" class="scene title-card">
|
||||
<div class="classification">Restricted · HDR Regression</div>
|
||||
<h1 class="display">
|
||||
THE <span class="accent">SDR / HDR</span><br />HANDOFF
|
||||
</h1>
|
||||
<div class="subdisplay">A study in mixed transfer functions</div>
|
||||
<div class="colophon">
|
||||
<span>Case File · Volume III</span>
|
||||
<span>BT.709 ⇄ BT.2020 PQ</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Scene 2: SDR video A -->
|
||||
<section id="scene-sdr-vid" class="scene media-frame">
|
||||
<video
|
||||
id="vid-sdr"
|
||||
class="clip"
|
||||
src="../assets/sdr.mp4"
|
||||
data-start="1.85"
|
||||
data-duration="1.35"
|
||||
muted
|
||||
playsinline
|
||||
></video>
|
||||
<div class="shadow-vignette"></div>
|
||||
<div class="corner-tick top-left"></div>
|
||||
<div class="corner-tick top-right"></div>
|
||||
<div class="corner-tick bottom-left"></div>
|
||||
<div class="corner-tick bottom-right"></div>
|
||||
<div class="evidence top-left">
|
||||
<div class="red">Plate I · Source A</div>
|
||||
<div>SDR · 8-bit · BT.709</div>
|
||||
</div>
|
||||
<div class="evidence top-right">
|
||||
<div class="red">Transfer / sRGB</div>
|
||||
<div>Auto-detected</div>
|
||||
</div>
|
||||
<div class="case-label">
|
||||
709<span class="label">REC. ITU-R</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Scene 3: HDR video A -->
|
||||
<section id="scene-hdr-vid" class="scene media-frame">
|
||||
<video
|
||||
id="vid-hdr"
|
||||
class="clip"
|
||||
src="../assets/hdr.mp4"
|
||||
data-start="3.65"
|
||||
data-duration="1.35"
|
||||
muted
|
||||
playsinline
|
||||
></video>
|
||||
<div class="shadow-vignette"></div>
|
||||
<div class="corner-tick top-left"></div>
|
||||
<div class="corner-tick top-right"></div>
|
||||
<div class="corner-tick bottom-left"></div>
|
||||
<div class="corner-tick bottom-right"></div>
|
||||
<div class="evidence top-left">
|
||||
<div class="red">Plate II · Source B</div>
|
||||
<div>HDR · 10-bit · BT.2020</div>
|
||||
</div>
|
||||
<div class="evidence top-right">
|
||||
<div class="red">Transfer / SMPTE 2084</div>
|
||||
<div>Auto-detected</div>
|
||||
</div>
|
||||
<div class="case-label">
|
||||
2084<span class="label">SMPTE PQ</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Scene 4: SDR still photograph -->
|
||||
<section id="scene-sdr-still" class="scene media-frame">
|
||||
<img
|
||||
id="img-sdr"
|
||||
src="../assets/photo-sdr.jpg"
|
||||
alt="SDR reference photograph"
|
||||
/>
|
||||
<div class="shadow-vignette"></div>
|
||||
<div class="corner-tick top-left"></div>
|
||||
<div class="corner-tick top-right"></div>
|
||||
<div class="corner-tick bottom-left"></div>
|
||||
<div class="corner-tick bottom-right"></div>
|
||||
<div class="evidence top-left">
|
||||
<div class="red">Plate III · Stills</div>
|
||||
<div>JPEG · 8-bit · sRGB</div>
|
||||
</div>
|
||||
<div class="evidence top-right">
|
||||
<div class="red">Source / archival</div>
|
||||
<div>Auto-detected</div>
|
||||
</div>
|
||||
<div class="case-label">
|
||||
JPEG<span class="label">8-BIT sRGB</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Scene 5: HDR still photograph (16-bit PNG) -->
|
||||
<section id="scene-hdr-still" class="scene media-frame">
|
||||
<img
|
||||
id="img-hdr"
|
||||
src="../assets/photo-hdr.png"
|
||||
alt="HDR reference photograph"
|
||||
/>
|
||||
<div class="shadow-vignette"></div>
|
||||
<div class="corner-tick top-left"></div>
|
||||
<div class="corner-tick top-right"></div>
|
||||
<div class="corner-tick bottom-left"></div>
|
||||
<div class="corner-tick bottom-right"></div>
|
||||
<div class="evidence top-left">
|
||||
<div class="red">Plate IV · Stills</div>
|
||||
<div>PNG · 16-bit · linear</div>
|
||||
</div>
|
||||
<div class="evidence top-right">
|
||||
<div class="red">Source / reference</div>
|
||||
<div>HDR · BT.2020</div>
|
||||
</div>
|
||||
<div class="case-label">
|
||||
PNG16<span class="label">LINEAR LIGHT</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Scene 6: closing card -->
|
||||
<section id="scene-outro" class="scene title-card">
|
||||
<div class="classification">End of File · Shadow Cut</div>
|
||||
<h1 class="display">
|
||||
ONE PIPELINE.<br /><span class="accent">TWO LIGHTS.</span>
|
||||
</h1>
|
||||
<div class="subdisplay">Auto-detected. Cross-converted. Composited.</div>
|
||||
<div class="colophon">
|
||||
<span>Volume III</span>
|
||||
<span>End of Case File</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================
|
||||
Voiceover — one audio track for the full composition
|
||||
============================================================ -->
|
||||
<audio
|
||||
id="vo"
|
||||
src="../assets/voiceover.mp3"
|
||||
data-start="0"
|
||||
data-duration="10.0"
|
||||
></audio>
|
||||
|
||||
<!-- ============================================================
|
||||
Captions — sub-composition, lower-third, Shadow Cut styling
|
||||
============================================================ -->
|
||||
<div
|
||||
id="captions-comp"
|
||||
data-composition-id="captions"
|
||||
data-composition-src="compositions/captions.html"
|
||||
data-start="0"
|
||||
data-duration="10.0"
|
||||
></div>
|
||||
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
</script>
|
||||
<script src="script.js"></script>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,252 @@
|
||||
/* Shadow Cut — six-scene mixed-transfer composition.
|
||||
|
||||
Scene visibility / handoffs are owned entirely by the
|
||||
@hyperframes/shader-transitions library (Domain Warp on every
|
||||
cut, per the Shadow Cut style guide). We build a paused
|
||||
timeline of *internal* scene motion only — slow creeping
|
||||
push-ins, classification text reveals, dramatic bottom-up
|
||||
case-label entrances — and hand it to HyperShader.init().
|
||||
|
||||
The library appends GL transitions and exposes the shader
|
||||
metadata on window.__hf.transitions so the HDR engine can
|
||||
reproduce each Domain Warp transition in rgb48le pixel
|
||||
space, cross-converting any SDR → PQ or HDR → BT.709 as the
|
||||
render target requires.
|
||||
|
||||
Motion rules (Shadow Cut): power3.out for reveals, power4.in
|
||||
for exits. The pause before the hit matters. Nothing bouncy. */
|
||||
|
||||
(function () {
|
||||
const tl = gsap.timeline({ paused: true, defaults: { ease: "power3.out" } });
|
||||
|
||||
/* ---------- Scene 1 · Intro (0.00 – 1.40) ---------- */
|
||||
const introClass = document.querySelector("#scene-intro .classification");
|
||||
const introDisplay = document.querySelector("#scene-intro .display");
|
||||
const introSub = document.querySelector("#scene-intro .subdisplay");
|
||||
const introFoot = document.querySelector("#scene-intro .colophon");
|
||||
if (introClass && introDisplay && introSub && introFoot) {
|
||||
tl.from(
|
||||
introClass,
|
||||
{ y: 12, opacity: 0, duration: 0.6, ease: "power3.out" },
|
||||
0.05,
|
||||
);
|
||||
tl.from(
|
||||
introDisplay,
|
||||
{ y: 30, opacity: 0, scale: 1.04, duration: 0.85, ease: "power3.out" },
|
||||
0.18,
|
||||
);
|
||||
tl.from(
|
||||
introSub,
|
||||
{ y: 10, opacity: 0, duration: 0.55, ease: "power3.out" },
|
||||
0.55,
|
||||
);
|
||||
tl.from(
|
||||
introFoot,
|
||||
{ y: 8, opacity: 0, duration: 0.5, ease: "power3.out" },
|
||||
0.78,
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- Scene 2 · SDR video A (1.85 – 3.20) ----------
|
||||
Slow creeping push-in. The cross-transfer happens inside
|
||||
the engine when it composites this scene's frames into the
|
||||
PQ working space (or leaves them as-is for SDR output). */
|
||||
const vidSdr = document.getElementById("vid-sdr");
|
||||
if (vidSdr) {
|
||||
tl.fromTo(
|
||||
vidSdr,
|
||||
{ scale: 1.08 },
|
||||
{ scale: 1.0, duration: 1.35, ease: "power3.out" },
|
||||
1.85,
|
||||
);
|
||||
}
|
||||
const evSdr = document.querySelectorAll(
|
||||
"#scene-sdr-vid .evidence, #scene-sdr-vid .case-label",
|
||||
);
|
||||
if (evSdr.length) {
|
||||
tl.from(
|
||||
evSdr,
|
||||
{ opacity: 0, y: 8, duration: 0.5, ease: "power3.out", stagger: 0.07 },
|
||||
2.0,
|
||||
);
|
||||
}
|
||||
const ticksSdr = document.querySelectorAll("#scene-sdr-vid .corner-tick");
|
||||
if (ticksSdr.length) {
|
||||
tl.from(
|
||||
ticksSdr,
|
||||
{ opacity: 0, scale: 0.6, duration: 0.4, ease: "power3.out", stagger: 0.04 },
|
||||
1.95,
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- Scene 3 · HDR video A (3.65 – 5.00) ----------
|
||||
Pull-back rather than push-in to vary the rhythm. Native
|
||||
PQ source — the engine reads this with its 10-bit reader. */
|
||||
const vidHdr = document.getElementById("vid-hdr");
|
||||
if (vidHdr) {
|
||||
tl.fromTo(
|
||||
vidHdr,
|
||||
{ scale: 1.0 },
|
||||
{ scale: 1.06, duration: 1.35, ease: "power3.out" },
|
||||
3.65,
|
||||
);
|
||||
}
|
||||
const evHdr = document.querySelectorAll(
|
||||
"#scene-hdr-vid .evidence, #scene-hdr-vid .case-label",
|
||||
);
|
||||
if (evHdr.length) {
|
||||
tl.from(
|
||||
evHdr,
|
||||
{ opacity: 0, y: 8, duration: 0.5, ease: "power3.out", stagger: 0.07 },
|
||||
3.8,
|
||||
);
|
||||
}
|
||||
const ticksHdr = document.querySelectorAll("#scene-hdr-vid .corner-tick");
|
||||
if (ticksHdr.length) {
|
||||
tl.from(
|
||||
ticksHdr,
|
||||
{ opacity: 0, scale: 0.6, duration: 0.4, ease: "power3.out", stagger: 0.04 },
|
||||
3.75,
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- Scene 4 · SDR still (5.45 – 6.50) ----------
|
||||
Slow horizontal drift to give the still life. */
|
||||
const stillSdr = document.getElementById("img-sdr");
|
||||
if (stillSdr) {
|
||||
tl.fromTo(
|
||||
stillSdr,
|
||||
{ scale: 1.1, x: -22 },
|
||||
{ scale: 1.04, x: 14, duration: 1.05, ease: "power3.out" },
|
||||
5.45,
|
||||
);
|
||||
}
|
||||
const evStillSdr = document.querySelectorAll(
|
||||
"#scene-sdr-still .evidence, #scene-sdr-still .case-label",
|
||||
);
|
||||
if (evStillSdr.length) {
|
||||
tl.from(
|
||||
evStillSdr,
|
||||
{ opacity: 0, y: 8, duration: 0.45, ease: "power3.out", stagger: 0.07 },
|
||||
5.55,
|
||||
);
|
||||
}
|
||||
const ticksStillSdr = document.querySelectorAll(
|
||||
"#scene-sdr-still .corner-tick",
|
||||
);
|
||||
if (ticksStillSdr.length) {
|
||||
tl.from(
|
||||
ticksStillSdr,
|
||||
{ opacity: 0, scale: 0.6, duration: 0.35, ease: "power3.out", stagger: 0.04 },
|
||||
5.5,
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- Scene 5 · HDR still (6.95 – 8.00) ----------
|
||||
Counter-direction drift so the rhythm doesn't feel mechanical. */
|
||||
const stillHdr = document.getElementById("img-hdr");
|
||||
if (stillHdr) {
|
||||
tl.fromTo(
|
||||
stillHdr,
|
||||
{ scale: 1.04, x: 18 },
|
||||
{ scale: 1.1, x: -18, duration: 1.05, ease: "power3.out" },
|
||||
6.95,
|
||||
);
|
||||
}
|
||||
const evStillHdr = document.querySelectorAll(
|
||||
"#scene-hdr-still .evidence, #scene-hdr-still .case-label",
|
||||
);
|
||||
if (evStillHdr.length) {
|
||||
tl.from(
|
||||
evStillHdr,
|
||||
{ opacity: 0, y: 8, duration: 0.45, ease: "power3.out", stagger: 0.07 },
|
||||
7.05,
|
||||
);
|
||||
}
|
||||
const ticksStillHdr = document.querySelectorAll(
|
||||
"#scene-hdr-still .corner-tick",
|
||||
);
|
||||
if (ticksStillHdr.length) {
|
||||
tl.from(
|
||||
ticksStillHdr,
|
||||
{ opacity: 0, scale: 0.6, duration: 0.35, ease: "power3.out", stagger: 0.04 },
|
||||
7.0,
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- Scene 6 · Outro (8.45 – 10.00) ---------- */
|
||||
const outroClass = document.querySelector("#scene-outro .classification");
|
||||
const outroDisplay = document.querySelector("#scene-outro .display");
|
||||
const outroSub = document.querySelector("#scene-outro .subdisplay");
|
||||
const outroFoot = document.querySelector("#scene-outro .colophon");
|
||||
if (outroClass && outroDisplay && outroSub && outroFoot) {
|
||||
tl.from(
|
||||
outroClass,
|
||||
{ y: 12, opacity: 0, duration: 0.55, ease: "power3.out" },
|
||||
8.5,
|
||||
);
|
||||
tl.from(
|
||||
outroDisplay,
|
||||
{ y: 28, opacity: 0, scale: 1.04, duration: 0.85, ease: "power3.out" },
|
||||
8.62,
|
||||
);
|
||||
tl.from(
|
||||
outroSub,
|
||||
{ y: 10, opacity: 0, duration: 0.55, ease: "power3.out" },
|
||||
9.0,
|
||||
);
|
||||
tl.from(
|
||||
outroFoot,
|
||||
{ y: 8, opacity: 0, duration: 0.5, ease: "power3.out" },
|
||||
9.25,
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- Shader transitions ----------
|
||||
Domain Warp on every handoff (Shadow Cut style guide).
|
||||
init() reads __hf if present (set by the HDR engine), wires
|
||||
transitions into the timeline at the requested times, and
|
||||
registers the timeline on window.__timelines under the
|
||||
compositionId we pass.
|
||||
|
||||
Transition layout (5 cuts, 0.45s each):
|
||||
1.40 → 1.85: intro → sdr-vid
|
||||
3.20 → 3.65: sdr-vid → hdr-vid (BT.709 → PQ jump)
|
||||
5.00 → 5.45: hdr-vid → sdr-still (PQ → BT.709 jump)
|
||||
6.50 → 6.95: sdr-still → hdr-still (BT.709 → PQ jump)
|
||||
8.00 → 8.45: hdr-still → outro
|
||||
*/
|
||||
if (typeof HyperShader !== "undefined" && HyperShader.init) {
|
||||
HyperShader.init({
|
||||
bgColor: "#0a0a0a",
|
||||
accentColor: "#C1121F",
|
||||
compositionId: "main-comp",
|
||||
scenes: [
|
||||
"scene-intro",
|
||||
"scene-sdr-vid",
|
||||
"scene-hdr-vid",
|
||||
"scene-sdr-still",
|
||||
"scene-hdr-still",
|
||||
"scene-outro",
|
||||
],
|
||||
transitions: [
|
||||
{ time: 1.4, shader: "domain-warp", duration: 0.45, ease: "power3.out" },
|
||||
{ time: 3.2, shader: "domain-warp", duration: 0.45, ease: "power3.out" },
|
||||
{ time: 5.0, shader: "domain-warp", duration: 0.45, ease: "power3.out" },
|
||||
{ time: 6.5, shader: "domain-warp", duration: 0.45, ease: "power3.out" },
|
||||
{ time: 8.0, shader: "domain-warp", duration: 0.45, ease: "power3.out" },
|
||||
],
|
||||
timeline: tl,
|
||||
});
|
||||
}
|
||||
|
||||
/* When init() runs above with a custom timeline it does NOT
|
||||
auto-register on window.__timelines — registration only
|
||||
happens when the library owns the timeline. So we always
|
||||
register the timeline ourselves; this also covers the
|
||||
fallback case where HyperShader is unavailable (CDN miss),
|
||||
in which case the engine renders scenes as hard cuts but
|
||||
internal scene motion still plays. */
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__timelines["main-comp"] = tl;
|
||||
})();
|
||||
@@ -0,0 +1,314 @@
|
||||
/* ============================================================
|
||||
Shadow Cut — Hans Hillmann inspired film-noir card style.
|
||||
Near-monochrome stack: deep black, cold grey, stark white,
|
||||
plus one accent — blood red (#C1121F). Every reveal emerges
|
||||
from darkness; the shader transition (Domain Warp) is what
|
||||
dissolves reality before the next plate appears.
|
||||
============================================================ */
|
||||
|
||||
@import url("https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Inter:wght@300;400;600&family=JetBrains+Mono:wght@400;600&display=swap");
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #0a0a0a;
|
||||
font-family: "Inter", system-ui, sans-serif;
|
||||
font-weight: 400;
|
||||
color: #f4f4f4;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#main {
|
||||
position: relative;
|
||||
width: 1920px;
|
||||
height: 1080px;
|
||||
background: #0a0a0a;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Persistent darkness — a faint cold-grey vignette that lives
|
||||
behind every scene so even when html2canvas captures an
|
||||
"empty" backdrop for a transition it carries the mood. */
|
||||
#main::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
background:
|
||||
radial-gradient(
|
||||
ellipse at 50% 50%,
|
||||
rgba(58, 58, 58, 0.18) 0%,
|
||||
rgba(10, 10, 10, 0.0) 65%
|
||||
),
|
||||
linear-gradient(
|
||||
180deg,
|
||||
#050505 0%,
|
||||
#0a0a0a 50%,
|
||||
#050505 100%
|
||||
);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Faint horizontal interrogation bars — top + bottom. Lend a
|
||||
noir "redacted document" feel without competing with copy. */
|
||||
.bar {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 28px;
|
||||
background: #050505;
|
||||
border-top: 1px solid rgba(193, 18, 31, 0.35);
|
||||
border-bottom: 1px solid rgba(193, 18, 31, 0.35);
|
||||
z-index: 4;
|
||||
}
|
||||
|
||||
.bar.top {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.bar.bottom {
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
/* ---------- Scene container baseline ----------
|
||||
Shadow Cut uses shader transitions, so the library will set
|
||||
non-target scenes to opacity:0 during transition and promote
|
||||
the target to opacity:1 on completion. We mirror that — only
|
||||
the intro is visible at frame 0. */
|
||||
|
||||
.scene {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
#scene-intro {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ---------- Title cards (intro + outro) ---------- */
|
||||
|
||||
.title-card {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
padding: 0 180px;
|
||||
}
|
||||
|
||||
.classification {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.36em;
|
||||
text-transform: uppercase;
|
||||
color: #C1121F;
|
||||
margin-bottom: 60px;
|
||||
}
|
||||
|
||||
.classification::before,
|
||||
.classification::after {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
width: 38px;
|
||||
height: 1px;
|
||||
background: #C1121F;
|
||||
}
|
||||
|
||||
.display {
|
||||
font-family: "Bebas Neue", "Oswald", sans-serif;
|
||||
font-size: 184px;
|
||||
font-weight: 400;
|
||||
line-height: 0.94;
|
||||
letter-spacing: 0.012em;
|
||||
color: #fafafa;
|
||||
text-shadow:
|
||||
0 6px 36px rgba(0, 0, 0, 0.85),
|
||||
0 0 1px rgba(255, 255, 255, 0.6);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.display .accent {
|
||||
color: #C1121F;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.subdisplay {
|
||||
margin-top: 48px;
|
||||
font-family: "Inter", sans-serif;
|
||||
font-size: 16px;
|
||||
font-weight: 300;
|
||||
letter-spacing: 0.42em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(244, 244, 244, 0.55);
|
||||
}
|
||||
|
||||
.colophon {
|
||||
margin-top: 72px;
|
||||
display: flex;
|
||||
gap: 72px;
|
||||
justify-content: center;
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.28em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(193, 18, 31, 0.85);
|
||||
}
|
||||
|
||||
/* ---------- Media-frame scenes ---------- */
|
||||
|
||||
.media-frame {
|
||||
background: #050505;
|
||||
}
|
||||
|
||||
.media-frame video,
|
||||
.media-frame img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
}
|
||||
|
||||
/* Heavy shadow vignette so plates look like they're emerging
|
||||
from darkness — Shadow Cut's whole reveal-from-black thing. */
|
||||
.shadow-vignette {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(193, 18, 31, 0.22),
|
||||
inset 0 0 220px rgba(0, 0, 0, 0.78),
|
||||
inset 0 0 80px rgba(0, 0, 0, 0.65);
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
/* Evidence-stamp HUD overlays in the corners. Mono, condensed. */
|
||||
.evidence {
|
||||
position: absolute;
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.28em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(244, 244, 244, 0.85);
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.evidence .red {
|
||||
color: #C1121F;
|
||||
}
|
||||
|
||||
.evidence.top-left {
|
||||
top: 64px;
|
||||
left: 64px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.evidence.top-right {
|
||||
top: 64px;
|
||||
right: 64px;
|
||||
text-align: right;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.evidence.bottom-left {
|
||||
bottom: 64px;
|
||||
left: 64px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* Bottom-right "case label" — large mono number. */
|
||||
.case-label {
|
||||
position: absolute;
|
||||
bottom: 56px;
|
||||
right: 64px;
|
||||
text-align: right;
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
font-weight: 600;
|
||||
font-size: 64px;
|
||||
letter-spacing: 0.04em;
|
||||
color: #fafafa;
|
||||
line-height: 1;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.case-label .label {
|
||||
display: block;
|
||||
margin-top: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.32em;
|
||||
color: rgba(193, 18, 31, 0.95);
|
||||
}
|
||||
|
||||
/* Diagonal corner ticks — 4 small marks at the inside edge of
|
||||
the plate, like a film slate. */
|
||||
.corner-tick {
|
||||
position: absolute;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.corner-tick::before,
|
||||
.corner-tick::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
background: rgba(244, 244, 244, 0.78);
|
||||
}
|
||||
|
||||
.corner-tick::before {
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 22px;
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
.corner-tick::after {
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 1px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.corner-tick.top-left {
|
||||
top: 56px;
|
||||
left: 56px;
|
||||
}
|
||||
|
||||
.corner-tick.top-right {
|
||||
top: 56px;
|
||||
right: 56px;
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
|
||||
.corner-tick.bottom-left {
|
||||
bottom: 56px;
|
||||
left: 56px;
|
||||
transform: scaleY(-1);
|
||||
}
|
||||
|
||||
.corner-tick.bottom-right {
|
||||
bottom: 56px;
|
||||
right: 56px;
|
||||
transform: scale(-1, -1);
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 250 KiB |
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,142 @@
|
||||
[
|
||||
{
|
||||
"text": "the",
|
||||
"start": 0.1,
|
||||
"end": 0.31
|
||||
},
|
||||
{
|
||||
"text": "makers",
|
||||
"start": 0.31,
|
||||
"end": 0.86
|
||||
},
|
||||
{
|
||||
"text": "of",
|
||||
"start": 0.94,
|
||||
"end": 1.13
|
||||
},
|
||||
{
|
||||
"text": "Subnautica,",
|
||||
"start": 1.13,
|
||||
"end": 2.28
|
||||
},
|
||||
{
|
||||
"text": "and",
|
||||
"start": 2.28,
|
||||
"end": 2.44
|
||||
},
|
||||
{
|
||||
"text": "he",
|
||||
"start": 2.44,
|
||||
"end": 2.54
|
||||
},
|
||||
{
|
||||
"text": "promised",
|
||||
"start": 2.54,
|
||||
"end": 2.99
|
||||
},
|
||||
{
|
||||
"text": "the",
|
||||
"start": 2.99,
|
||||
"end": 3.12
|
||||
},
|
||||
{
|
||||
"text": "founders",
|
||||
"start": 3.12,
|
||||
"end": 3.65
|
||||
},
|
||||
{
|
||||
"text": "$250",
|
||||
"start": 3.75,
|
||||
"end": 4.71
|
||||
},
|
||||
{
|
||||
"text": "million",
|
||||
"start": 4.71,
|
||||
"end": 5.4
|
||||
},
|
||||
{
|
||||
"text": "if",
|
||||
"start": 5.44,
|
||||
"end": 5.6
|
||||
},
|
||||
{
|
||||
"text": "their",
|
||||
"start": 5.6,
|
||||
"end": 5.95
|
||||
},
|
||||
{
|
||||
"text": "next",
|
||||
"start": 5.95,
|
||||
"end": 6.23
|
||||
},
|
||||
{
|
||||
"text": "game",
|
||||
"start": 6.24,
|
||||
"end": 6.64
|
||||
},
|
||||
{
|
||||
"text": "hits",
|
||||
"start": 6.64,
|
||||
"end": 6.91
|
||||
},
|
||||
{
|
||||
"text": "certain",
|
||||
"start": 6.98,
|
||||
"end": 7.36
|
||||
},
|
||||
{
|
||||
"text": "targets.",
|
||||
"start": 7.36,
|
||||
"end": 7.98
|
||||
},
|
||||
{
|
||||
"text": "And",
|
||||
"start": 8.06,
|
||||
"end": 8.14
|
||||
},
|
||||
{
|
||||
"text": "at",
|
||||
"start": 8.14,
|
||||
"end": 8.25
|
||||
},
|
||||
{
|
||||
"text": "the",
|
||||
"start": 8.25,
|
||||
"end": 8.41
|
||||
},
|
||||
{
|
||||
"text": "time,",
|
||||
"start": 8.41,
|
||||
"end": 8.72
|
||||
},
|
||||
{
|
||||
"text": "he",
|
||||
"start": 8.72,
|
||||
"end": 8.82
|
||||
},
|
||||
{
|
||||
"text": "thought",
|
||||
"start": 8.83,
|
||||
"end": 9.19
|
||||
},
|
||||
{
|
||||
"text": "there",
|
||||
"start": 9.24,
|
||||
"end": 9.48
|
||||
},
|
||||
{
|
||||
"text": "was",
|
||||
"start": 9.48,
|
||||
"end": 9.63
|
||||
},
|
||||
{
|
||||
"text": "no",
|
||||
"start": 9.64,
|
||||
"end": 9.75
|
||||
},
|
||||
{
|
||||
"text": "way-",
|
||||
"start": 9.75,
|
||||
"end": 9.98
|
||||
}
|
||||
]
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "HDR Regression — SDR Baseline (Velvet Standard)",
|
||||
"description": "Baseline 1920x1080 SDR composition with all media types (video, audio, images), CSS/GSAP transitions, captions, and the Velvet Standard visual identity. Used to confirm that introducing the HDR pipeline does not regress SDR output.",
|
||||
"tags": ["hdr-regression", "sdr", "video", "audio", "image", "captions", "css-transitions", "velvet-standard"],
|
||||
|
||||
"minPsnr": 28,
|
||||
"maxFrameFailures": 0,
|
||||
|
||||
"minAudioCorrelation": 0.85,
|
||||
"maxAudioLagWindows": 120,
|
||||
|
||||
"renderConfig": {
|
||||
"fps": 30
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
<template id="captions-template">
|
||||
<div
|
||||
data-composition-id="captions"
|
||||
data-start="0"
|
||||
data-duration="10.0"
|
||||
data-width="1920"
|
||||
data-height="1080"
|
||||
>
|
||||
<div id="captions-container"></div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
|
||||
<style>
|
||||
[data-composition-id="captions"] {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 1920px;
|
||||
height: 1080px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-end;
|
||||
padding-bottom: 96px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#captions-container {
|
||||
position: relative;
|
||||
width: 1280px;
|
||||
min-height: 120px;
|
||||
text-align: center;
|
||||
font-family: "Cormorant Garamond", "Playfair Display", "Times New Roman", Times, serif;
|
||||
font-weight: 500;
|
||||
font-style: italic;
|
||||
font-size: 56px;
|
||||
color: #fffaf0;
|
||||
text-shadow: 0 4px 20px rgba(0, 0, 0, 0.7);
|
||||
letter-spacing: -0.005em;
|
||||
}
|
||||
|
||||
.vs-chunk {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
opacity: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 0 18px;
|
||||
}
|
||||
|
||||
.vs-word {
|
||||
opacity: 0;
|
||||
display: inline-block;
|
||||
transition: none;
|
||||
color: #fffaf0;
|
||||
}
|
||||
|
||||
.vs-word.is-active {
|
||||
color: #c9a55a;
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
(function () {
|
||||
const TRANSCRIPT = [
|
||||
{ "text": "the", "start": 0.1, "end": 0.31 },
|
||||
{ "text": "makers", "start": 0.31, "end": 0.86 },
|
||||
{ "text": "of", "start": 0.94, "end": 1.13 },
|
||||
{ "text": "Subnautica,", "start": 1.13, "end": 2.28 },
|
||||
{ "text": "and", "start": 2.28, "end": 2.44 },
|
||||
{ "text": "he", "start": 2.44, "end": 2.54 },
|
||||
{ "text": "promised", "start": 2.54, "end": 2.99 },
|
||||
{ "text": "the", "start": 2.99, "end": 3.12 },
|
||||
{ "text": "founders", "start": 3.12, "end": 3.65 },
|
||||
{ "text": "$250", "start": 3.75, "end": 4.71 },
|
||||
{ "text": "million", "start": 4.71, "end": 5.4 },
|
||||
{ "text": "if", "start": 5.44, "end": 5.6 },
|
||||
{ "text": "their", "start": 5.6, "end": 5.95 },
|
||||
{ "text": "next", "start": 5.95, "end": 6.23 },
|
||||
{ "text": "game", "start": 6.24, "end": 6.64 },
|
||||
{ "text": "hits", "start": 6.64, "end": 6.91 },
|
||||
{ "text": "certain", "start": 6.98, "end": 7.36 },
|
||||
{ "text": "targets.", "start": 7.36, "end": 7.98 },
|
||||
{ "text": "And", "start": 8.06, "end": 8.14 },
|
||||
{ "text": "at", "start": 8.14, "end": 8.25 },
|
||||
{ "text": "the", "start": 8.25, "end": 8.41 },
|
||||
{ "text": "time,", "start": 8.41, "end": 8.72 },
|
||||
{ "text": "he", "start": 8.72, "end": 8.82 },
|
||||
{ "text": "thought", "start": 8.83, "end": 9.19 },
|
||||
{ "text": "there", "start": 9.24, "end": 9.48 },
|
||||
{ "text": "was", "start": 9.48, "end": 9.63 },
|
||||
{ "text": "no", "start": 9.64, "end": 9.75 },
|
||||
{ "text": "way-", "start": 9.75, "end": 9.98 }
|
||||
];
|
||||
|
||||
const container = document.getElementById("captions-container");
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
|
||||
// Group into chunks of 4 words
|
||||
const chunks = [];
|
||||
let current = [];
|
||||
for (let i = 0; i < TRANSCRIPT.length; i++) {
|
||||
current.push(TRANSCRIPT[i]);
|
||||
if (current.length >= 4 || i === TRANSCRIPT.length - 1) {
|
||||
chunks.push(current);
|
||||
current = [];
|
||||
}
|
||||
}
|
||||
|
||||
chunks.forEach((chunk) => {
|
||||
const start = chunk[0].start;
|
||||
const end = chunk[chunk.length - 1].end;
|
||||
|
||||
const div = document.createElement("div");
|
||||
div.className = "vs-chunk";
|
||||
container.appendChild(div);
|
||||
|
||||
// Reveal chunk container
|
||||
tl.fromTo(
|
||||
div,
|
||||
{ opacity: 0, y: 14 },
|
||||
{ opacity: 1, y: 0, duration: 0.35, ease: "sine.out" },
|
||||
start - 0.1,
|
||||
);
|
||||
|
||||
chunk.forEach((wordData, wordIndex) => {
|
||||
const w = document.createElement("span");
|
||||
w.className = "vs-word";
|
||||
w.innerText = wordData.text;
|
||||
div.appendChild(w);
|
||||
|
||||
// Word entrance
|
||||
tl.to(
|
||||
w,
|
||||
{ opacity: 1, duration: 0.18, ease: "sine.out" },
|
||||
wordData.start,
|
||||
);
|
||||
|
||||
// Highlight on (gold)
|
||||
tl.to(
|
||||
w,
|
||||
{ color: "#c9a55a", duration: 0.08 },
|
||||
wordData.start,
|
||||
);
|
||||
|
||||
// Highlight off when next word fires (or chunk end)
|
||||
const offTime =
|
||||
wordIndex < chunk.length - 1 ? chunk[wordIndex + 1].start : end;
|
||||
tl.to(
|
||||
w,
|
||||
{ color: "#fffaf0", duration: 0.08 },
|
||||
offTime,
|
||||
);
|
||||
});
|
||||
|
||||
// Hide chunk after last word
|
||||
tl.to(
|
||||
div,
|
||||
{ opacity: 0, y: -10, duration: 0.35, ease: "sine.in" },
|
||||
end + 0.05,
|
||||
);
|
||||
// Deterministic kill — the karaoke word tweens can race the exit fade
|
||||
tl.set(div, { opacity: 0, visibility: "hidden" }, end + 0.4);
|
||||
});
|
||||
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__timelines["captions"] = tl;
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,126 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>HDR Regression — SDR Baseline</title>
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div
|
||||
id="main"
|
||||
data-composition-id="main-comp"
|
||||
data-start="0"
|
||||
data-duration="10.0"
|
||||
data-width="1920"
|
||||
data-height="1080"
|
||||
>
|
||||
<!-- ============================================================
|
||||
Velvet Standard — six scenes, ~10s, all hand-staged with
|
||||
CSS/GSAP transitions (no shaders). Tests SDR baseline path.
|
||||
============================================================ -->
|
||||
|
||||
<!-- Scene 1: cold open title card (0.0 – 1.6s) -->
|
||||
<section id="scene-intro" class="scene title-card">
|
||||
<div class="eyebrow">Hyperframes · HDR Regression Suite</div>
|
||||
<h1 class="display">
|
||||
A study in <span class="accent">light</span>,<br />precisely composed.
|
||||
</h1>
|
||||
<div class="colophon">
|
||||
<span>Volume I</span>
|
||||
<span>SDR · BT.709</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Scene 2: SDR video A (1.6 – 4.0s) -->
|
||||
<section id="scene-video-a" class="scene media-frame">
|
||||
<video
|
||||
id="vid-sdr-1"
|
||||
src="../assets/sdr-source-1.mp4"
|
||||
data-start="1.6"
|
||||
data-duration="2.4"
|
||||
muted
|
||||
playsinline
|
||||
></video>
|
||||
<div class="vignette"></div>
|
||||
<div class="glyph">Plate I</div>
|
||||
<div class="lower-third">
|
||||
<span class="kicker">Plate I · Subnautica</span>
|
||||
<span class="label">Saturation, restrained.</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Scene 3: SDR still photograph (4.0 – 5.8s) -->
|
||||
<section id="scene-still" class="scene media-frame">
|
||||
<img
|
||||
id="img-sdr"
|
||||
src="../assets/photo-sdr.jpg"
|
||||
alt="SDR reference photograph"
|
||||
/>
|
||||
<div class="vignette"></div>
|
||||
<div class="glyph">Plate II</div>
|
||||
<div class="lower-third">
|
||||
<span class="kicker">Plate II · Reference</span>
|
||||
<span class="label">Still life, sRGB.</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Scene 4: SDR video B (5.8 – 8.0s) -->
|
||||
<section id="scene-video-b" class="scene media-frame">
|
||||
<video
|
||||
id="vid-sdr-2"
|
||||
src="../assets/sdr-source-2.mp4"
|
||||
data-start="5.8"
|
||||
data-duration="2.2"
|
||||
muted
|
||||
playsinline
|
||||
></video>
|
||||
<div class="vignette"></div>
|
||||
<div class="glyph">Plate III</div>
|
||||
<div class="lower-third">
|
||||
<span class="kicker">Plate III · Studio cut</span>
|
||||
<span class="label">Movement, in measure.</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Scene 5: closing title (8.0 – 10.05s) -->
|
||||
<section id="scene-outro" class="scene title-card">
|
||||
<div class="eyebrow">Composition · Velvet Standard</div>
|
||||
<h1 class="display">
|
||||
<span class="accent">SDR</span> ·<br />the control plate.
|
||||
</h1>
|
||||
<div class="colophon">
|
||||
<span>BT.709 · 8-bit · Rec.709</span>
|
||||
<span>End of Volume I</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================
|
||||
Voiceover — single audio track for the entire composition
|
||||
============================================================ -->
|
||||
<audio
|
||||
id="vo"
|
||||
src="../assets/voiceover.mp3"
|
||||
data-start="0"
|
||||
data-duration="10.0"
|
||||
></audio>
|
||||
|
||||
<!-- ============================================================
|
||||
Captions — sub-composition, lower-third, 5–word chunks
|
||||
============================================================ -->
|
||||
<div
|
||||
id="captions-comp"
|
||||
data-composition-id="captions"
|
||||
data-composition-src="compositions/captions.html"
|
||||
data-start="0"
|
||||
data-duration="10.0"
|
||||
></div>
|
||||
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
</script>
|
||||
<script src="script.js"></script>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,112 @@
|
||||
/* Velvet Standard — six-scene SDR baseline.
|
||||
Motion rules: sine.inOut for all morphs, power1.out for entrances,
|
||||
long sustains, slow cross-warp morphs. No bouncing, no overshoot. */
|
||||
|
||||
(function () {
|
||||
const tl = gsap.timeline({ paused: true, defaults: { ease: "sine.inOut" } });
|
||||
|
||||
/* The whole comp is 10s. Six scenes, each with a slow Velvet handoff:
|
||||
0.00 – 1.60 intro card (1.60s on screen, 0.45s morph)
|
||||
1.60 – 4.00 sdr video A (2.40s)
|
||||
4.00 – 5.80 sdr still (1.80s)
|
||||
5.80 – 8.00 sdr video B (2.20s)
|
||||
8.00 – 10.00 outro card (2.00s)
|
||||
*/
|
||||
|
||||
const FADE = 0.55;
|
||||
|
||||
const scenes = [
|
||||
{ id: "scene-intro", in: 0.0, out: 1.6 },
|
||||
{ id: "scene-video-a", in: 1.6, out: 4.0 },
|
||||
{ id: "scene-still", in: 4.0, out: 5.8 },
|
||||
{ id: "scene-video-b", in: 5.8, out: 8.0 },
|
||||
{ id: "scene-outro", in: 8.0, out: 10.0 },
|
||||
];
|
||||
|
||||
scenes.forEach((s) => {
|
||||
const el = document.getElementById(s.id);
|
||||
|
||||
tl.fromTo(
|
||||
el,
|
||||
{ opacity: 0, scale: 1.04, filter: "blur(8px) brightness(0.85)" },
|
||||
{
|
||||
opacity: 1,
|
||||
scale: 1.0,
|
||||
filter: "blur(0px) brightness(1)",
|
||||
duration: FADE,
|
||||
ease: "power1.out",
|
||||
},
|
||||
s.in,
|
||||
);
|
||||
|
||||
if (s.out < 10.0) {
|
||||
tl.to(
|
||||
el,
|
||||
{
|
||||
opacity: 0,
|
||||
scale: 0.985,
|
||||
filter: "blur(6px) brightness(0.92)",
|
||||
duration: FADE,
|
||||
ease: "sine.inOut",
|
||||
},
|
||||
s.out - FADE,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
/* ----- Inner motion per scene ----- */
|
||||
|
||||
// Intro: serif headline glides up + gold rule sweeps in
|
||||
const introCard = document.querySelector("#scene-intro .display");
|
||||
const introBrow = document.querySelector("#scene-intro .eyebrow");
|
||||
if (introCard && introBrow) {
|
||||
tl.from(introBrow, { y: 14, opacity: 0, duration: 0.7, ease: "power1.out" }, 0.05);
|
||||
tl.from(introCard, { y: 26, opacity: 0, duration: 0.95, ease: "power1.out" }, 0.18);
|
||||
}
|
||||
|
||||
// SDR Video A: subtle Ken Burns push-in
|
||||
const vidA = document.getElementById("vid-sdr-1");
|
||||
if (vidA) {
|
||||
tl.fromTo(
|
||||
vidA,
|
||||
{ scale: 1.06 },
|
||||
{ scale: 1.0, duration: 2.4, ease: "sine.out" },
|
||||
1.6,
|
||||
);
|
||||
}
|
||||
|
||||
// Still: slow horizontal drift to give photo motion
|
||||
const still = document.getElementById("img-sdr");
|
||||
if (still) {
|
||||
tl.fromTo(
|
||||
still,
|
||||
{ scale: 1.08, x: -20 },
|
||||
{ scale: 1.02, x: 18, duration: 1.8, ease: "sine.inOut" },
|
||||
4.0,
|
||||
);
|
||||
}
|
||||
|
||||
// SDR Video B: pull-back, opposite Ken Burns
|
||||
const vidB = document.getElementById("vid-sdr-2");
|
||||
if (vidB) {
|
||||
tl.fromTo(
|
||||
vidB,
|
||||
{ scale: 1.0 },
|
||||
{ scale: 1.06, duration: 2.2, ease: "sine.inOut" },
|
||||
5.8,
|
||||
);
|
||||
}
|
||||
|
||||
// Outro: gold rule sweeps in, headline lifts
|
||||
const outroDisplay = document.querySelector("#scene-outro .display");
|
||||
const outroBrow = document.querySelector("#scene-outro .eyebrow");
|
||||
const outroFoot = document.querySelector("#scene-outro .colophon");
|
||||
if (outroDisplay && outroBrow && outroFoot) {
|
||||
tl.from(outroBrow, { y: 12, opacity: 0, duration: 0.7, ease: "power1.out" }, 8.05);
|
||||
tl.from(outroDisplay, { y: 20, opacity: 0, duration: 0.85, ease: "power1.out" }, 8.18);
|
||||
tl.from(outroFoot, { y: 8, opacity: 0, duration: 0.6, ease: "power1.out" }, 8.55);
|
||||
}
|
||||
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__timelines["main-comp"] = tl;
|
||||
})();
|
||||
@@ -0,0 +1,171 @@
|
||||
/* Velvet Standard — Massimo Vignelli editorial reduction.
|
||||
Deep navy + cream, generous whitespace, Bodoni-style serif headlines. */
|
||||
|
||||
:root {
|
||||
--ink: #fffaf0; /* warm cream */
|
||||
--paper: #0b0d1a; /* near-black with navy bias */
|
||||
--navy: #1a237e; /* deep editorial navy */
|
||||
--gold: #c9a55a; /* aged gold accent */
|
||||
--rule: rgba(255, 250, 240, 0.18);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
font-family: "Cormorant Garamond", "Playfair Display", "Times New Roman", Times, serif;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#main {
|
||||
position: relative;
|
||||
width: 1920px;
|
||||
height: 1080px;
|
||||
background: var(--paper);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.scene {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
.scene video,
|
||||
.scene img {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
/* ----- title cards (Velvet Standard layout) ----- */
|
||||
|
||||
.title-card {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--paper);
|
||||
display: grid;
|
||||
grid-template-rows: 1fr auto 1fr;
|
||||
align-items: center;
|
||||
padding: 96px 144px;
|
||||
}
|
||||
|
||||
.title-card .eyebrow {
|
||||
font-family: "Inter", "Helvetica Neue", Arial, sans-serif;
|
||||
font-weight: 300;
|
||||
font-size: 22px;
|
||||
letter-spacing: 0.42em;
|
||||
text-transform: uppercase;
|
||||
color: var(--gold);
|
||||
align-self: end;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
padding-bottom: 24px;
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
.title-card .display {
|
||||
font-family: "Cormorant Garamond", "Playfair Display", "Times New Roman", Times, serif;
|
||||
font-weight: 500;
|
||||
font-style: italic;
|
||||
font-size: 152px;
|
||||
line-height: 1.02;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--ink);
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.title-card .display .accent {
|
||||
color: var(--gold);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.title-card .colophon {
|
||||
font-family: "Inter", "Helvetica Neue", Arial, sans-serif;
|
||||
font-weight: 300;
|
||||
font-size: 18px;
|
||||
letter-spacing: 0.3em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink);
|
||||
opacity: 0.65;
|
||||
align-self: end;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
/* ----- media frames ----- */
|
||||
|
||||
.media-frame .vignette {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(
|
||||
ellipse at center,
|
||||
rgba(0, 0, 0, 0) 55%,
|
||||
rgba(11, 13, 26, 0.55) 100%
|
||||
);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.media-frame .lower-third {
|
||||
position: absolute;
|
||||
left: 96px;
|
||||
bottom: 96px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 20px 28px;
|
||||
border-left: 2px solid var(--gold);
|
||||
}
|
||||
|
||||
.media-frame .lower-third .kicker {
|
||||
font-family: "Inter", sans-serif;
|
||||
font-weight: 300;
|
||||
font-size: 18px;
|
||||
letter-spacing: 0.32em;
|
||||
text-transform: uppercase;
|
||||
color: var(--gold);
|
||||
}
|
||||
|
||||
.media-frame .lower-third .label {
|
||||
font-family: "Cormorant Garamond", serif;
|
||||
font-weight: 500;
|
||||
font-style: italic;
|
||||
font-size: 56px;
|
||||
line-height: 1;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* ----- corner glyph (rule signature) ----- */
|
||||
|
||||
.glyph {
|
||||
position: absolute;
|
||||
top: 56px;
|
||||
right: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
font-family: "Inter", sans-serif;
|
||||
font-weight: 300;
|
||||
font-size: 14px;
|
||||
letter-spacing: 0.4em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink);
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.glyph::before {
|
||||
content: "";
|
||||
width: 38px;
|
||||
height: 1px;
|
||||
background: var(--gold);
|
||||
}
|
||||
Reference in New Issue
Block a user