mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user