feat(producer,core): play animated GIF inputs frame-synced via prep-time VP9 transcode (#1335)

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
This commit is contained in:
Matt Van Horn
2026-06-10 22:39:19 -04:00
committed by GitHub
co-authored by Matt Van Horn
parent 30fcede44e
commit edd85473e7
14 changed files with 1290 additions and 21 deletions
@@ -0,0 +1,104 @@
import { describe, expect, it } from "vitest";
import { annotateGifAssetMetadata, type CatalogedAsset } from "./assetCataloger.js";
function u16(value: number): number[] {
return [value & 0xff, (value >> 8) & 0xff];
}
function ascii(value: string): number[] {
return Array.from(value).map((char) => char.charCodeAt(0));
}
function frame(delayCentiseconds: number): number[] {
return [
0x21,
0xf9,
0x04,
0x00,
...u16(delayCentiseconds),
0x00,
0x00,
0x2c,
0x00,
0x00,
0x00,
0x00,
0x01,
0x00,
0x01,
0x00,
0x00,
0x02,
0x02,
0x4c,
0x01,
0x00,
];
}
function gif(frames: number[], loopCount?: number): Uint8Array {
const loop =
loopCount === undefined
? []
: [0x21, 0xff, 0x0b, ...ascii("NETSCAPE2.0"), 0x03, 0x01, ...u16(loopCount), 0x00];
return Uint8Array.from([
...ascii("GIF89a"),
...u16(1),
...u16(1),
0x00,
0x00,
0x00,
...loop,
...frames,
0x3b,
]);
}
describe("annotateGifAssetMetadata", () => {
it("adds frame, duration, and loop notes for animated GIF assets", async () => {
const assets: CatalogedAsset[] = [
{
url: "https://cdn.example.com/reaction.gif?v=1",
type: "Image",
contexts: ["img[src]"],
notes: "reaction",
},
{
url: "https://cdn.example.com/logo.png",
type: "Image",
contexts: ["img[src]"],
},
];
const readUrls: string[] = [];
const annotated = await annotateGifAssetMetadata(assets, async (url) => {
readUrls.push(url);
return gif([...frame(5), ...frame(15)], 0);
});
expect(readUrls).toEqual(["https://cdn.example.com/reaction.gif?v=1"]);
expect(annotated[0]?.notes).toBe("reaction; animated GIF: 2 frames, 0.200s, loops forever");
expect(annotated[1]?.notes).toBeUndefined();
});
it("marks single-frame GIF assets without changing non-GIF assets", async () => {
const assets: CatalogedAsset[] = [
{
url: "https://cdn.example.com/still.gif",
type: "Image",
contexts: ["img[src]"],
},
{
url: "https://cdn.example.com/hero.webp",
type: "Image",
contexts: ["img[src]"],
notes: "hero",
},
];
const annotated = await annotateGifAssetMetadata(assets, async () => gif(frame(10)));
expect(annotated[0]?.notes).toBe("single-frame GIF");
expect(annotated[1]?.notes).toBe("hero");
});
});
+60 -1
View File
@@ -10,6 +10,7 @@
*/
import type { Page } from "puppeteer-core";
import { parseAnimatedGifMetadata } from "@hyperframes/core";
export interface CatalogedAsset {
url: string;
@@ -229,7 +230,65 @@ export async function catalogAssets(page: Page): Promise<CatalogedAsset[]> {
const raw = (assets as CatalogedAsset[]) || [];
// Deduplicate srcset resolution variants — keep highest resolution per base URL
return deduplicateSrcsetVariants(raw);
return annotateGifAssetMetadata(deduplicateSrcsetVariants(raw));
}
function isGifUrl(url: string): boolean {
try {
return new URL(url).pathname.toLowerCase().endsWith(".gif");
} catch {
return url.toLowerCase().split(/[?#]/, 1)[0]?.endsWith(".gif") ?? false;
}
}
function appendNote(existing: string | undefined, note: string): string {
return existing ? `${existing}; ${note}` : note;
}
async function readAssetBytes(url: string): Promise<Uint8Array | null> {
try {
const response = await fetch(url, { signal: AbortSignal.timeout(15_000) });
if (!response.ok) return null;
const contentLength = response.headers.get("content-length");
if (contentLength && Number.parseInt(contentLength, 10) > 25 * 1024 * 1024) return null;
return new Uint8Array(await response.arrayBuffer());
} catch {
return null;
}
}
export async function annotateGifAssetMetadata(
assets: CatalogedAsset[],
readBytes: (url: string) => Promise<Uint8Array | null> = readAssetBytes,
): Promise<CatalogedAsset[]> {
return Promise.all(
assets.map(async (asset) => {
if (!isGifUrl(asset.url)) return asset;
const bytes = await readBytes(asset.url);
if (!bytes) return asset;
const metadata = parseAnimatedGifMetadata(bytes);
if (!metadata) return asset;
if (!metadata.animated) {
return {
...asset,
notes: appendNote(asset.notes, "single-frame GIF"),
};
}
const loop =
metadata.loopCount === 0
? "loops forever"
: metadata.loopCount == null
? "no loop metadata"
: `loop count ${metadata.loopCount}`;
return {
...asset,
notes: appendNote(
asset.notes,
`animated GIF: ${metadata.frameCount} frames, ${metadata.durationSeconds.toFixed(3)}s, ${loop}`,
),
};
}),
);
}
/**
+48 -2
View File
@@ -31,6 +31,8 @@ import type { ScreenshotClip } from "@hyperframes/core/studio-api/screenshot-cli
import type { RenderJob } from "@hyperframes/producer";
const STUDIO_MANUAL_EDITS_PATH = ".hyperframes/studio-manual-edits.json";
const REMOTE_GIF_IMG_SRC_RE =
/<img\b[^>]*?\bsrc\s*=\s*["'](https:\/\/[^"']+\.gif(?:[?#][^"']*)?)["'][^>]*>/gi;
// ── Path resolution ─────────────────────────────────────────────────────────
@@ -119,6 +121,37 @@ async function reapplyStudioManualEditsToThumbnailPage(
});
}
function collectRemoteGifImageSources(html: string): string[] {
const urls = new Set<string>();
const re = new RegExp(REMOTE_GIF_IMG_SRC_RE.source, REMOTE_GIF_IMG_SRC_RE.flags);
let match: RegExpExecArray | null;
while ((match = re.exec(html)) !== null) {
if (match[1]) urls.add(match[1]);
}
return [...urls];
}
async function downloadRemoteGifImageSources(
html: string,
downloadDir: string,
downloadToTemp: (url: string, destDir: string) => Promise<string>,
): Promise<Map<string, string>> {
const sourceAssets = new Map<string, string>();
await Promise.all(
collectRemoteGifImageSources(html).map(async (url) => {
try {
sourceAssets.set(url, await downloadToTemp(url, downloadDir));
} catch (err) {
console.warn(
"[Studio] Remote animated GIF prep skipped:",
err instanceof Error ? err.message : err,
);
}
}),
);
return sourceAssets;
}
// ── Shared thumbnail browser (pool-backed) ──────────────────────────────────
// Uses the engine's browser pool so the thumbnail browser and render workers
// share a single Chrome process instead of running two independent ones.
@@ -247,10 +280,23 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
}
},
async transformPreviewHtml({ html }) {
async transformPreviewHtml({ html, project }) {
const { injectDeterministicFontFaces } =
await import("../../../producer/src/services/deterministicFonts.js");
return injectDeterministicFontFaces(html);
const { prepareAnimatedGifInputs } =
await import("../../../producer/src/services/animatedGifPrep.js");
const { downloadToTemp } = await import("../../../producer/src/utils/urlDownloader.js");
const gifOutputDir = join(project.dir, ".hyperframes", "prepared-assets", "gif");
const gifDownloadDir = join(project.dir, ".hyperframes", "prepared-assets", "downloads");
const prepared = await prepareAnimatedGifInputs(html, {
projectDir: project.dir,
downloadDir: gifDownloadDir,
outputDir: gifOutputDir,
outputSrcPrefix: ".hyperframes/prepared-assets/gif",
cacheDir: gifOutputDir,
sourceAssets: await downloadRemoteGifImageSources(html, gifDownloadDir, downloadToTemp),
});
return injectDeterministicFontFaces(prepared.html);
},
getProjectSignature(dir: string): string {
+6
View File
@@ -141,6 +141,12 @@ describe("@hyperframes/core public API exports", () => {
});
});
describe("media exports", () => {
it("exports parseAnimatedGifMetadata", () => {
expect(typeof core.parseAnimatedGifMetadata).toBe("function");
});
});
describe("inline-script exports", () => {
it("exports hyperframe runtime artifacts", () => {
expect(core.HYPERFRAME_RUNTIME_ARTIFACTS).toBeDefined();
+1
View File
@@ -136,6 +136,7 @@ export {
} from "./compiler/rewriteSubCompPaths";
export { CSS_URL_RE, isNonRelativeUrl, isPathInside } from "./compiler/assetPaths";
export { decodeUrlPathVariants } from "./utils/urlPath";
export { parseAnimatedGifMetadata, type AnimatedGifMetadata } from "./media/gif";
// Inline scripts
export {
+83
View File
@@ -0,0 +1,83 @@
import { describe, expect, it } from "vitest";
import { parseAnimatedGifMetadata } from "./gif";
function u16(value: number): number[] {
return [value & 0xff, (value >> 8) & 0xff];
}
function ascii(value: string): number[] {
return Array.from(value).map((char) => char.charCodeAt(0));
}
function frame(delayCentiseconds: number): number[] {
return [
0x21,
0xf9,
0x04,
0x00,
...u16(delayCentiseconds),
0x00,
0x00,
0x2c,
0x00,
0x00,
0x00,
0x00,
0x01,
0x00,
0x01,
0x00,
0x00,
0x02,
0x02,
0x4c,
0x01,
0x00,
];
}
function gif(frames: number[], loopCount?: number): Uint8Array {
const loop =
loopCount === undefined
? []
: [0x21, 0xff, 0x0b, ...ascii("NETSCAPE2.0"), 0x03, 0x01, ...u16(loopCount), 0x00];
return Uint8Array.from([
...ascii("GIF89a"),
...u16(1),
...u16(1),
0x00,
0x00,
0x00,
...loop,
...frames,
0x3b,
]);
}
describe("parseAnimatedGifMetadata", () => {
it("detects single-frame GIFs without marking them animated", () => {
const metadata = parseAnimatedGifMetadata(gif(frame(10)));
expect(metadata?.frameCount).toBe(1);
expect(metadata?.animated).toBe(false);
expect(metadata?.durationSeconds).toBe(0.1);
});
it("preserves variable frame delays", () => {
const metadata = parseAnimatedGifMetadata(gif([...frame(5), ...frame(15)]));
expect(metadata?.animated).toBe(true);
expect(metadata?.delaysCentiseconds).toEqual([5, 15]);
expect(metadata?.durationSeconds).toBe(0.2);
});
it("reads Netscape loop metadata", () => {
const metadata = parseAnimatedGifMetadata(gif([...frame(8), ...frame(8)], 0));
expect(metadata?.loopCount).toBe(0);
});
it("returns null for non-GIF data", () => {
expect(parseAnimatedGifMetadata(Uint8Array.from([0x89, 0x50, 0x4e, 0x47]))).toBeNull();
});
});
+159
View File
@@ -0,0 +1,159 @@
export interface AnimatedGifMetadata {
width: number;
height: number;
frameCount: number;
delaysCentiseconds: number[];
durationSeconds: number;
/** Netscape loop count. 0 means infinite; null means no loop extension was present. */
loopCount: number | null;
animated: boolean;
}
function readAscii(bytes: Uint8Array, start: number, length: number): string {
if (start + length > bytes.length) return "";
let value = "";
for (let i = start; i < start + length; i++) {
value += String.fromCharCode(bytes[i] ?? 0);
}
return value;
}
function readU16LE(bytes: Uint8Array, offset: number): number | null {
if (offset + 1 >= bytes.length) return null;
return (bytes[offset] ?? 0) | ((bytes[offset + 1] ?? 0) << 8);
}
function colorTableByteLength(packed: number): number {
return 3 * 2 ** ((packed & 0b0000_0111) + 1);
}
function skipSubBlocks(bytes: Uint8Array, offset: number): number | null {
let pos = offset;
while (pos < bytes.length) {
const size = bytes[pos];
if (size === undefined) return null;
pos += 1;
if (size === 0) return pos;
pos += size;
}
return null;
}
function parseApplicationExtension(
bytes: Uint8Array,
offset: number,
): {
nextOffset: number;
loopCount: number | null;
} | null {
const blockSize = bytes[offset];
if (blockSize === undefined || offset + 1 + blockSize > bytes.length) return null;
const appId = readAscii(bytes, offset + 1, blockSize);
let pos = offset + 1 + blockSize;
let loopCount: number | null = null;
while (pos < bytes.length) {
const size = bytes[pos];
if (size === undefined) return null;
pos += 1;
if (size === 0) return { nextOffset: pos, loopCount };
if (pos + size > bytes.length) return null;
if ((appId === "NETSCAPE2.0" || appId === "ANIMEXTS1.0") && size >= 3 && bytes[pos] === 1) {
loopCount = readU16LE(bytes, pos + 1);
}
pos += size;
}
return null;
}
export function parseAnimatedGifMetadata(bytes: Uint8Array): AnimatedGifMetadata | null {
if (bytes.length < 13) return null;
const signature = readAscii(bytes, 0, 6);
if (signature !== "GIF87a" && signature !== "GIF89a") return null;
const width = readU16LE(bytes, 6);
const height = readU16LE(bytes, 8);
if (width == null || height == null || width <= 0 || height <= 0) return null;
const packed = bytes[10] ?? 0;
let pos = 13;
if ((packed & 0b1000_0000) !== 0) {
pos += colorTableByteLength(packed);
}
let frameCount = 0;
const delaysCentiseconds: number[] = [];
let loopCount: number | null = null;
while (pos < bytes.length) {
const introducer = bytes[pos];
if (introducer === undefined) return null;
pos += 1;
if (introducer === 0x3b) break;
if (introducer === 0x21) {
const label = bytes[pos];
if (label === undefined) return null;
pos += 1;
if (label === 0xf9) {
const blockSize = bytes[pos];
if (blockSize !== 4 || pos + 6 > bytes.length) return null;
const delay = readU16LE(bytes, pos + 2);
if (delay == null) return null;
delaysCentiseconds.push(delay);
pos += 1 + blockSize;
if (bytes[pos] !== 0) return null;
pos += 1;
continue;
}
if (label === 0xff) {
const parsed = parseApplicationExtension(bytes, pos);
if (!parsed) return null;
if (parsed.loopCount != null) loopCount = parsed.loopCount;
pos = parsed.nextOffset;
continue;
}
const next = skipSubBlocks(bytes, pos);
if (next == null) return null;
pos = next;
continue;
}
if (introducer === 0x2c) {
if (pos + 9 > bytes.length) return null;
const imagePacked = bytes[pos + 8] ?? 0;
pos += 9;
if ((imagePacked & 0b1000_0000) !== 0) {
pos += colorTableByteLength(imagePacked);
}
if (pos >= bytes.length) return null;
pos += 1; // LZW minimum code size
const next = skipSubBlocks(bytes, pos);
if (next == null) return null;
pos = next;
frameCount += 1;
continue;
}
return null;
}
const durationSeconds =
delaysCentiseconds.reduce((total, delay) => total + Math.max(0, delay), 0) / 100;
return {
width,
height,
frameCount,
delaysCentiseconds,
durationSeconds,
loopCount,
animated: frameCount > 1,
};
}
@@ -0,0 +1,211 @@
import { describe, expect, it } from "bun:test";
import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { parseHTML } from "linkedom";
import {
buildAnimatedGifTranscodeArgs,
prepareAnimatedGifInputs,
type AnimatedGifTranscodeRequest,
} from "./animatedGifPrep.js";
function u16(value: number): number[] {
return [value & 0xff, (value >> 8) & 0xff];
}
function ascii(value: string): number[] {
return Array.from(value).map((char) => char.charCodeAt(0));
}
function frame(delayCentiseconds: number): number[] {
return [
0x21,
0xf9,
0x04,
0x00,
...u16(delayCentiseconds),
0x00,
0x00,
0x2c,
0x00,
0x00,
0x00,
0x00,
0x01,
0x00,
0x01,
0x00,
0x00,
0x02,
0x02,
0x4c,
0x01,
0x00,
];
}
function gif(frames: number[], loopCount?: number): Uint8Array {
const loop =
loopCount === undefined
? []
: [0x21, 0xff, 0x0b, ...ascii("NETSCAPE2.0"), 0x03, 0x01, ...u16(loopCount), 0x00];
return Uint8Array.from([
...ascii("GIF89a"),
...u16(1),
...u16(1),
0x00,
0x00,
0x00,
...loop,
...frames,
0x3b,
]);
}
function makeProject(): string {
return mkdtempSync(join(tmpdir(), "hf-gif-prep-"));
}
describe("buildAnimatedGifTranscodeArgs", () => {
it("builds VP9 WebM args with alpha and finite-loop expansion", () => {
const args = buildAnimatedGifTranscodeArgs({
inputPath: "/in/reaction.gif",
outputPath: "/out/reaction.webm",
loopIterations: 3,
});
expect(args).toContain("-stream_loop");
expect(args).toContain("2");
expect(args).toContain("libvpx-vp9");
expect(args).toContain("yuva420p");
expect(args).toContain("-ignore_loop");
// Output goes to an extension-less tmp path; the muxer must be explicit.
expect(args.join(" ")).toContain("-f webm");
});
it("clone-pads the tail when the clip window outlives the looped source", () => {
const args = buildAnimatedGifTranscodeArgs({
inputPath: "/in/reaction.gif",
outputPath: "/out/reaction.webm",
loopIterations: 2,
padSeconds: 1.5,
});
expect(args.join(" ")).toContain("tpad=stop_mode=clone:stop_duration=1.5");
});
});
describe("prepareAnimatedGifInputs", () => {
it("rewrites animated GIF images to muted looped videos", async () => {
const projectDir = makeProject();
writeFileSync(join(projectDir, "sticker.gif"), gif([...frame(5), ...frame(15)], 0));
const calls: AnimatedGifTranscodeRequest[] = [];
const result = await prepareAnimatedGifInputs(
`<img class="clip badge" data-start="1" data-duration="4" src="sticker.gif" alt="sticker" />`,
{
projectDir,
downloadDir: projectDir,
transcode: async (request) => {
calls.push(request);
writeFileSync(request.outputPath, "webm");
},
},
);
const { document } = parseHTML(result.html);
const video = document.querySelector("video");
expect(video?.getAttribute("src")).toMatch(/^_animated_gif\/hfgif-v1-/);
expect(video?.getAttribute("class")).toBe("clip badge");
expect(video?.hasAttribute("loop")).toBe(true);
expect(video?.hasAttribute("muted")).toBe(true);
expect(video?.getAttribute("data-has-audio")).toBe("false");
expect(video?.getAttribute("data-end")).toBe("5");
expect(document.querySelector("img")).toBeNull();
expect(result.preparedAssets.size).toBe(1);
expect(result.preparedGifs[0]?.metadata.delaysCentiseconds).toEqual([5, 15]);
expect(calls).toHaveLength(1);
// 0.2s source looped to cover the 4s clip window: 20 iterations baked in,
// because the render pipeline seek-syncs videos and ignores native loop.
expect(result.preparedGifs[0]?.loopIterations).toBe(20);
expect(calls[0]?.args.join(" ")).toContain("-stream_loop 19");
});
it("leaves single-frame GIF images unchanged", async () => {
const projectDir = makeProject();
writeFileSync(join(projectDir, "still.gif"), gif(frame(10)));
const result = await prepareAnimatedGifInputs(`<img src="still.gif" />`, {
projectDir,
downloadDir: projectDir,
transcode: async () => {
throw new Error("should not transcode");
},
});
expect(result.html).toContain("<img");
expect(result.preparedAssets.size).toBe(0);
});
it("lets data-loop override infinite GIF metadata", async () => {
const projectDir = makeProject();
writeFileSync(join(projectDir, "reaction.gif"), gif([...frame(10), ...frame(10)], 0));
const result = await prepareAnimatedGifInputs(
`<img data-start="0" data-duration="2" data-loop="false" src="reaction.gif" />`,
{
projectDir,
downloadDir: projectDir,
transcode: async (request) => {
writeFileSync(request.outputPath, "webm");
},
},
);
const { document } = parseHTML(result.html);
expect(document.querySelector("video")?.hasAttribute("loop")).toBe(false);
});
it("expands finite loop metadata into the transcoded source", async () => {
const projectDir = makeProject();
writeFileSync(join(projectDir, "finite.gif"), gif([...frame(10), ...frame(10)], 3));
const calls: AnimatedGifTranscodeRequest[] = [];
const result = await prepareAnimatedGifInputs(`<img src="finite.gif" />`, {
projectDir,
downloadDir: projectDir,
transcode: async (request) => {
calls.push(request);
writeFileSync(request.outputPath, "webm");
},
});
expect(result.preparedGifs[0]?.loop).toBe(false);
expect(result.preparedGifs[0]?.loopIterations).toBe(3);
expect(calls[0]?.args).toContain("-stream_loop");
expect(calls[0]?.args).toContain("2");
});
it("uses source asset mappings for remote GIF URLs", async () => {
const projectDir = makeProject();
const sourceUrl = "https://cdn.example.com/reaction.gif";
const sourcePath = join(projectDir, "downloaded.gif");
writeFileSync(sourcePath, gif([...frame(10), ...frame(10)], 0));
const result = await prepareAnimatedGifInputs(
`<img data-start="0" data-duration="2" src="${sourceUrl}" />`,
{
projectDir,
downloadDir: projectDir,
sourceAssets: new Map([[sourceUrl, sourcePath]]),
transcode: async (request) => {
writeFileSync(request.outputPath, "webm");
},
},
);
const { document } = parseHTML(result.html);
expect(document.querySelector("video")?.getAttribute("src")).toMatch(
/^_animated_gif\/hfgif-v1-/,
);
expect(result.preparedGifs[0]?.sourceSrc).toBe(sourceUrl);
});
});
@@ -0,0 +1,441 @@
import { createHash } from "node:crypto";
import { spawn } from "node:child_process";
import {
copyFileSync,
existsSync,
mkdirSync,
readFileSync,
renameSync,
rmSync,
statSync,
} from "node:fs";
import { dirname, isAbsolute, join, resolve } from "node:path";
import { parseHTML } from "linkedom";
import { parseAnimatedGifMetadata, type AnimatedGifMetadata } from "@hyperframes/core";
import { isHttpUrl } from "../utils/urlDownloader.js";
const PREPARED_GIF_SUBDIR = "_animated_gif";
const CACHE_SCHEMA = "hfgif-v1";
export interface PreparedAnimatedGif {
id: string;
sourceSrc: string;
outputSrc: string;
outputPath: string;
metadata: AnimatedGifMetadata;
loop: boolean;
loopIterations: number;
padSeconds: number;
}
export interface PrepareAnimatedGifInputsOptions {
projectDir: string;
downloadDir: string;
outputDir?: string;
outputSrcPrefix?: string;
cacheDir?: string;
sourceAssets?: Map<string, string>;
timeoutMs?: number;
transcode?: (input: AnimatedGifTranscodeRequest) => Promise<void>;
}
export interface AnimatedGifTranscodeRequest {
inputPath: string;
outputPath: string;
args: string[];
timeoutMs?: number;
}
export interface PrepareAnimatedGifInputsResult {
html: string;
preparedAssets: Map<string, string>;
preparedGifs: PreparedAnimatedGif[];
}
function splitUrlSuffix(src: string): { basePath: string; suffix: string } {
const queryIdx = src.indexOf("?");
const hashIdx = src.indexOf("#");
if (queryIdx < 0 && hashIdx < 0) return { basePath: src, suffix: "" };
const cutIdx = queryIdx < 0 ? hashIdx : hashIdx < 0 ? queryIdx : Math.min(queryIdx, hashIdx);
return { basePath: src.slice(0, cutIdx), suffix: src.slice(cutIdx) };
}
function normalizeRelPath(path: string): string {
return path.replace(/\\/g, "/").replace(/^\/+/, "");
}
function hasGifExtension(src: string): boolean {
const { basePath } = splitUrlSuffix(src.trim().toLowerCase());
return basePath.endsWith(".gif");
}
function readLoopOverride(el: Element): boolean | null {
const raw = el.getAttribute("data-loop");
if (raw == null) return null;
const normalized = raw.trim().toLowerCase();
if (normalized === "" || normalized === "true" || normalized === "1" || normalized === "yes") {
return true;
}
if (normalized === "false" || normalized === "0" || normalized === "no") {
return false;
}
return null;
}
function resolveGifSourcePath(
src: string,
options: Pick<PrepareAnimatedGifInputsOptions, "projectDir" | "downloadDir" | "sourceAssets">,
): string | null {
const trimmed = src.trim();
if (!trimmed || trimmed.startsWith("data:")) return null;
const { basePath } = splitUrlSuffix(trimmed);
const normalizedBase = normalizeRelPath(basePath);
const mapped =
options.sourceAssets?.get(trimmed) ??
options.sourceAssets?.get(basePath) ??
options.sourceAssets?.get(normalizedBase);
if (mapped && existsSync(mapped)) return mapped;
if (isHttpUrl(trimmed)) return null;
const projectRelative = basePath.startsWith("/") ? basePath.slice(1) : basePath;
const candidates = [
isAbsolute(basePath) ? basePath : resolve(options.projectDir, projectRelative),
resolve(options.downloadDir, normalizedBase),
];
return candidates.find((candidate) => existsSync(candidate)) ?? null;
}
function isUsableFile(path: string): boolean {
try {
const stat = statSync(path);
return stat.isFile() && stat.size > 0;
} catch {
return false;
}
}
function computePreparedGifHash(
bytes: Uint8Array,
loopIterations: number,
padSeconds: number,
): string {
return createHash("sha256")
.update(CACHE_SCHEMA)
.update("\0")
.update(String(loopIterations))
.update("\0")
.update(String(padSeconds))
.update("\0")
.update(bytes)
.digest("hex");
}
function resolveLoop(metadata: AnimatedGifMetadata, override: boolean | null): boolean {
if (override != null) return override;
return metadata.loopCount === 0;
}
/**
* The render pipeline seek-syncs <video> elements and does not honor the
* native `loop` attribute, so a 2s WebM inside a 10s clip would vanish after
* 2s. Prep-time knows the clip window, so looping is baked into the encoded
* file: enough `-stream_loop` iterations to cover the window, plus a
* clone-frame pad for finite-loop GIFs that hold their last frame.
*/
const MAX_LOOP_ITERATIONS = 1_000;
const MAX_PAD_SECONDS = 3_600;
function resolveCompositionDurationSeconds(document: Document): number | null {
let max: number | null = null;
for (const el of Array.from(document.querySelectorAll("[data-composition-id][data-duration]"))) {
const value = Number.parseFloat(el.getAttribute("data-duration") || "");
if (Number.isFinite(value) && value > 0) {
max = max == null ? value : Math.max(max, value);
}
}
return max;
}
function resolveClipWindowSeconds(img: Element, compositionDuration: number | null): number | null {
const durationRaw = img.getAttribute("data-duration");
if (durationRaw != null) {
const duration = Number.parseFloat(durationRaw);
if (Number.isFinite(duration) && duration > 0) return duration;
}
if (compositionDuration != null) {
const startRaw = Number.parseFloat(img.getAttribute("data-start") || "0");
const start = Number.isFinite(startRaw) && startRaw > 0 ? startRaw : 0;
const window = compositionDuration - start;
return window > 0 ? window : null;
}
return null;
}
function resolvePreparedPlayback(
metadata: AnimatedGifMetadata,
loop: boolean,
windowSeconds: number | null,
): { loopIterations: number; padSeconds: number } {
const gifDuration = metadata.durationSeconds > 0 ? metadata.durationSeconds : null;
let loopIterations = 1;
if (loop) {
loopIterations =
windowSeconds != null && gifDuration != null
? Math.min(MAX_LOOP_ITERATIONS, Math.max(1, Math.ceil(windowSeconds / gifDuration)))
: 1;
} else if (metadata.loopCount != null && metadata.loopCount > 1) {
loopIterations = Math.min(MAX_LOOP_ITERATIONS, metadata.loopCount);
}
let padSeconds = 0;
if (windowSeconds != null && gifDuration != null) {
const covered = gifDuration * loopIterations;
if (windowSeconds > covered) {
padSeconds = Math.min(windowSeconds - covered, MAX_PAD_SECONDS);
}
}
return { loopIterations, padSeconds: Math.round(padSeconds * 1000) / 1000 };
}
export function buildAnimatedGifTranscodeArgs(input: {
inputPath: string;
outputPath: string;
loopIterations: number;
padSeconds?: number;
}): string[] {
const args = ["-hide_banner", "-loglevel", "error"];
if (input.loopIterations > 1) {
args.push("-stream_loop", String(input.loopIterations - 1));
}
args.push(
"-ignore_loop",
"1",
"-i",
input.inputPath,
"-an",
"-c:v",
"libvpx-vp9",
"-pix_fmt",
"yuva420p",
"-auto-alt-ref",
"0",
"-deadline",
"good",
"-crf",
"18",
"-b:v",
"0",
...(input.padSeconds && input.padSeconds > 0
? ["-vf", `tpad=stop_mode=clone:stop_duration=${input.padSeconds}`]
: []),
// Explicit muxer: the transcode writes to a `.tmp-<pid>-<ts>` path whose
// extension ffmpeg cannot infer the container from.
"-f",
"webm",
"-y",
input.outputPath,
);
return args;
}
async function runAnimatedGifTranscode(request: AnimatedGifTranscodeRequest): Promise<void> {
await new Promise<void>((resolvePromise, reject) => {
const proc = spawn("ffmpeg", request.args);
let stderr = "";
const timeout = request.timeoutMs ?? 300_000;
const timer = setTimeout(() => {
proc.kill("SIGTERM");
reject(new Error(`Animated GIF transcode timed out after ${timeout}ms`));
}, timeout);
proc.stderr.on("data", (chunk: Buffer) => {
stderr += chunk.toString();
});
proc.on("close", (code) => {
clearTimeout(timer);
if (code === 0) {
resolvePromise();
return;
}
reject(new Error(`Animated GIF transcode failed (${code}): ${stderr.slice(-500)}`));
});
proc.on("error", (error) => {
clearTimeout(timer);
reject(error);
});
});
}
async function ensurePreparedWebm(input: {
sourcePath: string;
cachePath: string;
outputPath: string;
loopIterations: number;
padSeconds: number;
timeoutMs?: number;
transcode?: (request: AnimatedGifTranscodeRequest) => Promise<void>;
}): Promise<void> {
if (!existsSync(dirname(input.cachePath))) {
mkdirSync(dirname(input.cachePath), { recursive: true });
}
if (!existsSync(dirname(input.outputPath))) {
mkdirSync(dirname(input.outputPath), { recursive: true });
}
if (!isUsableFile(input.cachePath)) {
const tmpPath = `${input.cachePath}.tmp-${process.pid}-${Date.now()}`;
const args = buildAnimatedGifTranscodeArgs({
inputPath: input.sourcePath,
outputPath: tmpPath,
loopIterations: input.loopIterations,
padSeconds: input.padSeconds,
});
const transcode = input.transcode ?? runAnimatedGifTranscode;
try {
await transcode({
inputPath: input.sourcePath,
outputPath: tmpPath,
args,
timeoutMs: input.timeoutMs,
});
if (!isUsableFile(tmpPath)) {
throw new Error("Animated GIF transcode produced an empty output");
}
if (!isUsableFile(input.cachePath)) {
renameSync(tmpPath, input.cachePath);
} else {
rmSync(tmpPath, { force: true });
}
} catch (error) {
rmSync(tmpPath, { force: true });
throw error;
}
}
if (resolve(input.outputPath) !== resolve(input.cachePath) && !isUsableFile(input.outputPath)) {
copyFileSync(input.cachePath, input.outputPath);
}
}
function ensureElementId(el: Element, document: Document, fallbackIndex: number): string {
const existing = (el.getAttribute("id") || "").trim();
if (existing) return existing;
let next = fallbackIndex;
while (document.getElementById(`hf-gif-${next}`)) next += 1;
const id = `hf-gif-${next}`;
el.setAttribute("id", id);
return id;
}
function ensureTimingAttributes(video: Element): void {
if (!video.hasAttribute("data-start")) {
video.setAttribute("data-start", "0");
}
if (!video.hasAttribute("data-end")) {
const durationRaw = video.getAttribute("data-duration");
if (durationRaw != null) {
const start = Number.parseFloat(video.getAttribute("data-start") || "0");
const duration = Number.parseFloat(durationRaw);
if (Number.isFinite(start) && Number.isFinite(duration) && duration > 0) {
video.setAttribute("data-end", String(start + duration));
}
}
}
}
function replaceImageWithVideo(input: {
img: Element;
id: string;
outputSrc: string;
loop: boolean;
}): Element {
const document = input.img.ownerDocument;
const video = document.createElement("video");
for (const attr of Array.from(input.img.attributes)) {
if (attr.name === "src") continue;
video.setAttribute(attr.name, attr.value);
}
video.setAttribute("id", input.id);
video.setAttribute("src", input.outputSrc);
video.setAttribute("muted", "");
video.setAttribute("playsinline", "");
video.setAttribute("preload", "auto");
video.setAttribute("data-has-audio", "false");
video.setAttribute("data-hf-prepared-gif", "true");
if (input.loop) {
video.setAttribute("loop", "");
} else {
video.removeAttribute("loop");
}
ensureTimingAttributes(video);
input.img.replaceWith(video);
return video;
}
export async function prepareAnimatedGifInputs(
html: string,
options: PrepareAnimatedGifInputsOptions,
): Promise<PrepareAnimatedGifInputsResult> {
const outputDir = options.outputDir ?? join(options.downloadDir, PREPARED_GIF_SUBDIR);
const outputSrcPrefix = normalizeRelPath(options.outputSrcPrefix ?? PREPARED_GIF_SUBDIR);
const cacheDir = options.cacheDir ?? outputDir;
const { document } = parseHTML(html);
const preparedAssets = new Map<string, string>();
const preparedGifs: PreparedAnimatedGif[] = [];
const images = Array.from(document.querySelectorAll("img[src]"));
const compositionDuration = resolveCompositionDurationSeconds(document);
for (let i = 0; i < images.length; i++) {
const img = images[i]!;
const src = (img.getAttribute("src") || "").trim();
if (!hasGifExtension(src)) continue;
const sourcePath = resolveGifSourcePath(src, options);
if (!sourcePath) continue;
const bytes = readFileSync(sourcePath);
const metadata = parseAnimatedGifMetadata(bytes);
if (!metadata?.animated) continue;
const loop = resolveLoop(metadata, readLoopOverride(img));
const windowSeconds = resolveClipWindowSeconds(img, compositionDuration);
const { loopIterations, padSeconds } = resolvePreparedPlayback(metadata, loop, windowSeconds);
const hash = computePreparedGifHash(bytes, loopIterations, padSeconds);
const filename = `${CACHE_SCHEMA}-${hash.slice(0, 24)}.webm`;
const cachePath = join(cacheDir, filename);
const outputPath = join(outputDir, filename);
const outputSrc = `${outputSrcPrefix}/${filename}`;
await ensurePreparedWebm({
sourcePath,
cachePath,
outputPath,
loopIterations,
padSeconds,
timeoutMs: options.timeoutMs,
transcode: options.transcode,
});
const id = ensureElementId(img, document, i);
replaceImageWithVideo({ img, id, outputSrc, loop });
preparedAssets.set(outputSrc, outputPath);
preparedGifs.push({
id,
sourceSrc: src,
outputSrc,
outputPath,
metadata,
loop,
loopIterations,
padSeconds,
});
}
return {
html: preparedGifs.length > 0 ? document.toString() : html,
preparedAssets,
preparedGifs,
};
}
@@ -0,0 +1,127 @@
import { describe, expect, it } from "bun:test";
import { createHash } from "node:crypto";
import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { parseHTML } from "linkedom";
import { compileForRender } from "./htmlCompiler.js";
function u16(value: number): number[] {
return [value & 0xff, (value >> 8) & 0xff];
}
function ascii(value: string): number[] {
return Array.from(value).map((char) => char.charCodeAt(0));
}
function frame(delayCentiseconds: number): number[] {
return [
0x21,
0xf9,
0x04,
0x00,
...u16(delayCentiseconds),
0x00,
0x00,
0x2c,
0x00,
0x00,
0x00,
0x00,
0x01,
0x00,
0x01,
0x00,
0x00,
0x02,
0x02,
0x4c,
0x01,
0x00,
];
}
function gif(frames: number[], loopCount?: number): Uint8Array {
const loop =
loopCount === undefined
? []
: [0x21, 0xff, 0x0b, ...ascii("NETSCAPE2.0"), 0x03, 0x01, ...u16(loopCount), 0x00];
return Uint8Array.from([
...ascii("GIF89a"),
...u16(1),
...u16(1),
0x00,
0x00,
0x00,
...loop,
...frames,
0x3b,
]);
}
function preparedGifCachePath(
cacheDir: string,
bytes: Uint8Array,
loopIterations: number,
padSeconds: number,
): string {
const hash = createHash("sha256")
.update("hfgif-v1")
.update("\0")
.update(String(loopIterations))
.update("\0")
.update(String(padSeconds))
.update("\0")
.update(bytes)
.digest("hex");
return join(cacheDir, `hfgif-v1-${hash.slice(0, 24)}.webm`);
}
describe("compileForRender animated GIF inputs", () => {
it("rewrites animated GIF images to prepared synced videos", async () => {
const projectDir = mkdtempSync(join(tmpdir(), "hf-compiler-gif-"));
const cacheDir = join(projectDir, "gif-cache");
mkdirSync(cacheDir, { recursive: true });
const bytes = gif([...frame(5), ...frame(15)], 0);
writeFileSync(join(projectDir, "reaction.gif"), bytes);
// 0.2s source in a 2s clip window: prep bakes 10 loop iterations (pad 0).
writeFileSync(preparedGifCachePath(cacheDir, bytes, 10, 0), "webm");
writeFileSync(
join(projectDir, "index.html"),
`<!doctype html>
<html><body>
<div data-composition-id="root" data-width="1920" data-height="1080" data-duration="3">
<img id="reaction" class="clip sticker" data-start="1" data-duration="2" src="reaction.gif" />
<script>
window.__timelines = window.__timelines || {};
window.__timelines.root = { duration: function() { return 3; } };
</script>
</div>
</body></html>`,
);
const compiled = await compileForRender(
projectDir,
join(projectDir, "index.html"),
projectDir,
{
animatedGifCacheDir: cacheDir,
},
);
const { document } = parseHTML(compiled.html);
const video = document.querySelector("video#reaction");
const src = video?.getAttribute("src") ?? "";
expect(document.querySelector("img#reaction")).toBeNull();
expect(video?.getAttribute("class")).toBe("clip sticker");
expect(video?.hasAttribute("loop")).toBe(true);
expect(video?.getAttribute("data-hf-prepared-gif")).toBe("true");
expect(video?.getAttribute("data-end")).toBe("3");
expect(src).toMatch(/^_animated_gif\/hfgif-v1-/);
expect(compiled.videos.some((entry) => entry.id === "reaction")).toBe(true);
expect(compiled.images.some((entry) => entry.id === "reaction")).toBe(false);
expect(compiled.externalAssets.has(src)).toBe(true);
expect(existsSync(join(projectDir, src))).toBe(true);
});
});
+44 -16
View File
@@ -39,6 +39,7 @@ import {
import { assertPublicHttpsUrl, downloadToTemp, isHttpUrl } from "../utils/urlDownloader.js";
import type { Page } from "puppeteer-core";
import { injectDeterministicFontFaces } from "./deterministicFonts.js";
import { prepareAnimatedGifInputs } from "./animatedGifPrep.js";
import { createStudioPositionSeekReapplyScript } from "@hyperframes/core/studio-api/manual-edits-render-script";
import { defaultLogger } from "../logger.js";
@@ -1307,6 +1308,13 @@ export interface CompileForRenderOptions {
* prevent host-specific font capture from leaking into the planDir.
*/
allowSystemFontCapture?: boolean;
/**
* Optional persistent cache directory for prep-time animated GIF WebM
* transcodes. When omitted, the render's downloadDir is used.
*/
animatedGifCacheDir?: string;
/** FFmpeg timeout for animated GIF transcodes. */
ffmpegProcessTimeout?: number;
}
const GSAP_CDN_BASE = "https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/";
@@ -1391,11 +1399,6 @@ export async function compileForRender(
// tags that depend on it, causing "gsap is not defined" errors.
const assembledHtml = await inlineExternalScripts(coalescedHtml);
// Collect assets that resolve outside projectDir (e.g. ../shared-assets/hero.png).
// These can't be served by the file server, so we map them to paths the
// orchestrator will copy into the compiled output directory.
const { html: htmlWithAssets, externalAssets } = collectExternalAssets(assembledHtml, projectDir);
// Inject studio position seek re-apply script when positions are baked into HTML.
// GSAP overwrites the `translate` CSS property on every frame seek; this script
// re-asserts the CSS custom property var() form after each seek so dragged
@@ -1406,13 +1409,13 @@ export async function compileForRender(
'data-hf-studio-rotation="true"',
'data-hf-studio-motion="',
];
const hasPositionEdits = HF_POSITION_ATTRS.some((attr) => htmlWithAssets.includes(attr));
const hasPositionEdits = HF_POSITION_ATTRS.some((attr) => assembledHtml.includes(attr));
const htmlWithPositionScript = hasPositionEdits
? htmlWithAssets.replace(
? assembledHtml.replace(
/<\/body>/i,
`<script>${createStudioPositionSeekReapplyScript()}</script></body>`,
)
: htmlWithAssets;
: assembledHtml;
// Download remote <video> and <audio> sources to compiledDir and rewrite the
// src attributes so the renderer reads from localhost. Remote S3 URLs cause
@@ -1423,9 +1426,6 @@ export async function compileForRender(
htmlWithPositionScript,
downloadDir,
);
for (const [relPath, absPath] of remoteMediaAssets) {
externalAssets.set(relPath, absPath);
}
// Download remote <img> sources. Same race shape as video/audio: the
// readiness gate can pass before Chrome decodes the pixels, and Chrome can
@@ -1433,9 +1433,6 @@ export async function compileForRender(
// blank-frame flicker. Localising to disk removes both races.
const { html: htmlWithLocalImages, remoteMediaAssets: remoteImageAssets } =
await localizeRemoteImageSources(htmlWithLocalMedia, downloadDir);
for (const [relPath, absPath] of remoteImageAssets) {
externalAssets.set(relPath, absPath);
}
// Download remote @font-face src URLs and rewrite to local paths.
// Remote font URLs fail with a CORS rejection at render time (S3 does not
@@ -1443,11 +1440,42 @@ export async function compileForRender(
// back to the next font in the stack.
const { html: htmlWithLocalizedFonts, remoteMediaAssets: remoteFontAssets } =
await localizeRemoteFontFaces(htmlWithLocalImages, downloadDir);
const gifSourceAssets = new Map<string, string>(remoteImageAssets);
const {
html: htmlWithPreparedGifs,
preparedAssets: preparedGifAssets,
preparedGifs,
} = await prepareAnimatedGifInputs(htmlWithLocalizedFonts, {
projectDir,
downloadDir,
cacheDir: options.animatedGifCacheDir,
sourceAssets: gifSourceAssets,
timeoutMs: options.ffmpegProcessTimeout,
});
if (preparedGifs.length > 0) {
defaultLogger.info(`[Compiler] Prepared ${preparedGifs.length} animated GIF input(s) as WebM`);
}
const embeddedHtml = await embedLocalFontFaces(htmlWithPreparedGifs, projectDir);
// Collect assets that resolve outside projectDir (e.g. ../shared-assets/hero.png).
// These can't be served by the file server, so we map them to paths the
// orchestrator will copy into the compiled output directory.
const { html, externalAssets } = collectExternalAssets(embeddedHtml, projectDir);
for (const [relPath, absPath] of remoteMediaAssets) {
externalAssets.set(relPath, absPath);
}
for (const [relPath, absPath] of remoteImageAssets) {
externalAssets.set(relPath, absPath);
}
for (const [relPath, absPath] of remoteFontAssets) {
externalAssets.set(relPath, absPath);
}
const html = await embedLocalFontFaces(htmlWithLocalizedFonts, projectDir);
for (const [relPath, absPath] of preparedGifAssets) {
externalAssets.set(relPath, absPath);
}
// Parse main HTML elements
const mainVideos = parseVideoElements(html);
@@ -123,6 +123,10 @@ export async function runCompileStage(input: CompileStageInput): Promise<Compile
const compiled = await compileForRender(projectDir, htmlPath, join(workDir, "downloads"), {
failClosedFontFetch: failClosedFontFetch === true,
allowSystemFontCapture,
animatedGifCacheDir: cfg.extractCacheDir
? join(cfg.extractCacheDir, "animated-gif")
: undefined,
ffmpegProcessTimeout: cfg.ffmpegProcessTimeout,
});
assertNotAborted();
const compileOnlyMs = Date.now() - compileStart;