hyperframes 0.8.57 → 0.8.59

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (182) hide show
  1. package/dist/cli.js +5300 -3428
  2. package/dist/docs/data-attributes.md +2 -1
  3. package/dist/fontLocalizeCli.js +73 -12
  4. package/dist/hyperframe-runtime.js +25 -25
  5. package/dist/hyperframe.manifest.json +1 -1
  6. package/dist/hyperframe.runtime.iife.js +25 -25
  7. package/dist/hyperframes-player.global.js +3 -3
  8. package/dist/registry/localSemantic.js +548 -0
  9. package/dist/skills/hyperframes-cli/SKILL.md +2 -2
  10. package/dist/skills/media-use/SKILL.md +96 -0
  11. package/dist/skills/media-use/audio/assets/sfx/CREDITS.md +35 -0
  12. package/dist/skills/media-use/audio/assets/sfx/chime.mp3 +0 -0
  13. package/dist/skills/media-use/audio/assets/sfx/click-soft.mp3 +0 -0
  14. package/dist/skills/media-use/audio/assets/sfx/click.mp3 +0 -0
  15. package/dist/skills/media-use/audio/assets/sfx/error.mp3 +0 -0
  16. package/dist/skills/media-use/audio/assets/sfx/glitch-1.mp3 +0 -0
  17. package/dist/skills/media-use/audio/assets/sfx/glitch-2.mp3 +0 -0
  18. package/dist/skills/media-use/audio/assets/sfx/glitch-3.mp3 +0 -0
  19. package/dist/skills/media-use/audio/assets/sfx/impact-bass-1.mp3 +0 -0
  20. package/dist/skills/media-use/audio/assets/sfx/impact-bass-2.mp3 +0 -0
  21. package/dist/skills/media-use/audio/assets/sfx/key-press.mp3 +0 -0
  22. package/dist/skills/media-use/audio/assets/sfx/manifest.json +97 -0
  23. package/dist/skills/media-use/audio/assets/sfx/notification.mp3 +0 -0
  24. package/dist/skills/media-use/audio/assets/sfx/ping.mp3 +0 -0
  25. package/dist/skills/media-use/audio/assets/sfx/pop.mp3 +0 -0
  26. package/dist/skills/media-use/audio/assets/sfx/riser.mp3 +0 -0
  27. package/dist/skills/media-use/audio/assets/sfx/sparkle.mp3 +0 -0
  28. package/dist/skills/media-use/audio/assets/sfx/typing.mp3 +0 -0
  29. package/dist/skills/media-use/audio/assets/sfx/whoosh-cinematic.mp3 +0 -0
  30. package/dist/skills/media-use/audio/assets/sfx/whoosh-short.mp3 +0 -0
  31. package/dist/skills/media-use/audio/assets/sfx/whoosh.mp3 +0 -0
  32. package/dist/skills/media-use/audio/references/bgm.md +74 -0
  33. package/dist/skills/media-use/audio/references/captions/authoring.md +163 -0
  34. package/dist/skills/media-use/audio/references/captions/motion.md +87 -0
  35. package/dist/skills/media-use/audio/references/captions/transcript-handling.md +97 -0
  36. package/dist/skills/media-use/audio/references/remove-background.md +143 -0
  37. package/dist/skills/media-use/audio/references/requirements.md +29 -0
  38. package/dist/skills/media-use/audio/references/sfx.md +42 -0
  39. package/dist/skills/media-use/audio/references/transcribe.md +52 -0
  40. package/dist/skills/media-use/audio/references/tts-to-captions.md +26 -0
  41. package/dist/skills/media-use/audio/references/tts.md +165 -0
  42. package/dist/skills/media-use/audio/scripts/audio.mjs +295 -0
  43. package/dist/skills/media-use/audio/scripts/audio.test.mjs +49 -0
  44. package/dist/skills/media-use/audio/scripts/heygen-tts.mjs +121 -0
  45. package/dist/skills/media-use/audio/scripts/lib/audio-meta.mjs +39 -0
  46. package/dist/skills/media-use/audio/scripts/lib/audio-meta.test.mjs +136 -0
  47. package/dist/skills/media-use/audio/scripts/lib/bgm.mjs +257 -0
  48. package/dist/skills/media-use/audio/scripts/lib/bgm.test.mjs +30 -0
  49. package/dist/skills/media-use/audio/scripts/lib/concurrency.mjs +14 -0
  50. package/dist/skills/media-use/audio/scripts/lib/concurrency.test.mjs +41 -0
  51. package/dist/skills/media-use/audio/scripts/lib/heygen.mjs +156 -0
  52. package/dist/skills/media-use/audio/scripts/lib/heygen.test.mjs +103 -0
  53. package/dist/skills/media-use/audio/scripts/lib/python.mjs +63 -0
  54. package/dist/skills/media-use/audio/scripts/lib/python.test.mjs +68 -0
  55. package/dist/skills/media-use/audio/scripts/lib/sfx.mjs +143 -0
  56. package/dist/skills/media-use/audio/scripts/lib/sfx.test.mjs +69 -0
  57. package/dist/skills/media-use/audio/scripts/lib/tts.mjs +379 -0
  58. package/dist/skills/media-use/audio/scripts/lib/tts.spawn.test.mjs +158 -0
  59. package/dist/skills/media-use/audio/scripts/lib/tts.test.mjs +157 -0
  60. package/dist/skills/media-use/audio/scripts/lyria-recipe.py +136 -0
  61. package/dist/skills/media-use/audio/scripts/wait-bgm.mjs +171 -0
  62. package/dist/skills/media-use/audio/scripts/wait-bgm.test.mjs +65 -0
  63. package/dist/skills/media-use/luts/README.md +28 -0
  64. package/dist/skills/media-use/luts/index.json +57 -0
  65. package/dist/skills/media-use/references/audio.md +19 -0
  66. package/dist/skills/media-use/references/grading.md +160 -0
  67. package/dist/skills/media-use/references/media-treatment-recipes.md +929 -0
  68. package/dist/skills/media-use/references/media-treatments.md +202 -0
  69. package/dist/skills/media-use/references/memory.md +36 -0
  70. package/dist/skills/media-use/references/meta.md +61 -0
  71. package/dist/skills/media-use/references/operations.md +325 -0
  72. package/dist/skills/media-use/references/resolve.md +150 -0
  73. package/dist/skills/media-use/references/setup-providers.md +80 -0
  74. package/dist/skills/media-use/references/telemetry-dashboard.md +49 -0
  75. package/dist/skills/media-use/registry/localSemantic.js +548 -0
  76. package/dist/skills/media-use/scripts/audio-duck.mjs +113 -0
  77. package/dist/skills/media-use/scripts/compatibility.test.mjs +27 -0
  78. package/dist/skills/media-use/scripts/dither.mjs +328 -0
  79. package/dist/skills/media-use/scripts/dither.test.mjs +176 -0
  80. package/dist/skills/media-use/scripts/eval.mjs +369 -0
  81. package/dist/skills/media-use/scripts/lib/adopt.mjs +128 -0
  82. package/dist/skills/media-use/scripts/lib/adopt.test.mjs +95 -0
  83. package/dist/skills/media-use/scripts/lib/bgm-provider.mjs +20 -0
  84. package/dist/skills/media-use/scripts/lib/brand-provider.mjs +59 -0
  85. package/dist/skills/media-use/scripts/lib/bundled-sfx-provider.mjs +119 -0
  86. package/dist/skills/media-use/scripts/lib/bundled-sfx-provider.test.mjs +139 -0
  87. package/dist/skills/media-use/scripts/lib/cache.mjs +150 -0
  88. package/dist/skills/media-use/scripts/lib/candidates.mjs +91 -0
  89. package/dist/skills/media-use/scripts/lib/candidates.test.mjs +155 -0
  90. package/dist/skills/media-use/scripts/lib/codex-provider.mjs +161 -0
  91. package/dist/skills/media-use/scripts/lib/codex-provider.test.mjs +34 -0
  92. package/dist/skills/media-use/scripts/lib/coverage.test.mjs +132 -0
  93. package/dist/skills/media-use/scripts/lib/cube-build.mjs +181 -0
  94. package/dist/skills/media-use/scripts/lib/cube-build.test.mjs +80 -0
  95. package/dist/skills/media-use/scripts/lib/cube-validate.mjs +198 -0
  96. package/dist/skills/media-use/scripts/lib/cube-validate.test.mjs +125 -0
  97. package/dist/skills/media-use/scripts/lib/cutlist.mjs +184 -0
  98. package/dist/skills/media-use/scripts/lib/cutlist.test.mjs +162 -0
  99. package/dist/skills/media-use/scripts/lib/duck.mjs +95 -0
  100. package/dist/skills/media-use/scripts/lib/duck.test.mjs +153 -0
  101. package/dist/skills/media-use/scripts/lib/error-diffusion.mjs +230 -0
  102. package/dist/skills/media-use/scripts/lib/error-diffusion.test.mjs +102 -0
  103. package/dist/skills/media-use/scripts/lib/freeze.mjs +62 -0
  104. package/dist/skills/media-use/scripts/lib/freeze.test.mjs +46 -0
  105. package/dist/skills/media-use/scripts/lib/grade-analyzer.mjs +265 -0
  106. package/dist/skills/media-use/scripts/lib/grade-analyzer.test.mjs +285 -0
  107. package/dist/skills/media-use/scripts/lib/heygen-cli.mjs +191 -0
  108. package/dist/skills/media-use/scripts/lib/heygen-cli.test.mjs +301 -0
  109. package/dist/skills/media-use/scripts/lib/heygen-search.mjs +45 -0
  110. package/dist/skills/media-use/scripts/lib/heygen-search.test.mjs +49 -0
  111. package/dist/skills/media-use/scripts/lib/heygen-video-provider.mjs +138 -0
  112. package/dist/skills/media-use/scripts/lib/heygen-video-provider.test.mjs +435 -0
  113. package/dist/skills/media-use/scripts/lib/image-provider.mjs +44 -0
  114. package/dist/skills/media-use/scripts/lib/index-gen.mjs +63 -0
  115. package/dist/skills/media-use/scripts/lib/local-media-search.mjs +32 -0
  116. package/dist/skills/media-use/scripts/lib/local-models.mjs +318 -0
  117. package/dist/skills/media-use/scripts/lib/local-models.test.mjs +220 -0
  118. package/dist/skills/media-use/scripts/lib/local-run.mjs +78 -0
  119. package/dist/skills/media-use/scripts/lib/local-run.test.mjs +121 -0
  120. package/dist/skills/media-use/scripts/lib/logo-provider.mjs +223 -0
  121. package/dist/skills/media-use/scripts/lib/logo-provider.test.mjs +154 -0
  122. package/dist/skills/media-use/scripts/lib/ltx-video-provider.mjs +94 -0
  123. package/dist/skills/media-use/scripts/lib/ltx-video-provider.test.mjs +316 -0
  124. package/dist/skills/media-use/scripts/lib/lut-preset-provider.mjs +271 -0
  125. package/dist/skills/media-use/scripts/lib/lut-preset-provider.test.mjs +199 -0
  126. package/dist/skills/media-use/scripts/lib/manifest.mjs +229 -0
  127. package/dist/skills/media-use/scripts/lib/manifest.test.mjs +388 -0
  128. package/dist/skills/media-use/scripts/lib/match.mjs +47 -0
  129. package/dist/skills/media-use/scripts/lib/media-fetch.mjs +71 -0
  130. package/dist/skills/media-use/scripts/lib/media-fetch.test.mjs +169 -0
  131. package/dist/skills/media-use/scripts/lib/media-search.d.mts +9 -0
  132. package/dist/skills/media-use/scripts/lib/media-search.mjs +107 -0
  133. package/dist/skills/media-use/scripts/lib/mflux-provider.mjs +128 -0
  134. package/dist/skills/media-use/scripts/lib/mflux-provider.test.mjs +174 -0
  135. package/dist/skills/media-use/scripts/lib/misses.mjs +49 -0
  136. package/dist/skills/media-use/scripts/lib/misses.test.mjs +78 -0
  137. package/dist/skills/media-use/scripts/lib/npx-sync.mjs +31 -0
  138. package/dist/skills/media-use/scripts/lib/npx-sync.test.mjs +50 -0
  139. package/dist/skills/media-use/scripts/lib/parakeet-words.mjs +27 -0
  140. package/dist/skills/media-use/scripts/lib/parakeet-words.test.mjs +44 -0
  141. package/dist/skills/media-use/scripts/lib/prefs-store.mjs +180 -0
  142. package/dist/skills/media-use/scripts/lib/prefs-store.test.mjs +189 -0
  143. package/dist/skills/media-use/scripts/lib/probe.mjs +39 -0
  144. package/dist/skills/media-use/scripts/lib/probe.test.mjs +31 -0
  145. package/dist/skills/media-use/scripts/lib/providers.mjs +5 -0
  146. package/dist/skills/media-use/scripts/lib/recipe-store.mjs +367 -0
  147. package/dist/skills/media-use/scripts/lib/recipe-store.test.mjs +367 -0
  148. package/dist/skills/media-use/scripts/lib/registry.mjs +218 -0
  149. package/dist/skills/media-use/scripts/lib/registry.test.mjs +262 -0
  150. package/dist/skills/media-use/scripts/lib/search.mjs +24 -0
  151. package/dist/skills/media-use/scripts/lib/search.test.mjs +32 -0
  152. package/dist/skills/media-use/scripts/lib/sfx-provider.mjs +23 -0
  153. package/dist/skills/media-use/scripts/lib/specs.mjs +132 -0
  154. package/dist/skills/media-use/scripts/lib/specs.test.mjs +157 -0
  155. package/dist/skills/media-use/scripts/lib/stats.mjs +117 -0
  156. package/dist/skills/media-use/scripts/lib/stats.test.mjs +165 -0
  157. package/dist/skills/media-use/scripts/lib/telemetry.mjs +193 -0
  158. package/dist/skills/media-use/scripts/lib/telemetry.test.mjs +373 -0
  159. package/dist/skills/media-use/scripts/lib/tts-local-provider.mjs +92 -0
  160. package/dist/skills/media-use/scripts/lib/tts-local-provider.test.mjs +72 -0
  161. package/dist/skills/media-use/scripts/lib/usage.mjs +57 -0
  162. package/dist/skills/media-use/scripts/lib/usage.test.mjs +54 -0
  163. package/dist/skills/media-use/scripts/lib/voice-provider.mjs +57 -0
  164. package/dist/skills/media-use/scripts/lib/voice-provider.test.mjs +46 -0
  165. package/dist/skills/media-use/scripts/lib/words.mjs +18 -0
  166. package/dist/skills/media-use/scripts/prefs.mjs +70 -0
  167. package/dist/skills/media-use/scripts/recipe.mjs +103 -0
  168. package/dist/skills/media-use/scripts/resolve.mjs +1287 -0
  169. package/dist/skills/media-use/scripts/transcribe.mjs +180 -0
  170. package/dist/skills/media-use/scripts/transcript-cut.mjs +239 -0
  171. package/dist/skills/registry/localSemantic.js +548 -0
  172. package/dist/studio/assets/{hyperframes-player-c23wnIfc.js → hyperframes-player-BBub3ZdI.js} +3 -3
  173. package/dist/studio/assets/{index-IDNr3X1H.js → index-BxZ5Cw5w.js} +1 -1
  174. package/dist/studio/assets/{index-DgkAzx3n.js → index-CI7WON-E.js} +1 -1
  175. package/dist/studio/assets/{index-gbOCDCuZ.js → index-D1KNPijK.js} +192 -192
  176. package/dist/studio/assets/{index-DKJpHYYd.css → index-DZBHG9TW.css} +1 -1
  177. package/dist/studio/index.d.ts +577 -8
  178. package/dist/studio/index.html +2 -2
  179. package/dist/studio/index.js +22537 -21600
  180. package/dist/studio/index.js.map +1 -1
  181. package/dist/studio/styles.css +1 -1
  182. package/package.json +3 -3
@@ -0,0 +1,328 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { execFileSync, spawn } from "node:child_process";
4
+ import { once } from "node:events";
5
+ import { existsSync, mkdirSync, renameSync, rmSync } from "node:fs";
6
+ import { dirname, extname, resolve } from "node:path";
7
+ import { text } from "node:stream/consumers";
8
+ import { parseArgs } from "node:util";
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
+ }
@@ -0,0 +1,176 @@
1
+ import assert from "node:assert/strict";
2
+ import { execFileSync, spawnSync } from "node:child_process";
3
+ import { mkdtempSync, rmSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import test from "node:test";
7
+ import { fileURLToPath } from "node:url";
8
+
9
+ const SCRIPT = fileURLToPath(new URL("./dither.mjs", import.meta.url));
10
+ const HAS_FFMPEG =
11
+ spawnSync("ffmpeg", ["-version"], { stdio: "ignore" }).status === 0 &&
12
+ spawnSync("ffprobe", ["-version"], { stdio: "ignore" }).status === 0;
13
+
14
+ function fixture() {
15
+ const dir = mkdtempSync(join(tmpdir(), "media-use-dither-"));
16
+ return { dir, cleanup: () => rmSync(dir, { recursive: true, force: true }) };
17
+ }
18
+
19
+ function ffmpeg(args) {
20
+ execFileSync("ffmpeg", ["-y", "-hide_banner", "-loglevel", "error", ...args]);
21
+ }
22
+
23
+ function run(args) {
24
+ return spawnSync(process.execPath, [SCRIPT, ...args, "--json"], { encoding: "utf8" });
25
+ }
26
+
27
+ test("processes an image into the requested ordered palette", { skip: !HAS_FFMPEG }, (t) => {
28
+ const { dir, cleanup } = fixture();
29
+ t.after(cleanup);
30
+ const input = join(dir, "source.png");
31
+ const output = join(dir, "dithered.png");
32
+ ffmpeg(["-f", "lavfi", "-i", "testsrc2=size=16x12:rate=1", "-frames:v", "1", input]);
33
+
34
+ const result = run([
35
+ "--input",
36
+ input,
37
+ "--out",
38
+ output,
39
+ "--algorithm",
40
+ "floyd-steinberg",
41
+ "--palette",
42
+ "#000000,#ffffff",
43
+ "--point-size",
44
+ "3",
45
+ ]);
46
+ assert.equal(result.status, 0, result.stderr || result.stdout);
47
+ assert.equal(JSON.parse(result.stdout).type, "image");
48
+
49
+ const rgb = execFileSync("ffmpeg", [
50
+ "-hide_banner",
51
+ "-loglevel",
52
+ "error",
53
+ "-i",
54
+ output,
55
+ "-frames:v",
56
+ "1",
57
+ "-f",
58
+ "rawvideo",
59
+ "-pix_fmt",
60
+ "rgb24",
61
+ "-",
62
+ ]);
63
+ for (let index = 0; index < rgb.length; index += 3) {
64
+ const value = rgb[index];
65
+ assert.ok(value === 0 || value === 255);
66
+ assert.equal(rgb[index + 1], value);
67
+ assert.equal(rgb[index + 2], value);
68
+ }
69
+ });
70
+
71
+ test("processes moving MP4 frames, audio, and BT.709 metadata", { skip: !HAS_FFMPEG }, (t) => {
72
+ const { dir, cleanup } = fixture();
73
+ t.after(cleanup);
74
+ const input = join(dir, "source.mp4");
75
+ const output = join(dir, "dithered.mp4");
76
+ ffmpeg([
77
+ "-f",
78
+ "lavfi",
79
+ "-i",
80
+ "testsrc2=size=16x12:rate=3:duration=2",
81
+ "-f",
82
+ "lavfi",
83
+ "-i",
84
+ "sine=frequency=440:duration=3",
85
+ "-c:v",
86
+ "libx264",
87
+ "-pix_fmt",
88
+ "yuv420p",
89
+ "-c:a",
90
+ "aac",
91
+ input,
92
+ ]);
93
+
94
+ const result = run([
95
+ "--input",
96
+ input,
97
+ "--out",
98
+ output,
99
+ "--algorithm",
100
+ "atkinson",
101
+ "--palette",
102
+ "#0f380f,#306230,#8bac0f,#9bbc0f",
103
+ ]);
104
+ assert.equal(result.status, 0, result.stderr || result.stdout);
105
+ const probe = JSON.parse(
106
+ execFileSync(
107
+ "ffprobe",
108
+ ["-v", "error", "-print_format", "json", "-show_streams", "-show_format", "--", output],
109
+ {
110
+ encoding: "utf8",
111
+ },
112
+ ),
113
+ );
114
+ const video = probe.streams.find((stream) => stream.codec_type === "video");
115
+ const audio = probe.streams.find((stream) => stream.codec_type === "audio");
116
+ assert.equal(video.width, 16);
117
+ assert.equal(video.height, 12);
118
+ assert.equal(video.nb_frames, "6");
119
+ assert.equal(video.color_space, "bt709");
120
+ assert.equal(video.color_transfer, "bt709");
121
+ assert.equal(video.color_primaries, "bt709");
122
+ assert.equal(video.color_range, "tv");
123
+ assert.equal(audio.codec_name, "aac");
124
+ assert.ok(Number(probe.format.duration) < 2.5, "audio must not outlive processed video");
125
+ const keyframes = execFileSync(
126
+ "ffprobe",
127
+ [
128
+ "-v",
129
+ "error",
130
+ "-select_streams",
131
+ "v:0",
132
+ "-skip_frame",
133
+ "nokey",
134
+ "-show_entries",
135
+ "frame=best_effort_timestamp_time",
136
+ "-of",
137
+ "csv=p=0",
138
+ "--",
139
+ output,
140
+ ],
141
+ { encoding: "utf8" },
142
+ )
143
+ .trim()
144
+ .split(/\s+/)
145
+ .map((value) => Number.parseFloat(value));
146
+ assert.deepEqual(keyframes, [0, 1]);
147
+ });
148
+
149
+ test("rejects tagged PQ or HLG instead of silently producing SDR", { skip: !HAS_FFMPEG }, (t) => {
150
+ const { dir, cleanup } = fixture();
151
+ t.after(cleanup);
152
+ const input = join(dir, "hlg.mp4");
153
+ ffmpeg([
154
+ "-f",
155
+ "lavfi",
156
+ "-i",
157
+ "color=c=white:size=16x12:rate=1:duration=1",
158
+ "-c:v",
159
+ "libx264",
160
+ "-pix_fmt",
161
+ "yuv420p10le",
162
+ "-x264-params",
163
+ "colorprim=bt2020:transfer=arib-std-b67:colormatrix=bt2020nc",
164
+ "-color_primaries:v",
165
+ "bt2020",
166
+ "-color_trc:v",
167
+ "arib-std-b67",
168
+ "-colorspace:v",
169
+ "bt2020nc",
170
+ input,
171
+ ]);
172
+
173
+ const result = run(["--input", input, "--out", join(dir, "wrong.mp4")]);
174
+ assert.equal(result.status, 1);
175
+ assert.match(JSON.parse(result.stdout).error, /HDR arib-std-b67 input is not supported/);
176
+ });