Skip to content

feat(sidecar): version and publish the .rrdata format (v1) - #1423

Draft
magnusrodseth wants to merge 2 commits into
CyberTimon:mainfrom
magnusrodseth:feat/sidecar-schema-v1
Draft

feat(sidecar): version and publish the .rrdata format (v1)#1423
magnusrodseth wants to merge 2 commits into
CyberTimon:mainfrom
magnusrodseth:feat/sidecar-schema-v1

Conversation

@magnusrodseth

Copy link
Copy Markdown
Contributor

Phase 1 of #1421. Stacked on #1422 and contains its commit, so review that one first; this diff shrinks to just the format work once it merges.

Third-party tooling is already written against .rrdata, which makes it a public API whether or not it is documented as one. This publishes the contract and gives the version marker a meaning.

Your three open questions, answered with what I found

3. schemaVersion in .rrdata? Better than I proposed: it is already there. version has been written into every sidecar RapidRAW has ever saved, always as 1, and is read by nothing. Rather than adding a second field beside a vestigial one, this makes the existing field normative. Every file already on disk is stamped and negotiable, and there is no migration.

2. Committed or generated? Committed, and this turned out to be a fact rather than a preference. Value ranges exist in neither type system: Adjustments is [index: string]: any in TypeScript and an opaque serde_json::Value in Rust, and the bounds live only in JSX slider props (min={-100} max={100}). A generator emits the one thing integrators can already infer (the shape) and omits the one thing they cannot (the ranges). I extracted all 89 sliders programmatically to build the schema.

1. The local socket? Not touched here. That question only gates Phase 0, which I have deliberately not built pending your answer.

What is in it

  • schema/rrdata-v1.schema.json, draft-07 for the widest third-party validator support, plus three worked examples CI validates.
  • docs/sidecar-format.md for what a schema cannot express: version negotiation, round-trip guarantees, tag namespacing, and preset merge semantics.
  • scripts/check-schema-drift.mjs, wired into CI as a hard failure.

additionalProperties stays true. Rejecting unknown keys would contradict the preservation guarantee, so the schema would be describing a format that does not exist. It is strict on every key it does know, so a wrong type or an out-of-range value is caught while a forward-compatible addition is not.

Two things worth your call

centré. The detail-centring key genuinely ends in U+00E9, and it is load-bearing through TypeScript, Rust (pub centré: f32) and every sidecar on disk. Publishing v1 freezes it as public API. I documented it as normative rather than quietly renaming it, since a rename needs a migration and is your decision, not something to smuggle into a docs PR. Flagged as a v2 candidate.

Drift is real, so it is guarded. A hand-written schema can fall behind the code. The drift check fails CI when the schema and INITIAL_ADJUSTMENTS disagree about which keys exist, and asserts the schema still rejects malformed documents and still accepts unknown ones. I verified it has teeth by mutating the schema three ways (drop a key, invent a key, loosen a constraint); each was caught.

Things the docs pin down that surprised me

  • Applying a preset is a single one-level spread, so a preset carrying hsl replaces all 8 bands times 3 properties rather than merging into them. Documented with a worked example, since this silently breaks generated presets.
  • hue means three differently-scaled things: -180..180 globally, -100..100 per HSL band, 0..360 on a colour-grading wheel.
  • whiteLevel is -100..0 while blackLevel is 0..100.
  • lumaNoiseReduction/colorNoiseReduction are 0..100 globally but -100..100 inside a mask.
  • tags is a namespaced flat list (color:, user:, bare for AI tags).

Checks

cargo fmt, cargo clippy -D warnings, cargo test (11), npm run schema:check, eslint and prettier all pass.


Draft, as I would rather agree the shape than have you review a large diff you did not ask for. Happy to change any of it, or to land #1422 alone and drop this.

`load_sidecar` returns `ImageMetadata::default()` on any parse failure, and
sidecar writes are read-modify-write on top of it:

    let mut metadata = load_sidecar(&sidecar_path);   // -> defaults
    metadata.adjustments = final_adjustments;
    fs::write(&sidecar_path, to_string_pretty(&metadata)?)?;

So a sidecar that fails to parse for any reason, a truncated write, a
half-synced file, a hand-edited one, does not merely fail to load. The next
save writes the defaults back over it, and the image's rating, tags and
cached EXIF are gone with no warning. `save_metadata_and_update_thumbnail`
and `set_color_label_for_paths` both take this path.

An unreadable sidecar is now copied to `<name>.rrdata.bak` before anything
can overwrite it, with a warning naming both paths. The app still continues
with defaults, so one bad file cannot make an image uneditable, and the
behaviour for every sidecar that parses is unchanged.

Quarantine never destroys data. A backup whose contents differ is never
overwritten; the next free slot is used instead. An identical backup is a
no-op, so the repeated loads performed by the thumbnail and metadata workers
cannot pile up copies.

Also preserves unknown top-level keys. `adjustments` is an opaque Value and
already round-tripped anything it did not recognise, but unknown envelope
keys were dropped on every save, so external tooling could not annotate a
sidecar without the GUI silently stripping it. ImageMetadata now carries
them through with #[serde(flatten)].

Adds the repo's first tests (9, covering both quarantine paths and the round
trip) and a `cargo test` job to run them.
Third-party tooling is already written against .rrdata, which makes it a
public API whether or not it is documented as one. This publishes the
contract and gives the version marker a meaning.

Refs CyberTimon#1421. Stacked on the data-loss fix in the preceding commit, which
introduces the quarantine mechanism this reuses for version mismatches.

Versioning
  `version` has been written into every sidecar RapidRAW has ever saved,
  always as 1, and is read by nothing. Rather than adding a second
  `schemaVersion` field beside it, this makes the existing field normative.
  Every file already on disk is therefore stamped and negotiable, and no
  migration is needed. A sidecar declaring a higher version is quarantined
  rather than parsed as if it were v1.

Schema
  schema/rrdata-v1.schema.json, hand-written in draft-07 for the widest
  third-party validator support, plus three worked examples that CI
  validates.

  Hand-written rather than generated because value ranges exist in neither
  type system: Adjustments is `[index: string]: any` in TypeScript and an
  opaque serde_json::Value in Rust, and the bounds live only in JSX slider
  props. A generator would emit the one thing integrators can already infer
  (the shape) and omit the one thing they cannot (the ranges).

  additionalProperties stays true. Rejecting unknown keys would contradict
  the preservation guarantee, so the schema would be describing a format
  that does not exist. It is strict on every key it does know, so a wrong
  type or an out-of-range value is caught while a forward-compatible
  addition is not.

Docs
  docs/sidecar-format.md covers what a schema cannot: version negotiation,
  what survives a round trip, tag namespacing, and preset merge semantics.
  Applying a preset is a single one-level spread, so a preset carrying
  `hsl` replaces all eight bands times three properties rather than merging
  into them. That trap is documented with a worked example, along with the
  three differently-scaled meanings of `hue` and the asymmetric parametric
  whiteLevel/blackLevel ranges.

  `centré` is documented as normative in v1. The accented key is historical
  and load-bearing through TypeScript, Rust and every sidecar on disk; it is
  frozen here rather than quietly renamed, and flagged as a v2 candidate.

Drift protection
  A hand-written schema can fall behind the code it describes.
  scripts/check-schema-drift.mjs fails CI when the schema and
  INITIAL_ADJUSTMENTS disagree about which keys exist, and asserts the
  schema still rejects malformed documents and still accepts unknown ones.
  Wired into lint.yml as a hard failure.
@magnusrodseth
magnusrodseth force-pushed the feat/sidecar-schema-v1 branch from 8e65f99 to 45d0313 Compare July 25, 2026 16:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant