Files
webadderall eaf87be95b Recordly 2.0 local review stack 2/6
Dependency-ordered review preparation. Not pushed or remotely reviewed.
2026-11-06 05:24:21 +11:00

330 lines
10 KiB
TypeScript

import { spawn } from "node:child_process";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
export const MAX_GIF_OPTIMIZATION_BYTES = 512 * 1024 * 1024;
const DEFAULT_TIMEOUT_MS = 60_000;
const TARGET_RATIO = 0.1;
const MIN_LONG_EDGE = 320;
/** Predict the next resolution from measured bytes, with a small safety margin.
* Never upscale or shrink an already-small GIF just to chase an impossible target. */
export function nextGifTargetDimensions(
width: number,
height: number,
bytes: number,
targetBytes: number,
): { width: number; height: number } | null {
const longEdge = Math.max(width, height);
if (bytes <= targetBytes || longEdge <= MIN_LONG_EDGE) return null;
const scale = Math.max(
MIN_LONG_EDGE / longEdge,
Math.min(0.85, Math.sqrt(targetBytes / bytes) * 0.9),
);
return {
width: Math.max(1, Math.floor(width * scale)),
height: Math.max(1, Math.floor(height * scale)),
};
}
export interface GifAnimationMetadata {
width: number;
height: number;
/** Original hundredths of a second, including zero/one-centisecond delays. */
delays: number[];
/** null means no loop extension; zero means infinite looping. */
loopCount: number | null;
}
interface ParsedGif extends GifAnimationMetadata {
delayOffsets: Array<number | null>;
}
function parseGif(input: Uint8Array): ParsedGif {
const invalid = () => new Error("Invalid or unsupported GIF animation");
if (input.byteLength < 14 || input.byteLength > MAX_GIF_OPTIMIZATION_BYTES) {
throw invalid();
}
const signature = String.fromCharCode(...input.subarray(0, 6));
if (signature !== "GIF89a" && signature !== "GIF87a") throw invalid();
const word = (offset: number) => input[offset] | (input[offset + 1] << 8);
const width = word(6);
const height = word(8);
if (width === 0 || height === 0 || width > 8192 || height > 8192) throw invalid();
let cursor = 13 + (input[10] & 0x80 ? 3 * 2 ** ((input[10] & 7) + 1) : 0);
const delays: number[] = [];
const delayOffsets: Array<number | null> = [];
let loopCount: number | null = null;
let pendingDelayOffset: number | null = null;
const skipBlocks = () => {
while (cursor < input.length) {
const length = input[cursor++];
if (length === 0) return;
cursor += length;
if (cursor > input.length) throw invalid();
}
throw invalid();
};
while (cursor < input.length) {
const marker = input[cursor++];
if (marker === 0x3b) {
if (delays.length === 0) throw invalid();
return { width, height, delays, loopCount, delayOffsets };
}
if (marker === 0x21) {
const label = input[cursor++];
if (label === 0xf9) {
if (cursor + 6 > input.length || input[cursor] !== 4 || input[cursor + 5] !== 0) {
throw invalid();
}
pendingDelayOffset = cursor + 2;
cursor += 6;
} else {
if (label === 0xff && input[cursor] === 11) {
const application = String.fromCharCode(
...input.subarray(cursor + 1, cursor + 12),
);
if (application === "NETSCAPE2.0" || application === "ANIMEXTS1.0") {
if (
cursor + 17 > input.length ||
input[cursor + 12] !== 3 ||
input[cursor + 13] !== 1
) {
throw invalid();
}
loopCount = word(cursor + 14);
}
}
// Plain-text rendering is outside the encoder's image-only contract.
if (label === 0x01) throw invalid();
skipBlocks();
}
} else if (marker === 0x2c) {
if (cursor + 9 > input.length) throw invalid();
const frameWidth = word(cursor + 4);
const frameHeight = word(cursor + 6);
if (
!frameWidth ||
!frameHeight ||
word(cursor) + frameWidth > width ||
word(cursor + 2) + frameHeight > height
) {
throw invalid();
}
const packed = input[cursor + 8];
cursor += 9 + (packed & 0x80 ? 3 * 2 ** ((packed & 7) + 1) : 0);
if (cursor >= input.length || input[cursor] < 2 || input[cursor] > 8) throw invalid();
cursor++; // LZW minimum code size.
skipBlocks();
delays.push(pendingDelayOffset === null ? 0 : word(pendingDelayOffset));
delayOffsets.push(pendingDelayOffset);
pendingDelayOffset = null;
} else {
throw invalid();
}
}
throw invalid();
}
export function inspectGifAnimation(input: Uint8Array): GifAnimationMetadata {
const { delayOffsets: _offsets, ...metadata } = parseGif(input);
return metadata;
}
export function buildGifOptimizationArgs(
inputPath: string,
outputPath: string,
metadata: GifAnimationMetadata,
palettePath: string,
dimensions: { width: number; height: number } = metadata,
): string[] {
return [
"-hide_banner",
"-loglevel",
"error",
"-nostdin",
"-y",
"-threads",
"1",
"-filter_complex_threads",
"1",
"-f",
"gif",
"-min_delay",
"0",
"-ignore_loop",
"1",
"-i",
inputPath,
"-i",
palettePath,
"-filter_complex",
dimensions.width === metadata.width && dimensions.height === metadata.height
? "[0:v][1:v]paletteuse=dither=none:diff_mode=rectangle[out]"
: `[0:v]scale=${dimensions.width}:${dimensions.height}:flags=lanczos[scaled];[scaled][1:v]paletteuse=dither=none:diff_mode=rectangle[out]`,
"-map",
"[out]",
"-an",
"-fps_mode",
"passthrough",
"-gifflags",
"+transdiff+offsetting",
"-loop",
String(metadata.loopCount ?? -1),
"-final_delay",
String(metadata.delays[metadata.delays.length - 1]),
outputPath,
];
}
export interface GifCompressionOptions {
ffmpegPath: string;
signal?: AbortSignal;
/** Bounded to the production deadline; useful for deterministic cancellation tests. */
timeoutMs?: number;
}
/** Target 90% smaller using a shared 64-color palette and measured resolution steps.
* Preserve every frame, its exact delay, and looping; never return a larger GIF. */
export async function compressGifBuffer(
input: Uint8Array,
options: GifCompressionOptions,
): Promise<Uint8Array> {
const metadata = parseGif(input);
const controller = new AbortController();
const forwardAbort = () => controller.abort(options.signal?.reason);
options.signal?.addEventListener("abort", forwardAbort, { once: true });
if (options.signal?.aborted) forwardAbort();
const timeout = setTimeout(
() => controller.abort(new Error("GIF optimization timed out")),
Math.min(DEFAULT_TIMEOUT_MS, Math.max(1, options.timeoutMs ?? DEFAULT_TIMEOUT_MS)),
);
const checkAborted = () => {
if (controller.signal.aborted) {
throw controller.signal.reason instanceof Error
? controller.signal.reason
: new Error("GIF optimization canceled");
}
};
let tempDirectory: string | undefined;
let best = input;
try {
checkAborted();
tempDirectory = await fs.mkdtemp(path.join(os.tmpdir(), "recordly-gif-"));
checkAborted();
const inputPath = path.join(tempDirectory, "input.gif");
const outputPath = path.join(tempDirectory, "optimized.gif");
await fs.writeFile(inputPath, input);
checkAborted();
const runFfmpeg = (args: string[]) =>
new Promise<void>((resolve, reject) => {
checkAborted();
const process = spawn(options.ffmpegPath, args, {
windowsHide: true,
stdio: ["ignore", "ignore", "pipe"],
});
let stderr = "";
process.stderr.on("data", (chunk: Buffer) => {
stderr = (stderr + chunk.toString()).slice(-4096);
});
const abort = () => process.kill("SIGKILL");
controller.signal.addEventListener("abort", abort, { once: true });
if (controller.signal.aborted) abort();
// Waiting for close also waits for stdio and process exit before removing its files.
process.once("error", reject);
process.once("close", (code) => {
controller.signal.removeEventListener("abort", abort);
if (controller.signal.aborted) {
reject(
controller.signal.reason instanceof Error
? controller.signal.reason
: new Error("GIF optimization canceled"),
);
} else if (code !== 0) {
reject(
new Error(`FFmpeg GIF optimization failed (${code}): ${stderr.trim()}`),
);
} else resolve();
});
});
const palettePath = path.join(tempDirectory, "palette.png");
// Separate passes keep memory bounded: a split/palettegen graph buffers the
// entire decoded animation while waiting for the global palette.
await runFfmpeg([
"-hide_banner",
"-loglevel",
"error",
"-nostdin",
"-y",
"-threads",
"1",
"-filter_threads",
"1",
"-f",
"gif",
"-min_delay",
"0",
"-ignore_loop",
"1",
"-i",
inputPath,
"-vf",
"palettegen=max_colors=64:reserve_transparent=1:stats_mode=full",
"-frames:v",
"1",
palettePath,
]);
const targetBytes = Math.max(1, Math.floor(input.byteLength * TARGET_RATIO));
let dimensions = { width: metadata.width, height: metadata.height };
// Keep the original source and palette for each pass: no cumulative resampling.
// Most exports need only the full-size pass and one predicted smaller size.
for (let attempt = 0; attempt < 4; attempt++) {
await runFfmpeg(
buildGifOptimizationArgs(inputPath, outputPath, metadata, palettePath, dimensions),
);
checkAborted();
const stat = await fs.stat(outputPath);
if (stat.size > MAX_GIF_OPTIMIZATION_BYTES)
throw new Error("Optimized GIF is too large");
const output = await fs.readFile(outputPath);
checkAborted();
const compressed = parseGif(output);
if (
compressed.width !== dimensions.width ||
compressed.height !== dimensions.height ||
compressed.delays.length !== metadata.delays.length ||
compressed.loopCount !== metadata.loopCount
)
throw new Error("GIF optimization changed animation structure");
// FFmpeg normalizes very short delays; restore their exact encoded values.
for (const [index, delay] of metadata.delays.entries()) {
const offset = compressed.delayOffsets[index];
if (offset === null) {
if (delay !== 0) throw new Error("GIF optimization omitted frame timing");
} else output.writeUInt16LE(delay, offset);
}
if (output.byteLength < best.byteLength) best = output;
if (best.byteLength <= targetBytes) break;
const smaller = nextGifTargetDimensions(
dimensions.width,
dimensions.height,
output.byteLength,
targetBytes,
);
if (!smaller) break;
dimensions = smaller;
}
return best;
} catch (error) {
// A failed/deadline-limited later pass must not discard an earlier valid win.
// Explicit cancellation still stops the export rather than saving a partial result.
if (best !== input && !options.signal?.aborted) return best;
throw error;
} finally {
clearTimeout(timeout);
options.signal?.removeEventListener("abort", forwardAbort);
if (tempDirectory) await fs.rm(tempDirectory, { recursive: true, force: true });
}
}