Extract EXIF and color facts from raster image files - #5702
Merged
Conversation
Contributor
jurgenwerk
approved these changes
Aug 6, 2026
lukemelia
force-pushed
the
cs-12230-shared-metadata-fields-image-exif
branch
from
August 6, 2026 16:17
302516e to
932b26a
Compare
Base automatically changed from
cs-12229-shared-format-shells-resource-primitives
to
main
August 6, 2026 16:17
lukemelia
force-pushed
the
cs-12230-shared-metadata-fields-image-exif
branch
from
August 6, 2026 16:17
932b26a to
def6afb
Compare
Contributor
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
This PR introduces a shared “raster image metadata” layer that extracts and persists EXIF and container-level pixel encoding facts (bit depth/channels/alpha/color space) across multiple image formats, with unit + acceptance coverage.
Changes:
- Add shared metadata FieldDefs (EXIF + color profile) and a
RasterImageDefsubclass that owns these fields. - Implement header-level color profile extractors for PNG/JPEG/GIF/WebP/AVIF and wire them into the corresponding
*Defextract paths (plus EXIF for JPEG). - Add extensive byte-level unit tests and new acceptance tests validating end-to-end persistence into
searchDocand file-meta.
Reviewed changes
Copilot reviewed 18 out of 18 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/host/tests/unit/image-metadata-extractor-test.ts | New byte-level unit coverage for EXIF and per-format color-profile readers plus attribute assembly. |
| packages/host/tests/acceptance/image-def/png-image-def-test.gts | Acceptance test asserting PNG IHDR-derived color profile is persisted into searchDoc. |
| packages/host/tests/acceptance/image-def/jpg-image-def-test.gts | Acceptance tests asserting EXIF + JPEG color facts survive extract → index → file-meta. |
| packages/base/webp-meta-extractor.ts | Add WebP color profile extraction (alpha + optional ICC presence). |
| packages/base/webp-image-def.gts | Migrate WebP def to RasterImageDef and persist color profile attribute. |
| packages/base/png-meta-extractor.ts | Add PNG IHDR color profile extraction. |
| packages/base/png-image-def.gts | Migrate PNG def to RasterImageDef and persist color profile attribute. |
| packages/base/jpg-meta-extractor.ts | Add JPEG frame info + color profile extraction. |
| packages/base/jpg-image-def.gts | Migrate JPEG def to RasterImageDef and persist EXIF + color profile attributes. |
| packages/base/image-file-def.gts | Introduce RasterImageDef fields and rasterImageAttributes() assembly helper. |
| packages/base/image-color-profile.ts | New shared ImageColorProfile shape + pruning helper. |
| packages/base/gif-meta-extractor.ts | Add GIF header-derived color profile extraction. |
| packages/base/gif-image-def.gts | Migrate GIF def to RasterImageDef and persist color profile attribute. |
| packages/base/file-formats/metadata-fields.gts | New shared FieldDefs/vocabularies for EXIF + color profile UI/persistence. |
| packages/base/file-formats/index.ts | Export the new metadata FieldDefs via the barrel. |
| packages/base/exif-meta-extractor.ts | New portable EXIF parser + JPEG APP1 scanner. |
| packages/base/avif-meta-extractor.ts | Add AVIF color profile extraction from item properties (pixi/colr/auxC). |
| packages/base/avif-image-def.gts | Migrate AVIF def to RasterImageDef and persist color profile attribute. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
lukemelia
force-pushed
the
cs-12230-shared-metadata-fields-image-exif
branch
from
August 6, 2026 22:45
ff6256c to
def6afb
Compare
lukemelia
force-pushed
the
cs-12230-shared-metadata-fields-image-exif
branch
from
August 7, 2026 21:11
fae5f6b to
2bf4e42
Compare
The image FileDefs recorded width and height and nothing else, so a photograph in a realm indexed as anonymously as a generated placeholder — no camera, no capture time, no bit depth, no answer to "which of these images have an alpha channel". This adds the shared metadata FieldDefs every file family will write into, and populates the image family's own from bytes the extract pass already reads. `file-formats/metadata-fields.gts` holds the shapes: two primitives (`QuantityField`, `CodedValueField`) and the image family's `CameraCaptureField`, `GeoLocationField`, `ColorProfileField`, and `ExifMetadataField`. One shape per metadata *family* rather than per extension, so "where was this taken" is one question whether the answer came from a JPEG's GPS IFD or, later, a video container. `exif-meta-extractor.ts` reads a TIFF/EXIF block with nothing but `DataView` and `TextDecoder`. The unit of work is the block, not the JPEG, because EXIF is a TIFF structure several containers embed verbatim — so PNG's `eXIf` chunk and WebP's `EXIF` chunk can reuse `parseExifTiffBlock` as they land, and only `extractExifFromJpeg` knows about APP1 framing. Color facts come from each container's own header, added to the `*-meta-extractor` that already owns that format: PNG's IHDR, JPEG's frame header, GIF's logical screen descriptor, WebP's per-flavor alpha bit, and AVIF's `pixi`/`colr`/`auxC` item properties. Two judgment calls worth flagging: The handoff nests `colorProfile` inside EXIF. It's a sibling here instead. EXIF's `ColorSpace` tag is one weak signal, while the container states bit depth, channels, and alpha outright — nesting both would mean two `colorProfile` blocks disagreeing about one file. The container wins and the EXIF tag is folded in as a color-space fallback, normalized onto the same vocabulary so the persisted value doesn't depend on which container it arrived in. Absence stays distinguishable from ignorance. A GIF's transparency is declared in a Graphic Control Extension well past the header this pass reads, so `hasAlpha` is left unset rather than reported `false`; JPEG, which has no alpha in any mode, reports `false` outright. Same for an unrecognized code, an out-of-range coordinate, and a lone latitude — dropped, not guessed at or clamped. The fields attach to a new `RasterImageDef` rather than to `ImageDef` in `card-api`, which every card in every realm has in its dependency graph. Only realms holding image files pay for the metadata modules. `SvgDef` stays on `ImageDef`: a vector has no bit depth to record. Every payload read is bounds-checked and returns undefined rather than throwing, because these parsers run during indexing against whatever bytes a realm happens to hold. A truncated file yields partial metadata; it does not fail the extract. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ributes `createLocalFileDef` built the typed FileDef with `new FileDefKlass(attributes)`, which assigns every extracted attribute through the field setter. That rejects a composite field's serialized (plain-object) value, so a local raster image whose extraction now yields `colorProfile` / `exif` threw: field validation error: tried set instance of Object as field 'colorProfile' but it is not an instance of ColorProfileField Build the instance through `createFromSerialized` instead, which deserializes the nested objects into their FieldDef instances. No store is passed, so it stays a standalone instance as before. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Allocating a TextDecoder per auxC box while scanning is wasteful when the reader runs over many files during indexing. Hoist a single module-level decoder, the same pattern the EXIF extractor already uses. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
lukemelia
force-pushed
the
cs-12230-shared-metadata-fields-image-exif
branch
from
August 9, 2026 02:58
2bf4e42 to
017c362
Compare
A PNG truecolor IHDR (color types 2/6) and a JPEG frame header with 3 or
4 components fix the channel count but not the colorimetry: sRGB versus
Display P3, or RGB versus YCbCr and CMYK versus YCCK, is stated in a
later PNG chunk or an Adobe APP14 marker that these header-only reads
never touch. Leave `colorSpace` unset in those cases rather than assert
`srgb`/`ycbcr`/`cmyk` from the count alone; `channels` still records the
model. Grayscale and indexed, which the header does establish, are kept.
For an EXIF-bearing JPEG this lets the EXIF `ColorSpace` fallback supply
the value the container declines to guess.
Also surface `hasAlpha` in the ColorProfileField embedded template via a
computed label, so a persisted `false` ("no alpha channel") is visible
and distinguishable from an unset value rather than silently hidden.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Now scoped to the image family only. Audio, MIDI, and the extraction-efficiency work that had accumulated here moved to #5714, which stacks on this; video is #5713 behind that.
First slice of CS-12230, covering the metadata half of CS-12237.
The gap
The image FileDefs recorded
widthandheightand nothing else. A photograph in a realm indexed as anonymously as a generated placeholder — no camera, no capture time, no bit depth, and no way to answer "which of these images have an alpha channel".What's here
file-formats/metadata-fields.gts— the shared shapes. Two primitives (QuantityField,CodedValueField) plusCameraCaptureField,GeoLocationField,ColorProfileField, andExifMetadataField. One shape per metadata family rather than per extension, so "where was this taken" stays one question whether the answer came from a JPEG's GPS IFD or, later, a video container. Later slices append their own shapes here.exif-meta-extractor.ts— a TIFF/EXIF reader using nothing butDataViewandTextDecoder. The unit of work is deliberately the TIFF block, not the JPEG: EXIF is a TIFF structure several containers embed verbatim, so PNG'seXIfchunk and WebP'sEXIFchunk can reuseparseExifTiffBlockas they land, and onlyextractExifFromJpegknows about APP1 framing.Color facts per container, added to the
*-meta-extractorthat already owns each format: PNG's IHDR, JPEG's frame header, GIF's logical screen descriptor, WebP's per-flavor alpha bit, and AVIF'spixi/colr/auxCitem properties.Two judgment calls
colorProfileis a sibling ofexif, not nested inside it. The handoff nests it. EXIF'sColorSpacetag is one weak signal, while the container states bit depth, channels, and alpha outright — nesting both would mean twocolorProfileblocks disagreeing about one file. The container wins, and the EXIF tag is folded in as a color-space fallback, normalized onto the same vocabulary so the persisted value doesn't depend on which container it arrived in.Absence stays distinguishable from ignorance. A GIF's transparency is declared in a Graphic Control Extension well past the header this pass reads, so
hasAlphais left unset rather than reportedfalse. JPEG, which has no alpha in any mode, reportsfalseoutright. Same discipline for an unrecognized code, an out-of-range coordinate, and a lone latitude with no longitude — dropped, not guessed at or clamped onto the prime meridian.Dependency graph
The fields attach to a new
RasterImageDef, not toImageDefincard-api— which every card in every realm carries in its dependency graph. Only realms holding image files pay for the metadata modules.SvgDefstays onImageDef: a vector has no bit depth to record. Therealm indexingdependency assertions #5701 added as the guard on graph growth are unchanged and passing.This does add one level to the image prototype chain, so image file rows gain a
Raster Imageentry intypesanddisplay_names. Additive —on:/type:filters againstImageDefstill match.Robustness
These parsers run during indexing against whatever bytes a realm happens to hold, so every payload read is bounds-checked and returns
undefinedrather than throwing. A truncated file yields partial metadata; it does not fail the extract. Directory entry counts are capped, and there's no recursion, so a malformed pointer can't loop.Testing
unit/image-metadata-extractor-test.ts— 44 tests / 79 assertions over hand-assembled byte fixtures: both TIFF byte orders, GPS sign and altitude reference, the EXIF 2.3 ISO tag rename, timestamp conversion with and without a recorded UTC offset, XMP-vs-EXIF APP1 discrimination, and the malformed cases (truncated heap, garbage byte-order marker, implausible entry count, illegal PNG color type).acceptance/image-def/jpg-image-def-test.gts— an EXIF-bearing JPEG proves a value three levels deep (exif.capture.exposureTime.displayText) survives extract →search_doc→ file-meta, and that a JPEG without EXIF gains no emptyexifobject.acceptance/image-def/png-image-def-test.gts— IHDR color profile end-to-end.ember-tscfor host, ai-bot, bot-runner, and billing;packages/base's own lint; and the fullrealm indexingmodule all pass.The 6
authenticated images display in browserfailures in the image-def suites are pre-existing — they reproduce identically on a stashed baseline, including insvg image def, which this PR doesn't touch.One thing to know
packages/baseis template-lint-only in CI (itslintscript runsember-template-lint, and that passes). The pre-commit hook additionally runs the root eslint config over staged base files, where every base.gtsalready fails to parse on<template>.image-file-def.gtsis now the only base.gtswith decorators and no template, so it parses and trips the erasable-syntax decorator rule — a rule aimed at code Node runs under--experimental-strip-types, which base card modules never are. Non-blocking and CI-green; the real fix is scoping that rule away from card realms, which belongs in a config change rather than here.🤖 Generated with Claude Code