feat(kernel): ImageProcessor — Pillow decoding, oriented dimensions, pinned thumbnails (#16) - #88
Merged
Merged
Conversation
…pinned thumbnails (#16) Replaces the `MediaProcessor` placeholder with `ImageProcessor`, the first of the two media protocols, plus a Pillow adapter behind it and the fourth port on the composition point. - `domain/media.py`: `ImageFormat` (jpeg, png) and a frozen `ImageMetadata`. Dimensions are as-displayed — EXIF orientation is applied before they are reported — and the bytes decide the format, not the filename. - `ports/image_processor.py` (renamed from `media_processor.py`): `probe` and `thumbnail` as separate calls, `DEFAULT_THUMBNAIL_MAX_EDGE` and `THUMBNAIL_FORMAT`. Two protocols rather than one, so the ffmpeg adapter is never made to declare thumbnails. - `adapters/pillow_image_processor.py`: validation *is* the decode, never a header sniff and never `verify()`. Pillow's process-wide globals are left alone. - `errors.py`: `MediaError` with `name`/`reason`, split by remedy into `UnsupportedMedia` and `CorruptMedia` so a per-file report can separate operator noise from data loss. - `WorkspaceService` gains `image_processor_factory` on `init`/`open` and a `workspace.image_processor` property, the way the event bus arrived. - Thumbnails are JPEG with a pinned encoder, never enlarged, composited onto white and built on a fresh canvas so no source metadata reaches the bytes. Determinism is asserted as repeatability, never as a hardcoded hash. - Fixtures gain `write_image_in_unsupported_format` and `write_multi_picture_jpeg` so the "decodable but declined" and MPO branches are covered by generated media. No migration: `FORMAT_VERSION` stays 6 and no `Asset` field is added. Closes #16
JArmandoAnaya
added a commit
that referenced
this pull request
Aug 21, 2026
…pinned thumbnails (#16) (#88) Replaces the `MediaProcessor` placeholder with `ImageProcessor`, the first of the two media protocols, plus a Pillow adapter behind it and the fourth port on the composition point. - `domain/media.py`: `ImageFormat` (jpeg, png) and a frozen `ImageMetadata`. Dimensions are as-displayed — EXIF orientation is applied before they are reported — and the bytes decide the format, not the filename. - `ports/image_processor.py` (renamed from `media_processor.py`): `probe` and `thumbnail` as separate calls, `DEFAULT_THUMBNAIL_MAX_EDGE` and `THUMBNAIL_FORMAT`. Two protocols rather than one, so the ffmpeg adapter is never made to declare thumbnails. - `adapters/pillow_image_processor.py`: validation *is* the decode, never a header sniff and never `verify()`. Pillow's process-wide globals are left alone. - `errors.py`: `MediaError` with `name`/`reason`, split by remedy into `UnsupportedMedia` and `CorruptMedia` so a per-file report can separate operator noise from data loss. - `WorkspaceService` gains `image_processor_factory` on `init`/`open` and a `workspace.image_processor` property, the way the event bus arrived. - Thumbnails are JPEG with a pinned encoder, never enlarged, composited onto white and built on a fresh canvas so no source metadata reaches the bytes. Determinism is asserted as repeatability, never as a hardcoded hash. - Fixtures gain `write_image_in_unsupported_format` and `write_multi_picture_jpeg` so the "decodable but declined" and MPO branches are covered by generated media. No migration: `FORMAT_VERSION` stays 6 and no `Asset` field is added. Closes #16
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.
Replaces the nine-line
MediaProcessorplaceholder withImageProcessor— the first of M2's two media protocols — a Pillow adapter behind it, and the fourth port on the composition point.Two protocols, not one
The placeholder promised
probe() -> Mapping[str, object]for all media. It is replaced byImageProcessorhere andVideoProcessorin #17, each declared by the task that implements it: one shared protocol would force the ffmpeg adapter to declare thumbnails and the Pillow adapter to declare frame iteration — a runtime failure where a compile-time absence was available.probeandthumbnailare separate calls because the callers are different. #20 probes every file; #21 thumbnails a subset. One fused call would make the first pay for an encode it discards.Orientation is applied, not reported
ImageMetadata.width/.heightare as-displayed: a 32×24 JPEG tagged EXIF orientation 6 probes as 24×32, and its thumbnail comes out 24×32 to match. There is deliberately noorientation_appliedflag — a caller that could branch on it would be one who was handed the un-normalized case after all. The policy is format-independent; PNG carries EXIF in aneXIfchunk and is swept alongside JPEG.Refusals, split by remedy
MediaErroris the only error in the kernel with a constructor, carryingnameandreasonso a per-file report is a table rather than a list of sentences (reasonnever repeats the name). Two children, not one and not three:UnsupportedMedia— not an image, a format outsideImageFormat, or a decompression bomb. Remedy: filter, convert, or ask for the format.CorruptMedia— an accepted format whose bytes will not decode. Remedy: re-fetch the file.Collapsed into one, an ingest summary cannot separate operator noise from data loss. A third (
ThumbnailFailed) would have no independent cause.Thumbnails
Always JPEG with a pinned encoder (quality 85, 4:4:4, no Huffman search, no progressive), never enlarged, aspect preserved, transparency composited onto white, and built on a fresh canvas so no ICC profile or JFIF density from the source can become an input to the bytes. Determinism holds within one Pillow/libjpeg build, so the tests assert repeatability, never a hardcoded hash — the rule
tests/fixtures/media.pyalready states for ffmpeg.docs/media.mdspells out that a thumbnail hash is a cache key, not an identity, which is a constraint on #21.Decoder discipline
Validation is the decode — a file is accepted only once its pixels have come out, because a dataset that admits an asset on a convincing header discovers the truth during a training run.
Image.verify()is never called (it produces no pixels and leaves a laterload()raisingAssertionError).UnidentifiedImageErroris caught beforeOSError, which it subclasses;DecompressionBombErrorgets its own clause because it is not anOSError. Pillow's process-wide globals —MAX_IMAGE_PIXELS,LOAD_TRUNCATED_IMAGES— are left exactly where Pillow put them.Composition
image_processor_factoryonWorkspaceService.init/openand aworkspace.image_processorproperty, the zero-argument shape the EventBus established in #13. No service below the composition point namesPillowImageProcessor.Fixtures
tests/fixtures/media.pystays the one door to test media, and gains two generators for branches that had none:write_image_in_unsupported_format(a valid BMP — decodable and still declined) andwrite_multi_picture_jpeg(an MPO, what phones write in burst and portrait modes, which the adapter accepts as a JPEG via an alias in its decoder table).Not in scope
No migration —
FORMAT_VERSIONstays 6,VERSIONstays0.0.1.dev0, and noAssetfield is added (#20 owns format/origin, #21 ownsthumbnail_hash). No blob write, no video, no route change.Checks
Closes #16