feat(media-use): color grading — grade/lut resolve, smart-grade, grade-compare + compare (#2041)

* feat(media-use): color grading — grade/lut resolve, smart-grade, grade-compare CLI

Add color grading to media-use as first-class resolve types plus a faithful
comparison command. All local, offline, deterministic — no model, no GPU.

- resolve -t grade / -t lut: produce a data-color-grading block (or a frozen
  .cube). Look cascade: core preset (no file) -> bundled .cube library ->
  parametric buildCube. Emitted .cube is Rec.709 and validated against core's
  colorLuts constraints (LUT_3D_SIZE <= 64) before it is frozen.
- smart grade (grade --for <media>): ffmpeg signalstats -> adjust suggestion
  (exposure / contrast / white balance), surfaced with the measured evidence on
  stderr as a starting point; never auto-applied.
- hyperframes grade-compare: renders N candidate grades onto a reference frame
  through the real runtime shader into one labeled comparison PNG, so an agent
  picks a look without opening Studio. Prepends an "original" baseline cell by
  default (--no-baseline to omit). Shares the headless-capture pipeline with
  snapshot via capture/captureCompositionFrame.
- media-use SKILL: proactive "media opportunity pass" guidance (grounded
  signal -> offer, ask once, surface don't mutate).

Verified: media-use 116/116, grade-compare 7/7, snapshot 9/9, lint + format
clean, full build green, comparison renders end to end.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H5k87mPZ4d6yiFwcWSb8Vv

* test(cli): narrow grade-compare baseline assertion off unknown-typed grading

Assert the whole cell via toEqual instead of reaching into .grading.preset /
.grading.lut on the unknown-typed field, keeping the test typecheck-clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H5k87mPZ4d6yiFwcWSb8Vv

* feat(media-use): agent-authored LUTs via --params + validate --from cube; never-read-.cube guardrail

- resolve -t lut / -t grade --params '<json>': build a parametric .cube from
  explicit params (bypassing the intent cascade), validate, and freeze in one
  step. --intent becomes the optional description. Lets an agent commit a look
  it computed itself.
- --from <file.cube> now validates the ingested LUT for lut/grade types and
  rejects an invalid/oversized cube (no partial write) — the escape hatch for a
  LUT the agent generated with its own code.
- SKILL.md: hard rule to never read a .cube body into context (~size^3 lines,
  zero legible signal) — inspect via grade-compare (see it) or cube-validate
  (ok/size), read the manifest description for meaning; plus both authoring
  paths and the parametric-vs-film-stock ceiling note.

Verified: media-use 116/116, lint + format clean; smokes — --params builds a
valid frozen cube, grade --params returns a lut block, bad JSON and an oversized
--from cube are both rejected with no stray file.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H5k87mPZ4d6yiFwcWSb8Vv

* fix(cli): grade-compare validates referenced LUTs, warns on no-op cells, caps candidates

Bug-bash follow-ups — grade-compare silently accepted bad input:

- Validate LUT *content*, not just existence: each referenced .cube is parsed
  with core's parseCubeLut (now exported from @hyperframes/core) and rejected
  with a per-cell error ("LUT for \"<label>\" is not a valid .cube: ..."). A
  file that exists but isn't a valid cube no longer renders a silent no-op cell.
- Warn on inactive cells: a grading that normalizes to inactive (e.g. a
  malformed {lut:12345}) emits a stderr warning naming the cell; the
  auto-prepended "original" baseline is intentionally inactive and stays silent.
  stdout remains valid JSON.
- Cap candidates at 16 (excluding baseline): over-cap input renders the first N
  and reports {truncated:true, total:M} on stdout + a stderr note — no silent
  drop, no unbounded giant sheet.

Verified: grade-compare 10/10; non-cube LUT → clear error; {lut:12345} → warning
+ ok; 20 cells → cells=17 truncated total=20; valid runs unchanged. Lint/format
clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H5k87mPZ4d6yiFwcWSb8Vv

* feat(cli): general `hyperframes compare` visual-variant primitive

Generalize grade-compare's "render N variants → one labeled sheet → the agent
looks and picks" loop into a standalone command that works on ANY variation
(font, layout, motion, grade, whole compositions) — the tool never needs to
know what differs.

- `hyperframes compare <path...> [--at <sec>] [--labels a,b,c] [--out] [--cols]
  [--json]`: renders each agent-authored composition variant through the real
  runtime (captureCompositionFrame) and stitches one labeled comparison sheet +
  JSON ({ok, sheet, rendered, variants, truncated?/total?}). 2+ paths required;
  caps at 16 with loud truncation. It presents, it does not judge — choosing is
  the caller's job.
- Factored the shared "render a labeled set → contact sheet" path so compare,
  grade-compare, and snapshot all sit on it (no duplication). grade-compare is
  now the first color-specific specialization of this primitive.
- New pathArgs util + contactSheet test; hyperframes-cli SKILL documents compare
  as the agent's "see your own renders and choose" primitive.

Verified: 26/26 across compare + grade-compare + snapshot + contactSheet (no
regressions); compare renders 3 variants into one visibly-distinct labeled
sheet; 2+-path error path clean; lint/format clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H5k87mPZ4d6yiFwcWSb8Vv

* fix(ci): green the skills CI — skip ffmpeg tests when absent, oxfmt markdown

The "Test: skills" CI job runs bare `node --test` with no ffmpeg on PATH (by
design — skills tests are meant to be node-builtin-only). The grade-analyzer +
smart-grade tests shell to ffmpeg and were failing there with ENOENT. Guard
them to skip when ffmpeg isn't on PATH; they still run locally / where it is.

Also oxfmt README.md + hyperframes/media-use SKILL.md (the whole-repo
`oxfmt --check .` Format job caught markdown left unformatted by the rebase
conflict resolution).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H5k87mPZ4d6yiFwcWSb8Vv

* fix(ci): skip core-conformance test when tsx is unavailable

The "Test: skills" CI job installs no deps, so the normalizeHfColorGrading
conformance test (which imports core's TS via `node --import tsx`) failed there.
Guard it to skip when tsx can't resolve; runs locally / in the deps-installed
Test job. Completes the skills-CI greening (the ffmpeg guards handled the rest).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H5k87mPZ4d6yiFwcWSb8Vv

* fix(cli): escape grade-compare src double-quotes (CodeQL XSS) + Windows-safe compare test

- grade-compare built `<img src="...">` (double-quoted) with the single-quote
  escaper, leaving `"` unescaped — a `"` in the frame path could break out
  (CodeQL: incomplete HTML attribute sanitization). Use escapeXml for src.
- compare label test hard-coded POSIX paths that can't match on Windows; assert
  the derived labels (the subject); path resolution is covered elsewhere.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H5k87mPZ4d6yiFwcWSb8Vv

* refactor(media-use): generate LUT library from params (drop committed .cube files)

The 3 bundled .cube files were 733 lines each (2,199 total) and were themselves
buildCube output — pure repo bloat. Replace with compact per-look params in
luts/index.json, generated on resolve; add an optional `url` for future scanned
LUTs to be CDN-hosted + downloaded on demand (freezeUrl) instead of committed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H5k87mPZ4d6yiFwcWSb8Vv

* feat(media-use): serve library LUTs from CDN on-demand (static.heygen.ai/luts), params fallback

Looks now carry a CDN `url` (hosted at s3://heygen-public/luts → static.heygen.ai/luts/<id>.cube);
resolve downloads + validates + freezes on demand, like bgm/image. `params` stays
as the deterministic offline fallback (--local-only, or if the download fails), so
resolution is never blocked on the network. Provider prefers url, falls back to params.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H5k87mPZ4d6yiFwcWSb8Vv

* fix(media-use): address #2041 review — atomic LUT writes, compare telemetry, follow-ups

- Atomic .cube writes: library provider (url + params) and the parametric
  generator now write to a .tmp path, validate, then rename, so a crash can
  never orphan an invalid .cube at the final path (was validate-after-write).
- track("media_use_resolve") now emits provenance.via (url/params-fallback/params).
- grade-compare + compare: --timeout flag (was hardcoded 5000) and a
  media_use_compare event (cells, truncated, total, render_ready_timed_out);
  openSettledCompositionPage now surfaces the render-ready timeout.
- compare staging skips node_modules/.git; --for gets an upfront existence check.
- Rec.709 luma comment; HYPERFRAMES_ANALYZE_TIMEOUT_MS override; measured note
  uses basename; LUT s3 hosting moved from index.json into luts/README.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H5k87mPZ4d6yiFwcWSb8Vv

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Miguel Ángel
2026-07-08 22:20:16 -04:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 7face26f04
commit 57b3c78987
38 changed files with 4076 additions and 200 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
---
name: hyperframes-cli
description: HyperFrames CLI dev loop. Use when running npx hyperframes init, add, catalog, capture, lint, validate, inspect, layout, snapshot, preview, play, render, publish, feedback, lambda, doctor, browser, info, upgrade, skills, compositions, docs, benchmark, telemetry, transcribe, tts, or remove-background, or when troubleshooting the HyperFrames build/render environment. Entry point for AWS Lambda cloud rendering (`hyperframes lambda deploy / render / progress / destroy / policies / sites`).
description: HyperFrames CLI dev loop. Use when running npx hyperframes init, add, catalog, capture, lint, validate, inspect, layout, snapshot, compare, grade-compare, preview, play, render, publish, feedback, lambda, doctor, browser, info, upgrade, skills, compositions, docs, benchmark, telemetry, transcribe, tts, or remove-background, or when troubleshooting the HyperFrames build/render environment. Entry point for AWS Lambda cloud rendering (`hyperframes lambda deploy / render / progress / destroy / policies / sites`).
---
# HyperFrames CLI
@@ -24,7 +24,7 @@ Everything runs through `npx hyperframes` unless project instructions specify a
Run lint, validate, and inspect before preview. `lint` catches missing `data-composition-id`, overlapping tracks, and unregistered timelines. `validate` loads the composition in headless Chrome and reports runtime console errors plus WCAG contrast issues. `inspect` seeks through the timeline and reports text spilling out of bubbles/containers or off the canvas — and, when a `*.motion.json` sidecar is present, verifies motion intent (entrances firing under seek, stagger order, in-frame, liveness) against that same seeked timeline.
For motion-heavy work, prefer snapshot-driven iteration and a `*.motion.json` sidecar — see `references/lint-validate-inspect.md` for the discipline and motion-verification spec.
For motion-heavy work, prefer snapshot-driven iteration and a `*.motion.json` sidecar — see `references/lint-validate-inspect.md` for the discipline and motion-verification spec. To compare agent-authored candidate variants, use `npx hyperframes compare <path...> [--at <sec>] [--labels a,b,c] [--out compare.png] [--cols n] [--json]` to render each composition through its own runtime, assemble one labeled sheet, inspect it side by side, and choose. For color-grade selection, use the color-specific sibling `npx hyperframes grade-compare --for <frame> --grades grades.json` (or `--luts a.cube,b.cube`) to render every grading candidate through the real WebGL grading runtime into one labeled PNG before choosing the winner.
## Agent Conventions
+10 -10
View File
@@ -25,16 +25,16 @@ Below: a **capability map** (the domain skills, loaded on demand) and the **inte
Atomic capabilities you load **on demand** — not full workflows; they never own the end-to-end task. For "make me a…" intent, use the intent router below.
| You want to… | Skill |
| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------ |
| **Author / edit an HTML composition** — the `data-*` contract, clips, tracks, sub-compositions, variables | `/hyperframes-core` |
| **Animate** — atomic motion, scene blueprints, transitions, runtime adapters (GSAP / Lottie / Three.js / Anime.js / CSS / WAAPI / TypeGPU) | `/hyperframes-animation` |
| **Author seek-safe keyframes** — GSAP timelines, CSS keyframes, Anime.js, WAAPI, FLIP, paths, masks, SVG morph/draw, 3D depth, plus `hyperframes keyframes` diagnostics | `/hyperframes-keyframes` |
| **Creative direction**`frame.md` / `design.md`, palettes, typography, narration, beat planning, audio-reactive | `/hyperframes-creative` |
| **Media** — resolve/generate BGM, SFX, image, icon, brand logo, voice; TTS voiceover, transcription, background removal, captions; cross-project reuse | `/media-use` |
| **CLI dev loop** — init, lint, validate, inspect, preview, render, publish, doctor | `/hyperframes-cli` |
| **Install registry blocks / components** (`hyperframes add`) | `/hyperframes-registry` |
| **Import Figma content** — assets, tokens, components, storyboards→reconstructed motion (REST/CLI); Motion (MCP), shaders (MCP source / native export) | `/figma` |
| You want to… | Skill |
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------ |
| **Author / edit an HTML composition** — the `data-*` contract, clips, tracks, sub-compositions, variables | `/hyperframes-core` |
| **Animate** — atomic motion, scene blueprints, transitions, runtime adapters (GSAP / Lottie / Three.js / Anime.js / CSS / WAAPI / TypeGPU) | `/hyperframes-animation` |
| **Author seek-safe keyframes** — GSAP timelines, CSS keyframes, Anime.js, WAAPI, FLIP, paths, masks, SVG morph/draw, 3D depth, plus `hyperframes keyframes` diagnostics | `/hyperframes-keyframes` |
| **Creative direction**`frame.md` / `design.md`, palettes, typography, narration, beat planning, audio-reactive | `/hyperframes-creative` |
| **Media** — resolve/generate BGM, SFX, image, icon, brand logo, voice, color grade, LUT; TTS voiceover, transcription, background removal, captions; cross-project reuse | `/media-use` |
| **CLI dev loop** — init, lint, validate, inspect, preview, render, publish, doctor | `/hyperframes-cli` |
| **Install registry blocks / components** (`hyperframes add`) | `/hyperframes-registry` |
| **Import Figma content** — assets, tokens, components, storyboards→reconstructed motion (REST/CLI); Motion (MCP), shaders (MCP source / native export) | `/figma` |
---
+127 -14
View File
@@ -1,6 +1,6 @@
---
name: media-use
description: Agent Media OS, the single skill for every media need in a HyperFrames project. Resolve BGM, SFX, image, icon, brand logo, or voice into a frozen local file + ledger record (one verb, `resolve`); generate via TTS / music / image models when the catalog misses; produce voiceover, transcription, captions, and background removal through one shared audio engine; operate on media (cut / reframe / transform); and reuse assets across projects. Keeps search noise on disk, hands the agent a path. Use for any audio, image, icon, voiceover, caption, or media-asset need.
description: Agent Media OS, the single skill for every media need in a HyperFrames project. Resolve BGM, SFX, image, icon, brand logo, voice, color grade, or LUT into a frozen local file or paste-ready block + ledger record (one verb, `resolve`); generate via TTS / music / image models when the catalog misses; produce voiceover, transcription, captions, and background removal through one shared audio engine; operate on media (cut / reframe / transform); and reuse assets across projects. Keeps search noise on disk, hands the agent one path or block. Use for any audio, image, icon, logo, voiceover, caption, color-grading, or media-asset need.
---
# media-use
@@ -21,13 +21,36 @@ HyperFrames owns media _playback_; media-use owns everything else. Each row is e
| No transcript-driven cutting | `scripts/transcript-cut.mjs` compiles word-timestamp edits into cut lists |
| No auto-duck / publish loudness | `scripts/audio-duck.mjs` + `references/operations.md` loudnorm/sidechain recipes |
| No cross-project memory | global content-addressed cache + auto-promote (`~/.media`) |
| No color-grade authoring | `resolve --type grade` emits a paste-ready `data-color-grading` block; `resolve --type lut` freezes validated `.cube` files |
| No image generation | RAM-graded local mflux (FLUX) via `scripts/lib/mflux-provider.mjs`, codex `image_gen` upsell (`scripts/lib/codex-provider.mjs`) |
| No video generation | spec-gated local LTX (`videogen` in `scripts/lib/local-models.mjs`); `heygen video create` avatar upsell |
| Weak local-model defaults | free-usage HeyGen first (TTS, bg-removal) via the `heygen` CLI; local open-source only as an opt-out fallback (`scripts/lib/local-run.mjs`) |
## When to use
Call `resolve` whenever a composition needs media: background music, sound effects, images, icons, brand logos, or voice. For voiceover / TTS, music, SFX, and caption timing, use the **audio engine** (below); background removal is delegated to the `hyperframes` CLI; transcription defaults to Parakeet (better than whisper.cpp: 6.05% vs 7.44% WER, 5-10x faster) via `scripts/transcribe.mjs`, with whisper.cpp auto-fallback (see `references/operations.md`). For cutting / reframing / transforming existing media, see `references/operations.md`. media-use searches the HeyGen catalog first, freezes the best match locally, registers it in a manifest, and hands the agent one line; all search noise stays on disk.
Call `resolve` whenever a composition needs media: background music, sound effects, images, icons, brand logos, voice, a color grade, or a LUT. For voiceover / TTS, music, SFX, and caption timing, use the **audio engine** (below); background removal is delegated to the `hyperframes` CLI; transcription defaults to Parakeet (better than whisper.cpp: 6.05% vs 7.44% WER, 5-10x faster) via `scripts/transcribe.mjs`, with whisper.cpp auto-fallback (see `references/operations.md`). For cutting / reframing / transforming existing media, see `references/operations.md`. media-use searches the HeyGen catalog first for media files, resolves official logos through the logo cascade, uses local deterministic color grading for `grade`/`lut`, freezes the best match locally when a file is needed, registers it in a manifest, and hands the agent one line; all search noise stays on disk.
## Be proactive — run a media opportunity pass
The human usually can't tell which media would lift the piece. You can. When you build or review a composition, do **one** grounded scan and then **ask once** — don't silently add, and don't nag per asset.
Surface an opportunity only when a concrete signal is present:
| Signal detected | Offer |
| ------------------------------------------------------ | ------------------------------------------------------------------------------------------- |
| On-screen text / a script with no voiceover | TTS voiceover (audio engine) |
| Emoji or a `<div>` styled as an icon | resolve real `icon`s |
| Image that is a placeholder, tiny, or upscaled-looking | a better `image` (and/or upscale — see `references/operations.md`) |
| Hard scene cuts / transitions with no sound | transition `sfx` |
| A piece over ~10s with no music bed | `bgm` |
| Footage that reads under/over-exposed or color-cast | a corrective `grade` (analyze with `grade --for`, preview with `hyperframes grade-compare`) |
Rules that keep this a help, not nagware:
- **Grounded, not generic.** No signal → no suggestion. Never open with "want better images?".
- **Opinionated + concrete.** Propose the specific fix ("add a VO from your script, swap 3 emoji for real icons, replace the 400×400 hero, whooshes on the 4 cuts"), with defaults chosen — the human just approves **all / some / none**.
- **Once per project.** One consolidated ask, top few highest-value items. Respect "leave it" and don't re-raise.
- **Surface, never silently mutate.** Color grades especially: propose and preview, never auto-apply — a gray-world "correction" ruins an intentional sunset or neon look.
## Resolve
@@ -39,14 +62,16 @@ Returns one line: `resolved <id> → <path> (<type>, <metadata>)`
### Types
| Type | What it finds | Provider |
| ------- | -------------------- | -------------------------------------------------------- |
| `bgm` | Background music | HeyGen audio catalog (10k+ tracks) |
| `sfx` | Sound effects | Bundled 19-file library + HeyGen catalog |
| `image` | Photos, backgrounds | HeyGen asset search (75k+ vectors) |
| `icon` | Icons, symbols | HeyGen asset search (type=icon) |
| `logo` | Official brand marks | svgl → simple-icons → GitHub org avatar → domain favicon |
| `voice` | TTS voiceover | Local Kokoro (free); HeyGen TTS upsell |
| Type | What it finds | Provider / cascade |
| ------- | -------------------------------- | ------------------------------------------------------------ |
| `bgm` | Background music | HeyGen audio catalog (10k+ tracks) |
| `sfx` | Sound effects | Bundled 19-file library + HeyGen catalog |
| `image` | Photos, backgrounds | HeyGen asset search (75k+ vectors) |
| `icon` | Icons, symbols | HeyGen asset search (type=icon) |
| `logo` | Official brand marks | svgl → simple-icons → GitHub org avatar → domain favicon |
| `voice` | TTS voiceover | Local Kokoro (free); HeyGen TTS upsell |
| `grade` | HyperFrames color-grading blocks | Core preset → look index params/CDN LUT → deterministic cube |
| `lut` | Reusable `.cube` LUT files | Look index params/CDN LUT → deterministic cube |
### Examples
@@ -70,19 +95,28 @@ node <SKILL_DIR>/scripts/resolve.mjs --type icon --intent "rocket" --project .
# Brand logo (official mark — never redrawn by hand)
node <SKILL_DIR>/scripts/resolve.mjs --type logo --entity linkedin --intent "LinkedIn logo" --project .
# → resolved logo_001 → .media/images/logo_001.svg (logo, official mark)
# Color grade block
node <SKILL_DIR>/scripts/resolve.mjs --type grade --intent "warm daylight" --project . --json
# → {"ok":true,"preset":"warm-daylight","grading":{"preset":"warm-daylight","intensity":1},...}
# LUT file
node <SKILL_DIR>/scripts/resolve.mjs --type lut --intent "teal orange blockbuster" --project .
# → resolved lut_001 → .media/luts/lut_001.cube (lut)
```
### Flags
| Flag | Description |
| --------------- | ------------------------------------------------------------------------------------ |
| `--type, -t` | Media type: bgm, sfx, image, icon, voice |
| `--type, -t` | Media type: bgm, sfx, image, icon, logo, voice, grade, lut |
| `--intent, -i` | What you need (natural language) |
| `--entity, -e` | Entity name for cache matching (optional) |
| `--project, -p` | Project directory (default: .) |
| `--candidates` | List reusable assets (project + global cache) for `--type`; no download, no mutation |
| `--reuse <sha>` | Import a specific global-cache asset (by content sha/prefix, from `--candidates`) |
| `--from` | Freeze a local file or direct public URL (ingest) |
| `--for` | Analyze a local image/video and add measured adjust suggestions (`grade` only) |
| `--local-only` | Offline: skip every network provider (cache + local only) |
| `--provider` | Force one generator (e.g. `codex`, `mflux`, `kokoro`, `heygen`) |
| `--adopt` | Bulk-import existing assets/ into manifest |
@@ -90,7 +124,7 @@ node <SKILL_DIR>/scripts/resolve.mjs --type logo --entity linkedin --intent "Lin
## Reuse before you resolve
Before resolving bgm/sfx/image/icon, **check what already exists and reuse it when it fits.** media-use does not semantically match for you — you are the judge. It surfaces candidates; you decide.
Before resolving bgm/sfx/image/icon/logo/grade/lut, **check what already exists and reuse it when it fits.** media-use does not semantically match for you — you are the judge. It surfaces candidates; you decide.
```bash
node <SKILL_DIR>/scripts/resolve.mjs --type bgm --intent "upbeat tech launch" --candidates --project .
@@ -110,6 +144,84 @@ Read the list and judge semantic fit yourself — "upbeat tech launch" ≈ "ener
The deterministic floor still runs automatically: an identical (case/whitespace-insensitive) repeat auto-reuses with no `--candidates` step. `--candidates` is only for the semantic layer above that floor — and a fuzzy match is **never** auto-applied; reuse is always your explicit call. On a resolve that misses the floor and is about to fetch, media-use prints a one-line stderr hint when similar cached assets exist, pointing you back here.
## Color grading
Use `grade` when you need the actual HyperFrames `data-color-grading` value to paste onto an `<img>` or `<video>`. Core presets and params-backed library looks resolve locally; future CDN-backed library looks require network unless already frozen:
**Never `cat`/read a `.cube` file into context.** A 3D LUT is ~size^3 lines of raw numbers (33^3 ≈ 36k lines at the default size). It bloats context and carries zero human/agent-legible signal. To understand or choose a LUT, use `hyperframes grade-compare` to see it rendered, or `cube-validate.mjs` for a one-line `{ok,size}` check. Read `.media/index.md` or `luts/index.json` for the description. Never read the LUT body itself.
```bash
node <SKILL_DIR>/scripts/resolve.mjs --type grade --intent "warm daylight" --project . --json
```
Preset-first output uses the core runtime vocabulary and does not freeze a file:
```json
{
"preset": "warm-daylight",
"intensity": 1
}
```
Paste it as an attribute value after JSON string escaping:
```html
<video
class="clip"
src="./media/scene.mp4"
data-color-grading='{"preset":"warm-daylight","intensity":1}'
></video>
```
Looks beyond the preset vocabulary freeze a validated `.cube` under `.media/luts/` and return a block that references it:
```bash
node <SKILL_DIR>/scripts/resolve.mjs --type grade --intent "teal orange blockbuster" --project . --json
```
```json
{
"intensity": 1,
"lut": { "src": ".media/luts/grade_001.cube", "intensity": 0.85 }
}
```
Use `lut` when you only need the reusable `.cube` file:
```bash
node <SKILL_DIR>/scripts/resolve.mjs --type lut --intent "teal orange blockbuster" --project .
```
For a describable technical look, author an explicit parametric LUT with `--params`:
```bash
node <SKILL_DIR>/scripts/resolve.mjs --type lut --params '{"contrast":0.2,"temperature":-0.3}' --project .
node <SKILL_DIR>/scripts/resolve.mjs --type grade --params '{"exposure":0.2}' --project . --json
```
For a LUT generated by your own script, ingest it with `--from`; media-use validates it before registration and rejects invalid or oversized cubes:
```bash
node <SKILL_DIR>/scripts/resolve.mjs --type lut --from custom.cube --project .
```
Parametric math (`buildCube`) cannot reproduce real film stocks or emulsion looks. Use a CDN-backed scanned `.cube` entry or ingest a real scanned `.cube` for those.
For visual selection, list reusable looks with `resolve --type grade --candidates`, write the promising entries to a `grades.json`, run `hyperframes grade-compare --for <frame> --grades grades.json`, then commit the winner with `resolve -t grade` as the final `data-color-grading` block.
Smart grade is `grade --for <media>`. It runs local `ffmpeg`/`ffprobe` signalstats, merges a bounded `adjust` suggestion into the returned block, and prints the measured evidence to stderr. Stdout remains valid JSON under `--json`; the suggestion is a starting point for the agent to tune, not an automatic neutralization of intentional color.
```bash
node <SKILL_DIR>/scripts/resolve.mjs --type grade --intent "warm cinematic" --for ./frame.png --project . --json
```
Library looks live in `luts/index.json`. Each entry keeps `id`, `description`, `tags`, and `intensity`, then supplies either compact `params` for on-demand `buildCube(params)` generation or a direct CDN `url` for future scanned `.cube` files. Do not commit generated `.cube` bodies; resolve validates generated or downloaded cubes as it freezes them under `.media/luts/`.
```bash
node skills/media-use/scripts/resolve.mjs --type lut --intent "teal orange blockbuster" --project . --json
node skills/media-use/scripts/lib/cube-validate.mjs .media/luts/lut_001.cube
```
## Providers
media-use holds no keys; every external tool owns its auth. Generation is
@@ -124,6 +236,7 @@ ladder, `describeModelLadder`); the agent can see the ladder and override.
| voice | local **Kokoro** (free, on-device), then **heygen tts** paid upsell |
| icon | heygen asset search |
| logo | svgl, then simple-icons, then GitHub org avatar, then domain favicon (all free) |
| grade/lut | local core-preset map, params/CDN look index, deterministic `buildCube` fallback |
| video (local) | local LTX (`videogen` ladder); `heygen video create` avatar upsell |
Local Kokoro (voice), mflux (image), and LTX (video) run on-device (free,
@@ -147,7 +260,7 @@ leaving the project + global cache and any local provider.
1. Check project `.media/manifest.jsonl` for a prompt match (case- and whitespace-insensitive) — auto-reuse
2. Scan existing `assets/` directory for unregistered files that share a word with the need
3. Check global cache `~/.media/` for a reusable asset matched on the same normalized prompt — auto-reuse
4. Search via provider (HeyGen audio catalog, HeyGen asset search), then generate
4. Search via provider (HeyGen audio catalog, HeyGen asset search), or resolve color locally
5. Freeze file to `.media/<type>/`, register in manifest, regenerate `index.md`, auto-promote to `~/.media/`
Steps 1 and 3 are the **deterministic floor**: they only auto-reuse an exact-normalized match, never a fuzzy one. Semantic reuse ("close enough") is the agent's explicit call via [Reuse before you resolve](#reuse-before-you-resolve) — it never happens automatically. The agent gets back **one line**; candidates, scores, provenance stay on disk.
@@ -227,7 +340,7 @@ tool to unlock its free, private, on-device path. media-use holds no keys.
| Tool | Serves | Install |
| ------------------ | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------- |
| `ffmpeg`/`ffprobe` | adopt probing, cut, duck bake, loudnorm | system package (`brew install ffmpeg`) |
| `ffmpeg`/`ffprobe` | adopt probing, smart-grade signalstats, cut, duck bake, loudnorm | system package (`brew install ffmpeg`) |
| `heygen` | catalog (bgm/sfx/image/icon), TTS + avatar upsell | `curl -fsSL https://static.heygen.ai/cli/install.sh \| bash` then `heygen auth login --key <key>` (needs >= v0.1.6) |
| `mflux-generate` | local image gen (FLUX), best-for-RAM | `uv venv ~/.venvs/mflux && VIRTUAL_ENV=~/.venvs/mflux uv pip install mflux==0.9.6` |
| `codex` | image gen upsell (ChatGPT sub) | Codex CLI, logged in via ChatGPT (owns its own auth) |
+28
View File
@@ -0,0 +1,28 @@
# LUT library (authoring)
`index.json` is the agent-consumed catalog of color-grade looks. Each entry resolves
on demand — no `.cube` bodies are committed to the repo.
Each look has:
- `id`, `description`, `tags`, `intensity` — matching + application metadata.
- `url` (optional) — a hosted `.cube` downloaded, validated, and frozen at resolve
time, exactly like bgm/image assets.
- `params` (optional) — a deterministic `buildCube` spec used offline (`--local-only`)
or as a fallback if the `url` download/validation fails.
An entry needs at least one of `url` or `params`; prefer both (CDN url with a params
fallback) so resolution is never blocked on the network.
## Hosting a new look (operators)
1. Generate the `.cube` (e.g. `resolve -t lut --params '{...}'` or a graded export).
2. Upload it to the public CDN origin bucket:
```
aws s3 cp <id>.cube s3://heygen-public/luts/<id>.cube
```
It is then served at `https://static.heygen.ai/luts/<id>.cube` (CloudFront).
3. Add an entry to `index.json` with that `url` (and ideally a `params` fallback).
+57
View File
@@ -0,0 +1,57 @@
{
"notes": "Look entries resolve on-demand from a CDN .cube `url` (downloaded + frozen, like bgm/image); `params` is a deterministic buildCube fallback used offline (--local-only) or if the download/validation fails. No .cube bodies are committed. See README.md to author or host a new look.",
"looks": [
{
"id": "teal-orange-blockbuster",
"description": "Teal shadows and warm orange highlights for blockbuster-style cinematic footage.",
"tags": ["teal", "orange", "blockbuster", "cinematic", "split tone", "movie"],
"intensity": 0.85,
"url": "https://static.heygen.ai/luts/teal-orange-blockbuster.cube",
"params": {
"contrast": 0.18,
"saturation": 0.08,
"vibrance": 0.12,
"splitTone": {
"intensity": 0.62,
"balance": 0.52,
"shadows": [-0.04, 0.05, 0.09],
"highlights": [0.1, 0.04, -0.03]
}
}
},
{
"id": "bleach-bypass",
"description": "High-contrast desaturated bleach bypass look with strong blacks.",
"tags": ["bleach", "bypass", "desaturated", "high contrast", "gritty", "film"],
"intensity": 0.8,
"url": "https://static.heygen.ai/luts/bleach-bypass.cube",
"params": {
"blacks": 0.04,
"shadows": -0.08,
"highlights": 0.08,
"whites": 0.18,
"contrast": 0.55,
"temperature": -0.02,
"saturation": -0.72,
"vibrance": -0.25
}
},
{
"id": "film-fade",
"description": "Soft faded film wash with lifted blacks and warm highlights.",
"tags": ["film", "fade", "faded", "wash", "warm", "vintage"],
"intensity": 0.75,
"url": "https://static.heygen.ai/luts/film-fade.cube",
"params": {
"blacks": 0.35,
"shadows": 0.18,
"highlights": 0.02,
"whites": -0.03,
"contrast": -0.28,
"temperature": 0.16,
"saturation": -0.12,
"vibrance": -0.08
}
}
]
}
+2 -1
View File
@@ -75,7 +75,8 @@ export function formatCandidates(candidates, { truncated, total } = {}) {
if (candidates.length === 0) return "no reuse candidates found (project or global cache)";
const lines = [`${candidates.length} reuse candidate${candidates.length === 1 ? "" : "s"}:`, ""];
for (const c of candidates) {
const handle = c.scope === "global" ? `--reuse ${String(c.sha).slice(0, 16)}` : c.path;
const handle =
c.scope === "global" ? `--reuse ${String(c.sha).slice(0, 16)}` : c.path || `manifest:${c.id}`;
const m = meta(c);
lines.push(` [${c.scope}] ${c.description}${m ? ` (${m})` : ""}`);
lines.push(` ${handle}`);
+181
View File
@@ -0,0 +1,181 @@
const DEFAULT_SIZE = 33;
const MAX_SIZE = 64;
function clamp(value, min, max) {
if (!Number.isFinite(value)) return 0;
return Math.min(max, Math.max(min, value));
}
function clampUnit(value) {
return clamp(value, 0, 1);
}
function readParam(params, key, min, max) {
return clamp(Number(params?.[key] ?? 0), min, max);
}
function luma([r, g, b]) {
// Rec.709 luma weightings (matches the color space the grading runtime uses).
return r * 0.2126 + g * 0.7152 + b * 0.0722;
}
function smoothstep(edge0, edge1, value) {
const t = clampUnit((value - edge0) / (edge1 - edge0));
return t * t * (3 - 2 * t);
}
function applyLiftGain(color, params) {
const y = luma(color);
const blacks = readParam(params, "blacks", -1, 1);
const shadows = readParam(params, "shadows", -1, 1);
const highlights = readParam(params, "highlights", -1, 1);
const whites = readParam(params, "whites", -1, 1);
const shadowMask = 1 - smoothstep(0.18, 0.62, y);
const highlightMask = smoothstep(0.38, 0.82, y);
const offset =
blacks * 0.08 + shadows * 0.12 * shadowMask + highlights * 0.12 * highlightMask + whites * 0.08;
return color.map((channel) => clampUnit(channel + offset));
}
function applyExposure(color, params) {
const exposure = readParam(params, "exposure", -2, 2);
const gain = 2 ** exposure;
const lift = Math.max(0, exposure) * 0.015;
return color.map((channel) => clampUnit(channel * gain + lift));
}
function applyContrast(color, params) {
const contrast = readParam(params, "contrast", -1, 1);
if (contrast === 0) return color;
const factor = 1 + contrast * 1.2;
return color.map((channel) => clampUnit(0.5 + (channel - 0.5) * factor));
}
function applyWhiteBalance(color, params) {
const temperature = readParam(params, "temperature", -1, 1);
const tint = readParam(params, "tint", -1, 1);
const redScale = 1 + temperature * 0.28 + tint * 0.08;
const greenScale = 1 - Math.abs(tint) * 0.1 - tint * 0.08;
const blueScale = 1 - temperature * 0.28 + tint * 0.08;
return [
clampUnit(color[0] * redScale),
clampUnit(color[1] * greenScale),
clampUnit(color[2] * blueScale),
];
}
function applySplitTone(color, params) {
const split = params?.splitTone;
if (!split) return color;
const intensity = clampUnit(Number(split.intensity ?? 0));
if (intensity === 0) return color;
const balance = clampUnit(Number(split.balance ?? 0.5));
const y = luma(color);
const shadowMask = 1 - smoothstep(balance - 0.25, balance + 0.2, y);
const highlightMask = smoothstep(balance - 0.2, balance + 0.25, y);
const shadows = Array.isArray(split.shadows) ? split.shadows : [0, 0, 0];
const highlights = Array.isArray(split.highlights) ? split.highlights : [0, 0, 0];
return color.map((channel, i) =>
clampUnit(
channel +
Number(shadows[i] ?? 0) * shadowMask * intensity +
Number(highlights[i] ?? 0) * highlightMask * intensity,
),
);
}
function applySaturation(color, params) {
const saturation = readParam(params, "saturation", -1, 1);
const vibrance = readParam(params, "vibrance", -1, 1);
if (saturation === 0 && vibrance === 0) return color;
const y = luma(color);
const currentSat = Math.max(
Math.abs(color[0] - y),
Math.abs(color[1] - y),
Math.abs(color[2] - y),
);
const vibranceWeight = 1 - clampUnit(currentSat * 2);
const factor = clamp(1 + saturation + vibrance * vibranceWeight, 0, 2.5);
return color.map((channel) => clampUnit(y + (channel - y) * factor));
}
function applyParams(color, params) {
let out = applyLiftGain(color, params);
out = applyExposure(out, params);
out = applyContrast(out, params);
out = applyWhiteBalance(out, params);
out = applySplitTone(out, params);
out = applySaturation(out, params);
return out;
}
function formatNumber(value) {
return clampUnit(value).toFixed(6);
}
export function buildCube(params = {}, size = DEFAULT_SIZE) {
if (!Number.isInteger(size) || size < 2 || size > MAX_SIZE) {
throw new Error(`LUT size must be an integer from 2 to ${MAX_SIZE}`);
}
const lines = [
`TITLE "media-use parametric grade"`,
"DOMAIN_MIN 0 0 0",
"DOMAIN_MAX 1 1 1",
`LUT_3D_SIZE ${size}`,
];
const denom = size - 1;
for (let b = 0; b < size; b++) {
for (let g = 0; g < size; g++) {
for (let r = 0; r < size; r++) {
const out = applyParams([r / denom, g / denom, b / denom], params);
lines.push(`${formatNumber(out[0])} ${formatNumber(out[1])} ${formatNumber(out[2])}`);
}
}
}
return `${lines.join("\n")}\n`;
}
export function paramsFromIntent(intent) {
const text = String(intent ?? "").toLowerCase();
const params = {};
let matched = false;
if (/\b(warm|golden|sunlit|sunny)\b/.test(text)) {
params.temperature = 0.18;
matched = true;
} else if (/\b(cool|blue|icy|crisp)\b/.test(text)) {
params.temperature = -0.16;
matched = true;
}
if (/\b(cinematic|film|movie)\b/.test(text)) {
params.contrast = 0.08;
params.saturation = 0.04;
matched = true;
}
if (/\b(punchy|contrast|dramatic|bold)\b/.test(text)) {
params.contrast = Math.max(params.contrast ?? 0, 0.22);
matched = true;
}
if (/\b(bright|airy|lift)\b/.test(text)) {
params.exposure = 0.16;
params.shadows = 0.08;
matched = true;
}
if (/\b(dark|moody|low-key)\b/.test(text)) {
params.exposure = -0.12;
params.contrast = Math.max(params.contrast ?? 0, 0.12);
matched = true;
}
if (/\b(vibrant|saturated|colorful)\b/.test(text)) {
params.saturation = Math.max(params.saturation ?? 0, 0.16);
params.vibrance = 0.12;
matched = true;
}
if (/\b(muted|desaturated|washed)\b/.test(text)) {
params.saturation = Math.min(params.saturation ?? 0, -0.16);
matched = true;
}
return matched ? params : null;
}
@@ -0,0 +1,80 @@
import { strict as assert } from "node:assert";
import { test } from "node:test";
import { buildCube, paramsFromIntent } from "./cube-build.mjs";
import { validateCube } from "./cube-validate.mjs";
function rows(cube) {
return cube
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => /^[+-]?(?:\d|\.\d)/.test(line))
.map((line) => line.split(/\s+/).map(Number));
}
function rowAt(cubeRows, size, r, g, b) {
return cubeRows[(b * size + g) * size + r];
}
function luma(row) {
return row[0] * 0.2126 + row[1] * 0.7152 + row[2] * 0.0722;
}
test("all-zero params produce a near-identity LUT", () => {
const cube = buildCube({}, 3);
assert.equal(validateCube(cube).ok, true);
const parsed = rows(cube);
for (let b = 0; b < 3; b++) {
for (let g = 0; g < 3; g++) {
for (let r = 0; r < 3; r++) {
const row = rowAt(parsed, 3, r, g, b);
assert.ok(Math.abs(row[0] - r / 2) < 0.000001);
assert.ok(Math.abs(row[1] - g / 2) < 0.000001);
assert.ok(Math.abs(row[2] - b / 2) < 0.000001);
}
}
}
});
test("positive exposure increases unclipped output luma", () => {
const identity = rows(buildCube({}, 5));
const exposed = rows(buildCube({ exposure: 0.3 }, 5));
for (let i = 0; i < identity.length; i++) {
const before = luma(identity[i]);
if (before > 0.02 && before < 0.95) {
assert.ok(luma(exposed[i]) > before, `row ${i} should brighten`);
}
}
});
test("positive temperature warms mid-gray", () => {
const parsed = rows(buildCube({ temperature: 0.2 }, 3));
const mid = rowAt(parsed, 3, 1, 1, 1);
assert.ok(mid[0] > 0.5, "red channel should rise");
assert.ok(mid[2] < 0.5, "blue channel should fall");
});
test("positive contrast darkens shadows and brightens highlights", () => {
const parsed = rows(buildCube({ contrast: 0.3 }, 5));
const shadow = rowAt(parsed, 5, 1, 1, 1);
const highlight = rowAt(parsed, 5, 3, 3, 3);
assert.ok(luma(shadow) < 0.25, "below-mid gray should darken");
assert.ok(luma(highlight) > 0.75, "above-mid gray should brighten");
});
test("outputs validate at the default size and are deterministic", () => {
const params = { exposure: 0.15, contrast: 0.2, temperature: -0.1, saturation: 0.12 };
const a = buildCube(params);
const b = buildCube(params);
assert.equal(a, b);
assert.equal(validateCube(a).ok, true);
assert.equal(validateCube(a).size, 33);
});
test("paramsFromIntent declines zero-overlap prompts and maps technical words", () => {
assert.equal(paramsFromIntent("zqxv imaginary neutron look"), null);
assert.deepEqual(paramsFromIntent("warm cinematic"), {
temperature: 0.18,
contrast: 0.08,
saturation: 0.04,
});
});
@@ -0,0 +1,198 @@
#!/usr/bin/env node
// Standalone mirror of packages/core/src/colorLuts.ts. media-use cannot import
// the TypeScript source at runtime, so cube-validate.test.mjs mirrors core
// parser cases to catch drift in accepted .cube files before freezing them.
import { readFileSync } from "node:fs";
import { resolve as resolvePath } from "node:path";
import { fileURLToPath } from "node:url";
export const DEFAULT_MAX_CUBE_LUT_SIZE = 64;
const DEFAULT_DOMAIN_MIN = [0, 0, 0];
const DEFAULT_DOMAIN_MAX = [1, 1, 1];
class CubeValidateError extends Error {
constructor(message, lineNumber = null) {
super(lineNumber == null ? message : `${message} at line ${lineNumber}`);
this.name = "CubeValidateError";
this.lineNumber = lineNumber;
}
}
function stripComment(line) {
let inQuote = false;
for (let i = 0; i < line.length; i++) {
const char = line[i];
if (char === '"') inQuote = !inQuote;
if (char === "#" && !inQuote) return line.slice(0, i);
}
return line;
}
function parseFiniteNumber(value, lineNumber) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) {
throw new CubeValidateError(`Invalid number "${value}"`, lineNumber);
}
return parsed;
}
function parseVec3(parts, keyword, lineNumber) {
if (parts.length !== 3) {
throw new CubeValidateError(`${keyword} expects three numbers`, lineNumber);
}
return [
parseFiniteNumber(parts[0], lineNumber),
parseFiniteNumber(parts[1], lineNumber),
parseFiniteNumber(parts[2], lineNumber),
];
}
function parseSize(value, keyword, lineNumber) {
if (!value) throw new CubeValidateError(`${keyword} expects a size`, lineNumber);
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed < 2) {
throw new CubeValidateError(`${keyword} must be an integer greater than 1`, lineNumber);
}
return parsed;
}
function validateDomain(domainMin, domainMax) {
if (
domainMax[0] <= domainMin[0] ||
domainMax[1] <= domainMin[1] ||
domainMax[2] <= domainMin[2]
) {
throw new CubeValidateError("DOMAIN_MAX values must be greater than DOMAIN_MIN values");
}
}
function isNumericDataLine(token) {
return /^[+-]?(?:\d|\.\d)/.test(token);
}
function parseCube(input, options = {}) {
const maxSize = options.maxSize ?? DEFAULT_MAX_CUBE_LUT_SIZE;
let domainMin = DEFAULT_DOMAIN_MIN;
let domainMax = DEFAULT_DOMAIN_MAX;
let lut1dSize = null;
let lut3dSize = null;
let rows = 0;
const lines = String(input)
.replace(/^\uFEFF/, "")
.split(/\r?\n/);
for (let i = 0; i < lines.length; i++) {
const lineNumber = i + 1;
const line = stripComment(lines[i] ?? "").trim();
if (!line) continue;
const parts = line.split(/\s+/);
const keyword = (parts[0] ?? "").toUpperCase();
const rest = parts.slice(1);
if (keyword === "TITLE") continue;
if (keyword === "DOMAIN_MIN") {
domainMin = parseVec3(rest, keyword, lineNumber);
continue;
}
if (keyword === "DOMAIN_MAX") {
domainMax = parseVec3(rest, keyword, lineNumber);
continue;
}
if (keyword === "LUT_3D_INPUT_RANGE") {
if (rest.length !== 2) {
throw new CubeValidateError(`${keyword} expects two numbers`, lineNumber);
}
const min = parseFiniteNumber(rest[0], lineNumber);
const max = parseFiniteNumber(rest[1], lineNumber);
if (max <= min) {
throw new CubeValidateError("LUT_3D_INPUT_RANGE max must exceed min", lineNumber);
}
domainMin = [min, min, min];
domainMax = [max, max, max];
continue;
}
if (keyword === "LUT_1D_SIZE") {
lut1dSize = parseSize(rest[0], keyword, lineNumber);
continue;
}
if (keyword === "LUT_3D_SIZE") {
lut3dSize = parseSize(rest[0], keyword, lineNumber);
if (lut3dSize > maxSize) {
throw new CubeValidateError(`LUT_3D_SIZE ${lut3dSize} exceeds max ${maxSize}`, lineNumber);
}
continue;
}
if (!isNumericDataLine(keyword)) {
if (keyword.startsWith("LUT_")) {
throw new CubeValidateError(`Unsupported cube keyword ${keyword}`, lineNumber);
}
continue;
}
if (!lut3dSize) {
if (lut1dSize) {
throw new CubeValidateError("1D cube LUTs are not supported yet", lineNumber);
}
throw new CubeValidateError("LUT data appears before LUT_3D_SIZE", lineNumber);
}
if (parts.length !== 3) {
throw new CubeValidateError("LUT data rows must contain three numbers", lineNumber);
}
parseFiniteNumber(parts[0], lineNumber);
parseFiniteNumber(parts[1], lineNumber);
parseFiniteNumber(parts[2], lineNumber);
rows++;
}
if (lut1dSize && lut3dSize) {
throw new CubeValidateError("Mixed 1D and 3D cube LUTs are not supported yet");
}
if (!lut3dSize) {
if (lut1dSize) throw new CubeValidateError("1D cube LUTs are not supported yet");
throw new CubeValidateError("Missing LUT_3D_SIZE");
}
validateDomain(domainMin, domainMax);
const expectedRows = lut3dSize * lut3dSize * lut3dSize;
if (rows !== expectedRows) {
throw new CubeValidateError(
`Expected ${expectedRows} LUT rows for size ${lut3dSize}, found ${rows}`,
);
}
return { size: lut3dSize };
}
export function validateCube(input, options = {}) {
try {
const parsed = parseCube(input, options);
return { ok: true, size: parsed.size };
} catch (err) {
return { ok: false, error: err.message };
}
}
export function validateCubeFile(filePath, options = {}) {
return validateCube(readFileSync(filePath, "utf8"), options);
}
function main(argv) {
const file = argv[2];
if (!file) {
console.error("usage: cube-validate.mjs <file.cube>");
process.exit(2);
}
const result = validateCubeFile(file);
if (!result.ok) {
console.error(`error: ${result.error}`);
process.exit(1);
}
console.log(`ok: LUT_3D_SIZE ${result.size}`);
}
if (process.argv[1] && resolvePath(process.argv[1]) === fileURLToPath(import.meta.url)) {
main(process.argv);
}
@@ -0,0 +1,125 @@
import { strict as assert } from "node:assert";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { execFileSync } from "node:child_process";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { test } from "node:test";
import { validateCube } from "./cube-validate.mjs";
const IDENTITY_2 = `
# comment
TITLE "Identity 2"
DOMAIN_MIN 0 0 0
DOMAIN_MAX 1 1 1
LUT_3D_SIZE 2
0 0 0
1 0 0
0 1 0
1 1 0
0 0 1
1 0 1
0 1 1
1 1 1
`;
test("accepts a valid minimal 3D cube LUT", () => {
const result = validateCube(IDENTITY_2);
assert.deepEqual(result, { ok: true, size: 2 });
});
test("rejects oversize LUTs with the core parser message", () => {
const result = validateCube("LUT_3D_SIZE 65", { maxSize: 64 });
assert.equal(result.ok, false);
assert.match(result.error, /LUT_3D_SIZE 65 exceeds max 64/);
});
test("rejects data rows before LUT_3D_SIZE", () => {
const result = validateCube("0 0 0\nLUT_3D_SIZE 2");
assert.equal(result.ok, false);
assert.match(result.error, /LUT data appears before LUT_3D_SIZE/);
});
test("rejects missing LUT_3D_SIZE", () => {
const result = validateCube('TITLE "No Size"');
assert.equal(result.ok, false);
assert.match(result.error, /Missing LUT_3D_SIZE/);
});
test("rejects row count mismatches with the core parser message", () => {
const result = validateCube("LUT_3D_SIZE 2\n0 0 0");
assert.equal(result.ok, false);
assert.match(result.error, /Expected 8 LUT rows/);
});
test("rejects inverted domains", () => {
const result = validateCube(`
DOMAIN_MIN 0 0 0
DOMAIN_MAX 1 0 1
LUT_3D_SIZE 2
0 0 0
1 0 0
0 1 0
1 1 0
0 0 1
1 0 1
0 1 1
1 1 1
`);
assert.equal(result.ok, false);
assert.match(result.error, /DOMAIN_MAX values must be greater than DOMAIN_MIN values/);
});
test("rejects unsupported 1D and mixed cube LUTs", () => {
const oneD = validateCube("LUT_1D_SIZE 2\n0 0 0\n1 1 1");
assert.equal(oneD.ok, false);
assert.match(oneD.error, /1D cube LUTs are not supported yet/);
const mixed = validateCube(`
LUT_1D_SIZE 2
LUT_3D_SIZE 2
0 0 0
1 0 0
0 1 0
1 1 0
0 0 1
1 0 1
0 1 1
1 1 1
`);
assert.equal(mixed.ok, false);
assert.match(mixed.error, /Mixed 1D and 3D cube LUTs are not supported yet/);
});
test("CLI exits zero for valid files and non-zero for invalid files", () => {
const dir = mkdtempSync(join(tmpdir(), "mu-cube-validate-"));
try {
const valid = join(dir, "valid.cube");
const invalid = join(dir, "invalid.cube");
writeFileSync(valid, IDENTITY_2);
writeFileSync(invalid, "LUT_3D_SIZE 65");
const out = execFileSync(
process.execPath,
[new URL("./cube-validate.mjs", import.meta.url).pathname, valid],
{
encoding: "utf8",
},
);
assert.match(out, /ok: LUT_3D_SIZE 2/);
assert.throws(
() =>
execFileSync(
process.execPath,
[new URL("./cube-validate.mjs", import.meta.url).pathname, invalid],
{
encoding: "utf8",
stdio: "pipe",
},
),
(err) => err.status === 1 && String(err.stderr).includes("exceeds max 64"),
);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
@@ -0,0 +1,165 @@
import { execFileSync } from "node:child_process";
import { basename, extname } from "node:path";
const IMAGE_EXT = new Set([".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".tif", ".tiff"]);
const SAMPLE_FRAMES = 5;
// A long HD clip on slow storage can exceed the default 15s signalstats window;
// override without a code change via HYPERFRAMES_ANALYZE_TIMEOUT_MS.
const SIGNALSTATS_TIMEOUT_MS = Number(process.env.HYPERFRAMES_ANALYZE_TIMEOUT_MS) || 15000;
const ADJUST_LIMITS = {
exposure: { min: -2, max: 2 },
contrast: { min: -1, max: 1 },
highlights: { min: -1, max: 1 },
shadows: { min: -1, max: 1 },
whites: { min: -1, max: 1 },
blacks: { min: -1, max: 1 },
temperature: { min: -1, max: 1 },
tint: { min: -1, max: 1 },
vibrance: { min: -1, max: 1 },
saturation: { min: -1, max: 1 },
};
function clamp(value, key) {
const limit = ADJUST_LIMITS[key];
if (!Number.isFinite(value)) return 0;
return Math.min(limit.max, Math.max(limit.min, value));
}
function round(value) {
return Math.round(value * 1000) / 1000;
}
function avg(values) {
if (values.length === 0) return 0;
return values.reduce((sum, value) => sum + value, 0) / values.length;
}
function probeDuration(mediaPath) {
try {
const raw = execFileSync(
"ffprobe",
["-v", "quiet", "-print_format", "json", "-show_format", mediaPath],
{ encoding: "utf8", timeout: 5000 },
);
const parsed = JSON.parse(raw);
const duration = Number(parsed.format?.duration);
return Number.isFinite(duration) && duration > 0 ? duration : null;
} catch {
return null;
}
}
function filterFor(mediaPath) {
const ext = extname(mediaPath).toLowerCase();
if (IMAGE_EXT.has(ext)) return "signalstats,metadata=print:file=-";
const duration = probeDuration(mediaPath);
if (!duration || duration <= 1) return "signalstats,metadata=print:file=-";
const fps = Math.max(0.1, Math.min(2, SAMPLE_FRAMES / duration));
return `fps=${fps.toFixed(4)},signalstats,metadata=print:file=-`;
}
function parseSignalStats(raw) {
const frames = [];
let current = null;
for (const line of String(raw).split(/\r?\n/)) {
const frameMatch = line.match(/^frame:/);
if (frameMatch) {
if (current) frames.push(current);
current = {};
continue;
}
const match = line.match(/lavfi\.signalstats\.([A-Z]+)=([+-]?(?:\d+(?:\.\d+)?|\.\d+))/);
if (!match) continue;
if (!current) current = {};
current[match[1]] = Number(match[2]);
}
if (current) frames.push(current);
const complete = frames.filter(
(frame) =>
Number.isFinite(frame.YMIN) &&
Number.isFinite(frame.YMAX) &&
Number.isFinite(frame.YAVG) &&
Number.isFinite(frame.UAVG) &&
Number.isFinite(frame.VAVG),
);
if (complete.length === 0) {
throw new Error("no signalstats frames found");
}
return {
frames: complete.length,
yMin: Math.min(...complete.map((frame) => frame.YMIN)),
yMax: Math.max(...complete.map((frame) => frame.YMAX)),
yAvg: avg(complete.map((frame) => frame.YAVG)),
uAvg: avg(complete.map((frame) => frame.UAVG)),
vAvg: avg(complete.map((frame) => frame.VAVG)),
};
}
export function statsToAdjust(stats) {
const yMin = Number(stats.yMin);
const yMax = Number(stats.yMax);
const yAvg = Number(stats.yAvg);
const uAvg = Number(stats.uAvg);
const vAvg = Number(stats.vAvg);
const spread = (yMax - yMin) / 255;
const normalizedAvg = yAvg / 255;
const exposure = clamp((0.45 - normalizedAvg) * 1.8, "exposure");
const contrast = clamp((0.42 - spread) * 0.9, "contrast");
const whites =
yMax > 230 ? clamp(-((yMax - 230) / 40 + Math.max(0, normalizedAvg - 0.74)), "whites") : 0;
const blacks = yMin < 12 ? clamp((12 - yMin) / 80, "blacks") : 0;
const chromaWarmth = (vAvg - 128 + (128 - uAvg)) / 128;
const temperature = clamp(-chromaWarmth * 0.7, "temperature");
const tint = clamp(-(uAvg - 128 + (vAvg - 128)) / 256, "tint");
return {
adjust: {
exposure: round(exposure),
contrast: round(contrast),
blacks: round(blacks),
whites: round(whites),
temperature: round(temperature),
tint: round(tint),
},
measured: {
frames: Number(stats.frames ?? 1),
yMin: round(yMin),
yMax: round(yMax),
yAvg: round(yAvg),
uAvg: round(uAvg),
vAvg: round(vAvg),
},
};
}
export function analyzeMediaGrade(mediaPath) {
try {
const raw = execFileSync(
"ffmpeg",
[
"-hide_banner",
"-nostdin",
"-v",
"error",
"-i",
mediaPath,
"-vf",
filterFor(mediaPath),
"-frames:v",
String(SAMPLE_FRAMES),
"-f",
"null",
"-",
],
{ encoding: "utf8", timeout: SIGNALSTATS_TIMEOUT_MS, stdio: ["ignore", "pipe", "pipe"] },
);
return statsToAdjust(parseSignalStats(raw));
} catch (err) {
throw new Error(`grade analysis failed for ${mediaPath}: ${err.message}`);
}
}
export function formatMeasuredNote(mediaPath, measured) {
return `media-use: measured ${basename(mediaPath)}: frames=${measured.frames}, YMIN=${measured.yMin}, YMAX=${measured.yMax}, YAVG=${measured.yAvg}, UAVG=${measured.uAvg}, VAVG=${measured.vAvg}; adjust is a starting suggestion`;
}
@@ -0,0 +1,155 @@
import { strict as assert } from "node:assert";
import { execFileSync, spawnSync } from "node:child_process";
import { existsSync, mkdtempSync, rmSync, unlinkSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { test } from "node:test";
import { analyzeMediaGrade, formatMeasuredNote, statsToAdjust } from "./grade-analyzer.mjs";
// The "Test: skills" CI job runs bare `node --test` with no ffmpeg on PATH (by
// design — skills tests are meant to be node-builtin-only). Tests that shell to
// ffmpeg skip there and run wherever ffmpeg is present (locally, dev).
const FFMPEG_SKIP =
spawnSync("ffmpeg", ["-version"], { stdio: "ignore" }).status === 0
? false
: "ffmpeg not on PATH";
const ADJUST_LIMITS = {
exposure: { min: -2, max: 2 },
contrast: { min: -1, max: 1 },
highlights: { min: -1, max: 1 },
shadows: { min: -1, max: 1 },
whites: { min: -1, max: 1 },
blacks: { min: -1, max: 1 },
temperature: { min: -1, max: 1 },
tint: { min: -1, max: 1 },
vibrance: { min: -1, max: 1 },
saturation: { min: -1, max: 1 },
};
function makeFrame(dir, name, color) {
const out = join(dir, name);
execFileSync(
"ffmpeg",
[
"-hide_banner",
"-loglevel",
"error",
"-f",
"lavfi",
"-i",
`color=c=${color}:s=64x64`,
"-frames:v",
"1",
"-y",
out,
],
{ stdio: "pipe" },
);
return out;
}
function assertWithinLimits(adjust) {
for (const [key, value] of Object.entries(adjust)) {
const limit = ADJUST_LIMITS[key];
assert.ok(limit, `unexpected adjust key ${key}`);
assert.ok(value >= limit.min && value <= limit.max, `${key} out of range: ${value}`);
}
}
test("under-exposed synthetic frame suggests positive exposure", { skip: FFMPEG_SKIP }, () => {
const dir = mkdtempSync(join(tmpdir(), "mu-grade-under-"));
try {
const file = makeFrame(dir, "under.png", "0x202020");
const { adjust, measured } = analyzeMediaGrade(file);
assert.ok(measured.frames >= 1);
assert.ok(adjust.exposure > 0, `expected positive exposure, got ${adjust.exposure}`);
assertWithinLimits(adjust);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("over-exposed synthetic frame pulls exposure and whites down", { skip: FFMPEG_SKIP }, () => {
const dir = mkdtempSync(join(tmpdir(), "mu-grade-over-"));
try {
const file = makeFrame(dir, "over.png", "white");
const { adjust } = analyzeMediaGrade(file);
assert.ok(adjust.exposure < 0, `expected negative exposure, got ${adjust.exposure}`);
assert.ok(adjust.whites < 0, `expected negative whites, got ${adjust.whites}`);
assertWithinLimits(adjust);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test(
"warm-cast synthetic frame suggests negative temperature correction",
{ skip: FFMPEG_SKIP },
() => {
const dir = mkdtempSync(join(tmpdir(), "mu-grade-warm-"));
try {
const file = makeFrame(dir, "warm.png", "orange");
const { adjust } = analyzeMediaGrade(file);
assert.ok(adjust.temperature < 0, `expected cooling correction, got ${adjust.temperature}`);
assertWithinLimits(adjust);
} finally {
rmSync(dir, { recursive: true, force: true });
}
},
);
test("low-spread stats suggest positive contrast", () => {
const { adjust } = statsToAdjust({
frames: 1,
yMin: 104,
yMax: 116,
yAvg: 110,
uAvg: 128,
vAvg: 128,
});
assert.ok(adjust.contrast > 0, `expected positive contrast, got ${adjust.contrast}`);
assertWithinLimits(adjust);
});
test("malformed media fails cleanly", () => {
assert.throws(
() => analyzeMediaGrade(join(tmpdir(), "does-not-exist.png")),
/grade analysis failed/,
);
});
test(
"media path with shell metacharacters is passed as argv, not a shell string",
{
skip: FFMPEG_SKIP,
},
() => {
const dir = mkdtempSync(join(tmpdir(), "mu-grade-shell-"));
const sentinel = join(process.cwd(), "mu-grade-shell-sentinel.png");
try {
if (existsSync(sentinel)) unlinkSync(sentinel);
const file = makeFrame(dir, "frame; touch mu-grade-shell-sentinel.png", "orange");
const result = analyzeMediaGrade(file);
assert.ok(result.measured.frames >= 1);
assert.equal(existsSync(sentinel), false);
} finally {
if (existsSync(sentinel)) unlinkSync(sentinel);
rmSync(dir, { recursive: true, force: true });
}
},
);
test("measured note is a stderr-safe single-line summary", () => {
const note = formatMeasuredNote("/tmp/frame.png", {
frames: 1,
yMin: 10,
yMax: 240,
yAvg: 80,
uAvg: 120,
vAvg: 140,
});
assert.match(note, /^media-use: measured /);
assert.match(note, /YAVG=80/);
assert.equal(note.includes("\n"), false);
});
@@ -0,0 +1,232 @@
import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { allocateId } from "./manifest.mjs";
import { freezeUrl } from "./freeze.mjs";
import { tokenOverlap } from "./match.mjs";
import { buildCube } from "./cube-build.mjs";
import { validateCube, validateCubeFile } from "./cube-validate.mjs";
const SKILL_DIR = join(import.meta.dirname, "..", "..");
const LUT_DIR = join(SKILL_DIR, "luts");
const LUT_INDEX = join(LUT_DIR, "index.json");
export const LIBRARY_LUT_OFFLINE_CODE = "MEDIA_USE_LIBRARY_LUT_OFFLINE";
// Mirrored from packages/core/src/colorGrading.ts HfColorGradingPresetId.
// Keep this list in lockstep with the core runtime contract.
export const CORE_PRESET_IDS = [
"neutral",
"natural-lift",
"fresh-pop",
"warm-daylight",
"clean-studio",
"skin-soft",
"food-pop",
"night-lift",
"muted-editorial",
"vintage-wash",
"mono-clean",
"mono-fade",
"warm-clean",
"cool-clean",
"soft-boost",
"bright-pop",
"deep-contrast",
];
const PRESET_SYNONYMS = {
neutral: ["neutral", "identity", "none", "ungraded", "natural base"],
"natural-lift": ["natural lift", "natural light", "gentle lift", "soft natural"],
"fresh-pop": ["fresh pop", "fresh", "bright fresh", "clean colorful"],
"warm-daylight": [
"warm daylight",
"warm natural light",
"golden daylight",
"sunlit",
"warm sunny",
],
"clean-studio": ["clean studio", "studio clean", "cool studio", "product studio"],
"skin-soft": ["skin soft", "soft skin", "portrait soft", "beauty skin"],
"food-pop": ["food pop", "food vibrant", "appetizing", "restaurant color"],
"night-lift": ["night lift", "night", "low light lift", "city night"],
"muted-editorial": ["muted editorial", "editorial muted", "magazine muted"],
"vintage-wash": ["vintage wash", "vintage", "retro wash", "aged film"],
"mono-clean": ["mono clean", "black white clean", "monochrome clean"],
"mono-fade": ["mono fade", "black white fade", "faded monochrome"],
"warm-clean": ["warm clean", "clean warm", "warm product"],
"cool-clean": ["cool clean", "clean cool", "cool crisp"],
"soft-boost": ["soft boost", "soft bright", "gentle boost"],
"bright-pop": ["bright pop", "bright punchy", "vivid bright"],
"deep-contrast": ["deep contrast", "high contrast punchy", "punchy contrast", "bold contrast"],
};
function presetCandidates() {
return CORE_PRESET_IDS.map((id) => ({
kind: "preset",
preset: id,
synonyms: PRESET_SYNONYMS[id] ?? [],
text: [id, ...(PRESET_SYNONYMS[id] ?? [])].join(" "),
}));
}
export function readBundledLutIndex() {
if (!existsSync(LUT_INDEX)) return [];
const parsed = JSON.parse(readFileSync(LUT_INDEX, "utf8"));
const entries = Array.isArray(parsed) ? parsed : parsed.looks;
if (!Array.isArray(entries)) return [];
return entries.map((entry) => {
const params =
entry.params && typeof entry.params === "object" && !Array.isArray(entry.params)
? entry.params
: null;
const url = typeof entry.url === "string" && entry.url.trim() ? entry.url.trim() : null;
return {
id: String(entry.id),
description: String(entry.description ?? entry.id),
tags: Array.isArray(entry.tags) ? entry.tags.map(String) : [],
intensity: Number.isFinite(Number(entry.intensity)) ? Number(entry.intensity) : 1,
...(params && { params }),
...(url && { url }),
};
});
}
function libraryCandidates() {
return readBundledLutIndex().map((entry) => ({
kind: "library",
...entry,
text: [entry.id, entry.description, ...entry.tags].join(" "),
}));
}
export function matchColorLook(intent) {
const normalized = String(intent ?? "")
.trim()
.toLowerCase()
.replace(/\s+/g, " ");
for (const candidate of presetCandidates()) {
if (candidate.preset === normalized) {
return { kind: "preset", preset: candidate.preset, score: 99 };
}
}
const candidates = [...presetCandidates(), ...libraryCandidates()]
.map((candidate, index) => ({
...candidate,
index,
score: tokenOverlap(intent, candidate.text),
}))
.filter((candidate) => candidate.score >= 2)
.sort((a, b) => b.score - a.score || a.index - b.index);
if (candidates.length === 0) return null;
const best = candidates[0];
if (best.kind === "preset") {
return { kind: "preset", preset: best.preset, score: best.score };
}
return {
kind: "library",
id: best.id,
description: best.description,
tags: best.tags,
intensity: best.intensity,
...(best.params && { params: best.params }),
...(best.url && { url: best.url }),
score: best.score,
};
}
export function isLibraryLutOfflineMiss(err) {
return err?.code === LIBRARY_LUT_OFFLINE_CODE;
}
function libraryRecord(match, { id, localPath, fullPath, via }) {
return {
id,
localPath,
fullPath,
lut: { src: localPath, intensity: match.intensity },
source: "library",
description: match.description,
metadata: {
provider: "cube_lut.library",
provenance: {
look_id: match.id,
tags: match.tags,
via,
},
},
};
}
function assertValidCubeText(cube, label) {
const check = validateCube(cube);
if (!check.ok) throw new Error(`${label}: ${check.error}`);
}
function assertValidCubeFile(path, label) {
const check = validateCubeFile(path);
if (!check.ok) throw new Error(`${label}: ${check.error}`);
}
function offlineLibraryMiss(match) {
const err = new Error(`library LUT "${match.id}" is CDN-only and --local-only is set`);
err.code = LIBRARY_LUT_OFFLINE_CODE;
return err;
}
export async function freezeLibraryLut(match, { projectDir, type, localOnly = false }) {
if (!match || match.kind !== "library") {
throw new Error("freezeLibraryLut requires a library match");
}
// Prefer the CDN url so looks download on-demand (like bgm/image). Fall back
// to deterministic buildCube params when offline (--local-only) or if the
// download/validation fails, so resolution is never blocked on the network.
if (match.url && !localOnly) {
const { id, localPath } = allocateId(projectDir, type, ".cube");
const fullPath = join(projectDir, localPath);
const tmpPath = `${fullPath}.tmp`;
try {
// Download + validate at a .tmp path, then atomically rename. A crash
// (SIGKILL/OOM) between write and validate can't orphan an invalid .cube
// at the final path — only a validated cube is ever renamed into place.
await freezeUrl(match.url, tmpPath);
assertValidCubeFile(tmpPath, `downloaded library LUT ${match.id} failed validation`);
renameSync(tmpPath, fullPath);
return libraryRecord(match, { id, localPath, fullPath, via: "url" });
} catch (err) {
rmSync(tmpPath, { force: true });
if (!match.params) {
throw new Error(`failed to freeze library LUT ${match.id}: ${err.message}`);
}
// else: fall through to the params fallback below
}
}
if (match.params) {
const { id, localPath } = allocateId(projectDir, type, ".cube");
const fullPath = join(projectDir, localPath);
const tmpPath = `${fullPath}.tmp`;
try {
const cube = buildCube(match.params);
assertValidCubeText(cube, `invalid library LUT ${match.id}`);
// Write + validate at .tmp, then atomic rename — same no-orphan guarantee
// as the url path above.
writeFileSync(tmpPath, cube);
assertValidCubeFile(tmpPath, `invalid frozen LUT ${localPath}`);
renameSync(tmpPath, fullPath);
} catch (err) {
rmSync(tmpPath, { force: true });
throw err;
}
return libraryRecord(match, {
id,
localPath,
fullPath,
via: match.url ? "params-fallback" : "params",
});
}
if (match.url) throw offlineLibraryMiss(match); // url-only entry, offline
throw new Error(`misconfigured library LUT "${match.id}": expected params or url`);
}
@@ -0,0 +1,130 @@
import { strict as assert } from "node:assert";
import { mkdtempSync, rmSync, existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { test } from "node:test";
import {
CORE_PRESET_IDS,
LIBRARY_LUT_OFFLINE_CODE,
freezeLibraryLut,
matchColorLook,
readBundledLutIndex,
} from "./lut-preset-provider.mjs";
import { buildCube } from "./cube-build.mjs";
import { validateCube, validateCubeFile } from "./cube-validate.mjs";
const REPO_ROOT = join(import.meta.dirname, "..", "..", "..", "..");
function corePresetIdsFromSource() {
const src = readFileSync(join(REPO_ROOT, "packages/core/src/colorGrading.ts"), "utf8");
const match = src.match(/export type HfColorGradingPresetId =([\s\S]*?);/);
assert.ok(match, "core preset union should be readable");
return [...match[1].matchAll(/"([^"]+)"/g)].map((m) => m[1]);
}
test("warm daylight and warm natural light resolve to the core warm-daylight preset", () => {
assert.deepEqual(matchColorLook("warm daylight"), {
kind: "preset",
preset: "warm-daylight",
score: 2,
});
assert.equal(matchColorLook("warm natural light").preset, "warm-daylight");
});
test("high contrast punchy resolves to deep-contrast", () => {
assert.equal(matchColorLook("high contrast punchy").preset, "deep-contrast");
});
test("library look freezes a validated cube from params offline (--local-only)", async () => {
const projectDir = mkdtempSync(join(tmpdir(), "mu-lut-provider-"));
try {
const match = matchColorLook("teal orange blockbuster");
assert.equal(match.kind, "library");
// localOnly forces the deterministic params path (no network); online, the
// same look downloads its .cube from the CDN url (via "url").
const frozen = await freezeLibraryLut(match, { projectDir, type: "grade", localOnly: true });
assert.match(frozen.localPath, /^\.media\/luts\/grade_001\.cube$/);
assert.ok(existsSync(join(projectDir, frozen.localPath)));
assert.equal(validateCubeFile(join(projectDir, frozen.localPath)).ok, true);
assert.equal(frozen.lut.src, frozen.localPath);
assert.equal(frozen.metadata.provenance.via, "params-fallback");
} finally {
rmSync(projectDir, { recursive: true, force: true });
}
});
test("preset IDs stay in sync with packages/core/src/colorGrading.ts", () => {
assert.deepEqual(CORE_PRESET_IDS, corePresetIdsFromSource());
for (const id of CORE_PRESET_IDS) {
const match = matchColorLook(id);
assert.equal(match.kind, "preset");
assert.equal(match.preset, id);
}
});
test("zero-overlap intent returns no preset or library match", () => {
assert.equal(matchColorLook("zqxv imaginary neutron look"), null);
});
test("bundled LUT index entries resolve from params or url", () => {
for (const entry of readBundledLutIndex()) {
assert.ok(entry.id);
assert.ok(entry.description);
assert.ok(entry.params || entry.url, `${entry.id} should define params or url`);
if (entry.params) {
assert.equal(typeof entry.params, "object");
assert.equal(validateCube(buildCube(entry.params)).ok, true, `${entry.id} params validate`);
}
if (entry.url) assert.equal(typeof entry.url, "string");
}
});
test("url library entries respect localOnly and freeze through fetch", async () => {
const projectDir = mkdtempSync(join(tmpdir(), "mu-lut-url-provider-"));
const match = {
kind: "library",
id: "cdn-look",
description: "CDN-hosted look",
tags: ["cdn"],
intensity: 0.7,
url: "https://example.invalid/look.cube",
};
const originalFetch = globalThis.fetch;
let fetchCalls = 0;
try {
globalThis.fetch = async () => {
fetchCalls++;
throw new Error("network should be skipped under localOnly");
};
await assert.rejects(
freezeLibraryLut(match, { projectDir, type: "lut", localOnly: true }),
(err) => {
assert.equal(err.code, LIBRARY_LUT_OFFLINE_CODE);
assert.match(err.message, /--local-only/);
return true;
},
);
assert.equal(fetchCalls, 0);
const cube = buildCube({ contrast: 0.1 });
const body = Buffer.from(cube);
globalThis.fetch = async (url) => {
fetchCalls++;
assert.equal(url, match.url);
return {
ok: true,
headers: { get: () => String(body.length) },
body: [body],
};
};
const frozen = await freezeLibraryLut(match, { projectDir, type: "lut" });
assert.equal(fetchCalls, 1);
assert.match(frozen.localPath, /^\.media\/luts\/lut_001\.cube$/);
assert.equal(validateCubeFile(join(projectDir, frozen.localPath)).ok, true);
assert.equal(frozen.metadata.provenance.via, "url");
} finally {
globalThis.fetch = originalFetch;
rmSync(projectDir, { recursive: true, force: true });
}
});
@@ -24,6 +24,8 @@ const TYPE_DIRS = {
logo: "images",
brand: "images",
video: "video",
grade: "luts",
lut: "luts",
};
export function mediaDir(projectDir) {
@@ -13,6 +13,7 @@ import {
manifestPath,
mediaDir,
typeDirPath,
typeSubdir,
} from "./manifest.mjs";
import { regenerateIndex, generateIndexContent } from "./index-gen.mjs";
import {
@@ -81,6 +82,17 @@ function runTests() {
cleanup();
});
test("lut and grade artifacts use the shared .media/luts subdir", () => {
assert.equal(typeSubdir("lut"), "luts");
assert.equal(typeSubdir("grade"), "luts");
setup();
const allocated = allocateId(tmp, "lut", ".cube");
assert.equal(allocated.localPath, ".media/luts/lut_001.cube");
assert.ok(existsSync(join(tmp, allocated.localPath)));
cleanup();
});
test("appendRecord appends multiple records", () => {
setup();
appendRecord(tmp, makeRecord({ id: "bgm_001" }));
@@ -77,6 +77,15 @@ const REGISTRY = {
// Local design spec, not heygen — reads frame.md / design.md tokens.
A("design_spec", { search: brandProvider.search }),
],
grade: [
// Local deterministic cascade handled by resolve.mjs so grade records can
// carry an inline block as well as an optional frozen .cube file.
A("color_grade.local", { search: async () => null, generate: async () => null }),
],
lut: [
// Lower-level local LUT generation/freezing path handled by resolve.mjs.
A("cube_lut.local", { search: async () => null, generate: async () => null }),
],
};
function listFor(type) {
@@ -6,7 +6,7 @@ import { getProviders, getProvider, listTypes, runProviders, runCapability } fro
test("listTypes exposes the v2 media types", () => {
const types = listTypes();
for (const t of ["bgm", "sfx", "image", "icon", "voice", "brand"]) {
for (const t of ["bgm", "sfx", "image", "icon", "logo", "voice", "brand", "grade", "lut"]) {
assert.ok(types.includes(t), `missing type: ${t}`);
}
});
@@ -21,7 +21,7 @@ test("heygen provider is first for every type it serves", () => {
test("sanctioned providers only: heygen, local mflux/kokoro, codex, design spec, logo tiers", () => {
const allowed =
/^heygen|^mflux\.local$|^kokoro\.local$|^codex\.image_gen$|^design_spec$|^svgl$|^simple-icons$|^github\.avatar$|^favicon\.ddg$/;
/^heygen|^mflux\.local$|^kokoro\.local$|^codex\.image_gen$|^design_spec$|^svgl$|^simple-icons$|^github\.avatar$|^favicon\.ddg$|^color_grade\.local$|^cube_lut\.local$/;
for (const t of listTypes()) {
for (const p of getProviders(t)) {
assert.ok(allowed.test(p.name), `${t} lists unsanctioned provider: ${p.name}`);
+372 -20
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env node
import { existsSync, statSync } from "node:fs";
import { existsSync, statSync, writeFileSync, renameSync, rmSync } from "node:fs";
import { resolve, join, extname, basename } from "node:path";
import { parseArgs } from "node:util";
import { appendRecord, findByPrompt, findByEntity, nextId, allocateId } from "./lib/manifest.mjs";
@@ -13,6 +13,14 @@ import { track } from "./lib/telemetry.mjs";
import { typesMatch } from "./lib/match.mjs";
import { listCandidates, formatCandidates, CANDIDATE_CAP } from "./lib/candidates.mjs";
import { findGlobalBySha } from "./lib/cache.mjs";
import { buildCube, paramsFromIntent } from "./lib/cube-build.mjs";
import { validateCubeFile } from "./lib/cube-validate.mjs";
import { analyzeMediaGrade, formatMeasuredNote } from "./lib/grade-analyzer.mjs";
import {
freezeLibraryLut,
isLibraryLutOfflineMiss,
matchColorLook,
} from "./lib/lut-preset-provider.mjs";
const { values: args } = parseArgs({
options: {
@@ -25,6 +33,8 @@ const { values: args } = parseArgs({
"dry-run": { type: "boolean", default: false },
reuse: { type: "string" },
from: { type: "string" },
params: { type: "string" },
for: { type: "string" },
"local-only": { type: "boolean", default: false },
provider: { type: "string" },
json: { type: "boolean", default: false },
@@ -51,15 +61,24 @@ Options:
download, no mutation. Read them and decide reuse yourself.
--reuse <sha> Import a specific global-cache asset (by content sha/prefix,
from --candidates) into this project
--from <file> Freeze a local file or direct public URL (ingest)
--params <json> Build an explicit parametric LUT (lut/grade only)
--for <media> Analyze a local image/video and add measured grade adjust
suggestions (grade only)
--local-only Offline: skip every network provider
--provider Force one generator (e.g. codex, mflux, kokoro, heygen)
--json Output JSON instead of one-line result
--help, -h Show this help`);
process.exit(0);
}
const projectDir = resolve(args.project);
const type = args.type;
const intent = args.intent;
const entity = args.entity || null;
if (args.adopt) {
const { adoptExistingAssets } = await import("./lib/adopt.mjs");
const projectDir = resolve(args.project);
const adopted = adoptExistingAssets(projectDir);
if (args.json) {
console.log(JSON.stringify({ ok: true, adopted: adopted.length, assets: adopted }));
@@ -95,6 +114,23 @@ if (args.from) {
process.exit(0);
}
if (args.params !== undefined) {
if (type !== "lut" && type !== "grade") {
exitError(
type
? `--params only supports --type lut or grade (got ${type})`
: "--params requires --type lut or grade",
2,
);
}
try {
await runParams();
process.exit(0);
} catch (err) {
exitError(err.message, 1);
}
}
if (!args.type || !args.intent || !args.intent.trim()) {
console.error("error: --type and a non-empty --intent are required");
process.exit(2);
@@ -115,10 +151,21 @@ if (args.provider && !providerMatches(args.type, args.provider)) {
process.exit(2);
}
const projectDir = resolve(args.project);
const type = args.type;
const intent = args.intent;
const entity = args.entity || null;
function recordAvailable(projectDir, record) {
if (!record) return false;
if (record.path) return existsSync(join(projectDir, record.path));
return record.type === "grade" && record.grading;
}
function localizeImportedRecord(record, localPath) {
if (record?.type === "grade" && record.grading?.lut) {
record.grading = {
...record.grading,
lut: { ...record.grading.lut, src: localPath },
};
}
return record;
}
async function run() {
// A forced --provider means "(re)generate with THIS provider" — it bypasses
@@ -129,7 +176,7 @@ async function run() {
// 1. project manifest — exact-prompt match
const projectHit = forced ? null : findByPrompt(projectDir, intent, type);
if (projectHit && existsSync(join(projectDir, projectHit.path))) {
if (recordAvailable(projectDir, projectHit)) {
return result(projectHit, "cached");
}
@@ -138,17 +185,16 @@ async function run() {
// always recorded as type image while agents ask for logos as type icon.
if (!forced && entity) {
const entityHit = findByEntity(projectDir, entity);
if (
entityHit &&
typesMatch(entityHit.type, type) &&
existsSync(join(projectDir, entityHit.path))
) {
if (entityHit && typesMatch(entityHit.type, type) && recordAvailable(projectDir, entityHit)) {
return result(entityHit, "cached");
}
}
// 1c. scan existing assets/ directory for unregistered matches
const existingAsset = forced ? null : findExistingAsset(projectDir, intent, type);
const existingAsset =
forced || type === "grade" || type === "lut"
? null
: findExistingAsset(projectDir, intent, type);
if (existingAsset) {
const id = nextId(projectDir, type);
const record = {
@@ -169,7 +215,10 @@ async function run() {
if (cacheHit) {
const ext = extname(cacheHit.cached_path);
const { id, localPath } = allocateId(projectDir, type, ext);
const imported = importFromCache(cacheHit, projectDir, id, localPath);
const imported = localizeImportedRecord(
importFromCache(cacheHit, projectDir, id, localPath),
localPath,
);
if (imported) {
appendRecord(projectDir, imported);
regenerateIndex(projectDir);
@@ -182,7 +231,10 @@ async function run() {
if (entityCacheHit && typesMatch(entityCacheHit.type, type)) {
const ext = extname(entityCacheHit.cached_path);
const { id, localPath } = allocateId(projectDir, type, ext);
const imported = importFromCache(entityCacheHit, projectDir, id, localPath);
const imported = localizeImportedRecord(
importFromCache(entityCacheHit, projectDir, id, localPath),
localPath,
);
if (imported) {
appendRecord(projectDir, imported);
regenerateIndex(projectDir);
@@ -212,6 +264,10 @@ async function run() {
// hint is best-effort; never block a resolve
}
if (type === "grade" || type === "lut") {
return resolveColor(type, intent, { projectDir });
}
// 3. provider search — registry tries providers in order (heygen-CLI first)
let searchResult = null;
try {
@@ -303,9 +359,276 @@ async function run() {
return result(record, searchResult.source || "search");
}
function mergeSmartAdjust(block) {
if (!args.for) return block;
const mediaPath = resolve(args.for);
// Clear upfront error beats an ffmpeg "No such file" stack on a typo'd path.
if (!existsSync(mediaPath)) throw new Error(`--for file not found: ${mediaPath}`);
const analysis = analyzeMediaGrade(mediaPath);
console.error(formatMeasuredNote(mediaPath, analysis.measured));
return {
...block,
adjust: {
...(block.adjust || {}),
...analysis.adjust,
},
};
}
function freezeGeneratedLut(
params,
{
projectDir,
type,
description = "parametric color grade",
validationErrorPrefix = "generated LUT failed validation",
},
) {
const { id, localPath } = allocateId(projectDir, type, ".cube");
const fullPath = join(projectDir, localPath);
const tmpPath = `${fullPath}.tmp`;
try {
// Write + validate at .tmp, then atomic rename, so a crash between write and
// validate can't leave an invalid .cube at the final path.
writeFileSync(tmpPath, buildCube(params));
const check = validateCubeFile(tmpPath);
if (!check.ok) throw new Error(check.error);
renameSync(tmpPath, fullPath);
} catch (err) {
rmSync(tmpPath, { force: true });
throw new Error(`${validationErrorPrefix}: ${err.message}`);
}
return {
id,
localPath,
fullPath,
lut: { src: localPath, intensity: 1 },
source: "generated",
description,
metadata: {
provider: "cube_lut.builder",
provenance: { params },
},
};
}
function exitError(message, status = 1) {
if (args.json) {
console.log(JSON.stringify({ ok: false, error: message }));
} else {
console.error(`error: ${message}`);
}
process.exit(status);
}
function parseExplicitParams() {
try {
return JSON.parse(args.params);
} catch (err) {
throw new Error(`invalid --params JSON: ${err.message}`);
}
}
async function runParams() {
if (type === "lut" && args.for) {
throw new Error("--for is only supported with --type grade");
}
const params = parseExplicitParams();
const description =
typeof intent === "string" && intent.trim()
? intent.trim()
: `custom parametric ${type === "lut" ? "lut" : "grade"}`;
const frozen = freezeGeneratedLut(params, {
projectDir,
type,
description,
validationErrorPrefix: "--params produced an invalid LUT",
});
const record = {
id: frozen.id,
type,
path: frozen.localPath,
source: frozen.source,
description: frozen.description,
...(type === "grade" && { grading: mergeSmartAdjust({ intensity: 1, lut: frozen.lut }) }),
provenance: {
provider: frozen.metadata.provider,
...frozen.metadata.provenance,
},
};
return finalizeColorRecord(record, frozen.source, frozen.fullPath);
}
async function finalizeColorRecord(record, source, fullPath = null) {
appendRecord(projectDir, record);
regenerateIndex(projectDir);
if (fullPath) {
try {
cachePut(fullPath, record);
} catch {
// promotion is best-effort
}
}
return result(record, source);
}
async function colorMiss(type, intent) {
await track("media_use_resolve_miss", {
type,
local_only: !!args["local-only"],
provider_override: !!args.provider,
});
const msg = `no local color grade could resolve ${type}: "${intent}"`;
if (args.json) {
console.log(JSON.stringify({ ok: false, error: msg }));
} else {
console.error(`error: ${msg}`);
}
process.exit(1);
}
async function resolveGrade(intent, { projectDir }) {
const match = matchColorLook(intent);
if (match?.kind === "preset") {
const id = nextId(projectDir, "grade");
const grading = mergeSmartAdjust({ preset: match.preset, intensity: 1 });
const record = {
id,
type: "grade",
source: "preset",
description: intent,
grading,
provenance: {
provider: "color_grade.local",
prompt: intent,
preset: match.preset,
},
};
return finalizeColorRecord(record, "preset");
}
if (match?.kind === "library") {
let frozen;
try {
frozen = await freezeLibraryLut(match, {
projectDir,
type: "grade",
localOnly: args["local-only"],
});
} catch (err) {
if (isLibraryLutOfflineMiss(err)) return colorMiss("grade", intent);
throw err;
}
const grading = mergeSmartAdjust({ intensity: 1, lut: frozen.lut });
const record = {
id: frozen.id,
type: "grade",
path: frozen.localPath,
source: frozen.source,
description: frozen.description,
grading,
provenance: {
provider: frozen.metadata.provider,
prompt: intent,
...frozen.metadata.provenance,
},
};
return finalizeColorRecord(record, frozen.source, frozen.fullPath);
}
const params = paramsFromIntent(intent);
if (!params) {
// No creative look matched. With --for, the measured adjust block is a
// valid grade on its own (footage auto-correction); only a true miss
// (no look AND no analysis) aborts.
if (args.for) {
const grading = mergeSmartAdjust({ intensity: 1 });
const record = {
id: nextId(projectDir, "grade"),
type: "grade",
source: "measured",
description: intent,
grading,
provenance: { provider: "color_grade.local", prompt: intent, measured: true },
};
return finalizeColorRecord(record, "measured");
}
return colorMiss("grade", intent);
}
const frozen = freezeGeneratedLut(params, { projectDir, type: "grade" });
const grading = mergeSmartAdjust({ intensity: 1, lut: frozen.lut });
const record = {
id: frozen.id,
type: "grade",
path: frozen.localPath,
source: frozen.source,
description: intent,
grading,
provenance: {
provider: frozen.metadata.provider,
prompt: intent,
...frozen.metadata.provenance,
},
};
return finalizeColorRecord(record, frozen.source, frozen.fullPath);
}
async function resolveLut(intent, { projectDir }) {
if (args.for) {
throw new Error("--for is only supported with --type grade");
}
const match = matchColorLook(intent);
if (match?.kind === "library") {
let frozen;
try {
frozen = await freezeLibraryLut(match, {
projectDir,
type: "lut",
localOnly: args["local-only"],
});
} catch (err) {
if (isLibraryLutOfflineMiss(err)) return colorMiss("lut", intent);
throw err;
}
const record = {
id: frozen.id,
type: "lut",
path: frozen.localPath,
source: frozen.source,
description: frozen.description,
provenance: {
provider: frozen.metadata.provider,
prompt: intent,
...frozen.metadata.provenance,
},
};
return finalizeColorRecord(record, frozen.source, frozen.fullPath);
}
const params = paramsFromIntent(intent);
if (!params) return colorMiss("lut", intent);
const frozen = freezeGeneratedLut(params, { projectDir, type: "lut" });
const record = {
id: frozen.id,
type: "lut",
path: frozen.localPath,
source: frozen.source,
description: intent,
provenance: {
provider: frozen.metadata.provider,
prompt: intent,
...frozen.metadata.provenance,
},
};
return finalizeColorRecord(record, frozen.source, frozen.fullPath);
}
async function resolveColor(type, intent, options) {
if (type === "grade") return resolveGrade(intent, options);
return resolveLut(intent, options);
}
async function ingest(src) {
const projectDir = resolve(args.project);
const type = args.type;
if (!type || !listTypes().includes(type)) {
console.error(`error: --from requires --type (one of: ${listTypes().join(", ")})`);
process.exit(2);
@@ -332,6 +655,15 @@ async function ingest(src) {
const fullPath = join(projectDir, localPath);
if (isUrl) await freezeUrl(src, fullPath);
else freezeLocalFile(resolve(src), fullPath);
if (type === "lut" || type === "grade") {
try {
const check = validateCubeFile(fullPath);
if (!check.ok) throw new Error(check.error);
} catch (err) {
rmSync(fullPath, { force: true });
exitError(`ingested LUT is invalid: ${err.message}`, 1);
}
}
const record = {
id,
type,
@@ -407,7 +739,10 @@ async function reuseGlobal(shaArg) {
}
const ext = extname(rec.cached_path || "") || defaultExt(type);
const { id, localPath } = allocateId(projectDir, type, ext);
const imported = importFromCache(rec, projectDir, id, localPath);
const imported = localizeImportedRecord(
importFromCache(rec, projectDir, id, localPath),
localPath,
);
if (!imported) {
console.error(`error: cache entry for "${shaArg}" is incomplete or missing on disk`);
process.exit(1);
@@ -427,19 +762,34 @@ async function result(record, source) {
type: record.type,
source,
provider: record.provenance?.provider,
// How a library LUT resolved: "url" (CDN), "params-fallback" (CDN failed →
// parametric), or "params" (offline). Surfaces silent CDN→params downgrades
// in prod, which --doctor can't (it only answers "reachable now?").
via: record.provenance?.via,
local_only: !!args["local-only"],
provider_override: !!args.provider,
});
if (args.json) {
console.log(JSON.stringify({ ok: true, ...record, _source: source }));
const grading = record.type === "grade" && record.grading ? record.grading : null;
console.log(
JSON.stringify({
ok: true,
...record,
...(grading || {}),
...(grading && { grading }),
_source: source,
}),
);
} else {
const meta = formatMeta(record, source);
console.log(`resolved ${record.id}${record.path} (${meta})`);
console.log(`resolved ${record.id}${record.path || "inline"} (${meta})`);
}
}
function formatMeta(record, source) {
const parts = [record.type];
if (record.grading?.preset) parts.push(`preset ${record.grading.preset}`);
if (record.grading?.lut) parts.push("lut");
if (record.duration != null) parts.push(`${record.duration}s`);
if (record.width && record.height) parts.push(`${record.width}×${record.height}`);
if (record.transparent) parts.push("transparent");
@@ -464,6 +814,8 @@ const DEFAULT_EXT = {
icon: ".svg",
logo: ".svg",
brand: ".png",
grade: ".cube",
lut: ".cube",
};
function defaultExt(type) {
+316 -2
View File
@@ -1,16 +1,35 @@
import { strict as assert } from "node:assert";
import { mkdtempSync, rmSync, writeFileSync, readFileSync, mkdirSync, existsSync } from "node:fs";
import {
mkdtempSync,
rmSync,
writeFileSync,
readFileSync,
mkdirSync,
existsSync,
readdirSync,
} from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { execFileSync } from "node:child_process";
import { execFileSync, spawnSync } from "node:child_process";
import { appendRecord, readManifest } from "./lib/manifest.mjs";
import { regenerateIndex } from "./lib/index-gen.mjs";
import { getProvider } from "./lib/providers.mjs";
import { freezeLocalFile } from "./lib/freeze.mjs";
import { cachePut, cacheGet, importFromCache } from "./lib/cache.mjs";
import { validateCubeFile } from "./lib/cube-validate.mjs";
const REPO_ROOT = join(import.meta.dirname, "..", "..", "..");
const RESOLVE_CLI = join(import.meta.dirname, "resolve.mjs");
// The "Test: skills" CI job has no ffmpeg on PATH (by design). The smart-grade
// test shells to ffmpeg, so it's skipped there and runs where ffmpeg exists.
const HAS_FFMPEG = spawnSync("ffmpeg", ["-version"], { stdio: "ignore" }).status === 0;
// The core-conformance test imports core's TypeScript via tsx. The dependency-free
// "Test: skills" CI job has neither tsx nor installed deps, so skip it there; it
// runs wherever the workspace is installed (locally, the main Test job).
const CAN_TSX =
spawnSync(process.execPath, ["--import", "tsx", "--input-type=module", "-e", "0"], {
stdio: "ignore",
}).status === 0;
let tmp;
function setup() {
@@ -45,6 +64,60 @@ function runResolve(args, opts = {}) {
});
}
function spawnResolve(args, opts = {}) {
return spawnSync(process.execPath, [RESOLVE_CLI, ...args], {
cwd: REPO_ROOT,
encoding: "utf8",
...opts,
});
}
function makeFrame(dir, name, color) {
const out = join(dir, name);
execFileSync(
"ffmpeg",
[
"-hide_banner",
"-loglevel",
"error",
"-f",
"lavfi",
"-i",
`color=c=${color}:s=64x64`,
"-frames:v",
"1",
"-y",
out,
],
{ stdio: "pipe" },
);
return out;
}
function normalizeWithCoreSource(grading) {
const sourcePath = join(REPO_ROOT, "packages/core/src/colorGrading.ts");
const code = `
import { normalizeHfColorGrading } from ${JSON.stringify(sourcePath)};
const grading = JSON.parse(process.env.HF_GRADING_JSON);
const normalized = normalizeHfColorGrading(grading);
if (!normalized) process.exit(2);
console.log(JSON.stringify({
preset: normalized.preset,
intensity: normalized.intensity,
adjust: normalized.adjust,
lut: normalized.lut,
colorSpace: normalized.colorSpace
}));
`;
return JSON.parse(
execFileSync(process.execPath, ["--import", "tsx", "--input-type=module", "-e", code], {
cwd: REPO_ROOT,
encoding: "utf8",
env: { ...process.env, HF_GRADING_JSON: JSON.stringify(grading) },
}),
);
}
const tests = [];
function test(name, fn) {
tests.push({ name, fn });
@@ -217,6 +290,19 @@ test("--help exits 0", () => {
const out = runResolve(["--help"]);
assert.ok(out.includes("media-use resolve"));
assert.ok(out.includes("--type"));
assert.ok(out.includes("--for"));
assert.ok(out.includes("--from"));
assert.ok(out.includes("--local-only"));
});
test("unknown type error lists grade and lut", () => {
try {
runResolve(["--type", "bogus", "--intent", "x"], { stdio: "pipe" });
assert.fail("should have exited");
} catch (err) {
assert.equal(err.status, 2);
assert.match(String(err.stderr), /known: .*grade.*lut/);
}
});
test("missing required args exits 2", () => {
@@ -257,6 +343,234 @@ test("one-line output format matches contract", () => {
cleanup();
});
// --- color grading ---
test("grade resolves a preset-only look with no cube file", () => {
setup();
const out = runResolve([
"--type",
"grade",
"--intent",
"warm daylight",
"--project",
tmp,
"--json",
]);
const parsed = JSON.parse(out.trim());
assert.equal(parsed.ok, true);
assert.equal(parsed.type, "grade");
assert.equal(parsed.grading.preset, "warm-daylight");
assert.equal(parsed.grading.lut, undefined);
assert.equal(parsed.path, undefined);
assert.equal(readManifest(tmp).length, 1);
cleanup();
});
test("grade resolves a library LUT look and freezes a validated cube", () => {
setup();
const out = runResolve([
"--type",
"grade",
"--intent",
"teal orange blockbuster",
"--project",
tmp,
"--json",
]);
const parsed = JSON.parse(out.trim());
assert.equal(parsed.ok, true);
assert.match(parsed.grading.lut.src, /^\.media\/luts\/grade_001\.cube$/);
assert.equal(parsed.path, parsed.grading.lut.src);
assert.ok(existsSync(join(tmp, parsed.grading.lut.src)));
assert.equal(validateCubeFile(join(tmp, parsed.grading.lut.src)).ok, true);
cleanup();
});
test("smart grade merges measured adjust and keeps stdout valid JSON", () => {
if (!HAS_FFMPEG) {
console.log(" (skipped: ffmpeg not on PATH)");
return;
}
setup();
const frame = makeFrame(tmp, "under.png", "0x202020");
const proc = spawnResolve([
"--type",
"grade",
"--intent",
"warm cinematic",
"--for",
frame,
"--project",
tmp,
"--json",
]);
assert.equal(proc.status, 0, proc.stderr);
const parsed = JSON.parse(proc.stdout);
assert.equal(parsed.ok, true);
assert.ok(parsed.grading.adjust.exposure > 0, "under-exposed frame should suggest lift");
assert.match(proc.stderr, /media-use: measured/);
cleanup();
});
test("emitted grading block survives the core normalizeHfColorGrading contract", () => {
if (!CAN_TSX) {
console.log(" (skipped: tsx / core source unavailable)");
return;
}
setup();
const out = runResolve([
"--type",
"grade",
"--intent",
"teal orange blockbuster",
"--project",
tmp,
"--json",
]);
const parsed = JSON.parse(out.trim());
const normalized = normalizeWithCoreSource(parsed.grading);
assert.equal(normalized.lut.src, parsed.grading.lut.src);
assert.equal(normalized.lut.intensity, parsed.grading.lut.intensity);
assert.equal(normalized.colorSpace, "rec709");
cleanup();
});
test("lut resolves only the frozen cube path", () => {
setup();
const out = runResolve([
"--type",
"lut",
"--intent",
"teal orange blockbuster",
"--project",
tmp,
"--json",
]);
const parsed = JSON.parse(out.trim());
assert.equal(parsed.ok, true);
assert.equal(parsed.type, "lut");
assert.match(parsed.path, /^\.media\/luts\/lut_001\.cube$/);
assert.equal(parsed.grading, undefined);
assert.equal(validateCubeFile(join(tmp, parsed.path)).ok, true);
cleanup();
});
test("lut --params builds, validates, and freezes a cube", () => {
setup();
const params = { contrast: 0.2, temperature: -0.3 };
const out = runResolve(["-t", "lut", "--params", JSON.stringify(params), "-p", tmp, "--json"]);
const parsed = JSON.parse(out.trim());
assert.equal(parsed.ok, true);
assert.equal(parsed.type, "lut");
assert.match(parsed.path, /^\.media\/luts\/lut_001\.cube$/);
assert.equal(parsed.description, "custom parametric lut");
assert.equal(parsed.provenance.provider, "cube_lut.builder");
assert.deepEqual(parsed.provenance.params, params);
assert.ok(existsSync(join(tmp, parsed.path)));
assert.equal(validateCubeFile(join(tmp, parsed.path)).ok, true);
cleanup();
});
test("grade --params returns a grading block with a frozen valid cube", () => {
setup();
const out = runResolve([
"-t",
"grade",
"--params",
JSON.stringify({ exposure: 0.2 }),
"-p",
tmp,
"--json",
]);
const parsed = JSON.parse(out.trim());
assert.equal(parsed.ok, true);
assert.equal(parsed.type, "grade");
assert.equal(parsed.grading.intensity, 1);
assert.match(parsed.grading.lut.src, /^\.media\/luts\/grade_001\.cube$/);
assert.equal(parsed.lut.src, parsed.grading.lut.src);
assert.equal(parsed.path, parsed.grading.lut.src);
assert.equal(validateCubeFile(join(tmp, parsed.grading.lut.src)).ok, true);
cleanup();
});
test("--params malformed JSON errors cleanly without freezing a cube", () => {
setup();
const proc = spawnResolve(["-t", "lut", "--params", "{not json", "-p", tmp, "--json"]);
assert.equal(proc.status, 1, proc.stderr);
const parsed = JSON.parse(proc.stdout);
assert.equal(parsed.ok, false);
assert.match(parsed.error, /^invalid --params JSON:/);
assert.equal(readManifest(tmp).length, 0);
assert.equal(existsSync(join(tmp, ".media/luts")), false);
cleanup();
});
// buildCube clamps every accepted parameter and resolve.mjs does not expose
// the size argument, so there is no CLI input that can make --params emit a
// structurally invalid cube. Invalid cube cleanup is covered through --from.
test("--from rejects invalid lut cube without registering or leaving a frozen file", () => {
setup();
const broken = join(tmp, "broken.cube");
writeFileSync(broken, "LUT_3D_SIZE 999\n");
const proc = spawnResolve(["--from", broken, "-t", "lut", "-p", tmp, "--json"]);
assert.equal(proc.status, 1, proc.stderr);
const parsed = JSON.parse(proc.stdout);
assert.equal(parsed.ok, false);
assert.match(parsed.error, /^ingested LUT is invalid: LUT_3D_SIZE 999 exceeds max 64/);
assert.equal(readManifest(tmp).length, 0);
const lutDir = join(tmp, ".media/luts");
assert.deepEqual(existsSync(lutDir) ? readdirSync(lutDir) : [], []);
cleanup();
});
test("grade miss exits explicitly with no partial file", () => {
setup();
const missIntent = `zqxv imaginary neutron ${process.pid}`;
try {
runResolve(["--type", "grade", "--intent", missIntent, "--project", tmp, "--json"]);
assert.fail("should have exited");
} catch (err) {
assert.equal(err.status, 1);
const parsed = JSON.parse(String(err.stdout));
assert.equal(parsed.ok, false);
assert.match(parsed.error, /no local color grade could resolve/);
assert.equal(readManifest(tmp).length, 0);
assert.equal(existsSync(join(tmp, ".media/luts")), false);
}
cleanup();
});
test("identical grade resolve hits the project cache without re-freezing", () => {
setup();
const first = JSON.parse(
runResolve([
"--type",
"grade",
"--intent",
"teal orange blockbuster",
"--project",
tmp,
"--json",
]),
);
const second = JSON.parse(
runResolve([
"--type",
"grade",
"--intent",
"teal orange blockbuster",
"--project",
tmp,
"--json",
]),
);
assert.equal(second._source, "cached");
assert.equal(second.id, first.id);
assert.equal(second.path, first.path);
assert.equal(readManifest(tmp).length, 1);
cleanup();
});
// --- run ---
async function main() {