fix(engine): render into a folder whose name holds a percent sign (#6116)

* fix(engine): render into a folder whose name holds a percent sign

ffmpeg reads an image sequence path as a printf template, so a folder like
"12% Up" was parsed as a format code and every video render failed. The
folder part of each frame-sequence path now goes through one helper that
doubles the percent sign.

* fix(engine): drop the helper doc line the comment ratchet rejects
This commit is contained in:
Miguel Ángel
2026-10-23 07:33:25 +00:00
committed by GitHub
parent d1aeb31bf4
commit 69738b7ce4
8 changed files with 93 additions and 6 deletions
+1
View File
@@ -310,6 +310,7 @@ export {
// ── Utilities ──────────────────────────────────────────────────────────────────
export { quantizeTimeToFrame, MEDIA_VISUAL_STYLE_PROPERTIES } from "@hyperframes/core";
export { frameFileExtension } from "./services/frameCapture.js";
export { frameSequencePath } from "./services/extractedFrameIndex.js";
export type { MotionBlurOptions, MotionBlurBlendSpace } from "./services/motionBlur.js";
export type { PageMotionBlurOff } from "./types.js";
@@ -0,0 +1,53 @@
import { spawnSync } from "node:child_process";
import { existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { getFfmpegBinary } from "../utils/ffmpegBinaries.js";
import { encodeFramesFromDir } from "./chunkEncoder.js";
import { frameSequencePath } from "./extractedFrameIndex.js";
const HAS_FFMPEG = spawnSync(getFfmpegBinary(), ["-version"], { encoding: "utf-8" }).status === 0;
describe.skipIf(!HAS_FFMPEG)("frame-sequence encode from a directory with a printf code", () => {
let root: string;
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), "hf-percent-dir-"));
});
afterEach(() => {
rmSync(root, { recursive: true, force: true });
});
it("encodes frames read from a directory named like 'x 12% Up'", async () => {
const framesDir = join(root, "x 12% Up");
mkdirSync(framesDir);
const made = spawnSync(getFfmpegBinary(), [
"-v",
"error",
"-f",
"lavfi",
"-i",
"testsrc2=size=32x32:rate=25",
"-frames:v",
"10",
frameSequencePath(framesDir, "frame_%06d.jpg"),
]);
expect(made.status).toBe(0);
const outputPath = join(root, "out", "video.mp4");
const result = await encodeFramesFromDir(framesDir, "frame_%06d.jpg", outputPath, {
fps: { num: 25, den: 1 },
width: 32,
height: 32,
codec: "h264",
preset: "ultrafast",
quality: 30,
});
expect(result.error).toBeUndefined();
expect(result.success).toBe(true);
expect(existsSync(outputPath)).toBe(true);
});
});
+3 -2
View File
@@ -6,6 +6,7 @@
* Supports CPU (libx264) and GPU encoding.
*/
import { frameSequencePath } from "./extractedFrameIndex.js";
import {
closeSync,
copyFileSync,
@@ -566,7 +567,7 @@ export async function encodeFramesFromDir(
gpuEncoder = await getCachedGpuEncoder();
}
const inputPath = join(framesDir, framePattern);
const inputPath = frameSequencePath(framesDir, framePattern);
const inputArgs = ["-framerate", fpsToFfmpegArg(options.fps), "-i", inputPath];
const args = buildEncoderArgs(options, inputArgs, outputPath, gpuEncoder);
const encodeTimeout = config?.ffmpegEncodeTimeout ?? DEFAULT_CONFIG.ffmpegEncodeTimeout;
@@ -731,7 +732,7 @@ export async function encodeFramesChunkedConcat(
? ".mov"
: ".mp4";
const chunkPath = join(chunkDir, `chunk_${String(i).padStart(4, "0")}${ext}`);
const inputPath = join(framesDir, framePattern);
const inputPath = frameSequencePath(framesDir, framePattern);
const inputArgs = [
"-framerate",
fpsToFfmpegArg(options.fps),
@@ -5,6 +5,10 @@ export type ExtractedFrameFormat = "jpg" | "png";
export const FRAME_FILENAME_PREFIX = "frame_";
export function frameSequencePath(dir: string, framePattern: string): string {
return join(dir.replaceAll("%", "%%"), framePattern);
}
export class ExtractedFrameSequenceError extends Error {
constructor(message: string) {
super(message);
@@ -1388,6 +1388,19 @@ describe.skipIf(!HAS_FFMPEG)("video frame extraction format", () => {
};
}
it("extracts into a work directory whose name holds a printf code", async () => {
const outDir = join(FIXTURE_DIR, "x 12% Up");
mkdirSync(outDir, { recursive: true });
const result = await extractAllVideoFrames([fixtureVideo()], FIXTURE_DIR, {
fps: 1,
outputDir: outDir,
});
expect(result.errors).toEqual([]);
const frame = result.extracted[0]!.framePaths.get(0)!;
expect(frame.startsWith(outDir)).toBe(true);
expect(existsSync(frame)).toBe(true);
});
it("keeps the rightmost 8 columns of a 1080-wide (16n + 8) jpg extraction", async () => {
const source = join(FIXTURE_DIR, "white-1080.mp4");
const synth = await runFfmpeg([
@@ -81,7 +81,7 @@ import {
type CacheEntry,
type CacheFrameFormat,
} from "./extractionCache.js";
import { framePathsFromDirectory } from "./extractedFrameIndex.js";
import { framePathsFromDirectory, frameSequencePath } from "./extractedFrameIndex.js";
export { resolveProjectRelativeSrc };
@@ -1023,7 +1023,7 @@ async function extractVideoFramesRangeUnredacted(
}
const format = resolveFrameFormat(metadata, options.format);
const framePattern = `${FRAME_FILENAME_PREFIX}%05d.${format}`;
const outputPattern = join(videoOutputDir, framePattern);
const outputPattern = frameSequencePath(videoOutputDir, framePattern);
// Forced-SDR extraction tone-maps HDR with zscale, the Studio proxy policy. A macOS ffmpeg
// without zscale falls back to VideoToolbox, whose frames stay HDR and are tone-mapped by
@@ -8,6 +8,21 @@ import { runEncodeStage, type EncodeStageInput } from "./encodeStage.js";
const ffmpeg = (args: string[]) => spawnSync("ffmpeg", args);
describe("GIF input path", () => {
it("doubles a % in the frames directory so ffmpeg reads it literally", () => {
const args = buildGifPalettegenArgs({
framesDir: "/tmp/x 12% Up",
framePattern: "frame_%06d.png",
palettePath: "/tmp/p.png",
outputPath: "/tmp/o.gif",
fps: { num: 25, den: 1 },
loop: 0,
preserveAlpha: false,
});
expect(args[args.indexOf("-i") + 1]).toBe("/tmp/x 12%% Up/frame_%06d.png");
});
});
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-"));
@@ -1,4 +1,4 @@
import { join } from "node:path";
import { frameSequencePath } from "@hyperframes/engine";
import type { Fps } from "@hyperframes/core";
export interface GifEncodeArgsInput {
@@ -27,7 +27,7 @@ function framesInput(input: GifEncodeArgsInput, fpsArg: string): string[] {
fpsArg,
...KEEP_FILTER_GRAPH_WHEN_FRAMES_DROP_ALPHA,
"-i",
join(input.framesDir, input.framePattern),
frameSequencePath(input.framesDir, input.framePattern),
];
}