mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 14:50:02 +00:00
feat(cli): add agent-first media treatment tools
This commit is contained in:
@@ -0,0 +1,328 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { execFileSync, spawn } from "node:child_process";
|
||||
import { once } from "node:events";
|
||||
import { existsSync, mkdirSync, renameSync, rmSync } from "node:fs";
|
||||
import { dirname, extname, resolve } from "node:path";
|
||||
import { text } from "node:stream/consumers";
|
||||
import { parseArgs } from "node:util";
|
||||
import {
|
||||
ERROR_DIFFUSION_ALGORITHMS,
|
||||
applyErrorDiffusionRgba,
|
||||
errorDiffusionBufferLength,
|
||||
} from "./lib/error-diffusion.mjs";
|
||||
|
||||
const IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".webp", ".bmp", ".tif", ".tiff"]);
|
||||
const OUTPUT_IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".webp"]);
|
||||
|
||||
const { values: args } = parseArgs({
|
||||
options: {
|
||||
input: { type: "string", short: "i" },
|
||||
out: { type: "string", short: "o" },
|
||||
algorithm: { type: "string", short: "a", default: "floyd-steinberg" },
|
||||
palette: { type: "string", default: "#000000,#ffffff" },
|
||||
"point-size": { type: "string", default: "3" },
|
||||
brightness: { type: "string", default: "1" },
|
||||
contrast: { type: "string", default: "1.2" },
|
||||
detail: { type: "string", default: "1" },
|
||||
json: { type: "boolean", default: false },
|
||||
help: { type: "boolean", short: "h", default: false },
|
||||
},
|
||||
strict: true,
|
||||
});
|
||||
|
||||
if (args.help) {
|
||||
console.log(`media-use dither — exact cached error-diffusion for image or MP4 media
|
||||
|
||||
Usage:
|
||||
node dither.mjs --input in.mp4 --out out.mp4 [options]
|
||||
|
||||
Options:
|
||||
--algorithm, -a ${Object.keys(ERROR_DIFFUSION_ALGORITHMS).join(" | ")}
|
||||
--palette 2-6 authored-order #rrggbb colors, comma-separated
|
||||
--point-size Block size in pixels, 1-20 (default: 3)
|
||||
--brightness 0.5-2 (default: 1)
|
||||
--contrast 0.5-2 (default: 1.2)
|
||||
--detail Diffused-error strength, 0.1-1 (default: 1)
|
||||
--json Output JSON status
|
||||
--help, -h Show this help
|
||||
|
||||
Video output uses the source average frame rate as CFR; VFR cadence is normalized.
|
||||
|
||||
After processing, register the output with:
|
||||
node resolve.mjs --from <output> --type image|video`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await run();
|
||||
if (args.json) console.log(JSON.stringify({ ok: true, ...result }));
|
||||
else {
|
||||
console.log(`dithered ${result.input} -> ${result.out} (${result.algorithm})`);
|
||||
console.log(`next: resolve --from ${result.out} --type ${result.type}`);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (args.json) console.log(JSON.stringify({ ok: false, error: message }));
|
||||
else console.error(`error: ${message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
async function run() {
|
||||
if (!args.input || !args.out) throw new Error("--input and --out are required");
|
||||
const inputPath = resolve(args.input);
|
||||
const outPath = resolve(args.out);
|
||||
if (!existsSync(inputPath)) throw new Error(`input file not found: ${inputPath}`);
|
||||
if (inputPath === outPath) throw new Error("--out must differ from --input");
|
||||
|
||||
const metadata = probe(inputPath);
|
||||
if (metadata.colorTransfer === "smpte2084" || metadata.colorTransfer === "arib-std-b67") {
|
||||
throw new Error(
|
||||
`HDR ${metadata.colorTransfer} input is not supported by the 8-bit SDR dither processor; tone-map to Rec.709 first`,
|
||||
);
|
||||
}
|
||||
const options = {
|
||||
algorithm: args.algorithm,
|
||||
palette: args.palette.split(",").map((color) => color.trim()),
|
||||
pointSize: Number(args["point-size"]),
|
||||
brightness: Number(args.brightness),
|
||||
contrast: Number(args.contrast),
|
||||
detail: Number(args.detail),
|
||||
};
|
||||
// Validate before starting FFmpeg or creating an output file.
|
||||
applyErrorDiffusionRgba(new Uint8ClampedArray(4), 1, 1, options, new Float32Array(3));
|
||||
|
||||
mkdirSync(dirname(outPath), { recursive: true });
|
||||
const inputIsImage = IMAGE_EXTENSIONS.has(extname(inputPath).toLowerCase());
|
||||
if (inputIsImage) {
|
||||
if (!OUTPUT_IMAGE_EXTENSIONS.has(extname(outPath).toLowerCase())) {
|
||||
throw new Error("image output must use .png, .jpg, .jpeg, or .webp");
|
||||
}
|
||||
processImage(inputPath, outPath, metadata, options);
|
||||
} else {
|
||||
if (extname(outPath).toLowerCase() !== ".mp4") throw new Error("video output must use .mp4");
|
||||
await processVideo(inputPath, outPath, metadata, options);
|
||||
}
|
||||
|
||||
return {
|
||||
input: inputPath,
|
||||
out: outPath,
|
||||
type: inputIsImage ? "image" : "video",
|
||||
algorithm: options.algorithm,
|
||||
palette: options.palette,
|
||||
point_size: options.pointSize,
|
||||
brightness: options.brightness,
|
||||
contrast: options.contrast,
|
||||
detail: options.detail,
|
||||
};
|
||||
}
|
||||
|
||||
function probe(filePath) {
|
||||
const raw = execFileSync(
|
||||
"ffprobe",
|
||||
["-v", "error", "-print_format", "json", "-show_streams", "-show_format", filePath],
|
||||
{ encoding: "utf8", timeout: 10_000 },
|
||||
);
|
||||
const parsed = JSON.parse(raw);
|
||||
const video = parsed.streams?.find((stream) => stream.codec_type === "video");
|
||||
if (!video?.width || !video?.height)
|
||||
throw new Error(`no readable video/image stream: ${filePath}`);
|
||||
const fps = usableFrameRate(video.avg_frame_rate) ?? usableFrameRate(video.r_frame_rate) ?? "30";
|
||||
return {
|
||||
width: video.width,
|
||||
height: video.height,
|
||||
fps,
|
||||
colorTransfer: video.color_transfer || "",
|
||||
};
|
||||
}
|
||||
|
||||
function processImage(inputPath, outPath, metadata, options) {
|
||||
const frameBytes = metadata.width * metadata.height * 4;
|
||||
const rgba = execFileSync(
|
||||
"ffmpeg",
|
||||
[
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-nostdin",
|
||||
"-i",
|
||||
inputPath,
|
||||
"-frames:v",
|
||||
"1",
|
||||
"-f",
|
||||
"rawvideo",
|
||||
"-pix_fmt",
|
||||
"rgba",
|
||||
"-",
|
||||
],
|
||||
{ maxBuffer: frameBytes + 1024 },
|
||||
);
|
||||
if (rgba.length !== frameBytes)
|
||||
throw new Error(`decoded ${rgba.length} bytes; expected ${frameBytes}`);
|
||||
applyErrorDiffusionRgba(rgba, metadata.width, metadata.height, options);
|
||||
|
||||
const temporary = temporaryOutput(outPath);
|
||||
try {
|
||||
execFileSync(
|
||||
"ffmpeg",
|
||||
[
|
||||
"-y",
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-f",
|
||||
"rawvideo",
|
||||
"-pix_fmt",
|
||||
"rgba",
|
||||
"-s:v",
|
||||
`${metadata.width}x${metadata.height}`,
|
||||
"-i",
|
||||
"-",
|
||||
"-frames:v",
|
||||
"1",
|
||||
temporary,
|
||||
],
|
||||
{ input: rgba, maxBuffer: frameBytes + 1024 },
|
||||
);
|
||||
renameSync(temporary, outPath);
|
||||
} finally {
|
||||
rmSync(temporary, { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function processVideo(inputPath, outPath, metadata, options) {
|
||||
const temporary = temporaryOutput(outPath);
|
||||
const frameBytes = metadata.width * metadata.height * 4;
|
||||
const keyframeInterval = String(Math.max(1, Math.round(frameRateNumber(metadata.fps))));
|
||||
const errors = new Float32Array(
|
||||
errorDiffusionBufferLength(metadata.width, metadata.height, options.pointSize),
|
||||
);
|
||||
const decoder = spawn("ffmpeg", [
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-nostdin",
|
||||
"-i",
|
||||
inputPath,
|
||||
"-map",
|
||||
"0:v:0",
|
||||
"-f",
|
||||
"rawvideo",
|
||||
"-pix_fmt",
|
||||
"rgba",
|
||||
"-",
|
||||
]);
|
||||
const encoder = spawn("ffmpeg", [
|
||||
"-y",
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-f",
|
||||
"rawvideo",
|
||||
"-pix_fmt",
|
||||
"rgba",
|
||||
"-s:v",
|
||||
`${metadata.width}x${metadata.height}`,
|
||||
"-r",
|
||||
metadata.fps,
|
||||
"-i",
|
||||
"-",
|
||||
"-i",
|
||||
inputPath,
|
||||
"-map",
|
||||
"0:v:0",
|
||||
"-map",
|
||||
"1:a?",
|
||||
"-map_metadata",
|
||||
"1",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-preset",
|
||||
"veryfast",
|
||||
"-crf",
|
||||
"18",
|
||||
"-g",
|
||||
keyframeInterval,
|
||||
"-keyint_min",
|
||||
keyframeInterval,
|
||||
"-sc_threshold",
|
||||
"0",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-x264-params",
|
||||
"colorprim=bt709:transfer=bt709:colormatrix=bt709",
|
||||
"-color_primaries:v",
|
||||
"bt709",
|
||||
"-color_trc:v",
|
||||
"bt709",
|
||||
"-colorspace:v",
|
||||
"bt709",
|
||||
"-color_range",
|
||||
"tv",
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-b:a",
|
||||
"192k",
|
||||
"-shortest",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
temporary,
|
||||
]);
|
||||
const decoderError = text(decoder.stderr);
|
||||
const encoderError = text(encoder.stderr);
|
||||
const decoderDone = once(decoder, "close").then(([code]) => code ?? 1);
|
||||
const encoderDone = once(encoder, "close").then(([code]) => code ?? 1);
|
||||
|
||||
try {
|
||||
const frame = Buffer.allocUnsafe(frameBytes);
|
||||
let frameOffset = 0;
|
||||
for await (const chunk of decoder.stdout) {
|
||||
let chunkOffset = 0;
|
||||
while (chunkOffset < chunk.length) {
|
||||
const length = Math.min(frameBytes - frameOffset, chunk.length - chunkOffset);
|
||||
chunk.copy(frame, frameOffset, chunkOffset, chunkOffset + length);
|
||||
chunkOffset += length;
|
||||
frameOffset += length;
|
||||
if (frameOffset !== frameBytes) continue;
|
||||
applyErrorDiffusionRgba(frame, metadata.width, metadata.height, options, errors);
|
||||
await writeFrame(encoder.stdin, frame);
|
||||
frameOffset = 0;
|
||||
}
|
||||
}
|
||||
if (frameOffset)
|
||||
throw new Error(`decoder returned a partial RGBA frame (${frameOffset} bytes)`);
|
||||
encoder.stdin.end();
|
||||
const [decoderCode, encoderCode] = await Promise.all([decoderDone, encoderDone]);
|
||||
if (decoderCode !== 0) throw new Error(`FFmpeg decode failed: ${(await decoderError).trim()}`);
|
||||
if (encoderCode !== 0) throw new Error(`FFmpeg encode failed: ${(await encoderError).trim()}`);
|
||||
renameSync(temporary, outPath);
|
||||
} catch (error) {
|
||||
decoder.kill("SIGKILL");
|
||||
encoder.kill("SIGKILL");
|
||||
throw error;
|
||||
} finally {
|
||||
rmSync(temporary, { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function writeFrame(stream, frame) {
|
||||
return new Promise((resolveWrite, reject) => {
|
||||
stream.write(frame, (error) => (error ? reject(error) : resolveWrite()));
|
||||
});
|
||||
}
|
||||
|
||||
function usableFrameRate(value) {
|
||||
if (!value || value === "0/0") return null;
|
||||
const number = frameRateNumber(value);
|
||||
return Number.isFinite(number) && number > 0 ? value : null;
|
||||
}
|
||||
|
||||
function frameRateNumber(value) {
|
||||
const [numerator, denominator = "1"] = value.split("/");
|
||||
return Number(numerator) / Number(denominator);
|
||||
}
|
||||
|
||||
function temporaryOutput(outPath) {
|
||||
const extension = extname(outPath);
|
||||
return `${outPath.slice(0, -extension.length)}.part-${process.pid}${extension}`;
|
||||
}
|
||||
Reference in New Issue
Block a user