Skill Tracer
Skills › HyperFrames › media-use › dither.mjs

dither.mjs

328 lines · skills/media-use/scripts/dither.mjs at a595755

1#!/usr/bin/env node
2
3import { execFileSync, spawn } from "node:child_process";
4import { once } from "node:events";
5import { existsSync, mkdirSync, renameSync, rmSync } from "node:fs";
6import { dirname, extname, resolve } from "node:path";
7import { text } from "node:stream/consumers";
8import { parseArgs } from "node:util";
9import {
10 ERROR_DIFFUSION_ALGORITHMS,
11 applyErrorDiffusionRgba,
12 errorDiffusionBufferLength,
13} from "./lib/error-diffusion.mjs";
15const IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".webp", ".bmp", ".tif", ".tiff"]);
16const OUTPUT_IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".webp"]);
18const { values: args } = parseArgs({
19 options: {
20 input: { type: "string", short: "i" },
21 out: { type: "string", short: "o" },
22 algorithm: { type: "string", short: "a", default: "floyd-steinberg" },
23 palette: { type: "string", default: "#000000,#ffffff" },
24 "point-size": { type: "string", default: "3" },
25 brightness: { type: "string", default: "1" },
26 contrast: { type: "string", default: "1.2" },
27 detail: { type: "string", default: "1" },
28 json: { type: "boolean", default: false },
29 help: { type: "boolean", short: "h", default: false },
30 },
31 strict: true,
32});
34if (args.help) {
35 console.log(`media-use dither — exact cached error-diffusion for image or MP4 media
37Usage:
38 node dither.mjs --input in.mp4 --out out.mp4 [options]
40Options:
41 --algorithm, -a ${Object.keys(ERROR_DIFFUSION_ALGORITHMS).join(" | ")}
42 --palette 2-6 authored-order #rrggbb colors, comma-separated
43 --point-size Block size in pixels, 1-20 (default: 3)
44 --brightness 0.5-2 (default: 1)
45 --contrast 0.5-2 (default: 1.2)
46 --detail Diffused-error strength, 0.1-1 (default: 1)
47 --json Output JSON status
48 --help, -h Show this help
50Video output uses the source average frame rate as CFR; VFR cadence is normalized.
52After processing, register the output with:
53 node resolve.mjs --from <output> --type image|video`);
54 process.exit(0);
57try {
58 const result = await run();
59 if (args.json) console.log(JSON.stringify({ ok: true, ...result }));
60 else {
61 console.log(`dithered ${result.input} -> ${result.out} (${result.algorithm})`);
62 console.log(`next: resolve --from ${result.out} --type ${result.type}`);
63 }
64} catch (error) {
65 const message = error instanceof Error ? error.message : String(error);
66 if (args.json) console.log(JSON.stringify({ ok: false, error: message }));
67 else console.error(`error: ${message}`);
68 process.exit(1);
71async function run() {
72 if (!args.input || !args.out) throw new Error("--input and --out are required");
73 const inputPath = resolve(args.input);
74 const outPath = resolve(args.out);
75 if (!existsSync(inputPath)) throw new Error(`input file not found: ${inputPath}`);
76 if (inputPath === outPath) throw new Error("--out must differ from --input");
78 const metadata = probe(inputPath);
79 if (metadata.colorTransfer === "smpte2084" || metadata.colorTransfer === "arib-std-b67") {
80 throw new Error(
81 `HDR ${metadata.colorTransfer} input is not supported by the 8-bit SDR dither processor; tone-map to Rec.709 first`,
82 );
83 }
84 const options = {
85 algorithm: args.algorithm,
86 palette: args.palette.split(",").map((color) => color.trim()),
87 pointSize: Number(args["point-size"]),
88 brightness: Number(args.brightness),
89 contrast: Number(args.contrast),
90 detail: Number(args.detail),
91 };
92 // Validate before starting FFmpeg or creating an output file.
93 applyErrorDiffusionRgba(new Uint8ClampedArray(4), 1, 1, options, new Float32Array(3));
95 mkdirSync(dirname(outPath), { recursive: true });
96 const inputIsImage = IMAGE_EXTENSIONS.has(extname(inputPath).toLowerCase());
97 if (inputIsImage) {
98 if (!OUTPUT_IMAGE_EXTENSIONS.has(extname(outPath).toLowerCase())) {
99 throw new Error("image output must use .png, .jpg, .jpeg, or .webp");
100 }
101 processImage(inputPath, outPath, metadata, options);
102 } else {
103 if (extname(outPath).toLowerCase() !== ".mp4") throw new Error("video output must use .mp4");
104 await processVideo(inputPath, outPath, metadata, options);
105 }
107 return {
108 input: inputPath,
109 out: outPath,
110 type: inputIsImage ? "image" : "video",
111 algorithm: options.algorithm,
112 palette: options.palette,
113 point_size: options.pointSize,
114 brightness: options.brightness,
115 contrast: options.contrast,
116 detail: options.detail,
117 };
120function probe(filePath) {
121 const raw = execFileSync(
122 "ffprobe",
123 ["-v", "error", "-print_format", "json", "-show_streams", "-show_format", "--", filePath],
124 { encoding: "utf8", timeout: 10_000 },
125 );
126 const parsed = JSON.parse(raw);
127 const video = parsed.streams?.find((stream) => stream.codec_type === "video");
128 if (!video?.width || !video?.height)
129 throw new Error(`no readable video/image stream: ${filePath}`);
130 const fps = usableFrameRate(video.avg_frame_rate) ?? usableFrameRate(video.r_frame_rate) ?? "30";
131 return {
132 width: video.width,
133 height: video.height,
134 fps,
135 colorTransfer: video.color_transfer || "",
136 };
139function processImage(inputPath, outPath, metadata, options) {
140 const frameBytes = metadata.width * metadata.height * 4;
141 const rgba = execFileSync(
142 "ffmpeg",
143 [
144 "-hide_banner",
145 "-loglevel",
146 "error",
147 "-nostdin",
148 "-i",
149 inputPath,
150 "-frames:v",
151 "1",
152 "-f",
153 "rawvideo",
154 "-pix_fmt",
155 "rgba",
156 "-",
157 ],
158 { maxBuffer: frameBytes + 1024 },
159 );
160 if (rgba.length !== frameBytes)
161 throw new Error(`decoded ${rgba.length} bytes; expected ${frameBytes}`);
162 applyErrorDiffusionRgba(rgba, metadata.width, metadata.height, options);
164 const temporary = temporaryOutput(outPath);
165 try {
166 execFileSync(
167 "ffmpeg",
168 [
169 "-y",
170 "-hide_banner",
171 "-loglevel",
172 "error",
173 "-f",
174 "rawvideo",
175 "-pix_fmt",
176 "rgba",
177 "-s:v",
178 `${metadata.width}x${metadata.height}`,
179 "-i",
180 "-",
181 "-frames:v",
182 "1",
183 temporary,
184 ],
185 { input: rgba, maxBuffer: frameBytes + 1024 },
186 );
187 renameSync(temporary, outPath);
188 } finally {
189 rmSync(temporary, { force: true });
190 }
193async function processVideo(inputPath, outPath, metadata, options) {
194 const temporary = temporaryOutput(outPath);
195 const frameBytes = metadata.width * metadata.height * 4;
196 const keyframeInterval = String(Math.max(1, Math.round(frameRateNumber(metadata.fps))));
197 const errors = new Float32Array(
198 errorDiffusionBufferLength(metadata.width, metadata.height, options.pointSize),
199 );
200 const decoder = spawn("ffmpeg", [
201 "-hide_banner",
202 "-loglevel",
203 "error",
204 "-nostdin",
205 "-i",
206 inputPath,
207 "-map",
208 "0:v:0",
209 "-f",
210 "rawvideo",
211 "-pix_fmt",
212 "rgba",
213 "-",
214 ]);
215 const encoder = spawn("ffmpeg", [
216 "-y",
217 "-hide_banner",
218 "-loglevel",
219 "error",
220 "-f",
221 "rawvideo",
222 "-pix_fmt",
223 "rgba",
224 "-s:v",
225 `${metadata.width}x${metadata.height}`,
226 "-r",
227 metadata.fps,
228 "-i",
229 "-",
230 "-i",
231 inputPath,
232 "-map",
233 "0:v:0",
234 "-map",
235 "1:a?",
236 "-map_metadata",
237 "1",
238 "-c:v",
239 "libx264",
240 "-preset",
241 "veryfast",
242 "-crf",
243 "18",
244 "-g",
245 keyframeInterval,
246 "-keyint_min",
247 keyframeInterval,
248 "-sc_threshold",
249 "0",
250 "-pix_fmt",
251 "yuv420p",
252 "-x264-params",
253 "colorprim=bt709:transfer=bt709:colormatrix=bt709",
254 "-color_primaries:v",
255 "bt709",
256 "-color_trc:v",
257 "bt709",
258 "-colorspace:v",
259 "bt709",
260 "-color_range",
261 "tv",
262 "-c:a",
263 "aac",
264 "-b:a",
265 "192k",
266 "-shortest",
267 "-movflags",
268 "+faststart",
269 temporary,
270 ]);
271 const decoderError = text(decoder.stderr);
272 const encoderError = text(encoder.stderr);
273 const decoderDone = once(decoder, "close").then(([code]) => code ?? 1);
274 const encoderDone = once(encoder, "close").then(([code]) => code ?? 1);
276 try {
277 const frame = Buffer.allocUnsafe(frameBytes);
278 let frameOffset = 0;
279 for await (const chunk of decoder.stdout) {
280 let chunkOffset = 0;
281 while (chunkOffset < chunk.length) {
282 const length = Math.min(frameBytes - frameOffset, chunk.length - chunkOffset);
283 chunk.copy(frame, frameOffset, chunkOffset, chunkOffset + length);
284 chunkOffset += length;
285 frameOffset += length;
286 if (frameOffset !== frameBytes) continue;
287 applyErrorDiffusionRgba(frame, metadata.width, metadata.height, options, errors);
288 await writeFrame(encoder.stdin, frame);
289 frameOffset = 0;
290 }
291 }
292 if (frameOffset)
293 throw new Error(`decoder returned a partial RGBA frame (${frameOffset} bytes)`);
294 encoder.stdin.end();
295 const [decoderCode, encoderCode] = await Promise.all([decoderDone, encoderDone]);
296 if (decoderCode !== 0) throw new Error(`FFmpeg decode failed: ${(await decoderError).trim()}`);
297 if (encoderCode !== 0) throw new Error(`FFmpeg encode failed: ${(await encoderError).trim()}`);
298 renameSync(temporary, outPath);
299 } catch (error) {
300 decoder.kill("SIGKILL");
301 encoder.kill("SIGKILL");
302 throw error;
303 } finally {
304 rmSync(temporary, { force: true });
305 }
308function writeFrame(stream, frame) {
309 return new Promise((resolveWrite, reject) => {
310 stream.write(frame, (error) => (error ? reject(error) : resolveWrite()));
311 });
314function usableFrameRate(value) {
315 if (!value || value === "0/0") return null;
316 const number = frameRateNumber(value);
317 return Number.isFinite(number) && number > 0 ? value : null;
320function frameRateNumber(value) {
321 const [numerator, denominator = "1"] = value.split("/");
322 return Number(numerator) / Number(denominator);
325function temporaryOutput(outPath) {
326 const extension = extname(outPath);
327 return `${outPath.slice(0, -extension.length)}.part-${process.pid}${extension}`;