mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
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:
co-authored by
Matt Van Horn
parent
30fcede44e
commit
edd85473e7
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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}`,
|
||||
),
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user