Skip to content
Back to skills

Docs

ASecurity

color-grading-skill is a client of ffmpeg-skill's **public contract** (`ffmpeg-skill contract --json`, `contract_version 1.0`). It works with whichever ffmpeg-skill version is installed, as long as that version has the flags below: compatibility is detected from the checkout itself, never decided by a version window (docs/decisions.md ADR-19). CI runs against ffmpeg-skill main and against the oldest verified commit (0.12.3). It never calls `ffmpeg` or `ffprobe` itself.

  • 2 stars
  • 0 votes
  • 0 copies
  • 4 views
  • Added October 26, 2026
developmentnodeexpressgitapibackend

Works with

  • cli
  • api

Security analysis

A100/100

Pro scans all 6 files and shows the line behind each finding

Scanned October 26, 2026

npx -y skills add kajisho5/color-grading-skill --skill docs --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Docs?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Docs
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/kajisho5-docs/badge)](https://www.skillsdirectory.com/skills/kajisho5-docs)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
# ffmpeg-skill relationship

color-grading-skill is a client of ffmpeg-skill's **public contract** (`ffmpeg-skill contract --json`,
`contract_version 1.0`). It works with whichever ffmpeg-skill version is installed, as long as that version has the
flags below: compatibility is detected from the checkout itself, never decided by a version window (docs/decisions.md
ADR-19). CI runs against ffmpeg-skill main and against the oldest verified commit (0.12.3). It never calls `ffmpeg` or
`ffprobe` itself.

## Tools and flags used

| ffmpeg-skill tool | used for | flags emitted |
|---|---|---|
| `ffmpeg-skill/probe` | source facts, every artifact's validation | positional `inputs` (one path) |
| `ffmpeg-skill/color` | `HDR_TO_SDR` (`--to-sdr`), `LUT_APPLY` (`--lut`), `RETAG` (`--retag`), `STRIP_DOVI` (`--strip-dovi`), `PRIMARY_CORRECTION` (`--correct`) | `--to-sdr`, `--lut`, `--retag`, `--strip-dovi`, `--correct` (mutually exclusive, exactly one required — matches this skill's one-operation-per-node model exactly), `--tonemap`, `--peak`, `--desat`, `--force`, `--lut-strength`, `--exposure`, `--contrast`, `--saturation`, `--temperature`, `--tint`, `--gamma`, `--lift`, `--gain`, `--levels-in-black`, `--levels-in-white`, `--levels-out-black`, `--levels-out-white`, `--curves`, `--audio-stream`, `--quality` or `--crf` (whichever the checkout has), `--preset`, `-o`, `--json` |

`doctor` checks that every flag listed above exists in the tool's generated `input_schema` (and, when the contract
declares it, that `color` has `video_required: true`); a missing flag is a `fail` naming the flag, and `run` refuses
with `TOOL_ERROR` (`ffmpeg_skill_incompatible`). The version number is reported, never compared.

## Capability detection (no version window)

`adapter.FfmpegSkill.info()` reads the flags from the first source that answers: `scripts/_contract.py --json
--static`, `scripts/_contract.py --json`, `scripts/contract.py --json`, and finally each tool's own `--help`
(`adapter.flags_from_help`). What it found is in `doctor --json`'s `checks.ffmpeg_skill.detected`:

| difference between releases | detected by | what this skill does |
|---|---|---|
| `--crf` became `--quality` (added 1.10, `--crf` removed from `color.py` in 2.0; same CRF scale and default) | which of `quality` / `crf` the `color` input_schema has | emits `--quality` when present, else `--crf`, with the request's `crf` value (`encoder_quality_flag`) |
| `scripts/_common.py` became a `scripts/_common/` package (2.0) | the files on disk | nothing: this skill only runs `probe.py`/`color.py`; reported as `common_layout` |
| an existing output is refused unless `--overwrite` (1.10 opt-in, 2.0 default) | `overwrite` in the `color` input_schema | nothing: every intermediate path is deleted before its `color.py` call, so the tool never sees an existing output and no overwrite consent is given; reported as `overwrite_flag` |
| probe's `hdr` narrowed to PQ/HLG/Dolby Vision; `bt2020_or_hdr` carries the 1.x meaning (2.0) | which key probe's `video` object has | `adapter.probe_hdr` reads `bt2020_or_hdr` when present, else `hdr`: BT.2020 primaries or a PQ/HLG transfer in every version |
| no `_contract.py doctor` (a checkout without it) | the file on disk | ffmpeg/ffprobe/filter capabilities are `unknown`, not `unsupported`; the run and output validation decide |

The guarantees the former window encoded are implied by the flags: `--gamma`/`--lift`/`--gain`/`--levels-*`/
`--curves` first exist in 0.12.3, which is after both `--audio-stream` (0.12.0) and honest `dropped_non_av_streams`
reporting from `--to-sdr`/`--lut`/`--correct` (0.12.1). So a checkout that passes the flag check also has those, and
no version number needs comparing. The result JSON top-level keys this skill reads (`status`, `error`,
`measurements`, `reencoded`, `dropped_non_av_streams`, `commands`) are unchanged across 0.12.3 – 2.x.

## Why 0.12.3 is the oldest release that works

Every flag this skill emits was read directly from `scripts/color.py`'s argparse definition and confirmed present in
`scripts/_contract.py --json --static`'s generated `input_schema` at ffmpeg-skill 0.12.3 (0.9.1 lacked `--correct`
and its five original parameter flags, so PRIMARY_CORRECTION raised the window to 0.9.2 first, docs/decisions.md
ADR-15; `--audio-stream` does not exist in `color.py` before 0.12.0, and `--to-sdr`/`--lut`/`--correct` did not
honestly report `dropped_non_av_streams` before 0.12.1 — RETAG's own `reencoded`/`dropped_non_av_streams` pair is
0.12.0, docs/decisions.md ADR-17; `--gamma`/`--lift`/`--gain`/`--levels-*`/`--curves` do not exist in `color.py`
before 0.12.3, docs/decisions.md ADR-18). An earlier checkout lacks those flags, so the flag check refuses it and
names them; no version comparison is needed for that. There is no upper bound: ffmpeg-skill's 2.x compatibility
promise covers argument names and JSON keys, and the one rename it made (`--crf` → `--quality`) is detected.

## Observed behaviour this skill relies on (measured by reading ffmpeg-skill 0.12.3's `scripts/color.py`)

- `color.py` requires a video stream (`die("input has no video stream")` when `probe` reports none) and refuses to
  run without exactly one of `--to-sdr` / `--lut` / `--retag` / `--strip-dovi` / `--correct` (an argparse
  mutually-exclusive, required group) — this is exactly this skill's one-operation-per-node model; there is no
  request shape in which color-grading-skill could ask ffmpeg-skill/color to do more than one of these in a single
  call.
- `--to-sdr` refuses a source that is not tagged HDR (`color_transfer` / `color_primaries`) unless `--force` is
  given; it builds a `zscale → format=gbrpf32le → zscale → tonemap → zscale → format=yuv420p` chain from the
  *measured* input tags (falls back to `smpte2084`/`bt2020`/`bt2020nc`/`tv` when a tag is missing) and always
  re-encodes with libx264 (`x264_args`), carrying audio through AAC when present. **Measured**: `--force` on a
  source that genuinely never was PQ/HLG-encoded (no HDR tags, ordinary SDR content) can make the zscale/tonemap
  filter chain itself fail ("Nothing was written into output file..."), since the chain assumes a PQ-range input.
  This is a real ffmpeg failure surfaced as `TOOL_ERROR`, not a defect in this skill's validation; `force: true` is
  the caller's explicit request to treat unlabelled content as HDR anyway, and that can legitimately fail.
- `--retag`'s stream-copy path (`-c copy -colorspace/-color_primaries/-color_trc`) only reliably changes what
  `ffprobe` reports when the source's own bitstream (the H.264/HEVC SPS VUI) does not already carry an explicit,
  conflicting colour description. **Measured** with ffmpeg 6.1.1: retagging a file whose H.264 stream was encoded
  with explicit `-colorspace/-color_primaries/-color_trc` (baked into the SPS) leaves `ffprobe`'s reported tags
  unchanged after `--retag`, even though `color.py` reports `wrote ... (tags -> ...)` and exits 0 — the container-
  level override does not rewrite already-encoded VUI bits, and `color.py`'s fallback to a re-encode only triggers
  on a non-zero ffmpeg exit code, which this case does not produce. Retagging a source with *no* explicit tags (the
  common case `--retag` is documented for: "the colours are tagged wrong", i.e. missing or incorrect metadata on
  otherwise untagged footage) works as documented. This skill's own output validation (`executor._validate_artifact`,
  the `RETAG` branch) re-probes the output and compares the exact tag triple against `model.RETAG_TAGS[target]`, so
  a retag that silently did not take effect is reported as `VALIDATION_ERROR` (`reason: retag_mismatch`), never as a
  false success — the gap is ffmpeg-skill's, the honesty about it is this skill's.
- `--lut` applies `lut3d=file=<path>:interp=tetrahedral`; **`--lut-strength` blends the graded and original streams
  only for a value strictly between 0 and 1** (`0 < args.lut_strength < 1` in the source) — a strength of exactly
  `0.0` is *not* "no LUT": ffmpeg-skill applies the LUT at full strength for both `0.0` and `1.0`. This skill
  accepts the documented `[0.0, 1.0]` range unchanged (it is ffmpeg-skill's contract, not a bug this skill works
  around) and states the caveat in the parameter's own description (`model.OPERATION_TYPES["LUT_APPLY"]`).
  Always re-encodes with libx264; an HDR source keeps whatever tags the input had (the LUT path does not retag).
- `--retag` attempts a stream copy first (`-c copy -colorspace … -color_primaries … -color_trc …`); ffmpeg-skill
  itself falls back to a re-encode (`x264_args(crf, preset, keep_bt709=False)`) only when the stream copy fails for
  a given codec/container combination. This skill's `RETAG` parameters carry no `crf`/`preset` (unlike
  `HDR_TO_SDR`/`LUT_APPLY`): the common case is a stream copy where they have no effect, and ffmpeg-skill's own
  defaults (18 / medium) apply on the rare fallback. `audio_stream` is different: `color.py` forwards it
  (`-map 0:a:{audio_stream}?`) into that same re-encode fallback, so `RETAG` does carry `audio_stream` even though
  it carries no `crf`/`preset` — on the common stream-copy path it has no effect (every audio track is kept, `-map
  0`), same as `crf`/`preset`, but it is not simply irrelevant to `RETAG` the way they are. The four targets and
  their exact tag triples (`color_space, color_primaries, color_transfer`) are ffmpeg-skill's own mapping, copied
  verbatim into `model.RETAG_TAGS` and used again to verify the output.
- `--strip-dovi` only applies to HEVC video (`die` otherwise) and is always a stream copy
  (`-bsf:v filter_units=remove_types=62 -tag:v hvc1`), removing the Dolby Vision RPU NAL unit; it does not require
  the input to already carry detected Dolby Vision side data (it warns and proceeds), but this skill still verifies
  the *output* no longer reports `dolby_vision` after the call.
- `--correct` builds a fixed filter chain from four always-present stages plus two stages appended only when asked
  for: `exposure=exposure=<value>` (the dedicated `exposure` filter, distinct from `eq`'s brightness — confirmed
  via `ffmpeg -h filter=exposure` vs. `-h filter=eq`), `colortemperature=temperature=<value>` (6500 = neutral),
  `colorbalance=rs=<lift>:gs=<lift>:bs=<lift>:rm=<tint/2>:gm=<-tint>:bm=<tint/2>:rh=<gain>:gh=<gain>:bh=<gain>`
  (a single `colorbalance` call covering all three tonal ranges: `lift`/`gain` set the shadow/highlight channels
  directly, `tint` is expressed as a green(-1)/magenta(+1) midtone balance shift — magenta raises red and blue, cuts
  green), and `eq=contrast=<value>:saturation=<value>:gamma=<value>` (gamma folded into the same `eq` term
  contrast/saturation already use, as `eq`'s own gamma option) — always in that order, and always present because
  every one of those eight flags (`exposure`/`contrast`/`saturation`/`temperature`/`tint`/`gamma`/`lift`/`gain`) has
  an identity default value, so emitting the flag is harmless even unchanged. `colorlevels=<r/g/b in/out min/max>`
  (the four `--levels-*` 8-bit values divided by 255.0 into the filter's native 0..1 range) is appended only when
  they differ from `(0, 255, 0, 255)`, and `curves=preset=<name>` only when `--curves` was given — omitting either
  already gets the identity result without an extra filter term. The whole chain re-encodes with libx264
  (`--crf`/`--preset`). Every one of the thirteen typed flags is range-checked by `color.py` itself before ffmpeg
  runs (`die()` on an out-of-range value, or on `levels_in_black >= levels_in_white` / `levels_out_black >=
  levels_out_white`, matching the same ranges and ordering this skill's `PRIMARY_CORRECTION` parameters declare and
  validate) — this skill's own range and ordering checks in `model.py` duplicate ffmpeg-skill's guarantee at the
  request boundary (fail fast, before a subprocess starts) rather than relying on it exclusively.
  `--correct` also reports a `measurements` field: `{"input": analyze_levels(source), "output":
  analyze_levels(output)}`, reusing ffmpeg-skill's existing signalstats-based `analyze_levels` (the same primitive
  `probe.py --analyze` uses for Log-footage detection) — real, technical numbers (`y_min`/`y_max`/`y_avg`/
  `saturation_avg`), never a subjective "looks better" judgement. This skill carries that field through unchanged
  into `NodeState.measurements`, the manifest and provenance.
- Failure document: `{"status": "failed", "error": {"kind": "input|ffmpeg|missing_tool", "message"}}` with a
  non-zero exit; parsed into `TOOL_ERROR` with `details.error_kind`.
- `ffmpeg-skill/probe`'s `video.hdr` (before 2.0; `video.bt2020_or_hdr` from 2.0, where `hdr` means the transfer
  only) is `true` when `color_transfer` is `smpte2084` or `arib-std-b67`, or `color_primaries` is `bt2020`, or
  Dolby Vision side data is present; `video.dolby_vision` is `null` or a dict with
  `profile`/`level`/`bl_compatibility_id`. This skill's `HDR_TO_SDR` / `STRIP_DOVI` validation reads exactly these
  fields (`adapter.probe_hdr`), never re-derives HDR-ness from raw tags itself.
- **`--audio-stream N` (default 0, ffmpeg-skill >= 0.12.0)** selects which audio stream of a multi-track input to
  keep, 0-based in file order; `color.py` validates it against `probe()`'s own `audio_streams` count and `die()`s on
  an out-of-range value. It only affects paths that re-encode audio: `--to-sdr`/`--lut`/`--correct` always, and
  `--retag`'s automatic re-encode fallback (`-map 0:a:{audio_stream}?`) — a successful `--retag` stream copy and
  `--strip-dovi` both keep every audio track untouched (`-map 0`), so the flag has nothing to select for either,
  and this skill does not accept an `audio_stream` parameter on `STRIP_DOVI`. This skill's `HDR_TO_SDR`/
  `LUT_APPLY`/`RETAG`/`PRIMARY_CORRECTION` parameters all carry `audio_stream`, forwarded unchanged to
  `--audio-stream`; ffmpeg-skill's own range check against the real stream count is the authority (this skill only
  requires it to be a non-negative integer at the request boundary, since the real bound depends on the source).
- **`reencoded` / `dropped_non_av_streams` (ffmpeg-skill >= 0.12.0/0.12.1).** `--retag`'s JSON output reports
  `reencoded` (`true` when the stream-copy path failed and a re-encode fallback ran) and `dropped_non_av_streams`
  (`true` only when even the subtitle/data-preserving re-encode failed and streams beyond video+audio had to be
  dropped) since 0.12.0; `--to-sdr`/`--lut`/`--correct` gained `dropped_non_av_streams` the same way in 0.12.1 (they
  always re-encode, so `reencoded` would be redundant there and is not reported). This skill carries both fields
  (when present) onto `NodeState`, into every intermediate's manifest, and into the output provenance chain, the
  same way `measurements` already is (`executor._execute_node`). `executor._validate_artifact` also independently
  re-probes the output's `subtitle_streams`/`data_streams` counts against the source's whenever the source has any,
  and raises `VALIDATION_ERROR` (`reason: stream_loss_unreported`) if a count fell without ffmpeg-skill having
  reported `dropped_non_av_streams: true` for that call — the honest-drop case is surfaced, never hidden, but is not
  itself treated as this skill's own failure; only an *unreported* loss is.
- **`--lut` and a Windows drive-letter path.** `color.py` builds `lut3d=file=<escape_filter_path(args.lut)>:interp=
  tetrahedral` for the `-vf` filter graph. `escape_filter_path` backslash-escapes a colon (`C:\...` → `C\:/...`) so
  the graph-level parser does not treat it as a filter separator. **Measured** on a Windows CI runner (gyan.dev
  ffmpeg 9.0.1 essentials, Windows Server 2025): that escaping is rejected by this build's filter-*option* value
  parser (`No option name near '...invert.cube:interp=tetrahedral'`, `Error parsing filterchain ...`), so an
  absolute Windows LUT path made `--lut` fail outright — not a corrupted result, a hard failure ffmpeg-skill itself
  reports (`kind: ffmpeg`), which this skill's adapter turns into `TOOL_ERROR`. Since only the drive letter's colon
  triggers the bug, this skill sidesteps it entirely from its own side, without touching ffmpeg-skill:
  `executor._execute_node` runs the `ffmpeg-skill/color` subprocess for `LUT_APPLY` with `cwd` set to the LUT
  file's own directory (`adapter.run_tool`/`_popen` take an optional `cwd` override for this, defaulting to the
  ffmpeg-skill checkout as before for every other call) and `executor._lut_arg` passes just the LUT's bare file
  name as `--lut` — no drive letter, no colon, nothing for `escape_filter_path` to (mis)escape, regardless of what
  drive the LUT, the caller's workspace and the ffmpeg-skill checkout each happen to be on. (A relative-path
  attempt was tried first and was not enough: GitHub Actions Windows runners put the repository checkout and the
  OS temp directory — where a caller's own workspace typically lives — on different drives, and no relative path
  can cross a Windows drive at all.)

## Compatibility gaps (required capability → not implemented here)

| wanted operation | missing in ffmpeg-skill's public contract | consequence |
|---|---|---|
| Exposure / contrast / saturation / white balance (temperature + tint) | — implemented since ffmpeg-skill 0.9.2's `--correct` | `PRIMARY_CORRECTION` (this doc, above); no longer in `UNSUPPORTED_OPERATIONS` |
| Gamma / three-way lift / gain / levels / curves | — implemented since ffmpeg-skill 0.12.3's `--correct` flags | `PRIMARY_CORRECTION`'s `gamma`/`lift`/`gain`/`levels_*`/`curves` parameters (this doc, above; docs/decisions.md ADR-18); no longer in `UNSUPPORTED_OPERATIONS` |
| White balance as its own single operation type | no single "white balance" flag; only the two separate `temperature`/`tint` parameters that together achieve it | declared (`WHITE_BALANCE`); use `PRIMARY_CORRECTION`'s `temperature`/`tint` instead |
| Container / format conversion alongside grading | `color.py` writes to whatever extension `-o` names, no explicit "convert to X"; that is `export.py`'s job | outputs must keep the source's container (ADR-8); use ffmpeg-skill/export separately |
| Multiple LUTs / multiple colour ops in one ffmpeg process | `color.py`'s mode flags are mutually exclusive | each operation is its own `ffmpeg-skill/color` call and its own intermediate; a chain of N operations costs N re-encodes (documented in README's Current limitations) |
| Filter/encoder capability detection on FFmpeg ≥ 8.0 | ffmpeg-skill 0.9's `_ff_list` matches the pre-8.0 three-flag format; FFmpeg 8 prints two flags, so every filter is reported "missing" | when the doctor reports ffmpeg but zero filters, `filter:*` capabilities are `unknown`, not `unsupported`; execution proceeds and output validation decides (docs/decisions.md ADR-10) |

Each gap is a request to ffmpeg-skill (or a future second backend), not something this skill works around with its
own ffmpeg invocation or a raw filter string.

Files in this skill

  • architecture.md9.7 KB
  • decisions.md21.4 KB
  • ffmpeg-skill.md18.6 KB
  • security.md5.5 KB
  • testing.md6.2 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.