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
+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,
};
}