From 961ed440550bb4b65cafba884285e62b911b2e7b Mon Sep 17 00:00:00 2001 From: FragginWagon Date: Tue, 8 Sep 2026 17:40:07 -0400 Subject: [PATCH] feat: add image derivative orchestration Define thumbnail, medium, and large WebP derivative variants behind an injected renderer and write them through ImageStorage with deterministic keys. Add focused tests and document the remaining worker-backed renderer. Verification: npm test passed 70 tests and integration passed 14 tests. No build script is configured. --- .../images/image-derivative-processor.js | 32 +++++++++++++++ .../image-derivative-processor.test.js | 39 +++++++++++++++++++ docs/IMAGES.md | 5 +++ docs/IMPLEMENTATION-STATUS.md | 5 ++- 4 files changed, 79 insertions(+), 2 deletions(-) create mode 100644 apps/api/src/infrastructure/images/image-derivative-processor.js create mode 100644 apps/api/tests/application/image-derivative-processor.test.js diff --git a/apps/api/src/infrastructure/images/image-derivative-processor.js b/apps/api/src/infrastructure/images/image-derivative-processor.js new file mode 100644 index 0000000..20b2384 --- /dev/null +++ b/apps/api/src/infrastructure/images/image-derivative-processor.js @@ -0,0 +1,32 @@ +const variants = Object.freeze([ + { name: 'thumbnail', maxWidth: 320 }, + { name: 'medium', maxWidth: 1280 }, + { name: 'large', maxWidth: 2400 }, +]); + +function derivativeKey(sourceKey, variantName) { + const extensionIndex = sourceKey.lastIndexOf('.'); + const base = extensionIndex === -1 ? sourceKey : sourceKey.slice(0, extensionIndex); + return `${base}-${variantName}.webp`; +} + +export function createImageDerivativeProcessor({ imageStorage, renderer }) { + if (typeof renderer !== 'function') { + throw new TypeError('Image derivative renderer is required.'); + } + + return { + async process({ sourceKey, data }) { + const results = []; + + for (const variant of variants) { + const derivativeData = await renderer({ data, maxWidth: variant.maxWidth }); + const key = derivativeKey(sourceKey, variant.name); + await imageStorage.put({ key, data: derivativeData }); + results.push({ name: variant.name, key, maxWidth: variant.maxWidth }); + } + + return results; + }, + }; +} \ No newline at end of file diff --git a/apps/api/tests/application/image-derivative-processor.test.js b/apps/api/tests/application/image-derivative-processor.test.js new file mode 100644 index 0000000..bfa5eaa --- /dev/null +++ b/apps/api/tests/application/image-derivative-processor.test.js @@ -0,0 +1,39 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { createImageDerivativeProcessor } from '../../src/infrastructure/images/image-derivative-processor.js'; + +test('renders and stores thumbnail, medium, and large derivatives in order', async () => { + const renderedWidths = []; + const stored = []; + const processor = createImageDerivativeProcessor({ + imageStorage: { + async put(value) { stored.push(value); }, + }, + async renderer({ data, maxWidth }) { + renderedWidths.push(maxWidth); + return Buffer.concat([data, Buffer.from(`-${maxWidth}`)]); + }, + }); + + const results = await processor.process({ + sourceKey: 'inventory/item/front.jpg', + data: Buffer.from('source'), + }); + + assert.deepEqual(renderedWidths, [320, 1280, 2400]); + assert.deepEqual(results.map(({ name, key }) => ({ name, key })), [ + { name: 'thumbnail', key: 'inventory/item/front-thumbnail.webp' }, + { name: 'medium', key: 'inventory/item/front-medium.webp' }, + { name: 'large', key: 'inventory/item/front-large.webp' }, + ]); + assert.equal(stored.length, 3); + assert.equal(stored[1].data.toString(), 'source-1280'); +}); + +test('requires a renderer implementation', () => { + assert.throws( + () => createImageDerivativeProcessor({ imageStorage: {} }), + /renderer is required/, + ); +}); \ No newline at end of file diff --git a/docs/IMAGES.md b/docs/IMAGES.md index 11288d6..9a59bd7 100644 --- a/docs/IMAGES.md +++ b/docs/IMAGES.md @@ -68,6 +68,11 @@ Modern formats such as WebP/AVIF may be used where appropriate. Retain originals where practical. +Derivative orchestration currently defines thumbnail, medium, and large WebP +variants and writes them through `ImageStorage` using an injected renderer. +The concrete decoder/encoder and worker queue remain separate infrastructure +work so local development does not depend on a machine-specific binary. + ## Metadata Store: diff --git a/docs/IMPLEMENTATION-STATUS.md b/docs/IMPLEMENTATION-STATUS.md index 41593ae..d5aff5e 100644 --- a/docs/IMPLEMENTATION-STATUS.md +++ b/docs/IMPLEMENTATION-STATUS.md @@ -91,6 +91,7 @@ docker context use orbstack - Listing reads are seller-scoped and return only admin listing DTO fields. - Item-owned image metadata can be added through a seller-scoped, CSRF-protected admin route; publication uses the persisted image count. - `ImageStorage`, traversal-safe local storage, multipart upload transport, and binary signature validation are implemented; derivative processing remains. +- Derivative orchestration now defines thumbnail, medium, and large WebP storage outputs behind an injected renderer; the concrete worker-backed renderer remains. - Image metadata limits are enforced in the service boundary: supported MIME types, 10 MiB default file size, and 10,000-pixel maximum dimensions. ### Verified Baseline @@ -102,13 +103,13 @@ docker context use orbstack | HTTP | Product and inventory creation, validation, authentication/recovery, CSRF, rate limiting, and session management | | Integration | PostgreSQL catalog/session/audit/password recovery persistence and Redis-shared login throttling | -Latest result: `npm test` passed 68 tests; `npm run test:integration` passed 14 tests against OrbStack PostgreSQL and Redis. +Latest result: `npm test` passed 70 tests; `npm run test:integration` passed 14 tests against OrbStack PostgreSQL and Redis. ## Next Milestone Complete the inventory/listing integrity boundary before starting pricing and acquisition. -1. Add processing/derivative lifecycle for persisted image metadata. +1. Add a portable decoder/encoder and worker-backed derivative processing lifecycle. 2. Begin acquisition and pricing foundations after the listing/image boundary is complete. Acceptance criteria: listing reads and lifecycle writes preserve seller ownership, CSRF, inventory status, and publication prerequisites.