hyperframes 0.8.58 → 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 +4483 -3217
  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-zGyOcwwh.js → hyperframes-player-BBub3ZdI.js} +3 -3
  173. package/dist/studio/assets/{index-ZJaRCDYV.js → index-BxZ5Cw5w.js} +1 -1
  174. package/dist/studio/assets/{index-BcE2OSVA.js → index-CI7WON-E.js} +1 -1
  175. package/dist/studio/assets/{index-d8ferjRc.js → index-D1KNPijK.js} +190 -190
  176. package/dist/studio/assets/{index-DKJpHYYd.css → index-DZBHG9TW.css} +1 -1
  177. package/dist/studio/index.d.ts +548 -8
  178. package/dist/studio/index.html +2 -2
  179. package/dist/studio/index.js +21596 -20725
  180. package/dist/studio/index.js.map +1 -1
  181. package/dist/studio/styles.css +1 -1
  182. package/package.json +1 -1
@@ -0,0 +1,265 @@
1
+ // Vendored plain-JS copy of packages/core/src/mediaGradeAnalyzer.ts.
2
+ // packages/core/src/mediaGradeAnalyzer.vendoredParity.test.ts guards behavior drift.
3
+ import { execFileSync } from "node:child_process";
4
+ import { basename, extname } from "node:path";
5
+
6
+ const IMAGE_EXT = new Set([".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".tif", ".tiff"]);
7
+ const SAMPLE_FRAMES = 5;
8
+ const DEFAULT_TIMEOUT_MS = 15_000;
9
+
10
+ const ADJUST_LIMITS = {
11
+ exposure: { min: -2, max: 2 },
12
+ contrast: { min: -1, max: 1 },
13
+ whites: { min: -1, max: 1 },
14
+ blacks: { min: -1, max: 1 },
15
+ temperature: { min: -1, max: 1 },
16
+ tint: { min: -1, max: 1 },
17
+ };
18
+
19
+ function clamp(value, key) {
20
+ const limit = ADJUST_LIMITS[key];
21
+ if (!Number.isFinite(value)) return 0;
22
+ return Math.min(limit.max, Math.max(limit.min, value));
23
+ }
24
+
25
+ function round(value) {
26
+ const rounded = Math.round(value * 1000) / 1000;
27
+ return Object.is(rounded, -0) ? 0 : rounded;
28
+ }
29
+
30
+ function average(values) {
31
+ return values.reduce((sum, value) => sum + value, 0) / Math.max(1, values.length);
32
+ }
33
+
34
+ function probeMedia(mediaPath, ffprobePath) {
35
+ try {
36
+ const raw = execFileSync(
37
+ ffprobePath,
38
+ [
39
+ "-v",
40
+ "error",
41
+ "-select_streams",
42
+ "v:0",
43
+ "-show_entries",
44
+ "stream=color_space,color_transfer,color_primaries,pix_fmt,duration:format=duration",
45
+ "-of",
46
+ "json",
47
+ "--",
48
+ mediaPath,
49
+ ],
50
+ { encoding: "utf8", timeout: 5_000, stdio: ["ignore", "pipe", "pipe"] },
51
+ );
52
+ const parsed = JSON.parse(raw);
53
+ const stream = Array.isArray(parsed.streams) ? (parsed.streams[0] ?? {}) : {};
54
+ const duration = Number(stream.duration ?? parsed.format?.duration);
55
+ const text = (key) =>
56
+ typeof stream[key] === "string" && stream[key] ? stream[key] : "unknown";
57
+ return {
58
+ duration: Number.isFinite(duration) && duration > 0 ? duration : null,
59
+ colorSpace: text("color_space"),
60
+ transfer: text("color_transfer"),
61
+ primaries: text("color_primaries"),
62
+ pixelFormat: text("pix_fmt"),
63
+ };
64
+ } catch {
65
+ return {
66
+ duration: null,
67
+ colorSpace: "unknown",
68
+ transfer: "unknown",
69
+ primaries: "unknown",
70
+ pixelFormat: "unknown",
71
+ };
72
+ }
73
+ }
74
+
75
+ export function parseMediaTreatmentSignalStats(raw) {
76
+ const frames = [];
77
+ let current = null;
78
+ for (const line of String(raw).split(/\r?\n/)) {
79
+ // One digit is enough: .* consumes the rest without overlapping repetitions.
80
+ const frame = line.match(/^frame:\d.*pts_time:([+-]?(?:\d+(?:\.\d+)?|\.\d+))/);
81
+ if (frame) {
82
+ if (current) frames.push(current);
83
+ current = { ptsTime: Number(frame[1]) };
84
+ continue;
85
+ }
86
+ const stat = line.match(/lavfi\.signalstats\.([A-Z]+)=([+-]?(?:\d+(?:\.\d+)?|\.\d+))/);
87
+ if (!stat) continue;
88
+ current ??= {};
89
+ current[stat[1]] = Number(stat[2]);
90
+ }
91
+ if (current) frames.push(current);
92
+ return frames.filter(
93
+ (frame) =>
94
+ Number.isFinite(frame.YMIN) &&
95
+ Number.isFinite(frame.YLOW) &&
96
+ Number.isFinite(frame.YAVG) &&
97
+ Number.isFinite(frame.YHIGH) &&
98
+ Number.isFinite(frame.YMAX) &&
99
+ Number.isFinite(frame.UAVG) &&
100
+ Number.isFinite(frame.VAVG),
101
+ );
102
+ }
103
+
104
+ function summarizeFrames(frames) {
105
+ if (frames.length === 0) throw new Error("FFmpeg returned no analyzable video frames");
106
+ return {
107
+ frames: frames.length,
108
+ yMin: Math.min(...frames.map((frame) => frame.YMIN)),
109
+ yLow: average(frames.map((frame) => frame.YLOW)),
110
+ yAvg: average(frames.map((frame) => frame.YAVG)),
111
+ yHigh: average(frames.map((frame) => frame.YHIGH)),
112
+ yMax: Math.max(...frames.map((frame) => frame.YMAX)),
113
+ uAvg: average(frames.map((frame) => frame.UAVG)),
114
+ vAvg: average(frames.map((frame) => frame.VAVG)),
115
+ satAvg: average(frames.map((frame) => frame.SATAVG ?? 0)),
116
+ shadowClipRisk: average(frames.map((frame) => (frame.YLOW <= 16 ? 1 : 0))),
117
+ highlightClipRisk: average(frames.map((frame) => (frame.YHIGH >= 235 ? 1 : 0))),
118
+ };
119
+ }
120
+
121
+ function suggestedExposure(normalizedAverage, yLow, yHigh) {
122
+ if (normalizedAverage < 0.28 && yHigh / 255 < 0.65) {
123
+ return clamp((0.32 - normalizedAverage) * 1.2, "exposure");
124
+ }
125
+ if (normalizedAverage > 0.72 && yLow / 255 > 0.3) {
126
+ return clamp((0.68 - normalizedAverage) * 1.2, "exposure");
127
+ }
128
+ return 0;
129
+ }
130
+
131
+ function suggestedTemperature(uAverage, vAverage) {
132
+ const chromaWarmth = (vAverage - 128 + (128 - uAverage)) / 128;
133
+ return Math.abs(chromaWarmth) >= 0.08 ? clamp(-chromaWarmth * 0.25, "temperature") : 0;
134
+ }
135
+
136
+ function suggestedTint(uAverage, vAverage) {
137
+ const cast = uAverage + vAverage - 256;
138
+ return Math.abs(cast) >= 10 ? clamp(-cast / 512, "tint") : 0;
139
+ }
140
+
141
+ export function statsToAdjust(stats) {
142
+ const yMin = Number(stats.yMin);
143
+ const yLow = Number(stats.yLow ?? stats.yMin);
144
+ const yMax = Number(stats.yMax);
145
+ const yHigh = Number(stats.yHigh ?? stats.yMax);
146
+ const yAvg = Number(stats.yAvg);
147
+ const uAvg = Number(stats.uAvg);
148
+ const vAvg = Number(stats.vAvg);
149
+ const shadowClipRisk = Number(stats.shadowClipRisk ?? (yLow <= 16 ? 1 : 0));
150
+ const highlightClipRisk = Number(stats.highlightClipRisk ?? (yHigh >= 235 ? 1 : 0));
151
+ const percentileSpread = (yHigh - yLow) / 255;
152
+ const normalizedAverage = yAvg / 255;
153
+ const contrast = percentileSpread < 0.35 ? clamp((0.35 - percentileSpread) * 0.4, "contrast") : 0;
154
+
155
+ return {
156
+ adjust: {
157
+ exposure: round(suggestedExposure(normalizedAverage, yLow, yHigh)),
158
+ contrast: round(contrast),
159
+ blacks: round(clamp(shadowClipRisk * 0.08, "blacks")),
160
+ whites: round(clamp(-highlightClipRisk * 0.08, "whites")),
161
+ temperature: round(suggestedTemperature(uAvg, vAvg)),
162
+ tint: round(suggestedTint(uAvg, vAvg)),
163
+ },
164
+ measured: {
165
+ frames: Number(stats.frames ?? 1),
166
+ yMin: round(yMin),
167
+ yLow: round(yLow),
168
+ yAvg: round(yAvg),
169
+ yHigh: round(yHigh),
170
+ yMax: round(yMax),
171
+ uAvg: round(uAvg),
172
+ vAvg: round(vAvg),
173
+ satAvg: round(Number(stats.satAvg ?? 0)),
174
+ shadowClipRisk: round(shadowClipRisk),
175
+ highlightClipRisk: round(highlightClipRisk),
176
+ },
177
+ };
178
+ }
179
+
180
+ export function summarizeMediaTreatmentAnalysis(probe, frames) {
181
+ const result = statsToAdjust(summarizeFrames(frames));
182
+ const hdr = ["smpte2084", "arib-std-b67"].includes(probe.transfer);
183
+ const diagnosis = [];
184
+ if (result.measured.shadowClipRisk > 0) {
185
+ diagnosis.push("sampled frames contain deep or clipped shadows");
186
+ }
187
+ if (result.measured.highlightClipRisk > 0) {
188
+ diagnosis.push("sampled frames contain bright or clipped highlights");
189
+ }
190
+ if (diagnosis.length === 0) diagnosis.push("no obvious technical imbalance in sampled frames");
191
+
192
+ const warnings = [];
193
+ if (hdr) warnings.push("HDR transfer detected; the realtime treatment path is SDR/Rec.709.");
194
+ if (probe.colorSpace === "unknown" || probe.transfer === "unknown") {
195
+ warnings.push(
196
+ "Source color metadata is incomplete; camera LOG cannot be identified reliably from container metadata alone.",
197
+ );
198
+ }
199
+
200
+ return {
201
+ ...result,
202
+ source: {
203
+ colorSpace: probe.colorSpace,
204
+ transfer: probe.transfer,
205
+ primaries: probe.primaries,
206
+ pixelFormat: probe.pixelFormat,
207
+ hdr,
208
+ log: "unknown",
209
+ },
210
+ diagnosis,
211
+ warnings,
212
+ };
213
+ }
214
+
215
+ export function analyzeMediaGrade(
216
+ mediaPath,
217
+ { ffmpegPath = "ffmpeg", ffprobePath = "ffprobe" } = {},
218
+ ) {
219
+ try {
220
+ const probe = probeMedia(mediaPath, ffprobePath);
221
+ const isImage = IMAGE_EXT.has(extname(mediaPath).toLowerCase());
222
+ const fps =
223
+ !isImage && probe.duration
224
+ ? Math.max(0.1, Math.min(2, SAMPLE_FRAMES / probe.duration))
225
+ : null;
226
+ const filters = [
227
+ fps ? `fps=${fps.toFixed(4)}` : null,
228
+ "format=yuv444p",
229
+ "signalstats",
230
+ "metadata=print:file=-",
231
+ ]
232
+ .filter(Boolean)
233
+ .join(",");
234
+ const raw = execFileSync(
235
+ ffmpegPath,
236
+ [
237
+ "-hide_banner",
238
+ "-nostdin",
239
+ "-v",
240
+ "error",
241
+ "-i",
242
+ mediaPath,
243
+ "-vf",
244
+ filters,
245
+ "-frames:v",
246
+ String(SAMPLE_FRAMES),
247
+ "-f",
248
+ "null",
249
+ "-",
250
+ ],
251
+ {
252
+ encoding: "utf8",
253
+ timeout: Number(process.env.HYPERFRAMES_ANALYZE_TIMEOUT_MS) || DEFAULT_TIMEOUT_MS,
254
+ stdio: ["ignore", "pipe", "pipe"],
255
+ },
256
+ );
257
+ return summarizeMediaTreatmentAnalysis(probe, parseMediaTreatmentSignalStats(raw));
258
+ } catch (error) {
259
+ throw new Error(`grade analysis failed for ${mediaPath}: ${error.message}`);
260
+ }
261
+ }
262
+
263
+ export function formatMeasuredNote(mediaPath, measured) {
264
+ return `media-use: measured ${basename(mediaPath)}: frames=${measured.frames}, YMIN=${measured.yMin}, YLOW=${measured.yLow}, YAVG=${measured.yAvg}, YHIGH=${measured.yHigh}, YMAX=${measured.yMax}, UAVG=${measured.uAvg}, VAVG=${measured.vAvg}; adjust is a starting suggestion`;
265
+ }
@@ -0,0 +1,285 @@
1
+ import { strict as assert } from "node:assert";
2
+ import { execFileSync, spawnSync } from "node:child_process";
3
+ import { existsSync, mkdtempSync, rmSync, unlinkSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { tmpdir } from "node:os";
6
+ import { test } from "node:test";
7
+ import {
8
+ analyzeMediaGrade,
9
+ formatMeasuredNote,
10
+ parseMediaTreatmentSignalStats,
11
+ statsToAdjust,
12
+ summarizeMediaTreatmentAnalysis,
13
+ } from "./grade-analyzer.mjs";
14
+
15
+ const SIGNALSTATS = `frame:0 pts:0 pts_time:0
16
+ lavfi.signalstats.YMIN=0
17
+ lavfi.signalstats.YLOW=8
18
+ lavfi.signalstats.YAVG=100
19
+ lavfi.signalstats.YHIGH=240
20
+ lavfi.signalstats.YMAX=255
21
+ lavfi.signalstats.UAVG=120
22
+ lavfi.signalstats.VAVG=140
23
+ lavfi.signalstats.SATAVG=40
24
+ frame:1 pts:2000 pts_time:2
25
+ lavfi.signalstats.YMIN=10
26
+ lavfi.signalstats.YLOW=20
27
+ lavfi.signalstats.YAVG=130
28
+ lavfi.signalstats.YHIGH=220
29
+ lavfi.signalstats.YMAX=245
30
+ lavfi.signalstats.UAVG=130
31
+ lavfi.signalstats.VAVG=125
32
+ lavfi.signalstats.SATAVG=60`;
33
+
34
+ // The "Test: skills" CI job runs bare `node --test` with no ffmpeg on PATH (by
35
+ // design — skills tests are meant to be node-builtin-only). Tests that shell to
36
+ // ffmpeg skip there and run wherever ffmpeg is present (locally, dev).
37
+ const FFMPEG_SKIP =
38
+ spawnSync("ffmpeg", ["-version"], { stdio: "ignore" }).status === 0
39
+ ? false
40
+ : "ffmpeg not on PATH";
41
+
42
+ const ADJUST_LIMITS = {
43
+ exposure: { min: -2, max: 2 },
44
+ contrast: { min: -1, max: 1 },
45
+ highlights: { min: -1, max: 1 },
46
+ shadows: { min: -1, max: 1 },
47
+ whites: { min: -1, max: 1 },
48
+ blacks: { min: -1, max: 1 },
49
+ temperature: { min: -1, max: 1 },
50
+ tint: { min: -1, max: 1 },
51
+ vibrance: { min: -1, max: 1 },
52
+ saturation: { min: -1, max: 1 },
53
+ };
54
+
55
+ function makeFrame(dir, name, color) {
56
+ const out = join(dir, name);
57
+ execFileSync(
58
+ "ffmpeg",
59
+ [
60
+ "-hide_banner",
61
+ "-loglevel",
62
+ "error",
63
+ "-f",
64
+ "lavfi",
65
+ "-i",
66
+ `color=c=${color}:s=64x64`,
67
+ "-frames:v",
68
+ "1",
69
+ "-y",
70
+ out,
71
+ ],
72
+ { stdio: "pipe" },
73
+ );
74
+ return out;
75
+ }
76
+
77
+ function assertWithinLimits(adjust) {
78
+ for (const [key, value] of Object.entries(adjust)) {
79
+ const limit = ADJUST_LIMITS[key];
80
+ assert.ok(limit, `unexpected adjust key ${key}`);
81
+ assert.ok(value >= limit.min && value <= limit.max, `${key} out of range: ${value}`);
82
+ }
83
+ }
84
+
85
+ test("under-exposed synthetic frame suggests positive exposure", { skip: FFMPEG_SKIP }, () => {
86
+ const dir = mkdtempSync(join(tmpdir(), "mu-grade-under-"));
87
+ try {
88
+ const file = makeFrame(dir, "under.png", "0x202020");
89
+ const { adjust, measured } = analyzeMediaGrade(file);
90
+ assert.ok(measured.frames >= 1);
91
+ assert.ok(adjust.exposure > 0, `expected positive exposure, got ${adjust.exposure}`);
92
+ assertWithinLimits(adjust);
93
+ } finally {
94
+ rmSync(dir, { recursive: true, force: true });
95
+ }
96
+ });
97
+
98
+ test(
99
+ "over-exposed synthetic frame pulls exposure down without inventing clipping",
100
+ { skip: FFMPEG_SKIP },
101
+ () => {
102
+ const dir = mkdtempSync(join(tmpdir(), "mu-grade-over-"));
103
+ try {
104
+ const file = makeFrame(dir, "over.png", "white");
105
+ const { adjust } = analyzeMediaGrade(file);
106
+ assert.ok(adjust.exposure < 0, `expected negative exposure, got ${adjust.exposure}`);
107
+ assert.ok(adjust.whites <= 0, `expected non-positive whites, got ${adjust.whites}`);
108
+ assertWithinLimits(adjust);
109
+ } finally {
110
+ rmSync(dir, { recursive: true, force: true });
111
+ }
112
+ },
113
+ );
114
+
115
+ test(
116
+ "warm-cast synthetic frame suggests negative temperature correction",
117
+ { skip: FFMPEG_SKIP },
118
+ () => {
119
+ const dir = mkdtempSync(join(tmpdir(), "mu-grade-warm-"));
120
+ try {
121
+ const file = makeFrame(dir, "warm.png", "orange");
122
+ const { adjust } = analyzeMediaGrade(file);
123
+ assert.ok(adjust.temperature < 0, `expected cooling correction, got ${adjust.temperature}`);
124
+ assertWithinLimits(adjust);
125
+ } finally {
126
+ rmSync(dir, { recursive: true, force: true });
127
+ }
128
+ },
129
+ );
130
+
131
+ test("low-spread stats suggest positive contrast", () => {
132
+ const { adjust } = statsToAdjust({
133
+ frames: 1,
134
+ yMin: 104,
135
+ yMax: 116,
136
+ yAvg: 110,
137
+ uAvg: 128,
138
+ vAvg: 128,
139
+ });
140
+ assert.ok(adjust.contrast > 0, `expected positive contrast, got ${adjust.contrast}`);
141
+ assertWithinLimits(adjust);
142
+ });
143
+
144
+ test("malformed media fails cleanly", () => {
145
+ assert.throws(
146
+ () => analyzeMediaGrade(join(tmpdir(), "does-not-exist.png")),
147
+ /grade analysis failed/,
148
+ );
149
+ });
150
+
151
+ test(
152
+ "media path with shell metacharacters is passed as argv, not a shell string",
153
+ {
154
+ skip: FFMPEG_SKIP,
155
+ },
156
+ () => {
157
+ const dir = mkdtempSync(join(tmpdir(), "mu-grade-shell-"));
158
+ const sentinel = join(process.cwd(), "mu-grade-shell-sentinel.png");
159
+ try {
160
+ if (existsSync(sentinel)) unlinkSync(sentinel);
161
+ const file = makeFrame(dir, "frame; touch mu-grade-shell-sentinel.png", "orange");
162
+ const result = analyzeMediaGrade(file);
163
+ assert.ok(result.measured.frames >= 1);
164
+ assert.equal(existsSync(sentinel), false);
165
+ } finally {
166
+ if (existsSync(sentinel)) unlinkSync(sentinel);
167
+ rmSync(dir, { recursive: true, force: true });
168
+ }
169
+ },
170
+ );
171
+
172
+ test("measured note is a stderr-safe single-line summary", () => {
173
+ const note = formatMeasuredNote("/tmp/frame.png", {
174
+ frames: 1,
175
+ yMin: 10,
176
+ yMax: 240,
177
+ yAvg: 80,
178
+ uAvg: 120,
179
+ vAvg: 140,
180
+ });
181
+ assert.match(note, /^media-use: measured /);
182
+ assert.match(note, /YMIN=10/);
183
+ assert.match(note, /YMAX=240/);
184
+ assert.match(note, /YAVG=80/);
185
+ assert.equal(note.includes("\n"), false);
186
+ });
187
+
188
+ test("parses deterministic FFmpeg signalstats frames", () => {
189
+ const frames = parseMediaTreatmentSignalStats(SIGNALSTATS);
190
+ assert.equal(frames.length, 2);
191
+ assert.deepEqual(
192
+ {
193
+ ptsTime: frames[0]?.ptsTime,
194
+ YLOW: frames[0]?.YLOW,
195
+ YAVG: frames[0]?.YAVG,
196
+ YHIGH: frames[0]?.YHIGH,
197
+ SATAVG: frames[0]?.SATAVG,
198
+ },
199
+ { ptsTime: 0, YLOW: 8, YAVG: 100, YHIGH: 240, SATAVG: 40 },
200
+ );
201
+ });
202
+
203
+ test("keeps adjust output while adding HDR metadata and warnings", () => {
204
+ const result = summarizeMediaTreatmentAnalysis(
205
+ {
206
+ duration: 4,
207
+ colorSpace: "bt2020nc",
208
+ transfer: "smpte2084",
209
+ primaries: "bt2020",
210
+ pixelFormat: "yuv420p10le",
211
+ },
212
+ parseMediaTreatmentSignalStats(SIGNALSTATS),
213
+ );
214
+
215
+ assert.deepEqual(
216
+ {
217
+ blacks: result.adjust.blacks,
218
+ whites: result.adjust.whites,
219
+ frames: result.measured.frames,
220
+ yLow: result.measured.yLow,
221
+ yHigh: result.measured.yHigh,
222
+ satAvg: result.measured.satAvg,
223
+ shadowClipRisk: result.measured.shadowClipRisk,
224
+ highlightClipRisk: result.measured.highlightClipRisk,
225
+ },
226
+ {
227
+ blacks: 0.04,
228
+ whites: -0.04,
229
+ frames: 2,
230
+ yLow: 14,
231
+ yHigh: 230,
232
+ satAvg: 50,
233
+ shadowClipRisk: 0.5,
234
+ highlightClipRisk: 0.5,
235
+ },
236
+ );
237
+ assert.equal(result.source.hdr, true);
238
+ assert.equal(result.source.log, "unknown");
239
+ assert.match(result.warnings[0] ?? "", /HDR/);
240
+ });
241
+
242
+ test("does not force middle-gray exposure onto healthy intentional contrast", () => {
243
+ const result = summarizeMediaTreatmentAnalysis(
244
+ {
245
+ duration: 4,
246
+ colorSpace: "bt709",
247
+ transfer: "bt709",
248
+ primaries: "bt709",
249
+ pixelFormat: "yuv420p",
250
+ },
251
+ [
252
+ {
253
+ YMIN: 20,
254
+ YLOW: 42,
255
+ YAVG: 122,
256
+ YHIGH: 190,
257
+ YMAX: 220,
258
+ UAVG: 128,
259
+ VAVG: 128,
260
+ SATAVG: 40,
261
+ },
262
+ {
263
+ YMIN: 18,
264
+ YLOW: 38,
265
+ YAVG: 118,
266
+ YHIGH: 196,
267
+ YMAX: 225,
268
+ UAVG: 128,
269
+ VAVG: 128,
270
+ SATAVG: 42,
271
+ },
272
+ ],
273
+ );
274
+
275
+ assert.deepEqual(
276
+ {
277
+ exposure: result.adjust.exposure,
278
+ blacks: result.adjust.blacks,
279
+ whites: result.adjust.whites,
280
+ temperature: result.adjust.temperature,
281
+ tint: result.adjust.tint,
282
+ },
283
+ { exposure: 0, blacks: 0, whites: 0, temperature: 0, tint: 0 },
284
+ );
285
+ });