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:
Miguel Ángel
2026-09-28 06:48:49 +00:00
committed by GitHub
parent e7a0b6be2c
commit 5383e486bb
9 changed files with 451 additions and 58 deletions
+6 -1
View File
@@ -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,
+70 -1
View File
@@ -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);
});
});
+71 -2
View File
@@ -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,
];
}
+1 -1
View File
@@ -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",