Setting the file. One moment.
Dither · Media Use · heygen-com/hyperframes · Skills Docs
ContentsBack to the top of the page ⋯
scripts/11 files
scripts/ dither.mjs
JavaScript · 328 lines · 10 KB
;
9 import {
10 ERROR_DIFFUSION_ALGORITHMS,
11 applyErrorDiffusionRgba,
12 errorDiffusionBufferLength,
13 } from "./lib/error-diffusion.mjs" ;
14
15 const IMAGE_EXTENSIONS = new Set ([ ".png" , ".jpg" , ".jpeg" , ".webp" , ".bmp" , ".tif" , ".tiff" ]);
16 const OUTPUT_IMAGE_EXTENSIONS = new Set ([ ".png" , ".jpg" , ".jpeg" , ".webp" ]);
17
18 const { 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 });
33
34 if (args.help) {
35 console. log ( `media-use dither — exact cached error-diffusion for image or MP4 media
36
37 Usage:
38 node dither.mjs --input in.mp4 --out out.mp4 [options]
39
40 Options:
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
49
50 Video output uses the source average frame rate as CFR; VFR cadence is normalized.
51
52 After processing, register the output with:
53 node resolve.mjs --from <output> --type image|video` );
54 process. exit ( 0 );
55 }
56
57 try {
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 );
69 }
70
71 async 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" );
77
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 ));
94
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 }
106
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 };
118 }
119
120 function 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 };
137 }
138
139 function 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);
163
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 }
191 }
192
193 async 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 );
275
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 }
306 }
307
308 function writeFrame ( stream , frame ) {
309 return new Promise (( resolveWrite , reject ) => {
310 stream. write (frame, ( error ) => (error ? reject (error) : resolveWrite ()));
311 });
312 }
313
314 function usableFrameRate ( value ) {
315 if ( ! value || value === "0/0" ) return null ;
316 const number = frameRateNumber (value);
317 return Number. isFinite (number) && number > 0 ? value : null ;
318 }
319
320 function frameRateNumber ( value ) {
321 const [ numerator , denominator = "1" ] = value. split ( "/" );
322 return Number (numerator) / Number (denominator);
323 }
324
325 function temporaryOutput ( outPath ) {
326 const extension = extname (outPath);
327 return `${ outPath . slice ( 0 , - extension . length ) }.part-${ process . pid }${ extension }` ;
328 }