mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
fix(producer): gif renders survive rgb frames among transparent ones (#4610)
* fix(producer): gif renders survive rgb frames among transparent ones * test(producer): run the gif mixed-frame encode test in the ffmpeg lane * fix(producer): gif encode test passes the argv, reachability and comment checks * fix(producer): transparent gifs clear every frame so an opaque frame never lingers * fix(producer): only gifs that turn translucent after an opaque frame get whole cleared frames * fix(core): plain text keeps its own gif control block; gif encode failures share one shape
This commit is contained in:
@@ -184,7 +184,12 @@ export {
|
||||
export { RUNTIME_BOOTSTRAP_ATTR, stripEmbeddedRuntimeScripts } from "./compiler/htmlDocument";
|
||||
export { queryByAttr } from "./utils/cssSelector";
|
||||
export { decodeUrlPathVariants } from "./utils/urlPath";
|
||||
export { parseAnimatedGifMetadata, type AnimatedGifMetadata } from "./media/gif";
|
||||
export {
|
||||
clearGifFramesBeforeNext,
|
||||
gifClearsAfterLeavingFrameInPlace,
|
||||
parseAnimatedGifMetadata,
|
||||
type AnimatedGifMetadata,
|
||||
} from "./media/gif";
|
||||
export {
|
||||
HF_COLOR_GRADING_ATTR,
|
||||
HF_COLOR_GRADING_ADJUST_KEYS,
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseAnimatedGifMetadata } from "./gif";
|
||||
import {
|
||||
clearGifFramesBeforeNext,
|
||||
gifClearsAfterLeavingFrameInPlace,
|
||||
parseAnimatedGifMetadata,
|
||||
} from "./gif";
|
||||
|
||||
function u16(value: number): number[] {
|
||||
return [value & 0xff, (value >> 8) & 0xff];
|
||||
@@ -90,3 +94,68 @@ describe("parseAnimatedGifMetadata", () => {
|
||||
expect(parseAnimatedGifMetadata(Uint8Array.from([0x89, 0x50, 0x4e, 0x47]))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// A frame whose graphic control block has the given disposal and optional transparent index,
|
||||
// optionally carrying its own two-entry colour table.
|
||||
function controlledFrame(
|
||||
disposal: number,
|
||||
transparentIndex?: number,
|
||||
localTable = false,
|
||||
): number[] {
|
||||
const bytes = frame(10);
|
||||
bytes[3] = (disposal << 2) | (transparentIndex === undefined ? 0 : 1);
|
||||
bytes[6] = transparentIndex ?? 0;
|
||||
if (localTable) {
|
||||
bytes[17] = 0b1000_0000;
|
||||
bytes.splice(18, 0, 0, 0, 0, 255, 255, 255);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function controls(bytes: Uint8Array): number[][] {
|
||||
return [...bytes.keys()]
|
||||
.filter((i) => bytes[i] === 0x21 && bytes[i + 1] === 0xf9 && bytes[i + 2] === 0x04)
|
||||
.map((i) => [bytes[i + 3]!, bytes[i + 6]!]);
|
||||
}
|
||||
|
||||
describe("gifClearsAfterLeavingFrameInPlace", () => {
|
||||
it("is true only when a frame left in place is followed by one cleared to background", () => {
|
||||
const leftInPlace = controlledFrame(1);
|
||||
const cleared = controlledFrame(2, 1);
|
||||
expect(gifClearsAfterLeavingFrameInPlace(gif([...leftInPlace, ...cleared]))).toBe(true);
|
||||
expect(gifClearsAfterLeavingFrameInPlace(gif([...cleared, ...leftInPlace]))).toBe(false);
|
||||
expect(gifClearsAfterLeavingFrameInPlace(gif([...leftInPlace, ...leftInPlace]))).toBe(false);
|
||||
expect(gifClearsAfterLeavingFrameInPlace(gif([...cleared, ...cleared]))).toBe(false);
|
||||
expect(gifClearsAfterLeavingFrameInPlace(Uint8Array.from(ascii("not a gif")))).toBeNull();
|
||||
});
|
||||
|
||||
it("does not give an image the control block of the plain text before it", () => {
|
||||
const plainText = [0x21, 0x01, 12, ...new Array<number>(12).fill(0), 1, 65, 0];
|
||||
const textLeftInPlace = [...controlledFrame(1).slice(0, 8), ...plainText];
|
||||
const imageWithoutControl = controlledFrame(2, 1).slice(8);
|
||||
const bytes = gif([...textLeftInPlace, ...imageWithoutControl, ...controlledFrame(2, 1)]);
|
||||
expect(gifClearsAfterLeavingFrameInPlace(bytes)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearGifFramesBeforeNext", () => {
|
||||
it("clears every frame and names the shared table's transparent index where missing", () => {
|
||||
const bytes = gif([
|
||||
...controlledFrame(2, 1, true), // own table: its index means nothing in the shared one
|
||||
...controlledFrame(1),
|
||||
...controlledFrame(2, 0),
|
||||
]);
|
||||
|
||||
expect(clearGifFramesBeforeNext(bytes)).toBe(true);
|
||||
|
||||
expect(controls(bytes)).toEqual([
|
||||
[0b0000_1001, 1],
|
||||
[0b0000_1001, 0],
|
||||
[0b0000_1001, 0],
|
||||
]);
|
||||
});
|
||||
|
||||
it("refuses bytes that are not a GIF", () => {
|
||||
expect(clearGifFramesBeforeNext(Uint8Array.from(ascii("not a gif at all")))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -76,7 +76,17 @@ function parseApplicationExtension(
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseAnimatedGifMetadata(bytes: Uint8Array): AnimatedGifMetadata | null {
|
||||
const PLAIN_TEXT_LABEL = 0x01;
|
||||
|
||||
export interface GifFrameLayout {
|
||||
controlPackedOffset: number | null;
|
||||
localColorTable: boolean;
|
||||
}
|
||||
|
||||
export function parseAnimatedGifMetadata(
|
||||
bytes: Uint8Array,
|
||||
onFrame?: (frame: GifFrameLayout) => void,
|
||||
): AnimatedGifMetadata | null {
|
||||
if (bytes.length < 13) return null;
|
||||
const signature = readAscii(bytes, 0, 6);
|
||||
if (signature !== "GIF87a" && signature !== "GIF89a") return null;
|
||||
@@ -92,6 +102,7 @@ export function parseAnimatedGifMetadata(bytes: Uint8Array): AnimatedGifMetadata
|
||||
}
|
||||
|
||||
let frameCount = 0;
|
||||
let controlPackedOffset: number | null = null;
|
||||
const delaysCentiseconds: number[] = [];
|
||||
let loopCount: number | null = null;
|
||||
|
||||
@@ -113,6 +124,7 @@ export function parseAnimatedGifMetadata(bytes: Uint8Array): AnimatedGifMetadata
|
||||
const delay = readU16LE(bytes, pos + 2);
|
||||
if (delay == null) return null;
|
||||
delaysCentiseconds.push(normalizeDelayCentiseconds(delay));
|
||||
controlPackedOffset = pos + 1;
|
||||
pos += 1 + blockSize;
|
||||
if (bytes[pos] !== 0) return null;
|
||||
pos += 1;
|
||||
@@ -127,6 +139,7 @@ export function parseAnimatedGifMetadata(bytes: Uint8Array): AnimatedGifMetadata
|
||||
continue;
|
||||
}
|
||||
|
||||
if (label === PLAIN_TEXT_LABEL) controlPackedOffset = null;
|
||||
const next = skipSubBlocks(bytes, pos);
|
||||
if (next == null) return null;
|
||||
pos = next;
|
||||
@@ -136,8 +149,11 @@ export function parseAnimatedGifMetadata(bytes: Uint8Array): AnimatedGifMetadata
|
||||
if (introducer === 0x2c) {
|
||||
if (pos + 9 > bytes.length) return null;
|
||||
const imagePacked = bytes[pos + 8] ?? 0;
|
||||
const localColorTable = (imagePacked & 0b1000_0000) !== 0;
|
||||
onFrame?.({ controlPackedOffset, localColorTable });
|
||||
controlPackedOffset = null;
|
||||
pos += 9;
|
||||
if ((imagePacked & 0b1000_0000) !== 0) {
|
||||
if (localColorTable) {
|
||||
pos += colorTableByteLength(imagePacked);
|
||||
}
|
||||
if (pos >= bytes.length) return null;
|
||||
@@ -164,3 +180,56 @@ export function parseAnimatedGifMetadata(bytes: Uint8Array): AnimatedGifMetadata
|
||||
animated: frameCount > 1,
|
||||
};
|
||||
}
|
||||
|
||||
const DISPOSAL_BITS = 0b0001_1100;
|
||||
const LEAVE_IN_PLACE = 1 << 2;
|
||||
const DISPOSE_TO_BACKGROUND = 2 << 2;
|
||||
const HAS_TRANSPARENT_INDEX = 0b0000_0001;
|
||||
const TRANSPARENT_INDEX_AFTER_PACKED = 3;
|
||||
|
||||
function frameLayouts(bytes: Uint8Array): GifFrameLayout[] | null {
|
||||
const frames: GifFrameLayout[] = [];
|
||||
return parseAnimatedGifMetadata(bytes, (frame) => frames.push(frame)) === null ? null : frames;
|
||||
}
|
||||
|
||||
function disposalOf(bytes: Uint8Array, frame: GifFrameLayout): number | null {
|
||||
return frame.controlPackedOffset === null
|
||||
? null
|
||||
: (bytes[frame.controlPackedOffset] ?? 0) & DISPOSAL_BITS;
|
||||
}
|
||||
|
||||
export function gifClearsAfterLeavingFrameInPlace(bytes: Uint8Array): boolean | null {
|
||||
const frames = frameLayouts(bytes);
|
||||
if (frames === null) return null;
|
||||
let sawOpaque = false;
|
||||
for (const frame of frames) {
|
||||
const disposal = disposalOf(bytes, frame);
|
||||
if (disposal === LEAVE_IN_PLACE) sawOpaque = true;
|
||||
else if (disposal === DISPOSE_TO_BACKGROUND && sawOpaque) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function clearGifFramesBeforeNext(bytes: Uint8Array): boolean {
|
||||
const frames = frameLayouts(bytes);
|
||||
if (frames === null) return false;
|
||||
const controls = frames.flatMap((f) =>
|
||||
f.controlPackedOffset === null ? [] : [f.controlPackedOffset],
|
||||
);
|
||||
const shared = frames.flatMap((f) =>
|
||||
f.controlPackedOffset === null || f.localColorTable ? [] : [f.controlPackedOffset],
|
||||
);
|
||||
const hasIndex = (at: number) => ((bytes[at] ?? 0) & HAS_TRANSPARENT_INDEX) !== 0;
|
||||
const source = shared.find(hasIndex);
|
||||
for (const at of controls) {
|
||||
bytes[at] = ((bytes[at] ?? 0) & ~DISPOSAL_BITS) | DISPOSE_TO_BACKGROUND;
|
||||
}
|
||||
if (source === undefined) return true;
|
||||
for (const at of shared) {
|
||||
if (hasIndex(at)) continue;
|
||||
bytes[at] = (bytes[at] ?? 0) | HAS_TRANSPARENT_INDEX;
|
||||
bytes[at + TRANSPARENT_INDEX_AFTER_PACKED] =
|
||||
bytes[source + TRANSPARENT_INDEX_AFTER_PACKED] ?? 0;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ const INTEGRATION_TEST_FILES = new Set([
|
||||
"src/services/htmlCompiler.naturalDuration.test.ts",
|
||||
"src/services/render/hlsRender.integration.test.ts",
|
||||
"src/services/render/stages/compileStage.mediaType.test.ts",
|
||||
"src/services/render/stages/gifEncodeArgs.test.ts",
|
||||
"src/utils/audioRegression.test.ts",
|
||||
"src/utils/streamDurationParity.test.ts",
|
||||
]);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it, mock } from "bun:test";
|
||||
@@ -131,6 +131,8 @@ describe("gif encode args", () => {
|
||||
"-y",
|
||||
"-framerate",
|
||||
"15",
|
||||
"-reinit_filter",
|
||||
"0",
|
||||
"-i",
|
||||
"/tmp/hf/captured-frames/frame_%06d.jpg",
|
||||
"-vf",
|
||||
@@ -144,6 +146,8 @@ describe("gif encode args", () => {
|
||||
"-y",
|
||||
"-framerate",
|
||||
"15",
|
||||
"-reinit_filter",
|
||||
"0",
|
||||
"-i",
|
||||
"/tmp/hf/captured-frames/frame_%06d.jpg",
|
||||
"-i",
|
||||
@@ -402,20 +406,31 @@ describe("runEncodeStage config plumbing", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("encodes alpha GIFs from PNG frames with explicit transparency filters", async () => {
|
||||
async function encodeAlphaGif(paths: { framesDir: string; root: string }, outputPath: string) {
|
||||
const { runEncodeStage } = await import("./encodeStage.js");
|
||||
const paths = createFramesDir("png");
|
||||
|
||||
await runEncodeStage(
|
||||
return runEncodeStage(
|
||||
makeInput({
|
||||
framesDir: paths.framesDir,
|
||||
outputPath: join(paths.root, "out.gif"),
|
||||
outputPath,
|
||||
videoOnlyPath: join(paths.root, "video-only.mp4"),
|
||||
isGif: true,
|
||||
needsAlpha: true,
|
||||
captureImageFormat: "png",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
it("encodes alpha GIFs from PNG frames with explicit transparency filters", async () => {
|
||||
const paths = createFramesDir("png");
|
||||
// One 1x1 frame whose graphic control block says "leave in place" (disposal 1).
|
||||
const oneFrameGif = Buffer.from(
|
||||
"47494638396101000100800000000000ffffff21f90405000000002c00000000010001000002024401003b",
|
||||
"hex",
|
||||
);
|
||||
const outputPath = join(paths.root, "out.gif");
|
||||
writeFileSync(outputPath, oneFrameGif);
|
||||
|
||||
await encodeAlphaGif(paths, outputPath);
|
||||
|
||||
expect(runFfmpegMock).toHaveBeenCalledTimes(2);
|
||||
expect(runFfmpegMock.mock.calls[0]?.[0]).toContain(join(paths.framesDir, "frame_%06d.png"));
|
||||
@@ -426,6 +441,40 @@ describe("runEncodeStage config plumbing", () => {
|
||||
expect(runFfmpegMock.mock.calls[1]?.[0]).toContain(
|
||||
"fps=30 [x]; [x][1:v] paletteuse=dither=sierra2_4a:alpha_threshold=128",
|
||||
);
|
||||
// No frame turns translucent after an opaque one, so the plain encode is left untouched.
|
||||
expect(readFileSync(outputPath).equals(oneFrameGif)).toBe(true);
|
||||
});
|
||||
|
||||
it("re-encodes whole frames and clears them when a translucent frame follows an opaque one", async () => {
|
||||
const paths = createFramesDir("png");
|
||||
const image = "2c0000000001000100000202440100";
|
||||
// Frame 1 is left in place (opaque); frame 2 clears to background with transparent index 0.
|
||||
const staleProne = Buffer.from(
|
||||
`47494638396101000100800000000000ffffff21f9040400000000${image}21f9040900000000${image}3b`,
|
||||
"hex",
|
||||
);
|
||||
const outputPath = join(paths.root, "out.gif");
|
||||
writeFileSync(outputPath, staleProne);
|
||||
|
||||
await encodeAlphaGif(paths, outputPath);
|
||||
|
||||
expect(runFfmpegMock).toHaveBeenCalledTimes(3);
|
||||
expect(runFfmpegMock.mock.calls[1]?.[0]).not.toContain("-gifflags");
|
||||
expect(runFfmpegMock.mock.calls[2]?.[0]).toContain("-gifflags");
|
||||
const out = readFileSync(outputPath);
|
||||
const packed = [...out.keys()]
|
||||
.filter((i) => out[i] === 0x21 && out[i + 1] === 0xf9 && out[i + 2] === 0x04)
|
||||
.map((i) => out[i + 3]);
|
||||
expect(packed).toEqual([0b0000_1001, 0b0000_1001]);
|
||||
});
|
||||
|
||||
it("fails the encode when the GIF it wrote cannot be read back", async () => {
|
||||
const paths = createFramesDir("png");
|
||||
const outputPath = join(paths.root, "out.gif");
|
||||
writeFileSync(outputPath, "not a gif");
|
||||
|
||||
await expect(encodeAlphaGif(paths, outputPath)).rejects.toThrow("could not be parsed");
|
||||
expect(runFfmpegMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("keeps opaque GIF encoding on JPEG frames without alpha-only filters", async () => {
|
||||
@@ -442,6 +491,7 @@ describe("runEncodeStage config plumbing", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(runFfmpegMock.mock.calls[1]?.[0]).not.toContain("-gifflags");
|
||||
expect(runFfmpegMock.mock.calls[0]?.[0]).toContain(join(paths.framesDir, "frame_%06d.jpg"));
|
||||
expect(runFfmpegMock.mock.calls[0]?.[0]).not.toContain("reserve_transparent");
|
||||
expect(runFfmpegMock.mock.calls[1]?.[0]).not.toContain("alpha_threshold");
|
||||
|
||||
@@ -28,7 +28,16 @@
|
||||
* `success: false`.
|
||||
*/
|
||||
|
||||
import { copyFileSync, existsSync, mkdirSync, readdirSync, rmSync, statSync } from "node:fs";
|
||||
import {
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import {
|
||||
encodeFramesChunkedConcat,
|
||||
@@ -41,7 +50,11 @@ import {
|
||||
type EngineConfig,
|
||||
type EncodeResult,
|
||||
} from "@hyperframes/engine";
|
||||
import type { Fps } from "@hyperframes/core";
|
||||
import {
|
||||
clearGifFramesBeforeNext,
|
||||
gifClearsAfterLeavingFrameInPlace,
|
||||
type Fps,
|
||||
} from "@hyperframes/core";
|
||||
import type { ProducerLogger } from "../../../logger.js";
|
||||
import { formatExportFrameName } from "../../../utils/paths.js";
|
||||
import type { ProgressCallback, RenderJob } from "../../renderOrchestrator.js";
|
||||
@@ -131,6 +144,43 @@ function resolveGifLoop(loop: number | undefined): number {
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function gifEncodeFailure(
|
||||
startTime: number,
|
||||
outputPath: string,
|
||||
error: string,
|
||||
failureReason?: EncodeResult["failureReason"],
|
||||
): EncodeResult {
|
||||
return {
|
||||
success: false,
|
||||
outputPath,
|
||||
durationMs: Date.now() - startTime,
|
||||
framesEncoded: 0,
|
||||
fileSize: 0,
|
||||
error,
|
||||
failureReason,
|
||||
};
|
||||
}
|
||||
|
||||
async function reencodeWithClearedWholeFrames(
|
||||
argsInput: GifEncodeArgsInput,
|
||||
run: { signal?: AbortSignal; timeout: number },
|
||||
startTime: number,
|
||||
): Promise<EncodeResult | null> {
|
||||
const { outputPath } = argsInput;
|
||||
const result = await runFfmpeg(buildGifPaletteuseArgs({ ...argsInput, wholeFrames: true }), run);
|
||||
if (!result.success) {
|
||||
const error = formatFfmpegError(result.exitCode, result.stderr);
|
||||
return gifEncodeFailure(startTime, outputPath, error, result.failureReason);
|
||||
}
|
||||
const gif = readFileSync(outputPath);
|
||||
if (!clearGifFramesBeforeNext(gif)) {
|
||||
const error = "[FFmpeg] GIF output could not be parsed to clear its frames";
|
||||
return gifEncodeFailure(startTime, outputPath, error);
|
||||
}
|
||||
writeFileSync(outputPath, gif);
|
||||
return null;
|
||||
}
|
||||
|
||||
async function encodeGifFromDir(
|
||||
framesDir: string,
|
||||
framePattern: string,
|
||||
@@ -148,14 +198,7 @@ async function encodeGifFromDir(
|
||||
const files = readdirSync(framesDir).filter((file) => file.match(/\.(jpg|jpeg|png)$/i));
|
||||
const frameCount = files.length;
|
||||
if (frameCount === 0) {
|
||||
return {
|
||||
success: false,
|
||||
outputPath,
|
||||
durationMs: Date.now() - startTime,
|
||||
framesEncoded: 0,
|
||||
fileSize: 0,
|
||||
error: "[FFmpeg] No frame files found in directory",
|
||||
};
|
||||
return gifEncodeFailure(startTime, outputPath, "[FFmpeg] No frame files found in directory");
|
||||
}
|
||||
|
||||
const argsInput: GifEncodeArgsInput = {
|
||||
@@ -168,36 +211,20 @@ async function encodeGifFromDir(
|
||||
preserveAlpha: input.preserveAlpha,
|
||||
};
|
||||
try {
|
||||
const paletteResult = await runFfmpeg(buildGifPalettegenArgs(argsInput), {
|
||||
signal: input.signal,
|
||||
timeout: input.timeout,
|
||||
});
|
||||
if (!paletteResult.success) {
|
||||
return {
|
||||
success: false,
|
||||
outputPath,
|
||||
durationMs: Date.now() - startTime,
|
||||
framesEncoded: 0,
|
||||
fileSize: 0,
|
||||
error: formatFfmpegError(paletteResult.exitCode, paletteResult.stderr),
|
||||
failureReason: paletteResult.failureReason,
|
||||
};
|
||||
const run = { signal: input.signal, timeout: input.timeout };
|
||||
for (const args of [buildGifPalettegenArgs(argsInput), buildGifPaletteuseArgs(argsInput)]) {
|
||||
const result = await runFfmpeg(args, run);
|
||||
if (!result.success) {
|
||||
const error = formatFfmpegError(result.exitCode, result.stderr);
|
||||
return gifEncodeFailure(startTime, outputPath, error, result.failureReason);
|
||||
}
|
||||
}
|
||||
|
||||
const gifResult = await runFfmpeg(buildGifPaletteuseArgs(argsInput), {
|
||||
signal: input.signal,
|
||||
timeout: input.timeout,
|
||||
});
|
||||
if (!gifResult.success) {
|
||||
return {
|
||||
success: false,
|
||||
outputPath,
|
||||
durationMs: Date.now() - startTime,
|
||||
framesEncoded: 0,
|
||||
fileSize: 0,
|
||||
error: formatFfmpegError(gifResult.exitCode, gifResult.stderr),
|
||||
failureReason: gifResult.failureReason,
|
||||
};
|
||||
if (
|
||||
input.preserveAlpha &&
|
||||
gifClearsAfterLeavingFrameInPlace(readFileSync(outputPath)) !== false
|
||||
) {
|
||||
const failure = await reencodeWithClearedWholeFrames(argsInput, run, startTime);
|
||||
if (failure) return failure;
|
||||
}
|
||||
|
||||
const fileSize = existsSync(outputPath) ? statSync(outputPath).size : 0;
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { buildGifPalettegenArgs, buildGifPaletteuseArgs } from "./gifEncodeArgs.js";
|
||||
import { runEncodeStage, type EncodeStageInput } from "./encodeStage.js";
|
||||
|
||||
const ffmpeg = (args: string[]) => spawnSync("ffmpeg", args);
|
||||
|
||||
describe("GIF encode of RGB frames among RGBA frames", () => {
|
||||
it("encodes every frame without rebuilding the filter graph", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "hf-gif-mixed-"));
|
||||
try {
|
||||
const frame = (i: number) => join(dir, `frame_${String(i).padStart(6, "0")}.png`);
|
||||
for (const [i, color, pixFmt] of [
|
||||
[1, "red@0.2", "rgba"],
|
||||
[2, "blue", "rgb24"],
|
||||
] as const) {
|
||||
const lavfi = `color=c=${color}:s=64x36,format=rgba`;
|
||||
expect(
|
||||
ffmpeg(["-f", "lavfi", "-i", lavfi, "-frames:v", "1", "-pix_fmt", pixFmt, frame(i)])
|
||||
.status,
|
||||
).toBe(0);
|
||||
}
|
||||
for (let i = 3; i <= 16; i++) copyFileSync(frame(2 - (i % 2)), frame(i));
|
||||
const args = {
|
||||
framesDir: dir,
|
||||
framePattern: "frame_%06d.png",
|
||||
palettePath: join(dir, "palette.png"),
|
||||
outputPath: join(dir, "out.gif"),
|
||||
fps: { num: 10, den: 1 },
|
||||
loop: 0,
|
||||
preserveAlpha: true,
|
||||
};
|
||||
expect(ffmpeg(buildGifPalettegenArgs(args)).status).toBe(0);
|
||||
// Without the fix the crash is a race that most, not all, runs lose.
|
||||
for (let run = 0; run < 5; run++) {
|
||||
const encode = ffmpeg(buildGifPaletteuseArgs(args));
|
||||
expect(encode.stderr.toString()).not.toContain("Reconfiguring filter graph");
|
||||
expect(encode.status).toBe(0);
|
||||
}
|
||||
const decoded = ffmpeg([
|
||||
"-i",
|
||||
args.outputPath,
|
||||
"-vf",
|
||||
"crop=1:1:0:0,format=rgba",
|
||||
"-f",
|
||||
"rawvideo",
|
||||
"-",
|
||||
]);
|
||||
const alphas = [...decoded.stdout].filter((_, n) => n % 4 === 3);
|
||||
expect(alphas).toHaveLength(16);
|
||||
expect(alphas.slice(0, 2)).toEqual([0, 255]);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
function encodeInput(dir: string, framesDir: string): EncodeStageInput {
|
||||
return {
|
||||
job: { config: { fps: { num: 10, den: 1 }, gifLoop: 0 } },
|
||||
log: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} },
|
||||
outputPath: join(dir, "out.gif"),
|
||||
framesDir,
|
||||
videoOnlyPath: join(dir, "video-only.mp4"),
|
||||
needsAlpha: true,
|
||||
captureImageFormat: "png",
|
||||
hasAudio: false,
|
||||
isPngSequence: false,
|
||||
isGif: true,
|
||||
engineConfig: { ffmpegEncodeTimeout: 60_000 },
|
||||
abortSignal: undefined,
|
||||
assertNotAborted: () => {},
|
||||
} as unknown as EncodeStageInput;
|
||||
}
|
||||
|
||||
// Writes one PNG per lavfi source, each converted to the given pixel format.
|
||||
function writeFrames(framesDir: string, sources: ReadonlyArray<readonly [string, string]>): void {
|
||||
mkdirSync(framesDir, { recursive: true });
|
||||
sources.forEach(([lavfi, pixFmt], n) => {
|
||||
const out = join(framesDir, `frame_${String(n + 1).padStart(6, "0")}.png`);
|
||||
const args = ["-f", "lavfi", "-i", lavfi, "-frames:v", "1", "-pix_fmt", pixFmt, out];
|
||||
expect(ffmpeg(args).status).toBe(0);
|
||||
});
|
||||
}
|
||||
|
||||
const PATTERN = "testsrc2=s=64x36:d=1,format=rgba";
|
||||
const SMALL_CHANGE = ",drawbox=x=24:y=12:w=8:h=8:color=red:t=fill";
|
||||
|
||||
describe("transparent GIF encode", () => {
|
||||
it("keeps opaque frames whole and transparent pixels transparent after them", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "hf-gif-dispose-"));
|
||||
try {
|
||||
const framesDir = join(dir, "frames");
|
||||
// The third frame changes only a small box, so ffmpeg would crop it against the second.
|
||||
writeFrames(framesDir, [
|
||||
["color=c=red@0.2:s=64x36,format=rgba", "rgba"],
|
||||
[PATTERN, "rgb24"],
|
||||
[PATTERN + SMALL_CHANGE, "rgb24"],
|
||||
["color=c=green@0.2:s=64x36,format=rgba", "rgba"],
|
||||
]);
|
||||
const input = encodeInput(dir, framesDir);
|
||||
await runEncodeStage(input);
|
||||
const decoded = ffmpeg(["-i", input.outputPath, "-vf", "format=rgba", "-f", "rawvideo", "-"]);
|
||||
const pixels = 64 * 36;
|
||||
const transparentPerFrame = [0, 1, 2, 3].map((f) => {
|
||||
let count = 0;
|
||||
for (let p = 0; p < pixels; p++)
|
||||
if (decoded.stdout[(f * pixels + p) * 4 + 3] === 0) count++;
|
||||
return count;
|
||||
});
|
||||
expect(transparentPerFrame).toEqual([pixels, 0, 0, pixels]);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
it.each([
|
||||
[
|
||||
"all opaque",
|
||||
[
|
||||
[PATTERN, "rgb24"],
|
||||
["color=c=blue:s=64x36,format=rgba", "rgb24"],
|
||||
[PATTERN + SMALL_CHANGE, "rgb24"],
|
||||
],
|
||||
],
|
||||
[
|
||||
"translucent throughout",
|
||||
[
|
||||
["color=c=red@0.2:s=64x36,format=rgba" + SMALL_CHANGE, "rgba"],
|
||||
["color=c=green@0.2:s=64x36,format=rgba", "rgba"],
|
||||
],
|
||||
],
|
||||
] as const)(
|
||||
"leaves a GIF that is %s exactly as the plain encode",
|
||||
async (_name, sources) => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "hf-gif-plain-"));
|
||||
try {
|
||||
const framesDir = join(dir, "frames");
|
||||
writeFrames(framesDir, sources);
|
||||
const input = encodeInput(dir, framesDir);
|
||||
await runEncodeStage(input);
|
||||
const plain = {
|
||||
framesDir,
|
||||
framePattern: "frame_%06d.png",
|
||||
palettePath: join(dir, "plain-palette.png"),
|
||||
outputPath: join(dir, "plain.gif"),
|
||||
fps: { num: 10, den: 1 },
|
||||
loop: 0,
|
||||
preserveAlpha: true,
|
||||
};
|
||||
expect(ffmpeg(buildGifPalettegenArgs(plain)).status).toBe(0);
|
||||
expect(ffmpeg(buildGifPaletteuseArgs(plain)).status).toBe(0);
|
||||
expect(readFileSync(input.outputPath).equals(readFileSync(plain.outputPath))).toBe(true);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
60_000,
|
||||
);
|
||||
});
|
||||
@@ -9,21 +9,32 @@ export interface GifEncodeArgsInput {
|
||||
fps: Fps;
|
||||
loop: number;
|
||||
preserveAlpha: boolean;
|
||||
wholeFrames?: boolean;
|
||||
}
|
||||
|
||||
function fpsToFfmpegArg(fps: Fps): string {
|
||||
return fps.den === 1 ? String(fps.num) : `${fps.num}/${fps.den}`;
|
||||
}
|
||||
|
||||
const KEEP_FILTER_GRAPH_WHEN_FRAMES_DROP_ALPHA = ["-reinit_filter", "0"];
|
||||
const WRITE_EVERY_FRAME_WHOLE = ["-gifflags", "0"];
|
||||
|
||||
function framesInput(input: GifEncodeArgsInput, fpsArg: string): string[] {
|
||||
return [
|
||||
"-framerate",
|
||||
fpsArg,
|
||||
...KEEP_FILTER_GRAPH_WHEN_FRAMES_DROP_ALPHA,
|
||||
"-i",
|
||||
join(input.framesDir, input.framePattern),
|
||||
];
|
||||
}
|
||||
|
||||
export function buildGifPalettegenArgs(input: GifEncodeArgsInput): string[] {
|
||||
const fpsArg = fpsToFfmpegArg(input.fps);
|
||||
const transparency = input.preserveAlpha ? ":reserve_transparent=1" : "";
|
||||
return [
|
||||
"-y",
|
||||
"-framerate",
|
||||
fpsArg,
|
||||
"-i",
|
||||
join(input.framesDir, input.framePattern),
|
||||
...framesInput(input, fpsArg),
|
||||
"-vf",
|
||||
`fps=${fpsArg},palettegen=stats_mode=diff${transparency}`,
|
||||
input.palettePath,
|
||||
@@ -35,16 +46,14 @@ export function buildGifPaletteuseArgs(input: GifEncodeArgsInput): string[] {
|
||||
const transparency = input.preserveAlpha ? ":alpha_threshold=128" : "";
|
||||
return [
|
||||
"-y",
|
||||
"-framerate",
|
||||
fpsArg,
|
||||
"-i",
|
||||
join(input.framesDir, input.framePattern),
|
||||
...framesInput(input, fpsArg),
|
||||
"-i",
|
||||
input.palettePath,
|
||||
"-lavfi",
|
||||
`fps=${fpsArg} [x]; [x][1:v] paletteuse=dither=sierra2_4a${transparency}`,
|
||||
"-loop",
|
||||
String(input.loop),
|
||||
...(input.wholeFrames ? WRITE_EVERY_FRAME_WHOLE : []),
|
||||
input.outputPath,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
],
|
||||
"sources": {
|
||||
"packages/producer/scripts/run-test-lane.mjs": "bff02680f19eebcce0df72cdeedbbc05edfdcede3f3f60658fc0cb18ed8c59f6",
|
||||
"packages/producer/scripts/test-classification.mjs": "040fd35bae0ec736c61b78c74c6836ee0f49d4ff3860a95dc75fa52f1c939822",
|
||||
"packages/producer/scripts/test-classification.mjs": "e68069c96f9c77a8fb415eab2f31a0e730234b352a456a0ccc95b811053132fd",
|
||||
".github/workflows/ci.yml#job#producer-source-tests": "3709c6ac6c9f5c7c66f57fc6c1c106aec4ffe906f919eb7cdf2a6ce1d4e0974c",
|
||||
"packages/producer/package.json#script#test:unit": "ae6beda8319d166774be958ea5593cd27dbf7f6c9bce1d787a3fe28dfc3477b1",
|
||||
"packages/producer/package.json#script#test:integration": "008589f37d026877595a13501cbb66abd5cbe50e21fdcac6904f45466c3f475d",
|
||||
|
||||
Reference in New Issue
Block a user