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.