dsh-hyperframes 0.10.2 → 0.11.0

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 (113) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.en.md +2 -1
  3. package/README.md +2 -1
  4. package/docs/WORKBENCH.md +6 -0
  5. package/lib/client.js +40 -2
  6. package/lib/media-templates.d.ts +1 -1
  7. package/lib/media-templates.js +1 -1
  8. package/package.json +2 -2
  9. package/plugin.json +1 -1
  10. package/skills/NOTICE.md +1 -1
  11. package/skills/UPSTREAM.json +93 -8
  12. package/skills/embedded-captions/SKILL.md +1 -1
  13. package/skills/faceless-explainer/SKILL.md +1 -1
  14. package/skills/general-video/SKILL.md +1 -1
  15. package/skills/hyperframes-animation/SKILL.md +1 -1
  16. package/skills/hyperframes-audio/SKILL.md +1 -1
  17. package/skills/hyperframes-core/SKILL.md +1 -1
  18. package/skills/hyperframes-creative/SKILL.md +1 -1
  19. package/skills/hyperframes-keyframes/SKILL.md +1 -1
  20. package/skills/hyperframes-studio/SKILL.md +1 -1
  21. package/skills/media-use/scripts/lib/adopt.mjs +128 -0
  22. package/skills/media-use/scripts/lib/adopt.test.mjs +95 -0
  23. package/skills/media-use/scripts/lib/bgm-provider.mjs +20 -0
  24. package/skills/media-use/scripts/lib/brand-provider.mjs +59 -0
  25. package/skills/media-use/scripts/lib/bundled-sfx-provider.mjs +119 -0
  26. package/skills/media-use/scripts/lib/bundled-sfx-provider.test.mjs +139 -0
  27. package/skills/media-use/scripts/lib/cache.mjs +162 -0
  28. package/skills/media-use/scripts/lib/cache.test.mjs +110 -0
  29. package/skills/media-use/scripts/lib/candidates.mjs +91 -0
  30. package/skills/media-use/scripts/lib/candidates.test.mjs +155 -0
  31. package/skills/media-use/scripts/lib/codex-provider.mjs +161 -0
  32. package/skills/media-use/scripts/lib/codex-provider.test.mjs +34 -0
  33. package/skills/media-use/scripts/lib/config-lock.d.mts +3 -0
  34. package/skills/media-use/scripts/lib/config-lock.test.mjs +79 -0
  35. package/skills/media-use/scripts/lib/coverage.test.mjs +136 -0
  36. package/skills/media-use/scripts/lib/cube-build.mjs +181 -0
  37. package/skills/media-use/scripts/lib/cube-build.test.mjs +80 -0
  38. package/skills/media-use/scripts/lib/cube-validate.mjs +197 -0
  39. package/skills/media-use/scripts/lib/cube-validate.test.mjs +125 -0
  40. package/skills/media-use/scripts/lib/cutlist.test.mjs +174 -0
  41. package/skills/media-use/scripts/lib/duck.test.mjs +319 -0
  42. package/skills/media-use/scripts/lib/error-diffusion.test.mjs +102 -0
  43. package/skills/media-use/scripts/lib/ff-binaries.mjs +37 -0
  44. package/skills/media-use/scripts/lib/freeze.mjs +72 -0
  45. package/skills/media-use/scripts/lib/freeze.test.mjs +138 -0
  46. package/skills/media-use/scripts/lib/grade-analyzer.mjs +277 -0
  47. package/skills/media-use/scripts/lib/grade-analyzer.test.mjs +285 -0
  48. package/skills/media-use/scripts/lib/heygen-cli.mjs +208 -0
  49. package/skills/media-use/scripts/lib/heygen-cli.test.mjs +315 -0
  50. package/skills/media-use/scripts/lib/heygen-search.mjs +45 -0
  51. package/skills/media-use/scripts/lib/heygen-search.test.mjs +49 -0
  52. package/skills/media-use/scripts/lib/heygen-video-provider.mjs +138 -0
  53. package/skills/media-use/scripts/lib/heygen-video-provider.test.mjs +435 -0
  54. package/skills/media-use/scripts/lib/image-provider.mjs +44 -0
  55. package/skills/media-use/scripts/lib/local-media-search.mjs +32 -0
  56. package/skills/media-use/scripts/lib/local-models.mjs +318 -0
  57. package/skills/media-use/scripts/lib/local-models.test.mjs +220 -0
  58. package/skills/media-use/scripts/lib/local-run.mjs +78 -0
  59. package/skills/media-use/scripts/lib/local-run.test.mjs +121 -0
  60. package/skills/media-use/scripts/lib/logo-provider.mjs +244 -0
  61. package/skills/media-use/scripts/lib/logo-provider.test.mjs +298 -0
  62. package/skills/media-use/scripts/lib/ltx-video-provider.mjs +94 -0
  63. package/skills/media-use/scripts/lib/ltx-video-provider.test.mjs +316 -0
  64. package/skills/media-use/scripts/lib/lut-preset-provider.mjs +271 -0
  65. package/skills/media-use/scripts/lib/lut-preset-provider.test.mjs +199 -0
  66. package/skills/media-use/scripts/lib/main-module.mjs +13 -0
  67. package/skills/media-use/scripts/lib/manifest.d.mts +20 -0
  68. package/skills/media-use/scripts/lib/manifest.test.mjs +397 -0
  69. package/skills/media-use/scripts/lib/match.mjs +47 -0
  70. package/skills/media-use/scripts/lib/media-fetch.test.mjs +197 -0
  71. package/skills/media-use/scripts/lib/media-home.test.mjs +61 -0
  72. package/skills/media-use/scripts/lib/media-search.d.mts +9 -0
  73. package/skills/media-use/scripts/lib/media-search.mjs +107 -0
  74. package/skills/media-use/scripts/lib/mflux-provider.mjs +128 -0
  75. package/skills/media-use/scripts/lib/mflux-provider.test.mjs +174 -0
  76. package/skills/media-use/scripts/lib/misses.mjs +49 -0
  77. package/skills/media-use/scripts/lib/misses.test.mjs +89 -0
  78. package/skills/media-use/scripts/lib/npx-sync.mjs +2 -28
  79. package/skills/media-use/scripts/lib/npx-sync.test.mjs +50 -0
  80. package/skills/media-use/scripts/lib/openverse.mjs +134 -0
  81. package/skills/media-use/scripts/lib/openverse.test.mjs +219 -0
  82. package/skills/media-use/scripts/lib/parakeet-words.test.mjs +44 -0
  83. package/skills/media-use/scripts/lib/prefs-store.concurrent.test.mjs +173 -0
  84. package/skills/media-use/scripts/lib/prefs-store.test.mjs +245 -0
  85. package/skills/media-use/scripts/lib/probe.mjs +46 -0
  86. package/skills/media-use/scripts/lib/probe.test.mjs +221 -0
  87. package/skills/media-use/scripts/lib/providers.mjs +5 -0
  88. package/skills/media-use/scripts/lib/recipe-store.test.mjs +391 -0
  89. package/skills/media-use/scripts/lib/registry.mjs +214 -0
  90. package/skills/media-use/scripts/lib/registry.test.mjs +262 -0
  91. package/skills/media-use/scripts/lib/search.mjs +24 -0
  92. package/skills/media-use/scripts/lib/search.test.mjs +32 -0
  93. package/skills/media-use/scripts/lib/sfx-provider.mjs +33 -0
  94. package/skills/media-use/scripts/lib/specs.mjs +132 -0
  95. package/skills/media-use/scripts/lib/specs.test.mjs +157 -0
  96. package/skills/media-use/scripts/lib/stats.mjs +117 -0
  97. package/skills/media-use/scripts/lib/stats.test.mjs +165 -0
  98. package/skills/media-use/scripts/lib/svg-sanitize.mjs +21 -0
  99. package/skills/media-use/scripts/lib/telemetry.test.mjs +437 -53
  100. package/skills/media-use/scripts/lib/test-home.test.mjs +10 -0
  101. package/skills/media-use/scripts/lib/transcriptCutFade.test.mjs +33 -0
  102. package/skills/media-use/scripts/lib/tts-local-provider.mjs +94 -0
  103. package/skills/media-use/scripts/lib/tts-local-provider.test.mjs +72 -0
  104. package/skills/media-use/scripts/lib/usage.mjs +57 -0
  105. package/skills/media-use/scripts/lib/usage.test.mjs +54 -0
  106. package/skills/media-use/scripts/lib/voice-provider.mjs +57 -0
  107. package/skills/media-use/scripts/lib/voice-provider.test.mjs +46 -0
  108. package/skills/media-use/scripts/resolve.mjs +1360 -27
  109. package/skills/motion-graphics/SKILL.md +1 -1
  110. package/skills/music-to-video/SKILL.md +1 -1
  111. package/skills/pr-to-video/SKILL.md +1 -1
  112. package/skills/product-launch-video/SKILL.md +1 -1
  113. package/skills/talking-head-recut/SKILL.md +1 -1
@@ -0,0 +1,136 @@
1
+ import { strict as assert } from "node:assert";
2
+ import { test } from "node:test";
3
+ import { existsSync } from "node:fs";
4
+ import { join, dirname } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { listTypes, getProviders } from "./registry.mjs";
7
+ import { CAPABILITIES, listModels } from "./local-models.mjs";
8
+
9
+ // Capstone: media-use must actually OWN each hyperframes media weakness. This
10
+ // test enforces the weakness→owner matrix in references/meta.md so a claim can't rot — if
11
+ // a capability's entrypoint disappears, this fails.
12
+
13
+ const SKILL = join(
14
+ dirname(fileURLToPath(import.meta.url)),
15
+ "..",
16
+ "..",
17
+ "..",
18
+ "..",
19
+ "..",
20
+ "skills",
21
+ "media-use",
22
+ );
23
+
24
+ test("weakness: audio-only → media-use resolves image + icon", () => {
25
+ for (const t of ["image", "icon"]) {
26
+ assert.ok(getProviders(t).length > 0, `no provider for ${t}`);
27
+ }
28
+ });
29
+
30
+ test("weakness: no third-party brand logos → media-use resolves logo", () => {
31
+ assert.ok(listTypes().includes("logo"), "logo type missing");
32
+ assert.deepEqual(
33
+ getProviders("logo").map((p) => p.name),
34
+ ["thesvg", "github.avatar", "favicon.ddg"],
35
+ "logo cascade incomplete",
36
+ );
37
+ });
38
+
39
+ test("weakness: no voice/audio gen → media-use exposes voice + the audio engine", () => {
40
+ assert.ok(listTypes().includes("voice"), "voice type missing");
41
+ assert.ok(getProviders("voice").length > 0, "no enabled voice provider (Bin approved)");
42
+ assert.ok(existsSync(join(SKILL, "audio", "scripts", "audio.mjs")), "audio engine missing");
43
+ });
44
+
45
+ test("weakness: scattered audio engine → consolidated under media-use (hyperframes-media gone)", () => {
46
+ assert.ok(existsSync(join(SKILL, "audio", "scripts", "lib", "tts.mjs")), "tts engine missing");
47
+ assert.ok(
48
+ existsSync(join(SKILL, "audio", "assets", "sfx", "manifest.json")),
49
+ "bundled SFX missing",
50
+ );
51
+ });
52
+
53
+ test("weakness: no media-ops → ops guidance reference exists", () => {
54
+ assert.ok(existsSync(join(SKILL, "references", "operations.md")), "operations.md missing");
55
+ });
56
+
57
+ test("weakness: no transcript-driven cutting → cut compiler entrypoints exist", async () => {
58
+ assert.ok(existsSync(join(SKILL, "scripts", "transcript-cut.mjs")), "transcript-cut missing");
59
+ assert.ok(
60
+ existsSync(join(dirname(fileURLToPath(import.meta.url)), "cutlist.mjs")),
61
+ "cutlist lib missing",
62
+ );
63
+ const cutlist = await import("./cutlist.mjs");
64
+ assert.equal(typeof cutlist.compileCutList, "function");
65
+ });
66
+
67
+ test("weakness: whisper.cpp is weak → better local ASR (Parakeet) entrypoint exists", async () => {
68
+ assert.ok(existsSync(join(SKILL, "scripts", "transcribe.mjs")), "transcribe.mjs missing");
69
+ const pw = await import("./parakeet-words.mjs");
70
+ assert.equal(typeof pw.mergeTokensToWords, "function", "token->word merge missing");
71
+ const lm = await import("./local-models.mjs");
72
+ const asr = lm.listModels("asr");
73
+ const parakeet = asr.find((m) => m.id === "parakeet-mlx");
74
+ assert.ok(parakeet && parakeet.rank === 0, "Parakeet must be the rank-0 preferred ASR");
75
+ });
76
+
77
+ test("weakness: no auto-duck/loudness → duck compiler and recipes exist", async () => {
78
+ assert.ok(existsSync(join(SKILL, "scripts", "audio-duck.mjs")), "audio-duck missing");
79
+ assert.ok(
80
+ existsSync(join(dirname(fileURLToPath(import.meta.url)), "duck.mjs")),
81
+ "duck lib missing",
82
+ );
83
+ assert.ok(existsSync(join(SKILL, "references", "operations.md")), "operations.md missing");
84
+ const duck = await import("./duck.mjs");
85
+ assert.equal(typeof duck.speechSpans, "function");
86
+ assert.equal(typeof duck.duckKeyframes, "function");
87
+ });
88
+
89
+ test("weakness: no cross-project memory → global cache + ingest entrypoints exist", async () => {
90
+ const cache = await import("./cache.mjs");
91
+ assert.equal(typeof cache.cachePut, "function");
92
+ assert.equal(typeof cache.promote, "function");
93
+ assert.equal(typeof cache.globalMediaDir, "function");
94
+ const freeze = await import("./freeze.mjs");
95
+ assert.equal(typeof freeze.isDirectMediaUrl, "function", "ingest URL guard missing");
96
+ });
97
+
98
+ // Wenbo (06-29): heygen free-usage is the default; local models are the opt-out
99
+ // fallback ("if user no, then local"). We still assert the fallback table is
100
+ // populated so the opt-out path stays real.
101
+ test("weakness: weak local defaults → local models exist as the opt-out fallback (tts/asr/upscale)", () => {
102
+ for (const cap of ["tts", "asr", "upscale"]) {
103
+ assert.ok(CAPABILITIES.includes(cap), `capability ${cap} missing`);
104
+ assert.ok(listModels(cap).length > 0, `no local models for ${cap}`);
105
+ }
106
+ });
107
+
108
+ test("weakness: no image generation → local mflux (RAM-graded) + codex upsell", async () => {
109
+ const ps = getProviders("image");
110
+ assert.ok(
111
+ ps.some((p) => p.name === "mflux.local" && typeof p.generate === "function"),
112
+ "local image gen missing",
113
+ );
114
+ assert.ok(
115
+ ps.some((p) => p.name === "codex.image_gen" && typeof p.generate === "function"),
116
+ "codex image upsell missing",
117
+ );
118
+ const lm = await import("./local-models.mjs");
119
+ assert.ok(lm.CAPABILITIES.includes("imagegen"), "imagegen capability missing");
120
+ assert.ok(lm.listModels("imagegen").length >= 3, "imagegen RAM ladder too small");
121
+ assert.equal(typeof lm.describeModelLadder, "function", "agent-facing ladder missing");
122
+ });
123
+
124
+ test("weakness: no video generation → local videogen ladder + heygen avatar upsell", async () => {
125
+ const lm = await import("./local-models.mjs");
126
+ assert.ok(lm.CAPABILITIES.includes("videogen"), "videogen capability missing");
127
+ assert.ok(lm.listModels("videogen").length >= 2, "videogen ladder too small");
128
+ const ops = existsSync(join(SKILL, "references", "operations.md"));
129
+ assert.ok(ops, "operations.md (avatar-upsell recipe) missing");
130
+ });
131
+
132
+ test("every resolve type has at least one enabled provider", () => {
133
+ for (const t of listTypes()) {
134
+ assert.ok(getProviders(t).length > 0, `type ${t} has no enabled provider`);
135
+ }
136
+ });
@@ -0,0 +1,181 @@
1
+ const DEFAULT_SIZE = 33;
2
+ const MAX_SIZE = 64;
3
+
4
+ function clamp(value, min, max) {
5
+ if (!Number.isFinite(value)) return 0;
6
+ return Math.min(max, Math.max(min, value));
7
+ }
8
+
9
+ function clampUnit(value) {
10
+ return clamp(value, 0, 1);
11
+ }
12
+
13
+ function readParam(params, key, min, max) {
14
+ return clamp(Number(params?.[key] ?? 0), min, max);
15
+ }
16
+
17
+ function luma([r, g, b]) {
18
+ // Rec.709 luma weightings (matches the color space the grading runtime uses).
19
+ return r * 0.2126 + g * 0.7152 + b * 0.0722;
20
+ }
21
+
22
+ function smoothstep(edge0, edge1, value) {
23
+ const t = clampUnit((value - edge0) / (edge1 - edge0));
24
+ return t * t * (3 - 2 * t);
25
+ }
26
+
27
+ function applyLiftGain(color, params) {
28
+ const y = luma(color);
29
+ const blacks = readParam(params, "blacks", -1, 1);
30
+ const shadows = readParam(params, "shadows", -1, 1);
31
+ const highlights = readParam(params, "highlights", -1, 1);
32
+ const whites = readParam(params, "whites", -1, 1);
33
+ const shadowMask = 1 - smoothstep(0.18, 0.62, y);
34
+ const highlightMask = smoothstep(0.38, 0.82, y);
35
+ const offset =
36
+ blacks * 0.08 + shadows * 0.12 * shadowMask + highlights * 0.12 * highlightMask + whites * 0.08;
37
+ return color.map((channel) => clampUnit(channel + offset));
38
+ }
39
+
40
+ function applyExposure(color, params) {
41
+ const exposure = readParam(params, "exposure", -2, 2);
42
+ const gain = 2 ** exposure;
43
+ const lift = Math.max(0, exposure) * 0.015;
44
+ return color.map((channel) => clampUnit(channel * gain + lift));
45
+ }
46
+
47
+ function applyContrast(color, params) {
48
+ const contrast = readParam(params, "contrast", -1, 1);
49
+ if (contrast === 0) return color;
50
+ const factor = 1 + contrast * 1.2;
51
+ return color.map((channel) => clampUnit(0.5 + (channel - 0.5) * factor));
52
+ }
53
+
54
+ function applyWhiteBalance(color, params) {
55
+ const temperature = readParam(params, "temperature", -1, 1);
56
+ const tint = readParam(params, "tint", -1, 1);
57
+ const redScale = 1 + temperature * 0.28 + tint * 0.08;
58
+ const greenScale = 1 - Math.abs(tint) * 0.1 - tint * 0.08;
59
+ const blueScale = 1 - temperature * 0.28 + tint * 0.08;
60
+ return [
61
+ clampUnit(color[0] * redScale),
62
+ clampUnit(color[1] * greenScale),
63
+ clampUnit(color[2] * blueScale),
64
+ ];
65
+ }
66
+
67
+ function applySplitTone(color, params) {
68
+ const split = params?.splitTone;
69
+ if (!split) return color;
70
+ const intensity = clampUnit(Number(split.intensity ?? 0));
71
+ if (intensity === 0) return color;
72
+ const balance = clampUnit(Number(split.balance ?? 0.5));
73
+ const y = luma(color);
74
+ const shadowMask = 1 - smoothstep(balance - 0.25, balance + 0.2, y);
75
+ const highlightMask = smoothstep(balance - 0.2, balance + 0.25, y);
76
+ const shadows = Array.isArray(split.shadows) ? split.shadows : [0, 0, 0];
77
+ const highlights = Array.isArray(split.highlights) ? split.highlights : [0, 0, 0];
78
+ return color.map((channel, i) =>
79
+ clampUnit(
80
+ channel +
81
+ Number(shadows[i] ?? 0) * shadowMask * intensity +
82
+ Number(highlights[i] ?? 0) * highlightMask * intensity,
83
+ ),
84
+ );
85
+ }
86
+
87
+ function applySaturation(color, params) {
88
+ const saturation = readParam(params, "saturation", -1, 1);
89
+ const vibrance = readParam(params, "vibrance", -1, 1);
90
+ if (saturation === 0 && vibrance === 0) return color;
91
+ const y = luma(color);
92
+ const currentSat = Math.max(
93
+ Math.abs(color[0] - y),
94
+ Math.abs(color[1] - y),
95
+ Math.abs(color[2] - y),
96
+ );
97
+ const vibranceWeight = 1 - clampUnit(currentSat * 2);
98
+ const factor = clamp(1 + saturation + vibrance * vibranceWeight, 0, 2.5);
99
+ return color.map((channel) => clampUnit(y + (channel - y) * factor));
100
+ }
101
+
102
+ function applyParams(color, params) {
103
+ let out = applyLiftGain(color, params);
104
+ out = applyExposure(out, params);
105
+ out = applyContrast(out, params);
106
+ out = applyWhiteBalance(out, params);
107
+ out = applySplitTone(out, params);
108
+ out = applySaturation(out, params);
109
+ return out;
110
+ }
111
+
112
+ function formatNumber(value) {
113
+ return clampUnit(value).toFixed(6);
114
+ }
115
+
116
+ export function buildCube(params = {}, size = DEFAULT_SIZE) {
117
+ if (!Number.isInteger(size) || size < 2 || size > MAX_SIZE) {
118
+ throw new Error(`LUT size must be an integer from 2 to ${MAX_SIZE}`);
119
+ }
120
+ const lines = [
121
+ `TITLE "media-use parametric grade"`,
122
+ "DOMAIN_MIN 0 0 0",
123
+ "DOMAIN_MAX 1 1 1",
124
+ `LUT_3D_SIZE ${size}`,
125
+ ];
126
+ const denom = size - 1;
127
+ for (let b = 0; b < size; b++) {
128
+ for (let g = 0; g < size; g++) {
129
+ for (let r = 0; r < size; r++) {
130
+ const out = applyParams([r / denom, g / denom, b / denom], params);
131
+ lines.push(`${formatNumber(out[0])} ${formatNumber(out[1])} ${formatNumber(out[2])}`);
132
+ }
133
+ }
134
+ }
135
+ return `${lines.join("\n")}\n`;
136
+ }
137
+
138
+ export function paramsFromIntent(intent) {
139
+ const text = String(intent ?? "").toLowerCase();
140
+ const params = {};
141
+ let matched = false;
142
+
143
+ if (/\b(warm|golden|sunlit|sunny)\b/.test(text)) {
144
+ params.temperature = 0.18;
145
+ matched = true;
146
+ } else if (/\b(cool|blue|icy|crisp)\b/.test(text)) {
147
+ params.temperature = -0.16;
148
+ matched = true;
149
+ }
150
+
151
+ if (/\b(cinematic|film|movie)\b/.test(text)) {
152
+ params.contrast = 0.08;
153
+ params.saturation = 0.04;
154
+ matched = true;
155
+ }
156
+ if (/\b(punchy|contrast|dramatic|bold)\b/.test(text)) {
157
+ params.contrast = Math.max(params.contrast ?? 0, 0.22);
158
+ matched = true;
159
+ }
160
+ if (/\b(bright|airy|lift)\b/.test(text)) {
161
+ params.exposure = 0.16;
162
+ params.shadows = 0.08;
163
+ matched = true;
164
+ }
165
+ if (/\b(dark|moody|low-key)\b/.test(text)) {
166
+ params.exposure = -0.12;
167
+ params.contrast = Math.max(params.contrast ?? 0, 0.12);
168
+ matched = true;
169
+ }
170
+ if (/\b(vibrant|saturated|colorful)\b/.test(text)) {
171
+ params.saturation = Math.max(params.saturation ?? 0, 0.16);
172
+ params.vibrance = 0.12;
173
+ matched = true;
174
+ }
175
+ if (/\b(muted|desaturated|washed)\b/.test(text)) {
176
+ params.saturation = Math.min(params.saturation ?? 0, -0.16);
177
+ matched = true;
178
+ }
179
+
180
+ return matched ? params : null;
181
+ }
@@ -0,0 +1,80 @@
1
+ import { strict as assert } from "node:assert";
2
+ import { test } from "node:test";
3
+ import { buildCube, paramsFromIntent } from "./cube-build.mjs";
4
+ import { validateCube } from "./cube-validate.mjs";
5
+
6
+ function rows(cube) {
7
+ return cube
8
+ .split(/\r?\n/)
9
+ .map((line) => line.trim())
10
+ .filter((line) => /^[+-]?(?:\d|\.\d)/.test(line))
11
+ .map((line) => line.split(/\s+/).map(Number));
12
+ }
13
+
14
+ function rowAt(cubeRows, size, r, g, b) {
15
+ return cubeRows[(b * size + g) * size + r];
16
+ }
17
+
18
+ function luma(row) {
19
+ return row[0] * 0.2126 + row[1] * 0.7152 + row[2] * 0.0722;
20
+ }
21
+
22
+ test("all-zero params produce a near-identity LUT", () => {
23
+ const cube = buildCube({}, 3);
24
+ assert.equal(validateCube(cube).ok, true);
25
+ const parsed = rows(cube);
26
+ for (let b = 0; b < 3; b++) {
27
+ for (let g = 0; g < 3; g++) {
28
+ for (let r = 0; r < 3; r++) {
29
+ const row = rowAt(parsed, 3, r, g, b);
30
+ assert.ok(Math.abs(row[0] - r / 2) < 0.000001);
31
+ assert.ok(Math.abs(row[1] - g / 2) < 0.000001);
32
+ assert.ok(Math.abs(row[2] - b / 2) < 0.000001);
33
+ }
34
+ }
35
+ }
36
+ });
37
+
38
+ test("positive exposure increases unclipped output luma", () => {
39
+ const identity = rows(buildCube({}, 5));
40
+ const exposed = rows(buildCube({ exposure: 0.3 }, 5));
41
+ for (let i = 0; i < identity.length; i++) {
42
+ const before = luma(identity[i]);
43
+ if (before > 0.02 && before < 0.95) {
44
+ assert.ok(luma(exposed[i]) > before, `row ${i} should brighten`);
45
+ }
46
+ }
47
+ });
48
+
49
+ test("positive temperature warms mid-gray", () => {
50
+ const parsed = rows(buildCube({ temperature: 0.2 }, 3));
51
+ const mid = rowAt(parsed, 3, 1, 1, 1);
52
+ assert.ok(mid[0] > 0.5, "red channel should rise");
53
+ assert.ok(mid[2] < 0.5, "blue channel should fall");
54
+ });
55
+
56
+ test("positive contrast darkens shadows and brightens highlights", () => {
57
+ const parsed = rows(buildCube({ contrast: 0.3 }, 5));
58
+ const shadow = rowAt(parsed, 5, 1, 1, 1);
59
+ const highlight = rowAt(parsed, 5, 3, 3, 3);
60
+ assert.ok(luma(shadow) < 0.25, "below-mid gray should darken");
61
+ assert.ok(luma(highlight) > 0.75, "above-mid gray should brighten");
62
+ });
63
+
64
+ test("outputs validate at the default size and are deterministic", () => {
65
+ const params = { exposure: 0.15, contrast: 0.2, temperature: -0.1, saturation: 0.12 };
66
+ const a = buildCube(params);
67
+ const b = buildCube(params);
68
+ assert.equal(a, b);
69
+ assert.equal(validateCube(a).ok, true);
70
+ assert.equal(validateCube(a).size, 33);
71
+ });
72
+
73
+ test("paramsFromIntent declines zero-overlap prompts and maps technical words", () => {
74
+ assert.equal(paramsFromIntent("zqxv imaginary neutron look"), null);
75
+ assert.deepEqual(paramsFromIntent("warm cinematic"), {
76
+ temperature: 0.18,
77
+ contrast: 0.08,
78
+ saturation: 0.04,
79
+ });
80
+ });
@@ -0,0 +1,197 @@
1
+ #!/usr/bin/env node
2
+
3
+ // Standalone mirror of packages/core/src/colorLuts.ts. media-use cannot import
4
+ // the TypeScript source at runtime, so cube-validate.test.mjs mirrors core
5
+ // parser cases to catch drift in accepted .cube files before freezing them.
6
+
7
+ import { readFileSync } from "node:fs";
8
+ import { isMainModule } from "./main-module.mjs";
9
+
10
+ export const DEFAULT_MAX_CUBE_LUT_SIZE = 64;
11
+
12
+ const DEFAULT_DOMAIN_MIN = [0, 0, 0];
13
+ const DEFAULT_DOMAIN_MAX = [1, 1, 1];
14
+
15
+ class CubeValidateError extends Error {
16
+ constructor(message, lineNumber = null) {
17
+ super(lineNumber == null ? message : `${message} at line ${lineNumber}`);
18
+ this.name = "CubeValidateError";
19
+ this.lineNumber = lineNumber;
20
+ }
21
+ }
22
+
23
+ function stripComment(line) {
24
+ let inQuote = false;
25
+ for (let i = 0; i < line.length; i++) {
26
+ const char = line[i];
27
+ if (char === '"') inQuote = !inQuote;
28
+ if (char === "#" && !inQuote) return line.slice(0, i);
29
+ }
30
+ return line;
31
+ }
32
+
33
+ function parseFiniteNumber(value, lineNumber) {
34
+ const parsed = Number(value);
35
+ if (!Number.isFinite(parsed)) {
36
+ throw new CubeValidateError(`Invalid number "${value}"`, lineNumber);
37
+ }
38
+ return parsed;
39
+ }
40
+
41
+ function parseVec3(parts, keyword, lineNumber) {
42
+ if (parts.length !== 3) {
43
+ throw new CubeValidateError(`${keyword} expects three numbers`, lineNumber);
44
+ }
45
+ return [
46
+ parseFiniteNumber(parts[0], lineNumber),
47
+ parseFiniteNumber(parts[1], lineNumber),
48
+ parseFiniteNumber(parts[2], lineNumber),
49
+ ];
50
+ }
51
+
52
+ function parseSize(value, keyword, lineNumber) {
53
+ if (!value) throw new CubeValidateError(`${keyword} expects a size`, lineNumber);
54
+ const parsed = Number(value);
55
+ if (!Number.isInteger(parsed) || parsed < 2) {
56
+ throw new CubeValidateError(`${keyword} must be an integer greater than 1`, lineNumber);
57
+ }
58
+ return parsed;
59
+ }
60
+
61
+ function validateDomain(domainMin, domainMax) {
62
+ if (
63
+ domainMax[0] <= domainMin[0] ||
64
+ domainMax[1] <= domainMin[1] ||
65
+ domainMax[2] <= domainMin[2]
66
+ ) {
67
+ throw new CubeValidateError("DOMAIN_MAX values must be greater than DOMAIN_MIN values");
68
+ }
69
+ }
70
+
71
+ function isNumericDataLine(token) {
72
+ return /^[+-]?(?:\d|\.\d)/.test(token);
73
+ }
74
+
75
+ function parseCube(input, options = {}) {
76
+ const maxSize = options.maxSize ?? DEFAULT_MAX_CUBE_LUT_SIZE;
77
+ let domainMin = DEFAULT_DOMAIN_MIN;
78
+ let domainMax = DEFAULT_DOMAIN_MAX;
79
+ let lut1dSize = null;
80
+ let lut3dSize = null;
81
+ let rows = 0;
82
+
83
+ const lines = String(input)
84
+ .replace(/^\uFEFF/, "")
85
+ .split(/\r?\n/);
86
+ for (let i = 0; i < lines.length; i++) {
87
+ const lineNumber = i + 1;
88
+ const line = stripComment(lines[i] ?? "").trim();
89
+ if (!line) continue;
90
+ const parts = line.split(/\s+/);
91
+ const keyword = (parts[0] ?? "").toUpperCase();
92
+ const rest = parts.slice(1);
93
+
94
+ if (keyword === "TITLE") continue;
95
+ if (keyword === "DOMAIN_MIN") {
96
+ domainMin = parseVec3(rest, keyword, lineNumber);
97
+ continue;
98
+ }
99
+ if (keyword === "DOMAIN_MAX") {
100
+ domainMax = parseVec3(rest, keyword, lineNumber);
101
+ continue;
102
+ }
103
+ if (keyword === "LUT_3D_INPUT_RANGE") {
104
+ if (rest.length !== 2) {
105
+ throw new CubeValidateError(`${keyword} expects two numbers`, lineNumber);
106
+ }
107
+ const min = parseFiniteNumber(rest[0], lineNumber);
108
+ const max = parseFiniteNumber(rest[1], lineNumber);
109
+ if (max <= min) {
110
+ throw new CubeValidateError("LUT_3D_INPUT_RANGE max must exceed min", lineNumber);
111
+ }
112
+ domainMin = [min, min, min];
113
+ domainMax = [max, max, max];
114
+ continue;
115
+ }
116
+ if (keyword === "LUT_1D_SIZE") {
117
+ lut1dSize = parseSize(rest[0], keyword, lineNumber);
118
+ continue;
119
+ }
120
+ if (keyword === "LUT_3D_SIZE") {
121
+ lut3dSize = parseSize(rest[0], keyword, lineNumber);
122
+ if (lut3dSize > maxSize) {
123
+ throw new CubeValidateError(`LUT_3D_SIZE ${lut3dSize} exceeds max ${maxSize}`, lineNumber);
124
+ }
125
+ continue;
126
+ }
127
+
128
+ if (!isNumericDataLine(keyword)) {
129
+ if (keyword.startsWith("LUT_")) {
130
+ throw new CubeValidateError(`Unsupported cube keyword ${keyword}`, lineNumber);
131
+ }
132
+ continue;
133
+ }
134
+ if (!lut3dSize) {
135
+ if (lut1dSize) {
136
+ throw new CubeValidateError("1D cube LUTs are not supported yet", lineNumber);
137
+ }
138
+ throw new CubeValidateError("LUT data appears before LUT_3D_SIZE", lineNumber);
139
+ }
140
+ if (parts.length !== 3) {
141
+ throw new CubeValidateError("LUT data rows must contain three numbers", lineNumber);
142
+ }
143
+ parseFiniteNumber(parts[0], lineNumber);
144
+ parseFiniteNumber(parts[1], lineNumber);
145
+ parseFiniteNumber(parts[2], lineNumber);
146
+ rows++;
147
+ }
148
+
149
+ if (lut1dSize && lut3dSize) {
150
+ throw new CubeValidateError("Mixed 1D and 3D cube LUTs are not supported yet");
151
+ }
152
+ if (!lut3dSize) {
153
+ if (lut1dSize) throw new CubeValidateError("1D cube LUTs are not supported yet");
154
+ throw new CubeValidateError("Missing LUT_3D_SIZE");
155
+ }
156
+ validateDomain(domainMin, domainMax);
157
+
158
+ const expectedRows = lut3dSize * lut3dSize * lut3dSize;
159
+ if (rows !== expectedRows) {
160
+ throw new CubeValidateError(
161
+ `Expected ${expectedRows} LUT rows for size ${lut3dSize}, found ${rows}`,
162
+ );
163
+ }
164
+
165
+ return { size: lut3dSize };
166
+ }
167
+
168
+ export function validateCube(input, options = {}) {
169
+ try {
170
+ const parsed = parseCube(input, options);
171
+ return { ok: true, size: parsed.size };
172
+ } catch (err) {
173
+ return { ok: false, error: err.message };
174
+ }
175
+ }
176
+
177
+ export function validateCubeFile(filePath, options = {}) {
178
+ return validateCube(readFileSync(filePath, "utf8"), options);
179
+ }
180
+
181
+ function main(argv) {
182
+ const file = argv[2];
183
+ if (!file) {
184
+ console.error("usage: cube-validate.mjs <file.cube>");
185
+ process.exit(2);
186
+ }
187
+ const result = validateCubeFile(file);
188
+ if (!result.ok) {
189
+ console.error(`error: ${result.error}`);
190
+ process.exit(1);
191
+ }
192
+ console.log(`ok: LUT_3D_SIZE ${result.size}`);
193
+ }
194
+
195
+ if (isMainModule(import.meta.url)) {
196
+ main(process.argv);
197
+ }