# ColorStamp

A mode of **FrameExtract** that takes LOG stills exported from Pomfort
LiveGrade, applies a user-selected `.cube` LUT, and exports new stills with
the LUT baked in and the correct output colorspace tagged (ICC embedded) in
the file — optionally with a colorspace suffix in the filename.

## Files

| File | Role |
|---|---|
| `CubeLUTParser.swift` | `.cube` parsing (`CubeLUT` value type) |
| `OutputColorSpace.swift` | Single source of truth for the 7 output colorspaces + the Rec.709 G2.4 ICC builder |
| `LUTColorSpaceDetector.swift` | Auto-detection of the LUT's output colorspace |
| `ColorStampPipeline.swift` | Un-color-managed Core Image bake + tagged render |
| `ColorStampExporter.swift` | Naming, metadata, encode (shares the FrameExtract encoder) |
| `ColorStampModels.swift` / `ColorStampViewModel.swift` / `ColorStampView.swift` | Settings, state, UI |
| `ColorStampWatchEngine.swift` / `ColorStampWatchSupport.swift` | Watch-folder mode: engine, FSEvents wrapper, stability check, dedupe ledger |
| `ColorStampBatchRunner.swift` | Shared, serialized batch executor (manual + watcher) |

## .cube parser behavior

Tolerant superset of the IRIDAS/Adobe spec — LiveGrade and Resolve exports
parse cleanly:

- `TITLE` (quoted or bare), `LUT_3D_SIZE` (2–256), `LUT_1D_SIZE` (2–65536)
- `DOMAIN_MIN` / `DOMAIN_MAX`, plus Resolve's `LUT_3D_INPUT_RANGE` /
  `LUT_1D_INPUT_RANGE` scalar form
- `#` comments (leading ones are kept for auto-detection), blank lines
- LF, CRLF, and lone-CR line endings; UTF-8 BOM; scientific-notation floats
- **Unknown keyword lines are skipped**, not fatal (e.g. LiveGrade's
  `LUT_IN_VIDEO_RANGE`)
- Validates data count = N³×3 (3D) or N×3 (1D) and rejects non-finite
  values, inverted domains, missing/conflicting size lines — every failure
  is a typed, human-readable error

## Color pipeline (why the output matches Resolve)

1. Sources load with `.colorSpace: NSNull()` — LOG code values are **never
   color-managed into a working space**, so the LUT sees exactly the values
   LiveGrade wrote.
2. The shared `CIContext` is unmanaged (`workingColorSpace: NSNull`) with a
   float (`RGBAf`) working format — no 8-bit re-quantization between stages.
3. `CIColorCube` applies the table (`.cube` entry order is already Core
   Image's order: RGB triples, red fastest). Non-0–1 domains are normalized
   first with a color matrix. 1D LUTs become an equivalent cube of three
   independent per-channel curves (exact when the 1D size ≤ 128, resampled
   above that).
4. The selected colorspace is **assigned** at render time (a tag), never
   converted to. Verified by test: an identity LUT leaves pixel bytes
   untouched for every selectable colorspace.
5. HDR outputs (PQ/HLG) and >8-bit sources render to 16-bit before encode;
   the embedded ICC survives a read-back round-trip (tested for TIFF and
   PNG).

**Rec.709 (Gamma 2.4)** uses a purpose-built ICC v4 profile (BT.709
primaries, Bradford-adapted to D50, pure 2.4 power) because Apple's named
`itur_709` space uses the scene OETF, not display gamma. It shows up as
"Rec. 709 Gamma 2.4 (DITools)" in Finder/Preview.

## Auto-detection token table

Filename is consulted first, then `TITLE`, then leading comments. Tokens
after the **last** `to` / `2` / `→` separator win (left side is the input
space). A transfer token without a gamut defaults to Rec.2020. Manual
selection always wins and disables re-detection until a new LUT is chosen.

| Output colorspace | Tokens | Suffix |
|---|---|---|
| Rec.709 (Gamma 2.4) | `rec709` `bt709` `r709` `709` `rec1886` `bt1886` | `_Rec709` |
| sRGB | `srgb` | `_sRGB` |
| P3-D65 | `p3` `p3d65` `displayp3` `dp3` | `_P3D65` |
| Rec.2020 | `rec2020` `bt2020` `r2020` `2020` | `_Rec2020` |
| Rec.2100 PQ | `pq` `st2084` `2084` `rec2100pq` (+ gamut defaulting) | `_Rec2100PQ` |
| Rec.2100 HLG | `hlg` `rec2100hlg` `stdb67` | `_Rec2100HLG` |
| P3-D65 PQ | `p3pq` `p3d65pq`, or `p3` + any PQ token | `_P3D65PQ` |

Compound tokens split at letter/digit boundaries (`2020pq` → `2020` + `pq`)
and adjacent tokens join (`Rec.709` → `rec709`), so dotted/underscored
variants all match.

## Output

- Formats/quality reuse FrameExtract's `OutputFormat` (Preserve original /
  JPEG / PNG / TIFF) and encoder.
- Default destination: a `ColorStamp` folder next to each source; collisions
  get `_1`, `_2`, … (FrameExtract convention — never overwrites).
- Source EXIF/TIFF metadata is carried over; a provenance note (LUT
  filename, output colorspace, DITools version) is written to EXIF
  UserComment and TIFF ImageDescription (ASCII only — ImageIO mangles
  non-ASCII UserComments).


## Watch Folder (auto ColorStamp)

Monitors a folder for new LiveGrade stills and exports them automatically
with the current LUT / colorspace / format / suffix settings.

**Behavior**

- Detection is event-driven (FSEvents, ~0.5 s coalescing, stream starts
  "now" so pre-existing files don't fire events — a file written in the
  final moments before enabling the watcher may still be detected, which
  the dedupe ledger renders harmless). The "Process every"
  interval only controls how often the accumulated queue is processed as
  one batch; if a batch is still running when the next tick fires, that
  tick is skipped — batches never overlap.
- Manual drag-and-drop exports stay usable while watching: both paths go
  through the shared `ColorStampBatchRunner` actor, so batches serialize
  (4-wide concurrency inside each batch) and collision numbering stays
  race-free.
- Settings/LUT changes mid-watch apply from the next batch onward and are
  noted in the activity log.
- "Process existing files on start" (default off) sweeps the folder once
  when the watcher starts; otherwise only new files are picked up.
- On launch, watching never silently resumes: if it was enabled, the UI
  shows "Ready to watch …" with a Resume button unless "Resume watching
  on launch" is opted into.
- A failed file is logged and not retried this session (unless it changes
  on disk); the watcher keeps running. If the watch folder disappears
  (unmount, delete), the watcher stops gracefully with an Error status.

**Stability check**

A file is eligible only when its size is non-zero AND (size, mtime) are
unchanged across two consecutive checks at least 1 second apart — files
LiveGrade is still writing stay pending and are re-checked next tick.
Frequent re-checks compare against the FIRST sighting, so checking often
cannot postpone eligibility.

**Dedupe ledger**

`~/Library/Application Support/DITools/ColorStamp/ledger-<sha256[0..16]
of watch-folder path>.json` — a JSON array of
`{record: {path, size, modifiedMs}, addedAt}` entries, oldest first,
capped at 10,000 with oldest-first eviction. Identity is path + size +
mtime, so a genuinely re-exported still with the same name IS processed
again. "Reset processed history" deletes the file (with confirmation).

**Limitations**

- The app is not sandboxed, so the watch folder persists as a plain path
  (no security-scoped bookmark needed).
- FSEvents on network volumes can be unreliable; local folders are the
  supported case.
- Watching continues while other modules are in front (by design); it
  stops on toggle-off or app quit.

## Known limitations

- **DPX and EXR are out of scope for v1** — they list as "Unsupported
  format" per file and are skipped, never failing the batch.
- **8-bit JPEG + PQ/HLG** warns (non-blocking, in the UI and the per-file
  result): banding is expected; use TIFF or PNG.
- 3D cubes larger than **128³** are rejected (Core Image's `CIColorCube`
  ceiling); real-world LUTs are 17/33/65.
- Auto-detection is a heuristic: an *inverse* LUT named `Rec709_to_LogC`
  has no recognizable output token on the right side and will fall back to
  matching anywhere in the name. The Auto/Manual badge makes this visible.
- EXIF orientation is preserved as a tag but not applied (same policy as
  the Extract mode — geometry passes through untouched).
- Folder imports scan one level deep, matching LiveGrade's export layout.
