Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/guides/rendering.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ Use GIF when the output needs to autoplay inline in GitHub PRs, READMEs, issue r
npx hyperframes render --format gif --fps 15 --gif-loop 0 --output demo.gif
```

GIF output uses a two-pass FFmpeg palette encode (`palettegen` with diff statistics, then `paletteuse` with Sierra dithering) for better gradients and text edges than a single-pass conversion. GIFs are still much larger than MP4/WebM at the same dimensions, so prefer `--fps 15` and short compositions. Hyperframes caps GIF renders at 30fps.
GIF output uses a two-pass FFmpeg palette encode (`palettegen` with diff statistics, then `paletteuse` with Sierra dithering) for better gradients and text edges than a single-pass conversion. GIFs are still much larger than MP4/WebM at the same dimensions, so prefer short compositions. GIF renders are capped at 30fps; pass `--fps 15` for smaller files.

GIF does not carry audio and only has 1-bit transparency. For transparent overlays, use `--format webm`, `--format mov`, or `--format png-sequence` instead.

Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/server/studioServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ 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;
/<img\b[^>]*?\bsrc\s*=\s*["'](https?:\/\/[^"']+\.gif(?:[?#][^"']*)?)["'][^>]*>/gi;

// ── Path resolution ─────────────────────────────────────────────────────────

Expand Down
9 changes: 9 additions & 0 deletions packages/core/src/media/gif.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,15 @@ describe("parseAnimatedGifMetadata", () => {
expect(metadata?.durationSeconds).toBe(0.2);
});

it("clamps all-zero frame delays to the browser playback minimum", () => {
const frames = Array.from({ length: 10 }, () => frame(0)).flat();
const metadata = parseAnimatedGifMetadata(gif(frames, 0));

expect(metadata?.animated).toBe(true);
expect(metadata?.delaysCentiseconds).toEqual(Array.from({ length: 10 }, () => 10));
expect(metadata?.durationSeconds).toBe(1);
});

it("reads Netscape loop metadata", () => {
const metadata = parseAnimatedGifMetadata(gif([...frame(8), ...frame(8)], 0));

Expand Down
13 changes: 10 additions & 3 deletions packages/core/src/media/gif.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@ export interface AnimatedGifMetadata {
animated: boolean;
}

const BROWSER_MIN_DELAY_CENTISECONDS = 10;

function normalizeDelayCentiseconds(delay: number): number {
// Chrome clamps GIF frame delays <= 1cs to 10cs (100ms); mirror browser playback timing.
if (delay <= 1) return BROWSER_MIN_DELAY_CENTISECONDS;
return Math.max(0, delay);
}

function readAscii(bytes: Uint8Array, start: number, length: number): string {
if (start + length > bytes.length) return "";
let value = "";
Expand Down Expand Up @@ -104,7 +112,7 @@ export function parseAnimatedGifMetadata(bytes: Uint8Array): AnimatedGifMetadata
if (blockSize !== 4 || pos + 6 > bytes.length) return null;
const delay = readU16LE(bytes, pos + 2);
if (delay == null) return null;
delaysCentiseconds.push(delay);
delaysCentiseconds.push(normalizeDelayCentiseconds(delay));
pos += 1 + blockSize;
if (bytes[pos] !== 0) return null;
pos += 1;
Expand Down Expand Up @@ -144,8 +152,7 @@ export function parseAnimatedGifMetadata(bytes: Uint8Array): AnimatedGifMetadata
return null;
}

const durationSeconds =
delaysCentiseconds.reduce((total, delay) => total + Math.max(0, delay), 0) / 100;
const durationSeconds = delaysCentiseconds.reduce((total, delay) => total + delay, 0) / 100;

return {
width,
Expand Down
18 changes: 18 additions & 0 deletions packages/producer/src/services/animatedGifPrep.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,4 +208,22 @@ describe("prepareAnimatedGifInputs", () => {
);
expect(result.preparedGifs[0]?.sourceSrc).toBe(sourceUrl);
});

it("propagates actionable transcode failure messages", async () => {
const projectDir = makeProject();
const sourcePath = join(projectDir, "broken.gif");
writeFileSync(sourcePath, gif([...frame(10), ...frame(10)], 0));

await expect(
prepareAnimatedGifInputs(`<img src="broken.gif" />`, {
projectDir,
downloadDir: projectDir,
transcode: async (request) => {
throw new Error(
`ffmpeg failed for ${request.inputPath}: Invalid data found when processing input`,
);
},
}),
).rejects.toThrow(`ffmpeg failed for ${sourcePath}: Invalid data found`);
});
});
71 changes: 38 additions & 33 deletions packages/producer/src/services/render/stages/encodeStage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
* `success: false`.
*/

import { copyFileSync, existsSync, mkdirSync, readdirSync, statSync } from "node:fs";
import { copyFileSync, existsSync, mkdirSync, readdirSync, rmSync, statSync } from "node:fs";
import { dirname, join } from "node:path";
import {
DEFAULT_CONFIG,
Expand Down Expand Up @@ -145,44 +145,49 @@ async function encodeGifFromDir(
fps: input.fps,
loop: input.loop,
};
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),
};
}
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),
};
}

const gifResult = await runFfmpeg(buildGifPaletteuseArgs(argsInput), {
signal: input.signal,
timeout: input.timeout,
});
if (!gifResult.success) {
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),
};
}

const fileSize = existsSync(outputPath) ? statSync(outputPath).size : 0;
return {
success: false,
success: true,
outputPath,
durationMs: Date.now() - startTime,
framesEncoded: 0,
fileSize: 0,
error: formatFfmpegError(gifResult.exitCode, gifResult.stderr),
framesEncoded: frameCount,
fileSize,
};
} finally {
// The GIF palette is a temp file; remove it after success or any encode failure.
rmSync(input.palettePath, { force: true });
}

const fileSize = existsSync(outputPath) ? statSync(outputPath).size : 0;
return {
success: true,
outputPath,
durationMs: Date.now() - startTime,
framesEncoded: frameCount,
fileSize,
};
}

export async function runEncodeStage(input: EncodeStageInput): Promise<EncodeStageResult> {
Expand Down
Loading