Skip to content

Commit 28e2ab9

Browse files
authored
fix: address review feedback from #1333 and #1335 (#1343)
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
1 parent 1ea0ed5 commit 28e2ab9

6 files changed

Lines changed: 77 additions & 38 deletions

File tree

docs/guides/rendering.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ Use GIF when the output needs to autoplay inline in GitHub PRs, READMEs, issue r
161161
npx hyperframes render --format gif --fps 15 --gif-loop 0 --output demo.gif
162162
```
163163

164-
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.
164+
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.
165165

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

packages/cli/src/server/studioServer.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ import type { RenderJob } from "@hyperframes/producer";
3232

3333
const STUDIO_MANUAL_EDITS_PATH = ".hyperframes/studio-manual-edits.json";
3434
const REMOTE_GIF_IMG_SRC_RE =
35-
/<img\b[^>]*?\bsrc\s*=\s*["'](https:\/\/[^"']+\.gif(?:[?#][^"']*)?)["'][^>]*>/gi;
35+
/<img\b[^>]*?\bsrc\s*=\s*["'](https?:\/\/[^"']+\.gif(?:[?#][^"']*)?)["'][^>]*>/gi;
3636

3737
// ── Path resolution ─────────────────────────────────────────────────────────
3838

packages/core/src/media/gif.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,15 @@ describe("parseAnimatedGifMetadata", () => {
7171
expect(metadata?.durationSeconds).toBe(0.2);
7272
});
7373

74+
it("clamps all-zero frame delays to the browser playback minimum", () => {
75+
const frames = Array.from({ length: 10 }, () => frame(0)).flat();
76+
const metadata = parseAnimatedGifMetadata(gif(frames, 0));
77+
78+
expect(metadata?.animated).toBe(true);
79+
expect(metadata?.delaysCentiseconds).toEqual(Array.from({ length: 10 }, () => 10));
80+
expect(metadata?.durationSeconds).toBe(1);
81+
});
82+
7483
it("reads Netscape loop metadata", () => {
7584
const metadata = parseAnimatedGifMetadata(gif([...frame(8), ...frame(8)], 0));
7685

packages/core/src/media/gif.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,14 @@ export interface AnimatedGifMetadata {
99
animated: boolean;
1010
}
1111

12+
const BROWSER_MIN_DELAY_CENTISECONDS = 10;
13+
14+
function normalizeDelayCentiseconds(delay: number): number {
15+
// Chrome clamps GIF frame delays <= 1cs to 10cs (100ms); mirror browser playback timing.
16+
if (delay <= 1) return BROWSER_MIN_DELAY_CENTISECONDS;
17+
return Math.max(0, delay);
18+
}
19+
1220
function readAscii(bytes: Uint8Array, start: number, length: number): string {
1321
if (start + length > bytes.length) return "";
1422
let value = "";
@@ -104,7 +112,7 @@ export function parseAnimatedGifMetadata(bytes: Uint8Array): AnimatedGifMetadata
104112
if (blockSize !== 4 || pos + 6 > bytes.length) return null;
105113
const delay = readU16LE(bytes, pos + 2);
106114
if (delay == null) return null;
107-
delaysCentiseconds.push(delay);
115+
delaysCentiseconds.push(normalizeDelayCentiseconds(delay));
108116
pos += 1 + blockSize;
109117
if (bytes[pos] !== 0) return null;
110118
pos += 1;
@@ -144,8 +152,7 @@ export function parseAnimatedGifMetadata(bytes: Uint8Array): AnimatedGifMetadata
144152
return null;
145153
}
146154

147-
const durationSeconds =
148-
delaysCentiseconds.reduce((total, delay) => total + Math.max(0, delay), 0) / 100;
155+
const durationSeconds = delaysCentiseconds.reduce((total, delay) => total + delay, 0) / 100;
149156

150157
return {
151158
width,

packages/producer/src/services/animatedGifPrep.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,4 +208,22 @@ describe("prepareAnimatedGifInputs", () => {
208208
);
209209
expect(result.preparedGifs[0]?.sourceSrc).toBe(sourceUrl);
210210
});
211+
212+
it("propagates actionable transcode failure messages", async () => {
213+
const projectDir = makeProject();
214+
const sourcePath = join(projectDir, "broken.gif");
215+
writeFileSync(sourcePath, gif([...frame(10), ...frame(10)], 0));
216+
217+
await expect(
218+
prepareAnimatedGifInputs(`<img src="broken.gif" />`, {
219+
projectDir,
220+
downloadDir: projectDir,
221+
transcode: async (request) => {
222+
throw new Error(
223+
`ffmpeg failed for ${request.inputPath}: Invalid data found when processing input`,
224+
);
225+
},
226+
}),
227+
).rejects.toThrow(`ffmpeg failed for ${sourcePath}: Invalid data found`);
228+
});
211229
});

packages/producer/src/services/render/stages/encodeStage.ts

Lines changed: 38 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
* `success: false`.
2929
*/
3030

31-
import { copyFileSync, existsSync, mkdirSync, readdirSync, statSync } from "node:fs";
31+
import { copyFileSync, existsSync, mkdirSync, readdirSync, rmSync, statSync } from "node:fs";
3232
import { dirname, join } from "node:path";
3333
import {
3434
encodeFramesChunkedConcat,
@@ -148,44 +148,49 @@ async function encodeGifFromDir(
148148
fps: input.fps,
149149
loop: input.loop,
150150
};
151-
const paletteResult = await runFfmpeg(buildGifPalettegenArgs(argsInput), {
152-
signal: input.signal,
153-
timeout: input.timeout,
154-
});
155-
if (!paletteResult.success) {
156-
return {
157-
success: false,
158-
outputPath,
159-
durationMs: Date.now() - startTime,
160-
framesEncoded: 0,
161-
fileSize: 0,
162-
error: formatFfmpegError(paletteResult.exitCode, paletteResult.stderr),
163-
};
164-
}
151+
try {
152+
const paletteResult = await runFfmpeg(buildGifPalettegenArgs(argsInput), {
153+
signal: input.signal,
154+
timeout: input.timeout,
155+
});
156+
if (!paletteResult.success) {
157+
return {
158+
success: false,
159+
outputPath,
160+
durationMs: Date.now() - startTime,
161+
framesEncoded: 0,
162+
fileSize: 0,
163+
error: formatFfmpegError(paletteResult.exitCode, paletteResult.stderr),
164+
};
165+
}
165166

166-
const gifResult = await runFfmpeg(buildGifPaletteuseArgs(argsInput), {
167-
signal: input.signal,
168-
timeout: input.timeout,
169-
});
170-
if (!gifResult.success) {
167+
const gifResult = await runFfmpeg(buildGifPaletteuseArgs(argsInput), {
168+
signal: input.signal,
169+
timeout: input.timeout,
170+
});
171+
if (!gifResult.success) {
172+
return {
173+
success: false,
174+
outputPath,
175+
durationMs: Date.now() - startTime,
176+
framesEncoded: 0,
177+
fileSize: 0,
178+
error: formatFfmpegError(gifResult.exitCode, gifResult.stderr),
179+
};
180+
}
181+
182+
const fileSize = existsSync(outputPath) ? statSync(outputPath).size : 0;
171183
return {
172-
success: false,
184+
success: true,
173185
outputPath,
174186
durationMs: Date.now() - startTime,
175-
framesEncoded: 0,
176-
fileSize: 0,
177-
error: formatFfmpegError(gifResult.exitCode, gifResult.stderr),
187+
framesEncoded: frameCount,
188+
fileSize,
178189
};
190+
} finally {
191+
// The GIF palette is a temp file; remove it after success or any encode failure.
192+
rmSync(input.palettePath, { force: true });
179193
}
180-
181-
const fileSize = existsSync(outputPath) ? statSync(outputPath).size : 0;
182-
return {
183-
success: true,
184-
outputPath,
185-
durationMs: Date.now() - startTime,
186-
framesEncoded: frameCount,
187-
fileSize,
188-
};
189194
}
190195

191196
export async function runEncodeStage(input: EncodeStageInput): Promise<EncodeStageResult> {

0 commit comments

Comments
 (0)