From 20bd4032438d170170039c89aa397888688e9f01 Mon Sep 17 00:00:00 2001 From: Dev Talan <84081651+devchaudhary24k@users.noreply.github.com> Date: Wed, 4 Nov 2026 01:08:46 +0530 Subject: [PATCH] feat: add node batches 2 and 3 with their templates - Add the 20 remaining catalogue nodes: Deduplicate, Auto-trim, Pad / Extend, Pixel-art upscale, Split / Tile, Adjust, Filters, Sharpen / Blur, Background, Text watermark, Image watermark, Border / Rounded corners, Set copyright / author, Best format, Responsive set, Favicon / App icons, Placeholder, Palette, Contact sheet and Images to PDF - Add the Watermark and compress, Instagram carousel and Responsive image set templates - Let combining nodes pass their inputs on by reference, so Deduplicate never holds the batch and kept items keep their step cache and source (ADR 0011) - Carry data and document items to Output and Rename; ICO files are documents (ADR 0012) - Show settings forms, summaries and icons for the new nodes in the Studio, and the text of data items in the inspector - Estimate work through Deduplicate, and refuse a save the browser has no room for instead of failing silently - Shard the node pair matrix so browsers run it in parallel --- CONTEXT.md | 10 +- ...ombining-nodes-pass-inputs-by-reference.md | 19 + docs/adr/0012-ico-files-are-documents.md | 7 + idea.md | 16 +- implementation.md | 31 +- .../engine/__tests__/estimate.test.ts | 12 + .../__tests__/pool-host.browser.test.ts | 85 ++ src/features/engine/__tests__/runner.test.ts | 101 ++ src/features/engine/constants.ts | 2 + src/features/engine/engine.worker.ts | 3 +- src/features/engine/estimate.ts | 29 +- src/features/engine/flow.ts | 130 +- src/features/engine/item-types.ts | 10 +- src/features/engine/opfs/run-stores.ts | 6 +- src/features/engine/step-cache.ts | 2 + src/features/engine/stores.ts | 12 +- src/features/engine/types.ts | 23 +- src/features/engine/worker-protocol.ts | 10 +- src/features/file-items/file-item.ts | 59 + src/features/file-items/ico.ts | 36 + src/features/file-items/pdf.ts | 123 ++ src/features/file-items/types.ts | 12 + src/features/images/blur.ts | 107 ++ src/features/images/blurhash.ts | 76 ++ src/features/images/colour.ts | 20 + src/features/images/compose.ts | 115 ++ src/features/images/fingerprint.ts | 86 ++ src/features/images/image-item.ts | 44 +- src/features/images/metadata/exif.ts | 38 + src/features/images/palette.ts | 95 ++ src/features/images/shape.ts | 138 ++ src/features/images/text.ts | 54 + src/features/images/tone.ts | 138 ++ .../__tests__/batch-three.browser.test.ts | 602 +++++++++ .../nodes/__tests__/batch-two.browser.test.ts | 664 ++++++++++ src/features/nodes/__tests__/harness.ts | 30 +- .../node-pair-matrix-1.browser.test.ts | 3 + .../node-pair-matrix-2.browser.test.ts | 3 + .../node-pair-matrix-3.browser.test.ts | 3 + .../node-pair-matrix-4.browser.test.ts | 3 + ...ix.browser.test.ts => node-pair-matrix.ts} | 78 +- src/features/nodes/__tests__/settings.test.ts | 55 + src/features/nodes/constants.ts | 15 +- src/features/nodes/definitions/adjust.ts | 39 + src/features/nodes/definitions/auto-trim.ts | 38 + src/features/nodes/definitions/background.ts | 34 + src/features/nodes/definitions/best-format.ts | 110 ++ src/features/nodes/definitions/border.ts | 76 ++ .../nodes/definitions/contact-sheet.ts | 176 +++ src/features/nodes/definitions/crop.ts | 21 +- src/features/nodes/definitions/deduplicate.ts | 90 ++ src/features/nodes/definitions/favicon.ts | 211 ++++ src/features/nodes/definitions/filters.ts | 44 + .../nodes/definitions/image-watermark.ts | 94 ++ .../nodes/definitions/images-to-pdf.ts | 135 ++ src/features/nodes/definitions/output.ts | 15 +- src/features/nodes/definitions/pad.ts | 105 ++ src/features/nodes/definitions/palette.ts | 53 + src/features/nodes/definitions/pixel-art.ts | 47 + src/features/nodes/definitions/placeholder.ts | 83 ++ src/features/nodes/definitions/rename.ts | 10 +- .../nodes/definitions/responsive-set.ts | 133 ++ .../nodes/definitions/set-copyright.ts | 51 + .../nodes/definitions/sharpen-blur.ts | 106 ++ src/features/nodes/definitions/split.ts | 81 ++ .../nodes/definitions/text-watermark.ts | 99 ++ src/features/nodes/registry.ts | 53 +- src/features/nodes/validators.ts | 11 + .../pipelines/__tests__/pipeline-file.test.ts | 28 +- .../pipelines/__tests__/storage.test.ts | 13 +- src/features/pipelines/storage.ts | 15 +- src/features/pipelines/templates.ts | 46 + .../__tests__/previewer.browser.test.ts | 27 + src/features/previews/preview.worker.ts | 18 +- src/features/previews/protocol.ts | 2 + .../__tests__/quick-tools.browser.test.ts | 91 +- src/features/quick-tools/settings-panels.tsx | 66 +- .../studio/__tests__/logo.browser.test.ts | 35 + .../studio/__tests__/node-summary.test.ts | 78 ++ src/features/studio/logo.ts | 63 + src/features/studio/node-editors.tsx | 1108 +++++++++++++++++ src/features/studio/node-inspector.tsx | 51 +- src/features/studio/node-settings.tsx | 36 +- src/features/studio/node-summary.ts | 131 ++ src/features/studio/node-ui.ts | 40 + src/features/studio/pipeline-node.tsx | 6 +- src/features/studio/settings-fields.tsx | 111 ++ src/features/studio/studio-page.tsx | 19 +- src/features/usage/__tests__/usage.test.ts | 25 + src/lib/base64.ts | 18 + 90 files changed, 6856 insertions(+), 191 deletions(-) create mode 100644 docs/adr/0011-combining-nodes-pass-inputs-by-reference.md create mode 100644 docs/adr/0012-ico-files-are-documents.md create mode 100644 src/features/file-items/file-item.ts create mode 100644 src/features/file-items/ico.ts create mode 100644 src/features/file-items/pdf.ts create mode 100644 src/features/file-items/types.ts create mode 100644 src/features/images/blur.ts create mode 100644 src/features/images/blurhash.ts create mode 100644 src/features/images/colour.ts create mode 100644 src/features/images/compose.ts create mode 100644 src/features/images/fingerprint.ts create mode 100644 src/features/images/palette.ts create mode 100644 src/features/images/shape.ts create mode 100644 src/features/images/text.ts create mode 100644 src/features/images/tone.ts create mode 100644 src/features/nodes/__tests__/batch-three.browser.test.ts create mode 100644 src/features/nodes/__tests__/batch-two.browser.test.ts create mode 100644 src/features/nodes/__tests__/node-pair-matrix-1.browser.test.ts create mode 100644 src/features/nodes/__tests__/node-pair-matrix-2.browser.test.ts create mode 100644 src/features/nodes/__tests__/node-pair-matrix-3.browser.test.ts create mode 100644 src/features/nodes/__tests__/node-pair-matrix-4.browser.test.ts rename src/features/nodes/__tests__/{node-pair-matrix.browser.test.ts => node-pair-matrix.ts} (61%) create mode 100644 src/features/nodes/__tests__/settings.test.ts create mode 100644 src/features/nodes/definitions/adjust.ts create mode 100644 src/features/nodes/definitions/auto-trim.ts create mode 100644 src/features/nodes/definitions/background.ts create mode 100644 src/features/nodes/definitions/best-format.ts create mode 100644 src/features/nodes/definitions/border.ts create mode 100644 src/features/nodes/definitions/contact-sheet.ts create mode 100644 src/features/nodes/definitions/deduplicate.ts create mode 100644 src/features/nodes/definitions/favicon.ts create mode 100644 src/features/nodes/definitions/filters.ts create mode 100644 src/features/nodes/definitions/image-watermark.ts create mode 100644 src/features/nodes/definitions/images-to-pdf.ts create mode 100644 src/features/nodes/definitions/pad.ts create mode 100644 src/features/nodes/definitions/palette.ts create mode 100644 src/features/nodes/definitions/pixel-art.ts create mode 100644 src/features/nodes/definitions/placeholder.ts create mode 100644 src/features/nodes/definitions/responsive-set.ts create mode 100644 src/features/nodes/definitions/set-copyright.ts create mode 100644 src/features/nodes/definitions/sharpen-blur.ts create mode 100644 src/features/nodes/definitions/split.ts create mode 100644 src/features/nodes/definitions/text-watermark.ts create mode 100644 src/features/nodes/validators.ts create mode 100644 src/features/studio/__tests__/logo.browser.test.ts create mode 100644 src/features/studio/logo.ts create mode 100644 src/features/studio/node-editors.tsx create mode 100644 src/features/studio/settings-fields.tsx create mode 100644 src/lib/base64.ts diff --git a/CONTEXT.md b/CONTEXT.md index 68d3f59..08516fe 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -63,9 +63,14 @@ One execution of a pipeline over a set of input items. _Avoid_: job, batch, execution **Item**: -One unit that flows through a pipeline: an image, data (JSON or text) or a document (PDF). +One unit that flows through a pipeline: an image, data (JSON or text) or a document (PDF or ICO). _Avoid_: file, asset +**Document**: +An item that is a finished file Hexlode makes but does not edit further, such as a PDF or an ICO +icon file. +_Avoid_: binary, attachment + **Accepts**: The kinds and formats of items a node can process. _Avoid_: supports, input types @@ -83,7 +88,8 @@ An item a node accepted but could not process. _Avoid_: errored, broken **Combining node**: -A node that waits for every upstream item and then runs once over all of them, such as Contact sheet. +A node that waits for every upstream item and then runs once over all of them, such as Contact +sheet, Images to PDF or Deduplicate. _Avoid_: gather node, aggregate node, merge node **Step cache**: diff --git a/docs/adr/0011-combining-nodes-pass-inputs-by-reference.md b/docs/adr/0011-combining-nodes-pass-inputs-by-reference.md new file mode 100644 index 0000000..1cc8e0d --- /dev/null +++ b/docs/adr/0011-combining-nodes-pass-inputs-by-reference.md @@ -0,0 +1,19 @@ +# Combining nodes pass their inputs on by reference + +Deduplicate must see every item before it can say which are duplicates, so it is a combining node, +yet it sends most items on unchanged. Instead of returning the items it read, a combining node +may return `{ port, input }`: the index of an item it received. The engine reloads that item from +where it waited, so the node never holds the batch in memory, and the item keeps its step cache +key and the source it came from, so later nodes reuse their cache and results still list the file. + +## Considered options + +- Returning the items themselves holds every kept item in memory at once, which grows with batch + size. +- Deduplicating per item would need a fingerprint store shared between workers, and items in + flight in other workers could still race past each other. + +## Consequences + +- Step cache entries record the input index of passed items, and a cache hit reloads them from the + waiting items of the current run, which every run writes before a combining node runs. diff --git a/docs/adr/0012-ico-files-are-documents.md b/docs/adr/0012-ico-files-are-documents.md new file mode 100644 index 0000000..1681d74 --- /dev/null +++ b/docs/adr/0012-ico-files-are-documents.md @@ -0,0 +1,7 @@ +# ICO files are documents + +Favicon / App icons makes `favicon.ico`. We treat ICO as a document format, beside PDF, rather +than an image format. Image formats are the ones Hexlode decodes and encodes, and every node that +takes images accepts all of them; an ICO image would reach Resize or Convert and fail. As a +document, an ICO file is a finished file that only Output and Rename take, and connections from +Favicon / App icons to image nodes narrow to the PNG icons. diff --git a/idea.md b/idea.md index 251a229..de6e82f 100644 --- a/idea.md +++ b/idea.md @@ -1,6 +1,6 @@ # Hexlode product -> Updated: 2026-09-28 (directed Studio scenes on the home page, footer credits and wordmark glow) +> Updated: 2026-11-04 (node batches 2 and 3) > Delivery plan: [implementation.md](./implementation.md). Vocabulary: [CONTEXT.md](./CONTEXT.md). > Decisions and their reasons: [docs/adr/](./docs/adr/). @@ -189,7 +189,7 @@ Items are images, data (JSON or text) or documents (PDF). Batch numbers match th | Files | Takes dropped files or a folder. Starts every pipeline. | 1 | | Filter | Routes items by rules on format, file size, dimensions, orientation or transparency. Each rule has its own output, plus an output for everything else. An item leaves by the first rule it matches. | 1 | | Inspect | Shows format, dimensions, size and metadata per item. Passes items through unchanged. | 1 | -| Deduplicate | Drops exact duplicates, or near duplicates by image fingerprint. | 2 | +| Deduplicate | Drops exact duplicates (the same pixels), or near duplicates by image fingerprint. Waits for every image; duplicates leave by their own output. | 2 | ### Size and shape @@ -225,7 +225,7 @@ Items are images, data (JSON or text) or documents (PDF). Batch numbers match th | Node | What it does | Batch | |---|---|---| | Strip metadata | Removes all metadata, only location data, or everything except copyright, without re-encoding. Keeps the colour profile unless told otherwise, and keeps a camera orientation tag so photos stay upright. | 1 | -| Set copyright / author | Writes author and copyright fields. | 3 | +| Set copyright / author | Writes author and copyright fields without re-encoding. Empty fields stay as they are. | 3 | ### Output and encoding @@ -238,12 +238,12 @@ Items are images, data (JSON or text) or documents (PDF). Batch numbers match th | Output | Saves items, passes them on, and delivers a ZIP or folder. | 1 | | Compare | Shows a before and after slider and the size difference. Passes items through. | 1 | | Best format | Encodes several formats and keeps the smallest. | 3 | -| Responsive set | Makes several widths and a ready `srcset` HTML snippet. | 3 | -| Favicon / App icons | Makes every favicon and app icon size from one image. | 3 | -| Placeholder | Generates a tiny blurred preview code for websites. | 3 | +| Responsive set | Makes several widths and a ready `srcset` HTML snippet. Widths larger than an image become its own width. | 3 | +| Favicon / App icons | Makes every favicon and app icon size from one image, an ICO file, a web app manifest and the HTML tags. | 3 | +| Placeholder | Generates a tiny blurred preview code for websites: a data URI or a BlurHash, with the size and average colour, as JSON. | 3 | | Palette | Extracts the main colours as JSON or CSS variables. | 3 | -| Contact sheet | Combines many images into one grid image. | 3 | -| Images to PDF | Combines images into one PDF. | 3 | +| Contact sheet | Combines many images into one grid image, with file names if wanted. Starts a new sheet when one is full. | 3 | +| Images to PDF | Combines images into one PDF, one page each. JPEG files go in as they are. | 3 | ## Saving and sharing pipelines diff --git a/implementation.md b/implementation.md index c424d1a..3f1c200 100644 --- a/implementation.md +++ b/implementation.md @@ -1,6 +1,6 @@ # Hexlode implementation plan -> Updated: 2026-09-27 +> Updated: 2026-11-04 > Product: [idea.md](./idea.md). Vocabulary: [CONTEXT.md](./CONTEXT.md). Decisions: [docs/adr/](./docs/adr/). ## Goal @@ -10,7 +10,7 @@ Studio with all 33 nodes from the catalogue in [idea.md](./idea.md). It ships in builds the whole application and node batch 1. Phases 2 and 3 only add node batches 2 and 3. Each phase is deployable and passes its exit gate before the next phase starts. -Active phase: **Phase 1**. +Active phase: **Phase 3**. Phases 2 and 3 are built and pass their exit gates. ## Phase 1: the application and node batch 1 @@ -87,6 +87,12 @@ Watermark and compress and Instagram carousel templates. Exit gate: each node has output tests on real fixtures, the node pair matrix passes with the new nodes, and the new templates run end to end. +Deduplicate is a combining node with two outputs, Unique and Duplicates, and passes items on by +reference ([ADR 0011](./docs/adr/0011-combining-nodes-pass-inputs-by-reference.md)). Image +watermark logos are kept in the pipeline itself, at most 512 KB; an SVG logo is drawn to a PNG +when it is chosen, because workers cannot draw SVG. Text is drawn with an OffscreenCanvas and +blended with the engine's own compositing. + ## Phase 3: node batch 3 Best format, Responsive set, Favicon / App icons, Placeholder, Palette, Contact sheet, Images to @@ -95,17 +101,24 @@ PDF, Set copyright / author. Adds the Responsive image set template. Exit gate: same as phase 2, plus tests for nodes that change the item count (one image to many, many images to one) and nodes that produce data or documents. +Output and Rename take every kind of item; the other nodes take images. Favicon / App icons makes +an ICO file, which is a document ([ADR 0012](./docs/adr/0012-ico-files-are-documents.md)), and puts +each image's icons in a folder named after it. Live previews show the text of data items, such as +a palette or a snippet, in the inspector. + ## Engine design - A pipeline is a directed graph without cycles. Items flow through it one by one, so an item can reach the last node while later items are still at the first node. -- Nodes that combine items (Contact sheet, Images to PDF, near-duplicate Deduplicate) wait until - every upstream item has arrived. +- Nodes that combine items (Contact sheet, Images to PDF, Deduplicate) wait until every upstream + item has arrived. A combining node that passes items on unchanged returns them by reference, so + it never holds the batch ([ADR 0011](./docs/adr/0011-combining-nodes-pass-inputs-by-reference.md)). - Decoding happens at most once per item, the first time a node needs pixels; nodes that only read headers or metadata (Filter by format, Inspect, Rename, Strip metadata) never decode. Nodes pass - pixels between them. Encoding happens at Convert, Compress to size and Optimize PNG. An image - that reaches Output without an encoding node keeps its source format, and its original bytes - when nothing changed its pixels. + pixels between them. Encoding happens at Convert, Compress to size and Optimize PNG, and at + the nodes that make finished files: Best format, Responsive set, Favicon / App icons and Contact + sheet. An image that reaches Output without an encoding node keeps its source format, and its + original bytes when nothing changed its pixels. - Metadata is rewritten in the container without re-encoding for JPEG, PNG, WebP and JPEG XL. AVIF metadata is read and can be removed in place but not added; QOI holds none. The run reports a warning when a format cannot keep metadata. @@ -139,7 +152,9 @@ removed. - Fixtures are small files in the repository, generated or with a licence that allows it, covering every input format, transparency, EXIF orientation, location metadata and a malformed file. - The node pair matrix connects every pair of node types and checks that the Studio's accept or - refuse decision matches what the engine does when it runs that pair. + refuse decision matches what the engine does when it runs that pair. It feeds one fixture per + format plus one different picture, so nodes that route by content send items down every output, + and runs as four shards that browsers run in parallel. - Worker, OPFS, codec and ZIP tests run in real browsers through Vitest browser mode. Locally they run in Chromium with `PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH`; `VITEST_BROWSERS=firefox` or `webkit` picks another engine. CI runs Chromium, Firefox and WebKit. Playwright's WebKit has no diff --git a/src/features/engine/__tests__/estimate.test.ts b/src/features/engine/__tests__/estimate.test.ts index 243cf75..ed9d1f3 100644 --- a/src/features/engine/__tests__/estimate.test.ts +++ b/src/features/engine/__tests__/estimate.test.ts @@ -45,6 +45,18 @@ describe('estimateRun', () => { expect(estimate).toMatchObject({ decodes: 4, encodes: 4 }) }) + it('follows items through Deduplicate, counting every item as unique', () => { + const estimate = estimateRun({ + pipeline: chain(['deduplicate'], ['convert', { format: 'webp' }]), + registry: productRegistry, + sources: sources(5), + workers: 1, + }) + // Deduplicate decodes each item for its fingerprint. The item waits without its pixels, so + // Convert decodes it again. + expect(estimate).toMatchObject({ decodes: 10, encodes: 5 }) + }) + it('counts items that will skip a branch', () => { const estimate = estimateRun({ pipeline: chain(['optimize-png']), diff --git a/src/features/engine/__tests__/pool-host.browser.test.ts b/src/features/engine/__tests__/pool-host.browser.test.ts index 681dde9..f105a43 100644 --- a/src/features/engine/__tests__/pool-host.browser.test.ts +++ b/src/features/engine/__tests__/pool-host.browser.test.ts @@ -89,6 +89,91 @@ describe('worker pool', () => { for (const name of await listNames(root)) await root.removeEntry(name, { recursive: true }) }) + it('passes the items Deduplicate keeps on from storage, and reuses them on the next run', async () => { + const root = await appDirectory() + const index = await createStepCacheIndex(root, 1024 ** 3) + const host = createWorkerPoolHost({ size: 2, createWorker, index }) + const pipeline = chain(['deduplicate', { mode: 'exact' }]) + // photo.png and photo.qoi have the same pixels; photo.jpg is lossy, so it differs. + const names = ['photo.png', 'photo.jpg', 'photo.qoi'] + // A new run deletes the last run's files, so each delivery is read before the next run. + const delivered = async (result: Awaited>['result']) => { + const entries = await unzip(result.deliveries[0].archive as Blob) + expect(entries.map(({ name }) => name)).toEqual(['photo.png', 'photo.jpg']) + expect(entries[0].bytes).toEqual(await fixtureBytes('photo.png')) + expect(entries[1].bytes).toEqual(await fixtureBytes('photo.jpg')) + } + const first = await run(pipeline, host, names) + expect(first.result.status).toBe('complete') + expect(first.statuses('deduplicate-1')).toEqual(['processed']) + await delivered(first.result) + const second = await run(pipeline, host, names) + expect(second.statuses('deduplicate-1')).toEqual(['cached']) + await delivered(second.result) + host.dispose() + }) + + it('delivers documents and data that nodes make in workers', async () => { + const host = createWorkerPoolHost({ size: 2, createWorker, index: null }) + const node = (id: string, type: string, settings = {}) => ({ + id, + type, + settings, + position: { x: 0, y: 0 }, + }) + const pipeline: Pipeline = { + nodes: [ + node('files', 'files'), + node('pdf', 'images-to-pdf', { name: 'album' }), + node('palette', 'palette', { count: 2 }), + node('icons', 'favicon'), + node('out-pdf', 'output'), + node('out-palette', 'output'), + node('out-icons', 'output'), + ], + connections: [ + ['files', 'pdf'], + ['files', 'palette'], + ['files', 'icons'], + ['pdf', 'out-pdf'], + ['palette', 'out-palette'], + ['icons', 'out-icons'], + ].map(([source, target]) => ({ + id: `${source}-${target}`, + source, + sourcePort: 'out', + target, + })), + } + const { result } = await run(pipeline, host, ['photo.jpg', 'photo.png']) + host.dispose() + expect(result.status).toBe('complete') + const delivered = async (nodeId: string) => { + const delivery = result.deliveries.find((entry) => entry.nodeId === nodeId) + return unzip(delivery?.archive as Blob) + } + const [pdf] = await delivered('out-pdf') + expect(pdf.name).toBe('album.pdf') + const pdfText = new TextDecoder('latin1').decode(pdf.bytes) + expect(pdfText.startsWith('%PDF-1.4')).toBe(true) + expect(pdfText.match(/\/Type \/Page(?!s)/g)).toHaveLength(2) + const palettes = await delivered('out-palette') + // Workers finish in any order, so Output numbers whichever arrives second. + expect(palettes.map(({ name }) => name).sort()).toEqual([ + 'photo.palette-2.json', + 'photo.palette.json', + ]) + const values = palettes.map(({ bytes }) => JSON.parse(new TextDecoder().decode(bytes))) + const png = values.find((value) => value.image === 'photo.png') + expect(png.colours.map(({ hex }: { hex: string }) => hex)).toEqual(['#0000ff', '#ff0000']) + const icons = (await delivered('out-icons')).map(({ name }) => name) + expect(icons).toContain('photo/favicon.ico') + expect(icons).toContain('photo/site.webmanifest') + // Both images are named photo, so Output numbers the second set's files. + expect(icons.filter((name) => name.startsWith('photo/icon-512'))).toHaveLength(2) + expect(icons).toHaveLength(18) + }) + it('runs items in workers and delivers a ZIP backed by OPFS', async () => { const host = createWorkerPoolHost({ size: 2, createWorker, index: null }) const { result } = await run(chain(['convert', { format: 'png' }]), host, ALL_FORMAT_FIXTURES) diff --git a/src/features/engine/__tests__/runner.test.ts b/src/features/engine/__tests__/runner.test.ts index a699458..673d476 100644 --- a/src/features/engine/__tests__/runner.test.ts +++ b/src/features/engine/__tests__/runner.test.ts @@ -241,6 +241,107 @@ describe('runPipeline', () => { expect(order.slice(3)).toEqual(['join:1.png+2.png+3.png', '%PDF 3 pages']) }) + describe('combining nodes that pass their inputs on', () => { + /** Waits for every item, then sends items with an odd first byte to `out`, others to `rest`. */ + const pick = defineTestNode({ + type: 'pick', + mode: 'all', + ports: ['out', 'rest'], + run: async (input) => { + if (input.mode !== 'all') throw new Error('all only') + const results = [] + let index = 0 + for await (const item of input.items) { + const odd = (item.payload as TestPayload).bytes[0] % 2 === 1 + results.push({ port: odd ? 'out' : 'rest', input: index }) + index += 1 + } + return results + }, + }) + + function setup() { + const seen: { name: string; bytes: number[] }[] = [] + const after = defineTestNode({ + type: 'after', + run: async (input) => { + if (input.mode !== 'each') throw new Error('each only') + const { bytes } = input.item.payload as TestPayload + seen.push({ name: input.item.meta.name, bytes: [...bytes] }) + return [{ port: 'out', item: input.item }] + }, + }) + const registry = createTestRegistry([sourceNode, pick, after]) + const pipeline = pipelineOf( + [ + ['files', 'source'], + ['pick', 'pick'], + ['after', 'after'], + ], + [ + ['files', 'pick'], + ['pick', 'after'], + ], + ) + return { seen, registry, pipeline } + } + + it('sends the items on unchanged, in order, still knowing their source file', async () => { + const { seen, registry, pipeline } = setup() + const { events } = await collect({ + pipeline, + registry, + sources: sources(['a.png'], ['b.png'], ['c.png']), + host: createInlineHost({ registry }), + }) + // The test items hold [index + 1, 7], so a.png and c.png have odd first bytes. + expect(seen).toEqual([ + { name: 'a.png', bytes: [1, 7] }, + { name: 'c.png', bytes: [3, 7] }, + ]) + const after = events.flatMap((event) => + event.type === 'node-item' && event.nodeId === 'after' ? [event.source] : [], + ) + expect(after).toEqual([0, 2]) + }) + + it('lets later nodes reuse their step cache for the items passed on', async () => { + const { seen, registry, pipeline } = setup() + const host = createInlineHost({ registry, cache: createMemoryStepCache() }) + const input = sources(['a.png'], ['b.png'], ['c.png']) + await collect({ pipeline, registry, sources: input, host }) + seen.length = 0 + const { events } = await collect({ pipeline, registry, sources: input, host }) + expect(statuses(events, 'pick')).toEqual(['cached']) + expect(statuses(events, 'after')).toEqual(['cached', 'cached']) + expect(seen).toEqual([]) + }) + + it('fails a per-item node that tries to pass its input on', async () => { + const cheat = defineTestNode({ type: 'cheat', run: async () => [{ port: 'out', input: 0 }] }) + const registry = createTestRegistry([sourceNode, cheat]) + const { events } = await collect({ + pipeline: pipelineOf( + [ + ['files', 'source'], + ['cheat', 'cheat'], + ], + [['files', 'cheat']], + ), + registry, + sources: sources(['a.png']), + host: createInlineHost({ registry }), + }) + expect(events).toContainEqual( + expect.objectContaining({ + nodeId: 'cheat', + status: 'failed', + error: 'Only combining nodes can pass their inputs on.', + }), + ) + }) + }) + it('runs only the changed node and the nodes after it when run again', async () => { const { calls, passthrough } = counter() const registry = createTestRegistry([ diff --git a/src/features/engine/constants.ts b/src/features/engine/constants.ts index 679e603..e62f2ed 100644 --- a/src/features/engine/constants.ts +++ b/src/features/engine/constants.ts @@ -20,6 +20,8 @@ export const PREVIEW_SAMPLE_EDGE = 1024 export const PREVIEW_THUMBNAIL_EDGE = 192 /** Compare nodes show a larger before and after pair. */ export const PREVIEW_COMPARE_EDGE = 640 +/** Data a node makes, such as a palette or a snippet, shows up to this many characters. */ +export const PREVIEW_TEXT_CHARACTERS = 4000 /** Shown when an engine worker fails without a message, which is how a script load failure looks. */ export const ENGINE_LOAD_FAILED_MESSAGE = diff --git a/src/features/engine/engine.worker.ts b/src/features/engine/engine.worker.ts index a078fa3..fb8d585 100644 --- a/src/features/engine/engine.worker.ts +++ b/src/features/engine/engine.worker.ts @@ -13,13 +13,14 @@ import { type WorkerResponse, workerRequestSchema, } from '#/features/engine/worker-protocol' +import { fileKind } from '#/features/file-items/file-item' import { jsquashCodecs } from '#/features/images/codecs' import { imageKind, loadImageItem } from '#/features/images/image-item' import { productRegistry } from '#/features/nodes/registry' const scope = self as unknown as DedicatedWorkerGlobalScope const post = (message: WorkerResponse) => scope.postMessage(message) -const kinds = { image: imageKind } +const kinds = { image: imageKind, data: fileKind, document: fileKind } let current: Omit | undefined /** Why the current run could not be set up. Each task of the run fails with it. */ diff --git a/src/features/engine/estimate.ts b/src/features/engine/estimate.ts index cd1dbb4..baa7b00 100644 --- a/src/features/engine/estimate.ts +++ b/src/features/engine/estimate.ts @@ -51,7 +51,10 @@ export function estimateRun(options: EstimateOptions): Estimate { return } const { definition, settings } = planned - if (definition.mode === 'all') return + if (definition.mode === 'all') { + passThrough(nodeId, state) + return + } const entry = entryKey(planned.key, [state.key]) entryKeys.push(entry) const cached = definition.cacheable ? options.lookup?.(entry) : undefined @@ -112,6 +115,30 @@ export function estimateRun(options: EstimateOptions): Estimate { } } + /** + * A combining node that passes items on by reference simulates where each item leaves. The item + * keeps its key, and waited without its pixels. Other combining nodes end the estimate. + */ + const passThrough = (nodeId: string, state: State) => { + const planned = plan.nodes.get(nodeId) + if (!planned) return + const { definition, settings } = planned + const passed = definition.simulate?.(settings, state.meta) + if (!passed) return + const cost = definition.cost?.(settings, state.meta, { encoded: state.encoded }) + if (cost?.needsPixels && !state.decoded) { + totals.decodes += state.weight + totals.ms += + DECODE_MS_PER_MEGAPIXEL[state.meta.format as ImageFormat] * + megapixels(state.meta) * + state.weight + } + totals.ms += (cost?.ms ?? 0) * state.weight + for (const output of passed) { + forward(nodeId, output.port, { ...state, meta: output.meta, decoded: false }) + } + } + const forward = (nodeId: string, port: string, state: State) => { const planned = plan.nodes.get(nodeId) for (const connection of planned?.outgoing.get(port) ?? []) visit(connection.target, state) diff --git a/src/features/engine/flow.ts b/src/features/engine/flow.ts index f90ecdd..a276d24 100644 --- a/src/features/engine/flow.ts +++ b/src/features/engine/flow.ts @@ -7,7 +7,7 @@ import { settingsOf, topologicalOrder } from '#/features/engine/compatibility' import { itemType } from '#/features/engine/item-types' import { entryKey, nodeKey, outputKey } from '#/features/engine/keys' import type { StepCache, StepCacheEntry } from '#/features/engine/step-cache' -import type { SpillStore } from '#/features/engine/stores' +import type { SpilledEntry, SpillStore } from '#/features/engine/stores' import type { AnyNodeDefinition, Connection, @@ -19,8 +19,8 @@ import type { ItemTypeSet, NodeContext, NodeInput, - NodeOutput, NodeRegistry, + NodeResult, Pipeline, PipelineNode, RunEvent, @@ -158,15 +158,45 @@ function errorMessage(reason: unknown) { return reason instanceof Error ? reason.message : 'The item could not be processed.' } -function sumSizes(outputs: NodeOutput[]) { +function sumSizes(metas: ItemMeta[]) { let total = 0 - for (const output of outputs) { - if (output.item.meta.size === undefined) return undefined - total += output.item.meta.size + for (const meta of metas) { + if (meta.size === undefined) return undefined + total += meta.size } return total } +/** What the engine knows about one result of a node, wherever its item comes from. */ +interface Described { + port: string + key: string + meta: ItemMeta + /** The item the node made, or the index of the combining node's input it passed on. */ + item?: Item + input?: number +} + +function isPassed(result: NodeResult): result is Extract { + return 'input' in result +} + +function describe(results: NodeResult[], entry: string, inputs: SpilledEntry[] | undefined) { + return results.map((result, index): Described => { + if (!isPassed(result)) { + return { + port: result.port, + key: outputKey(entry, index), + meta: result.item.meta, + item: result.item, + } + } + const passed = inputs?.[result.input] + if (!passed) throw new Error('Only combining nodes can pass their inputs on.') + return { port: result.port, key: passed.key, meta: passed.meta, input: result.input } + }) +} + function contextFor(deps: FlowDeps, nodeId: string, source: number | undefined): NodeContext { return { nodeId, @@ -191,16 +221,18 @@ async function runNode( reusable: unknown, recomputed: boolean, source: number | undefined, + inputs?: SpilledEntry[], ) { const started = performance.now() const nodeId = planned.node.id - let outputs: NodeOutput[] + let outputs: Described[] try { - outputs = await planned.definition.run( + const results = await planned.definition.run( input, planned.settings, contextFor(deps, nodeId, source), ) + outputs = describe(results, entry, inputs) } catch (reason) { deps.emit({ type: 'node-item', @@ -219,22 +251,25 @@ async function runNode( source, status: 'processed', bytesIn, - bytesOut: sumSizes(outputs), + bytesOut: sumSizes(outputs.map((output) => output.meta)), ms: performance.now() - started, ...(recomputed ? { recomputed: true } : {}), }) if (planned.definition.cacheable && deps.cache) { const record: StepCacheEntry = { nodeType: planned.definition.type, - outputs: outputs.map((output, index) => ({ + outputs: outputs.map((output) => ({ port: output.port, - key: outputKey(entry, index), - meta: output.item.meta, - reusesInput: reusable !== undefined && output.item.payload === reusable, + key: output.key, + meta: output.meta, + reusesInput: + output.input !== undefined || + (reusable !== undefined && output.item?.payload === reusable), + ...(output.input !== undefined ? { input: output.input } : {}), })), } const payloads = outputs.map((output, index) => { - if (record.outputs[index].reusesInput) return undefined + if (record.outputs[index].reusesInput || !output.item) return undefined const handler = deps.kinds?.[output.item.meta.kind] return handler ? handler.storable(output.item.payload) : output.item.payload }) @@ -276,25 +311,30 @@ function cachedRefs( cached: StepCacheEntry, order: number[], input: ItemRef | undefined, - recompute: () => Promise, + recompute: () => Promise, + passed?: (index: number) => ItemRef, ) { - let recomputed: Promise | undefined - return cached.outputs.map((output, index) => ({ - port: output.port, - ref: createRef(output.key, output.meta, [...order, index], async () => { - if (output.reusesInput && input) { - const source = await input.get() - return { meta: output.meta, payload: source.payload } - } - const payload = await deps.cache?.load(entry, index) - if (payload !== undefined) return { meta: output.meta, payload } - recomputed ??= recompute() - const outputs = await recomputed - const item = outputs?.[index]?.item - if (!item) throw new Error('An earlier step could not be rebuilt.') - return item - }), - })) + let recomputed: Promise | undefined + return cached.outputs.map((output, index) => { + if (output.input !== undefined && passed) + return { port: output.port, ref: passed(output.input) } + return { + port: output.port, + ref: createRef(output.key, output.meta, [...order, index], async () => { + if (output.reusesInput && input) { + const source = await input.get() + return { meta: output.meta, payload: source.payload } + } + const payload = await deps.cache?.load(entry, index) + if (payload !== undefined) return { meta: output.meta, payload } + recomputed ??= recompute() + const outputs = await recomputed + const item = outputs?.[index]?.item + if (!item) throw new Error('An earlier step could not be rebuilt.') + return item + }), + } + }) } async function visit(deps: FlowDeps, nodeId: string, ref: ItemRef): Promise { @@ -370,10 +410,10 @@ async function visit(deps: FlowDeps, nodeId: string, ref: ItemRef): Promise ({ port: output.port, ref: createRef( - outputKey(entry, index), - output.item.meta, + output.key, + output.meta, [...ref.order, index], - async () => output.item, + async () => output.item as Item, ), })), 'processed', @@ -405,6 +445,13 @@ export async function flowCombining(deps: FlowDeps, nodeId: string) { entries.map(({ key }) => key), ) const order = [COMBINING_ORDER + deps.plan.combining.indexOf(nodeId)] + // A passed input keeps its key and order, so it reuses later step caches and its source. + const passed = (index: number) => { + const waiting = entries[index] + return createRef(waiting.key, waiting.meta, waiting.order, () => + deps.spill.load(nodeId, waiting.key), + ) + } const execute = (recomputed: boolean) => { async function* items() { for (const { key } of entries) yield await deps.spill.load(nodeId, key) @@ -418,6 +465,7 @@ export async function flowCombining(deps: FlowDeps, nodeId: string) { undefined, recomputed, undefined, + entries, ) } @@ -428,7 +476,7 @@ export async function flowCombining(deps: FlowDeps, nodeId: string) { await forwardOutputs( deps, planned, - cachedRefs(deps, entry, cached, order, undefined, () => execute(true)), + cachedRefs(deps, entry, cached, order, undefined, () => execute(true), passed), 'cached', ) return @@ -440,12 +488,10 @@ export async function flowCombining(deps: FlowDeps, nodeId: string) { planned, outputs.map((output, index) => ({ port: output.port, - ref: createRef( - outputKey(entry, index), - output.item.meta, - [...order, index], - async () => output.item, - ), + ref: + output.input !== undefined + ? passed(output.input) + : createRef(output.key, output.meta, [...order, index], async () => output.item as Item), })), 'processed', ) diff --git a/src/features/engine/item-types.ts b/src/features/engine/item-types.ts index 2b61f69..32bcb6e 100644 --- a/src/features/engine/item-types.ts +++ b/src/features/engine/item-types.ts @@ -2,7 +2,7 @@ import type { ItemFormat, ItemKind, ItemType, ItemTypeSet } from '#/features/eng export const IMAGE_FORMATS = ['jpeg', 'png', 'webp', 'avif', 'jxl', 'qoi'] as const export const DATA_FORMATS = ['json', 'text'] as const -export const DOCUMENT_FORMATS = ['pdf'] as const +export const DOCUMENT_FORMATS = ['pdf', 'ico'] as const export const FORMAT_LABELS: Record = { jpeg: 'JPEG', @@ -14,6 +14,7 @@ export const FORMAT_LABELS: Record = { json: 'JSON', text: 'text', pdf: 'PDF', + ico: 'ICO', } const KIND_NOUNS: Record = { @@ -38,6 +39,13 @@ export function typesOf(kind: ItemKind, formats: readonly ItemFormat[] = KIND_FO export const ALL_IMAGE_TYPES: ItemTypeSet = typesOf('image') +/** Every item type: images, data and documents. */ +export const ALL_ITEM_TYPES: ItemTypeSet = new Set([ + ...ALL_IMAGE_TYPES, + ...typesOf('data'), + ...typesOf('document'), +]) + export function splitItemType(type: ItemType) { const [kind, format] = type.split(':') as [ItemKind, ItemFormat] return { kind, format } diff --git a/src/features/engine/opfs/run-stores.ts b/src/features/engine/opfs/run-stores.ts index 7f8520a..99e28d5 100644 --- a/src/features/engine/opfs/run-stores.ts +++ b/src/features/engine/opfs/run-stores.ts @@ -11,7 +11,7 @@ import { writeFile, } from '#/features/engine/opfs/files' import { decodeBlob, decodeHeader, encodeRecord } from '#/features/engine/opfs/record-file' -import type { OutputStore, SpillStore } from '#/features/engine/stores' +import type { OutputStore, SpilledEntry, SpillStore } from '#/features/engine/stores' import { compareOrder } from '#/features/engine/stores' import type { Delivery, Item, ItemMeta, OutputSink } from '#/features/engine/types' @@ -207,11 +207,11 @@ export function createOpfsSpillStore( }, async list(nodeId) { const dir = await directory(nodeId) - const entries: { key: string; order: number[] }[] = [] + const entries: SpilledEntry[] = [] for (const name of await listNames(dir)) { const key = name.replace(/\.bin$/, '') const record = await decodeHeader(reader(dir, key)) - if (record) entries.push({ key, order: record.header.order }) + if (record) entries.push({ key, order: record.header.order, meta: record.header.meta }) } return entries.sort((a, b) => compareOrder(a.order, b.order)) }, diff --git a/src/features/engine/step-cache.ts b/src/features/engine/step-cache.ts index 2bda777..5a710f1 100644 --- a/src/features/engine/step-cache.ts +++ b/src/features/engine/step-cache.ts @@ -6,6 +6,8 @@ export interface StepCacheOutput { meta: ItemMeta /** The output reuses the input payload unchanged (Rename, Inspect), so nothing is stored. */ reusesInput: boolean + /** A combining node passed its input at this index on; it loads from the waiting items. */ + input?: number } export interface StepCacheEntry { diff --git a/src/features/engine/stores.ts b/src/features/engine/stores.ts index 5f0d751..cc2c65a 100644 --- a/src/features/engine/stores.ts +++ b/src/features/engine/stores.ts @@ -1,13 +1,19 @@ -import type { Delivery, Item, OutputSink } from '#/features/engine/types' +import type { Delivery, Item, ItemMeta, OutputSink } from '#/features/engine/types' /** Holds items waiting at a node that combines items, until every upstream item has arrived. */ export interface SpillStore { put(nodeId: string, order: number[], key: string, item: Item): Promise /** Spilled entries for a node, in pipeline order (source index, then output index). */ - list(nodeId: string): Promise<{ key: string; order: number[] }[]> + list(nodeId: string): Promise load(nodeId: string, key: string): Promise } +export interface SpilledEntry { + key: string + order: number[] + meta: ItemMeta +} + /** Saves items reaching Output nodes and turns them into a delivery. */ export interface OutputStore extends OutputSink { deliver(nodeId: string): Promise @@ -31,7 +37,7 @@ export function createMemorySpillStore(): SpillStore { }, async list(nodeId) { return [...(spilled.get(nodeId)?.entries() ?? [])] - .map(([key, { order }]) => ({ key, order })) + .map(([key, { order, item }]) => ({ key, order, meta: item.meta })) .sort((a, b) => compareOrder(a.order, b.order)) }, async load(nodeId, key) { diff --git a/src/features/engine/types.ts b/src/features/engine/types.ts index 73e26cb..ae82136 100644 --- a/src/features/engine/types.ts +++ b/src/features/engine/types.ts @@ -6,7 +6,8 @@ export type ItemKind = 'image' | 'data' | 'document' export type ImageFormat = 'jpeg' | 'png' | 'webp' | 'avif' | 'jxl' | 'qoi' export type DataFormat = 'json' | 'text' -export type DocumentFormat = 'pdf' +/** Finished files Hexlode makes but does not edit further. */ +export type DocumentFormat = 'pdf' | 'ico' export type ItemFormat = ImageFormat | DataFormat | DocumentFormat /** `kind:format`, for example `image:png`. The unit of accepts and produces. */ @@ -68,6 +69,18 @@ export interface NodeOutput

{ item: Item

} +/** + * A combining node's output that passes one of its inputs on unchanged, by its place in arrival + * order. The engine reloads the item from where it waited, so the node never holds the batch, + * and the item keeps its step cache key and the source it came from. + */ +export interface PassedInput { + port: string + input: number +} + +export type NodeResult = NodeOutput | PassedInput + export interface NodeWarning { code: string message: string @@ -119,7 +132,10 @@ export interface NodeDefinition = Record = Record + /** Per-item nodes return NodeOutput items; combining nodes may also pass inputs on. */ + run(input: NodeInput, settings: S, context: NodeContext): Promise } export interface NodeCost { diff --git a/src/features/engine/worker-protocol.ts b/src/features/engine/worker-protocol.ts index ac0ebc3..0006642 100644 --- a/src/features/engine/worker-protocol.ts +++ b/src/features/engine/worker-protocol.ts @@ -55,7 +55,15 @@ export const workerRequestSchema = z.discriminatedUnion('type', [ const stepCacheEntry = z.object({ nodeType: z.string(), - outputs: z.array(z.object({ port: z.string(), key: z.string(), meta, reusesInput: z.boolean() })), + outputs: z.array( + z.object({ + port: z.string(), + key: z.string(), + meta, + reusesInput: z.boolean(), + input: z.number().int().nonnegative().optional(), + }), + ), }) export const workerResponseSchema = z.discriminatedUnion('type', [ diff --git a/src/features/file-items/file-item.ts b/src/features/file-items/file-item.ts new file mode 100644 index 0000000..54f7644 --- /dev/null +++ b/src/features/file-items/file-item.ts @@ -0,0 +1,59 @@ +/** + * Data items (JSON and text) and document items (PDF and ICO): files a node makes from images, + * such as a palette or a PDF. Output saves their bytes as they are. + */ +import type { Item, ItemKindHandler, ItemMeta } from '#/features/engine/types' +import type { FileItem, FileKind, FilePayload } from '#/features/file-items/types' + +export function fileItem( + type: FileKind, + name: string, + bytes: Uint8Array, + source: ItemMeta['source'], +): FileItem { + return { + meta: { kind: type.kind, format: type.format, name, size: bytes.byteLength, source }, + payload: { bytes }, + } +} + +export function textItem( + format: 'json' | 'text', + name: string, + content: string, + source: ItemMeta['source'], +) { + return fileItem({ kind: 'data', format }, name, new TextEncoder().encode(content), source) +} + +export function jsonItem(name: string, value: unknown, source: ItemMeta['source']) { + return textItem('json', name, `${JSON.stringify(value, null, 2)}\n`, source) +} + +export function isFileItem(item: Item): item is FileItem { + return item.meta.kind !== 'image' +} + +/** The file's bytes, for items that are already finished files. */ +export function fileBytes(item: Item) { + return (item.payload as FilePayload).bytes +} + +export const fileKind: ItemKindHandler = { + storable: (payload) => payload, + memoryBytes: (item) => fileBytes(item).byteLength, +} + +/** `trip/photo.jpg` becomes `trip/photo.palette.json` with ending `.palette.json`. */ +export function nameWithEnding(name: string, ending: string) { + const slash = name.lastIndexOf('/') + const dot = name.lastIndexOf('.') + return `${dot > slash + 1 ? name.slice(0, dot) : name}${ending}` +} + +/** The name without folders or extension, for labels and CSS names. */ +export function stemOf(name: string) { + const leaf = name.slice(name.lastIndexOf('/') + 1) + const dot = leaf.lastIndexOf('.') + return dot > 0 ? leaf.slice(0, dot) : leaf +} diff --git a/src/features/file-items/ico.ts b/src/features/file-items/ico.ts new file mode 100644 index 0000000..1ff6a87 --- /dev/null +++ b/src/features/file-items/ico.ts @@ -0,0 +1,36 @@ +/** + * ICO files with PNG images inside, which every current browser and Windows since Vista read. + * Layout: a 6-byte header, a 16-byte directory entry per image, then the PNG files. + */ + +export interface IcoImage { + width: number + height: number + png: Uint8Array +} + +const HEADER = 6 +const ENTRY = 16 + +export function packIco(images: IcoImage[]) { + const total = + HEADER + ENTRY * images.length + images.reduce((sum, image) => sum + image.png.length, 0) + const bytes = new Uint8Array(total) + const view = new DataView(bytes.buffer) + view.setUint16(2, 1, true) + view.setUint16(4, images.length, true) + let offset = HEADER + ENTRY * images.length + for (const [index, image] of images.entries()) { + const at = HEADER + index * ENTRY + // A size of 256 is written as 0. + bytes[at] = image.width >= 256 ? 0 : image.width + bytes[at + 1] = image.height >= 256 ? 0 : image.height + view.setUint16(at + 4, 1, true) + view.setUint16(at + 6, 32, true) + view.setUint32(at + 8, image.png.length, true) + view.setUint32(at + 12, offset, true) + bytes.set(image.png, offset) + offset += image.png.length + } + return bytes +} diff --git a/src/features/file-items/pdf.ts b/src/features/file-items/pdf.ts new file mode 100644 index 0000000..d98166d --- /dev/null +++ b/src/features/file-items/pdf.ts @@ -0,0 +1,123 @@ +/** + * A small PDF writer: one image per page, drawn into a box on the page. JPEG data is embedded + * as it is (DCTDecode); other pixels are embedded as zlib-compressed RGB (FlateDecode). + */ + +export interface PdfImage { + width: number + height: number + colourSpace: 'DeviceRGB' | 'DeviceGray' + filter: 'DCTDecode' | 'FlateDecode' + data: Uint8Array +} + +export interface PdfPage { + /** Page size in points. */ + width: number + height: number + image: PdfImage + /** Where the image is drawn, in points from the bottom-left corner. */ + box: { x: number; y: number; width: number; height: number } +} + +const encoder = new TextEncoder() + +/** Numbers with at most two decimals, as PDF readers expect. */ +const number = (value: number) => String(Number(value.toFixed(2))) + +/** Builds a PDF one page at a time. Object 1 is the catalog and object 2 the page tree. */ +export function createPdfWriter() { + const chunks: Uint8Array[] = [] + const offsets = new Map() + const pages: number[] = [] + let length = 0 + let next = 3 + + const write = (part: string | Uint8Array) => { + const bytes = typeof part === 'string' ? encoder.encode(part) : part + chunks.push(bytes) + length += bytes.length + } + const object = (id: number, body: string, stream?: Uint8Array) => { + offsets.set(id, length) + write(`${id} 0 obj\n${body}\n`) + if (stream) { + write('stream\n') + write(stream) + write('\nendstream\n') + } + write('endobj\n') + } + + // The header, then a comment of high bytes so tools treat the file as binary. + write('%PDF-1.4\n') + write(new Uint8Array([0x25, 0xe2, 0xe3, 0xcf, 0xd3, 0x0a])) + + return { + addPage(page: PdfPage) { + const [pageId, contentId, imageId] = [next, next + 1, next + 2] + next += 3 + const { image, box } = page + object( + imageId, + `<< /Type /XObject /Subtype /Image /Width ${image.width} /Height ${image.height} /ColorSpace /${image.colourSpace} /BitsPerComponent 8 /Filter /${image.filter} /Length ${image.data.length} >>`, + image.data, + ) + const content = encoder.encode( + `q ${number(box.width)} 0 0 ${number(box.height)} ${number(box.x)} ${number(box.y)} cm /Im0 Do Q`, + ) + object(contentId, `<< /Length ${content.length} >>`, content) + object( + pageId, + `<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${number(page.width)} ${number(page.height)}] /Resources << /XObject << /Im0 ${imageId} 0 R >> >> /Contents ${contentId} 0 R >>`, + ) + pages.push(pageId) + }, + finish() { + const infoId = next + object( + 2, + `<< /Type /Pages /Kids [${pages.map((id) => `${id} 0 R`).join(' ')}] /Count ${pages.length} >>`, + ) + object(1, '<< /Type /Catalog /Pages 2 0 R >>') + object(infoId, '<< /Producer (Hexlode) >>') + const xref = length + const size = infoId + 1 + const rows = ['xref', `0 ${size}`, '0000000000 65535 f '] + for (let id = 1; id < size; id += 1) { + rows.push(`${String(offsets.get(id) ?? 0).padStart(10, '0')} 00000 n `) + } + write(`${rows.join('\n')}\n`) + write( + `trailer\n<< /Size ${size} /Root 1 0 R /Info ${infoId} 0 R >>\nstartxref\n${xref}\n%%EOF\n`, + ) + const bytes = new Uint8Array(length) + let at = 0 + for (const chunk of chunks) { + bytes.set(chunk, at) + at += chunk.length + } + return bytes + }, + } +} + +/** zlib-compressed bytes, the format FlateDecode reads. */ +export async function deflate(bytes: Uint8Array) { + const stream = new Blob([bytes.slice()]).stream().pipeThrough(new CompressionStream('deflate')) + return new Uint8Array(await new Response(stream).arrayBuffer()) +} + +/** The colour components of a baseline or progressive JPEG: 1 grey, 3 colour, 4 CMYK. */ +export function jpegComponents(bytes: Uint8Array) { + let at = 2 + while (at + 9 < bytes.length) { + if (bytes[at] !== 0xff) return undefined + const marker = bytes[at + 1] + const size = (bytes[at + 2] << 8) | bytes[at + 3] + if (marker === 0xc0 || marker === 0xc1 || marker === 0xc2) return bytes[at + 9] + if (marker === 0xda || size < 2) return undefined + at += 2 + size + } + return undefined +} diff --git a/src/features/file-items/types.ts b/src/features/file-items/types.ts new file mode 100644 index 0000000..4fb75be --- /dev/null +++ b/src/features/file-items/types.ts @@ -0,0 +1,12 @@ +import type { DataFormat, DocumentFormat, Item } from '#/features/engine/types' + +/** Data and document items carry their finished file. Nothing after them re-encodes it. */ +export interface FilePayload { + bytes: Uint8Array +} + +export type FileItem = Item + +export type FileKind = + | { kind: 'data'; format: DataFormat } + | { kind: 'document'; format: DocumentFormat } diff --git a/src/features/images/blur.ts b/src/features/images/blur.ts new file mode 100644 index 0000000..89980f9 --- /dev/null +++ b/src/features/images/blur.ts @@ -0,0 +1,107 @@ +/** + * Gaussian blur, approximated by three box blurs per direction, and unsharp-mask sharpening. + * Colours are premultiplied by alpha while blurring, so edges next to transparent pixels keep + * their colour instead of darkening. + */ +import type { Pixels } from '#/features/images/types' + +const PASSES = 3 + +/** Box widths whose three passes approximate a Gaussian with this standard deviation. */ +function boxRadii(sigma: number) { + const ideal = Math.sqrt((12 * sigma * sigma) / PASSES + 1) + let lower = Math.floor(ideal) + if (lower % 2 === 0) lower -= 1 + const upper = lower + 2 + const lowerCount = Math.round( + (12 * sigma * sigma - PASSES * lower * lower - 4 * PASSES * lower - 3 * PASSES) / + (-4 * lower - 4), + ) + return Array.from({ length: PASSES }, (_, pass) => + Math.max(0, ((pass < lowerCount ? lower : upper) - 1) / 2), + ) +} + +/** Box-blurs `length` RGBA values from `from` into `to`, repeating edge values past the ends. */ +function boxLine(from: Float32Array, to: Float32Array, length: number, radius: number) { + if (radius === 0) { + to.set(from.subarray(0, length * 4)) + return + } + const width = radius * 2 + 1 + for (let channel = 0; channel < 4; channel += 1) { + const at = (index: number) => from[Math.min(length - 1, Math.max(0, index)) * 4 + channel] + let sum = 0 + for (let index = -radius; index <= radius; index += 1) sum += at(index) + for (let index = 0; index < length; index += 1) { + to[index * 4 + channel] = sum / width + sum += at(index + radius + 1) - at(index - radius) + } + } +} + +function blurLine(line: Float32Array, scratch: Float32Array, length: number, radii: number[]) { + let from = line + let to = scratch + for (const radius of radii) { + boxLine(from, to, length, radius) + ;[from, to] = [to, from] + } + if (from !== line) line.set(from.subarray(0, length * 4)) +} + +export function gaussianBlur(pixels: Pixels, sigma: number): Pixels { + const { width, height } = pixels + const data = new Uint8ClampedArray(pixels.data) + if (sigma <= 0) return { data, width, height } + const radii = boxRadii(sigma).map(Math.round) + const longest = Math.max(width, height) + const line = new Float32Array(longest * 4) + const scratch = new Float32Array(longest * 4) + + for (let y = 0; y < height; y += 1) { + const row = y * width * 4 + for (let x = 0; x < width; x += 1) { + const at = row + x * 4 + const alpha = data[at + 3] / 255 + line[x * 4] = data[at] * alpha + line[x * 4 + 1] = data[at + 1] * alpha + line[x * 4 + 2] = data[at + 2] * alpha + line[x * 4 + 3] = data[at + 3] + } + blurLine(line, scratch, width, radii) + data.set(line.subarray(0, width * 4), row) + } + for (let x = 0; x < width; x += 1) { + for (let y = 0; y < height; y += 1) { + const at = (y * width + x) * 4 + line.set(data.subarray(at, at + 4), y * 4) + } + blurLine(line, scratch, height, radii) + for (let y = 0; y < height; y += 1) { + const at = (y * width + x) * 4 + const alpha = line[y * 4 + 3] + const scale = alpha > 0 ? 255 / alpha : 0 + data[at] = line[y * 4] * scale + data[at + 1] = line[y * 4 + 1] * scale + data[at + 2] = line[y * 4 + 2] * scale + data[at + 3] = alpha + } + } + return { data, width, height } +} + +/** Unsharp mask: adds `amount` times the difference between the image and its blur. */ +export function sharpen(pixels: Pixels, sigma: number, amount: number): Pixels { + const blurred = gaussianBlur(pixels, sigma) + const source = pixels.data + const data = new Uint8ClampedArray(source.length) + for (let at = 0; at < source.length; at += 4) { + for (let channel = 0; channel < 3; channel += 1) { + const value = source[at + channel] + data[at + channel] = value + (value - blurred.data[at + channel]) * amount + } + data[at + 3] = source[at + 3] + } + return { data, width: pixels.width, height: pixels.height } +} diff --git a/src/features/images/blurhash.ts b/src/features/images/blurhash.ts new file mode 100644 index 0000000..066d0ea --- /dev/null +++ b/src/features/images/blurhash.ts @@ -0,0 +1,76 @@ +/** + * BlurHash encoding, following the specification at https://blurha.sh: the image as a few + * cosine components, quantised and written in base 83. + */ +import type { Pixels } from '#/features/images/types' + +const DIGITS = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~' + +function base83(value: number, length: number) { + let result = '' + for (let place = length - 1; place >= 0; place -= 1) { + result += DIGITS[Math.floor(value / 83 ** place) % 83] + } + return result +} + +const toLinear = (value: number) => { + const v = value / 255 + return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4 +} + +const toStored = (value: number) => { + const v = Math.max(0, Math.min(1, value)) + return Math.round((v <= 0.0031308 ? v * 12.92 : 1.055 * v ** (1 / 2.4) - 0.055) * 255) +} + +const signedPower = (value: number, exponent: number) => + Math.sign(value) * Math.abs(value) ** exponent + +/** Encodes opaque pixels with `across` by `down` components, each 1 to 9. */ +export function encodeBlurhash(pixels: Pixels, across: number, down: number) { + const { data, width, height } = pixels + const linear = new Float32Array(width * height * 3) + for (let index = 0; index < width * height; index += 1) { + for (let channel = 0; channel < 3; channel += 1) { + linear[index * 3 + channel] = toLinear(data[index * 4 + channel]) + } + } + const factors: number[][] = [] + for (let j = 0; j < down; j += 1) { + for (let i = 0; i < across; i += 1) { + const sum = [0, 0, 0] + for (let y = 0; y < height; y += 1) { + const vertical = Math.cos((Math.PI * j * y) / height) + for (let x = 0; x < width; x += 1) { + const basis = Math.cos((Math.PI * i * x) / width) * vertical + const at = (y * width + x) * 3 + sum[0] += basis * linear[at] + sum[1] += basis * linear[at + 1] + sum[2] += basis * linear[at + 2] + } + } + const scale = (i === 0 && j === 0 ? 1 : 2) / (width * height) + factors.push(sum.map((value) => value * scale)) + } + } + const [dc, ...ac] = factors + let hash = base83(across - 1 + (down - 1) * 9, 1) + let maximum = 1 + if (ac.length > 0) { + const largest = Math.max(...ac.flat().map(Math.abs)) + const quantised = Math.max(0, Math.min(82, Math.floor(largest * 166 - 0.5))) + maximum = (quantised + 1) / 166 + hash += base83(quantised, 1) + } else { + hash += base83(0, 1) + } + hash += base83((toStored(dc[0]) << 16) + (toStored(dc[1]) << 8) + toStored(dc[2]), 4) + for (const factor of ac) { + const [red, green, blue] = factor.map((value) => + Math.max(0, Math.min(18, Math.floor(signedPower(value / maximum, 0.5) * 9 + 9.5))), + ) + hash += base83(red * 19 * 19 + green * 19 + blue, 2) + } + return hash +} diff --git a/src/features/images/colour.ts b/src/features/images/colour.ts new file mode 100644 index 0000000..8a4900f --- /dev/null +++ b/src/features/images/colour.ts @@ -0,0 +1,20 @@ +/** Colours in node settings: `#rrggbb`, or `#rrggbbaa` with an alpha byte. */ + +export type Rgba = [red: number, green: number, blue: number, alpha: number] + +export const HEX_COLOUR = /^#[0-9a-f]{6}([0-9a-f]{2})?$/i + +export function rgbaOf(hex: string): Rgba { + if (!HEX_COLOUR.test(hex)) throw new Error(`${hex} is not a colour.`) + const byte = (at: number) => Number.parseInt(hex.slice(at, at + 2), 16) + return [byte(1), byte(3), byte(5), hex.length === 9 ? byte(7) : 255] +} + +export function hexOf(red: number, green: number, blue: number) { + return `#${[red, green, blue].map((value) => Math.round(value).toString(16).padStart(2, '0')).join('')}` +} + +/** Relative luminance weights of sRGB (Rec. 709), applied to the stored values. */ +export function lumaOf(red: number, green: number, blue: number) { + return 0.2126 * red + 0.7152 * green + 0.0722 * blue +} diff --git a/src/features/images/compose.ts b/src/features/images/compose.ts new file mode 100644 index 0000000..e2f2ffa --- /dev/null +++ b/src/features/images/compose.ts @@ -0,0 +1,115 @@ +/** + * Building images from parts: blank canvases, placing one image over another, and replacing + * transparency. Plain TypeScript on RGBA pixels, so it runs the same in every worker. + */ +import type { Rgba } from '#/features/images/colour' +import type { Pixels } from '#/features/images/types' + +export function canvas(width: number, height: number, colour: Rgba = [0, 0, 0, 0]): Pixels { + const data = new Uint8ClampedArray(width * height * 4) + const [red, green, blue, alpha] = colour + if (red || green || blue || alpha) { + const packed = new Uint32Array(data.buffer) + data.set([red, green, blue, alpha]) + packed.fill(packed[0]) + } + return { data, width, height } +} + +/** Blends one pixel over another with source-over compositing, in place. */ +function blendAt( + target: Uint8ClampedArray, + at: number, + red: number, + green: number, + blue: number, + alpha: number, +) { + if (alpha <= 0) return + if (alpha >= 255) { + target[at] = red + target[at + 1] = green + target[at + 2] = blue + target[at + 3] = 255 + return + } + const a = alpha / 255 + const below = target[at + 3] / 255 + const out = a + below * (1 - a) + if (out <= 0) return + const mix = (top: number, bottom: number) => (top * a + bottom * below * (1 - a)) / out + target[at] = mix(red, target[at]) + target[at + 1] = mix(green, target[at + 1]) + target[at + 2] = mix(blue, target[at + 2]) + target[at + 3] = out * 255 +} + +/** Draws `top` over `base` at (x, y), clipped to `base`, scaling top's alpha by `opacity`. */ +export function drawOver(base: Pixels, top: Pixels, x: number, y: number, opacity = 1) { + const left = Math.max(0, x) + const right = Math.min(base.width, x + top.width) + const upper = Math.max(0, y) + const lower = Math.min(base.height, y + top.height) + for (let row = upper; row < lower; row += 1) { + for (let column = left; column < right; column += 1) { + const from = ((row - y) * top.width + (column - x)) * 4 + const at = (row * base.width + column) * 4 + blendAt( + base.data, + at, + top.data[from], + top.data[from + 1], + top.data[from + 2], + top.data[from + 3] * opacity, + ) + } + } + return base +} + +/** The image over a solid colour, fully opaque when the colour is. */ +export function flatten(pixels: Pixels, colour: Rgba) { + return drawOver(canvas(pixels.width, pixels.height, colour), pixels, 0, 0) +} + +/** + * Rounds the corners with radius `radius`, smoothing the arc by how much of each pixel it + * covers. Outside the arc, pixels become `fill`, or transparent when `fill` is null. + */ +export function roundCorners(pixels: Pixels, radius: number, fill: Rgba | null): Pixels { + const { width, height } = pixels + const data = new Uint8ClampedArray(pixels.data) + const r = Math.min(radius, width / 2, height / 2) + if (r <= 0) return { data, width, height } + const span = Math.ceil(r) + const corners = [ + { x0: 0, y0: 0, cx: r, cy: r }, + { x0: width - span, y0: 0, cx: width - r, cy: r }, + { x0: 0, y0: height - span, cx: r, cy: height - r }, + { x0: width - span, y0: height - span, cx: width - r, cy: height - r }, + ] + for (const { x0, y0, cx, cy } of corners) { + for (let y = y0; y < y0 + span; y += 1) { + for (let x = x0; x < x0 + span; x += 1) { + const inside = + (x + 0.5 - cx) * (x0 === 0 ? -1 : 1) <= 0 || (y + 0.5 - cy) * (y0 === 0 ? -1 : 1) <= 0 + if (inside) continue + const distance = Math.hypot(x + 0.5 - cx, y + 0.5 - cy) + const coverage = Math.min(1, Math.max(0, r - distance + 0.5)) + if (coverage >= 1) continue + const at = (y * width + x) * 4 + if (!fill) { + data[at + 3] = data[at + 3] * coverage + continue + } + const red = data[at] + const green = data[at + 1] + const blue = data[at + 2] + const alpha = data[at + 3] + data.set(fill, at) + blendAt(data, at, red, green, blue, alpha * coverage) + } + } + } + return { data, width, height } +} diff --git a/src/features/images/fingerprint.ts b/src/features/images/fingerprint.ts new file mode 100644 index 0000000..c93fd46 --- /dev/null +++ b/src/features/images/fingerprint.ts @@ -0,0 +1,86 @@ +/** + * Fingerprints for finding duplicate images: a hash of the exact pixels, and a difference hash + * (dHash) that stays almost the same when an image is re-encoded, resized or lightly edited. + */ +import { lumaOf } from '#/features/images/colour' +import { flatten } from '#/features/images/compose' +import type { Codecs, Pixels } from '#/features/images/types' + +const WHITE = [255, 255, 255, 255] as [number, number, number, number] + +/** Neighbouring levels must differ by more than this to set a bit, so noise does not flip it. */ +const DHASH_DEAD_ZONE = 2 + +function mix(words: Uint32Array, seed: number, extra: number[]) { + let h1 = 0xdeadbeef ^ seed + let h2 = 0x41c6ce57 ^ seed + const take = (value: number) => { + h1 = Math.imul(h1 ^ value, 2654435761) + h2 = Math.imul(h2 ^ value, 1597334677) + } + for (const value of extra) take(value) + for (let index = 0; index < words.length; index += 1) take(words[index]) + h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909) + h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909) + return (4294967296 * (2097151 & h2) + (h1 >>> 0)).toString(36) +} + +/** A 106-bit hash of the dimensions and every pixel. Equal for identical images. */ +export function exactHash(pixels: Pixels) { + const words = new Uint32Array( + pixels.data.buffer, + pixels.data.byteOffset, + pixels.width * pixels.height, + ) + const size = [pixels.width, pixels.height] + return `${mix(words, 1, size)}${mix(words, 2, size)}` +} + +/** The difference hash compares neighbours on a grid this many cells across and down. */ +const DHASH_GRID = 16 + +/** Luminance of the image squeezed to `width` by `height` cells, on white. */ +async function levels(pixels: Pixels, width: number, height: number, codecs: Codecs) { + const small = await codecs.resize(flatten(pixels, WHITE), width, height, 'triangle') + return (x: number, y: number) => { + const at = (y * width + x) * 4 + return lumaOf(small.data[at], small.data[at + 1], small.data[at + 2]) + } +} + +/** + * A 512-bit difference hash: on a 16 by 16 grid, is each cell lighter than the cell to its right, + * and than the cell below it. Resaved and resized copies differ in a few percent of the bits; + * different photos, even of similar scenes, in well over a tenth. + */ +export async function differenceHash(pixels: Pixels, codecs: Codecs) { + const n = DHASH_GRID + const bits = new Uint32Array((n * n * 2) / 32) + let bit = 0 + const set = (on: boolean) => { + if (on) bits[bit >> 5] |= 1 << (bit & 31) + bit += 1 + } + const across = await levels(pixels, n + 1, n, codecs) + for (let y = 0; y < n; y += 1) { + for (let x = 0; x < n; x += 1) set(across(x, y) - across(x + 1, y) > DHASH_DEAD_ZONE) + } + const down = await levels(pixels, n, n + 1, codecs) + for (let y = 0; y < n; y += 1) { + for (let x = 0; x < n; x += 1) set(down(x, y) - down(x, y + 1) > DHASH_DEAD_ZONE) + } + return bits +} + +function bitCount(value: number) { + let v = value - ((value >>> 1) & 0x55555555) + v = (v & 0x33333333) + ((v >>> 2) & 0x33333333) + return (((v + (v >>> 4)) & 0x0f0f0f0f) * 0x01010101) >>> 24 +} + +/** The share of bits that differ between two difference hashes, from 0 to 1. */ +export function hashDistance(a: Uint32Array, b: Uint32Array) { + let differing = 0 + for (let index = 0; index < a.length; index += 1) differing += bitCount(a[index] ^ b[index]) + return differing / (a.length * 32) +} diff --git a/src/features/images/image-item.ts b/src/features/images/image-item.ts index 8f981c0..46f4d35 100644 --- a/src/features/images/image-item.ts +++ b/src/features/images/image-item.ts @@ -6,9 +6,10 @@ import { inspectImageHeader, } from '#/features/image-input/validators' import { readMetadata, writeMetadata } from '#/features/images/metadata/containers' -import { parseExif } from '#/features/images/metadata/exif' +import { parseExif, setExifOrientation } from '#/features/images/metadata/exif' import { isEmptyMetadata } from '#/features/images/metadata/strip' import type { ImageMetadata, MetadataPart } from '#/features/images/metadata/types' +import { applyOrientation } from '#/features/images/pixel-ops' import type { Codecs, EncodeOptions, @@ -42,6 +43,13 @@ export const DEFAULT_ENCODE: Record = { qoi: { format: 'qoi', options: {} }, } +/** Default settings for a format, with the given quality for the formats that take one. */ +export function encodeWithQuality(format: ImageFormat, quality: number): EncodeOptions { + const base = DEFAULT_ENCODE[format] + if (format === 'png' || format === 'qoi') return base + return { ...base, options: { ...base.options, quality } } as EncodeOptions +} + const PART_NAMES: Record = { exif: 'EXIF', xmp: 'XMP', @@ -74,6 +82,13 @@ export function replaceExtension(name: string, format: ImageFormat) { return `${stem}.${IMAGE_EXTENSIONS[format]}` } +/** `trip/photo.jpg` with suffix `-2` becomes `trip/photo-2.jpg`. */ +export function nameWithSuffix(name: string, suffix: string) { + const slash = name.lastIndexOf('/') + const dot = name.lastIndexOf('.') + return dot > slash + 1 ? `${name.slice(0, dot)}${suffix}${name.slice(dot)}` : `${name}${suffix}` +} + export function withPixels( item: ImageItem, pixels: Pixels, @@ -90,6 +105,33 @@ export function withPixels( } } +/** The item's pixels turned upright by its orientation tag, as a viewer shows them. */ +export async function uprightPixelsOf(item: ImageItem, codecs: Codecs) { + return applyOrientation(await pixelsOf(item, codecs), item.meta.orientation ?? 1) +} + +/** What an item's meta becomes once its pixels are turned upright and changed. */ +export function uprightMeta(meta: ItemMeta): ItemMeta { + const turned = (meta.orientation ?? 1) >= 5 + return { + ...meta, + width: turned ? meta.height : meta.width, + height: turned ? meta.width : meta.height, + orientation: 1, + size: undefined, + } +} + +/** Like withPixels, for pixels already turned upright: resets the orientation tag to 1. */ +export function withUprightPixels(item: ImageItem, pixels: Pixels): ImageItem { + const result = withPixels(item, pixels, { orientation: 1 }) + const { exif } = result.payload.metadata + if (exif && (item.meta.orientation ?? 1) !== 1) { + result.payload.metadata = { ...result.payload.metadata, exif: setExifOrientation(exif, 1) } + } + return result +} + export function withMetadata( item: ImageItem, metadata: ImageMetadata, diff --git a/src/features/images/metadata/exif.ts b/src/features/images/metadata/exif.ts index 47ffd35..a5a98d0 100644 --- a/src/features/images/metadata/exif.ts +++ b/src/features/images/metadata/exif.ts @@ -224,3 +224,41 @@ export function setExifOrientation(bytes: Uint8Array, orientation: number) { const ifd0 = [...block.ifd0.filter((candidate) => candidate.tag !== TAGS.orientation), entry] return writeExifBlock({ ...block, ifd0 }) } + +/** EXIF text is ASCII by the standard; readers commonly take Latin-1, so © survives. */ +function textValue(text: string) { + const latin1 = [...text].every((character) => (character.codePointAt(0) ?? 0) <= 0xff) + const encoded = latin1 + ? Uint8Array.from([...text], (character) => character.codePointAt(0) ?? 0) + : new TextEncoder().encode(text) + const value = new Uint8Array(encoded.length + 1) + value.set(encoded) + return value +} + +/** + * Returns an EXIF block with the given text fields of IFD0 set, keeping everything else. Builds + * a new block when there is none. Fields left undefined are not touched. + */ +export function setExifText( + bytes: Uint8Array | undefined, + fields: Partial>, +) { + const block: ExifBlock = (bytes && readExifBlock(bytes)) || { + littleEndian: false, + ifd0: [], + exif: [], + gps: [], + } + let ifd0 = block.ifd0 + for (const [name, text] of Object.entries(fields)) { + if (text === undefined) continue + const tag = TAGS[name as 'artist' | 'copyright'] + const value = textValue(text) + ifd0 = [ + ...ifd0.filter((entry) => entry.tag !== tag), + { tag, type: 2, count: value.length, value }, + ] + } + return writeExifBlock({ ...block, ifd0 }) +} diff --git a/src/features/images/palette.ts b/src/features/images/palette.ts new file mode 100644 index 0000000..b0a8620 --- /dev/null +++ b/src/features/images/palette.ts @@ -0,0 +1,95 @@ +/** + * The main colours of an image by median cut: start with one box holding every colour, split + * the box with the widest channel range at its median, and average each box at the end. + */ +import { hexOf } from '#/features/images/colour' +import type { Pixels } from '#/features/images/types' + +/** At most this many pixels are read, spread evenly over the image. */ +const SAMPLE_PIXELS = 16_384 +/** Pixels less opaque than this are left out. */ +const MIN_ALPHA = 128 + +export interface PaletteColour { + hex: string + /** Share of the counted pixels, rounded to four decimals. */ + share: number +} + +type Box = number[][] + +function sample(pixels: Pixels) { + const { data, width, height } = pixels + const step = Math.max(1, Math.ceil(Math.sqrt((width * height) / SAMPLE_PIXELS))) + const colours: number[][] = [] + for (let y = 0; y < height; y += step) { + for (let x = 0; x < width; x += step) { + const at = (y * width + x) * 4 + if (data[at + 3] >= MIN_ALPHA) colours.push([data[at], data[at + 1], data[at + 2]]) + } + } + return colours +} + +function widestChannel(box: Box) { + let best = { channel: 0, range: -1 } + for (let channel = 0; channel < 3; channel += 1) { + let low = 255 + let high = 0 + for (const colour of box) { + low = Math.min(low, colour[channel]) + high = Math.max(high, colour[channel]) + } + if (high - low > best.range) best = { channel, range: high - low } + } + return best +} + +/** Splits at the median, moved to the nearest change of value so equal colours stay together. */ +function split(box: Box, channel: number): [Box, Box] { + const sorted = [...box].sort((a, b) => a[channel] - b[channel]) + const end = sorted.length + const same = (index: number) => sorted[index - 1][channel] === sorted[index][channel] + const middle = Math.floor(end / 2) + let below = middle + while (below > 0 && same(below)) below -= 1 + let above = middle + while (above < end && same(above)) above += 1 + // The box has a range, so at least one side has a change of value. + const at = + below === 0 ? above : above === end ? below : middle - below <= above - middle ? below : above + return [sorted.slice(0, at), sorted.slice(at)] +} + +export function extractPalette(pixels: Pixels, count: number): PaletteColour[] { + const colours = sample(pixels) + if (colours.length === 0) return [] + const boxes: Box[] = [colours] + while (boxes.length < count) { + let target = -1 + let widest = 0 + for (const [index, box] of boxes.entries()) { + const { range } = widestChannel(box) + // Weigh by size so a few stray pixels do not take a colour from a large area. + const score = range * Math.sqrt(box.length) + if (range > 0 && score > widest) { + widest = score + target = index + } + } + if (target === -1) break + const [first, second] = split(boxes[target], widestChannel(boxes[target]).channel) + boxes.splice(target, 1, first, second) + } + const shares = new Map() + for (const box of boxes) { + const average = [0, 1, 2].map( + (channel) => box.reduce((total, colour) => total + colour[channel], 0) / box.length, + ) + const hex = hexOf(average[0], average[1], average[2]) + shares.set(hex, (shares.get(hex) ?? 0) + box.length) + } + return [...shares] + .map(([hex, size]) => ({ hex, share: Math.round((size / colours.length) * 10_000) / 10_000 })) + .sort((a, b) => b.share - a.share || (a.hex < b.hex ? -1 : 1)) +} diff --git a/src/features/images/shape.ts b/src/features/images/shape.ts new file mode 100644 index 0000000..da7a748 --- /dev/null +++ b/src/features/images/shape.ts @@ -0,0 +1,138 @@ +/** + * Changes to an image's outline: trimming borders, cutting tiles and scaling pixel art. + */ +import type { Pixels } from '#/features/images/types' + +export interface Area { + x: number + y: number + width: number + height: number +} + +/** + * The smallest area holding everything that differs from the top-left pixel by more than + * `tolerance` (0 to 100). When that pixel is fully transparent, every pixel at most that + * transparent counts as border. Returns null when the whole image is border. + */ +export function trimBounds(pixels: Pixels, tolerance: number): Area | null { + const { data, width, height } = pixels + const limit = (tolerance / 100) * 255 + const reference = [data[0], data[1], data[2], data[3]] + const transparent = reference[3] === 0 + const isBorder = (at: number) => { + if (transparent) return data[at + 3] <= limit + return ( + Math.abs(data[at] - reference[0]) <= limit && + Math.abs(data[at + 1] - reference[1]) <= limit && + Math.abs(data[at + 2] - reference[2]) <= limit && + Math.abs(data[at + 3] - reference[3]) <= limit + ) + } + const rowIsBorder = (y: number) => { + for (let x = 0; x < width; x += 1) if (!isBorder((y * width + x) * 4)) return false + return true + } + const columnIsBorder = (x: number, top: number, bottom: number) => { + for (let y = top; y <= bottom; y += 1) if (!isBorder((y * width + x) * 4)) return false + return true + } + let top = 0 + while (top < height && rowIsBorder(top)) top += 1 + if (top === height) return null + let bottom = height - 1 + while (bottom > top && rowIsBorder(bottom)) bottom -= 1 + let left = 0 + while (left < width && columnIsBorder(left, top, bottom)) left += 1 + let right = width - 1 + while (right > left && columnIsBorder(right, top, bottom)) right -= 1 + return { x: left, y: top, width: right - left + 1, height: bottom - top + 1 } +} + +/** Grows an area by `padding` on every side, within the image. */ +export function padArea(area: Area, padding: number, width: number, height: number): Area { + const x = Math.max(0, area.x - padding) + const y = Math.max(0, area.y - padding) + return { + x, + y, + width: Math.min(width, area.x + area.width + padding) - x, + height: Math.min(height, area.y + area.height + padding) - y, + } +} + +function words(pixels: Pixels) { + return new Uint32Array(pixels.data.buffer, pixels.data.byteOffset, pixels.width * pixels.height) +} + +/** Each pixel becomes a `scale` by `scale` block. */ +function scaleNearest(pixels: Pixels, scale: number): Pixels { + const width = pixels.width * scale + const height = pixels.height * scale + const data = new Uint8ClampedArray(width * height * 4) + const source = words(pixels) + const target = new Uint32Array(data.buffer) + for (let y = 0; y < height; y += 1) { + const from = Math.floor(y / scale) * pixels.width + for (let x = 0; x < width; x += 1) target[y * width + x] = source[from + Math.floor(x / scale)] + } + return { data, width, height } +} + +/** + * Scale2x and Scale3x (Andrea Mazzoleni): enlarge like nearest neighbour, but where two + * neighbouring pixels of one colour meet on a diagonal, continue that colour into the corner. + * Output pixels are always source colours, so edges stay sharp. + */ +function scaleSmooth(pixels: Pixels, scale: 2 | 3): Pixels { + const { width, height } = pixels + const source = words(pixels) + const outWidth = width * scale + const data = new Uint8ClampedArray(outWidth * height * scale * 4) + const target = new Uint32Array(data.buffer) + const at = (x: number, y: number) => + source[Math.min(height - 1, Math.max(0, y)) * width + Math.min(width - 1, Math.max(0, x))] + for (let y = 0; y < height; y += 1) { + for (let x = 0; x < width; x += 1) { + const a = at(x - 1, y - 1) + const b = at(x, y - 1) + const c = at(x + 1, y - 1) + const d = at(x - 1, y) + const e = at(x, y) + const f = at(x + 1, y) + const g = at(x - 1, y + 1) + const h = at(x, y + 1) + const i = at(x + 1, y + 1) + const set = (column: number, row: number, value: number) => { + target[(y * scale + row) * outWidth + x * scale + column] = value + } + if (scale === 2) { + set(0, 0, d === b && b !== f && d !== h ? d : e) + set(1, 0, b === f && b !== d && f !== h ? f : e) + set(0, 1, d === h && d !== b && h !== f ? d : e) + set(1, 1, h === f && d !== h && b !== f ? f : e) + continue + } + const db = d === b && d !== h && b !== f + const bf = b === f && b !== d && f !== h + const dh = d === h && d !== b && h !== f + const hf = h === f && h !== d && f !== b + set(0, 0, db ? d : e) + set(1, 0, (db && e !== c) || (bf && e !== a) ? b : e) + set(2, 0, bf ? f : e) + set(0, 1, (db && e !== g) || (dh && e !== a) ? d : e) + set(1, 1, e) + set(2, 1, (bf && e !== i) || (hf && e !== c) ? f : e) + set(0, 2, dh ? d : e) + set(1, 2, (dh && e !== i) || (hf && e !== g) ? h : e) + set(2, 2, hf ? f : e) + } + } + return { data, width: outWidth, height: height * scale } +} + +export function scalePixelArt(pixels: Pixels, scale: 2 | 3 | 4, method: 'nearest' | 'smooth') { + if (method === 'nearest') return scaleNearest(pixels, scale) + if (scale === 4) return scaleSmooth(scaleSmooth(pixels, 2), 2) + return scaleSmooth(pixels, scale) +} diff --git a/src/features/images/text.ts b/src/features/images/text.ts new file mode 100644 index 0000000..962d989 --- /dev/null +++ b/src/features/images/text.ts @@ -0,0 +1,54 @@ +/** + * Renders text to pixels with an OffscreenCanvas, which workers have. Only the glyph shapes come + * from the canvas; placing and blending them uses our own compositing, like every other node. + */ +import type { Rgba } from '#/features/images/colour' +import type { Pixels } from '#/features/images/types' + +export type FontFamily = 'sans-serif' | 'serif' | 'monospace' + +export interface TextOptions { + /** Font size in pixels. */ + size: number + colour: Rgba + family: FontFamily + bold: boolean + /** Clockwise rotation in degrees. */ + angle?: number +} + +function context2d(width: number, height: number) { + if (typeof OffscreenCanvas === 'undefined') throw new Error('Text needs canvas support.') + const context = new OffscreenCanvas(width, height).getContext('2d') + if (!context) throw new Error('Text needs canvas support.') + return context +} + +/** The text in its colour on a transparent background, cropped close to the glyphs. */ +export function renderText(text: string, options: TextOptions): Pixels { + const font = `${options.bold ? 'bold' : 'normal'} ${Math.max(1, options.size)}px ${options.family}` + const measure = context2d(1, 1) + measure.font = font + const metrics = measure.measureText(text) + const left = metrics.actualBoundingBoxLeft + const right = metrics.actualBoundingBoxRight + const ascent = metrics.actualBoundingBoxAscent + const descent = metrics.actualBoundingBoxDescent + const padding = Math.ceil(options.size * 0.05) + 1 + const boxWidth = Math.ceil(left + right) + padding * 2 + const boxHeight = Math.ceil(ascent + descent) + padding * 2 + const radians = ((options.angle ?? 0) * Math.PI) / 180 + const cos = Math.abs(Math.cos(radians)) + const sin = Math.abs(Math.sin(radians)) + const width = Math.max(1, Math.ceil(boxWidth * cos + boxHeight * sin)) + const height = Math.max(1, Math.ceil(boxWidth * sin + boxHeight * cos)) + const context = context2d(width, height) + const [red, green, blue] = options.colour + context.font = font + context.fillStyle = `rgb(${red} ${green} ${blue})` + context.translate(width / 2, height / 2) + context.rotate(radians) + context.fillText(text, (left - right) / 2, (ascent - descent) / 2) + const image = context.getImageData(0, 0, width, height) + return { data: image.data, width, height } +} diff --git a/src/features/images/tone.ts b/src/features/images/tone.ts new file mode 100644 index 0000000..fbc669c --- /dev/null +++ b/src/features/images/tone.ts @@ -0,0 +1,138 @@ +/** + * Colour and tone changes that map each pixel on its own: Adjust and Filters. Alpha is kept. + */ +import { lumaOf, type Rgba } from '#/features/images/colour' +import type { Pixels } from '#/features/images/types' + +export interface ToneAdjustments { + /** −100 to 100: shifts every level by up to 128. */ + brightness: number + /** −100 to 100: scales the distance from the middle level, from flat grey to double. */ + contrast: number + /** −100 to 100: −100 leaves only luminance, 100 doubles the distance from it. */ + saturation: number + /** Stops of light, −3 to 3. Each stop doubles or halves linear light. */ + exposure: number +} + +const toLinear = (value: number) => { + const v = value / 255 + return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4 +} + +const toStored = (linear: number) => { + const v = linear <= 0.0031308 ? linear * 12.92 : 1.055 * linear ** (1 / 2.4) - 0.055 + return v * 255 +} + +const clamp = (value: number) => Math.min(255, Math.max(0, value)) + +/** One lookup table for exposure, brightness and contrast, in that order. */ +function levelTable({ brightness, contrast, exposure }: ToneAdjustments) { + const gain = 2 ** exposure + const shift = (brightness / 100) * 128 + const spread = 1 + contrast / 100 + const table = new Uint8ClampedArray(256) + for (let level = 0; level < 256; level += 1) { + let value = exposure === 0 ? level : toStored(Math.min(1, toLinear(level) * gain)) + value = clamp(value + shift) + value = (value - 128) * spread + 128 + table[level] = Math.round(clamp(value)) + } + return table +} + +export function isNeutral(adjustments: ToneAdjustments) { + return ( + adjustments.brightness === 0 && + adjustments.contrast === 0 && + adjustments.saturation === 0 && + adjustments.exposure === 0 + ) +} + +export function adjustTone(pixels: Pixels, adjustments: ToneAdjustments): Pixels { + const table = levelTable(adjustments) + const colourScale = 1 + adjustments.saturation / 100 + const source = pixels.data + const data = new Uint8ClampedArray(source.length) + for (let at = 0; at < source.length; at += 4) { + let red = table[source[at]] + let green = table[source[at + 1]] + let blue = table[source[at + 2]] + if (colourScale !== 1) { + const luma = lumaOf(red, green, blue) + red = luma + (red - luma) * colourScale + green = luma + (green - luma) * colourScale + blue = luma + (blue - luma) * colourScale + } + data[at] = red + data[at + 1] = green + data[at + 2] = blue + data[at + 3] = source[at + 3] + } + return { data, width: pixels.width, height: pixels.height } +} + +export type FilterName = 'grayscale' | 'sepia' | 'duotone' | 'invert' + +export interface FilterOptions { + filter: FilterName + /** 0 to 100: how much of the filtered colour replaces the original. */ + strength: number + /** Duotone maps dark levels to `shadows` and light levels to `highlights`. */ + shadows: Rgba + highlights: Rgba +} + +/** The classic sepia matrix. */ +const SEPIA = [ + [0.393, 0.769, 0.189], + [0.349, 0.686, 0.168], + [0.272, 0.534, 0.131], +] + +export function applyFilter(pixels: Pixels, options: FilterOptions): Pixels { + const mix = options.strength / 100 + const source = pixels.data + const data = new Uint8ClampedArray(source.length) + const filtered: [number, number, number] = [0, 0, 0] + const { shadows, highlights } = options + for (let at = 0; at < source.length; at += 4) { + const red = source[at] + const green = source[at + 1] + const blue = source[at + 2] + switch (options.filter) { + case 'grayscale': { + const luma = lumaOf(red, green, blue) + filtered[0] = luma + filtered[1] = luma + filtered[2] = luma + break + } + case 'sepia': + for (let channel = 0; channel < 3; channel += 1) { + const [r, g, b] = SEPIA[channel] + filtered[channel] = r * red + g * green + b * blue + } + break + case 'duotone': { + const t = lumaOf(red, green, blue) / 255 + for (let channel = 0; channel < 3; channel += 1) { + filtered[channel] = shadows[channel] + (highlights[channel] - shadows[channel]) * t + } + break + } + case 'invert': + filtered[0] = 255 - red + filtered[1] = 255 - green + filtered[2] = 255 - blue + break + } + data[at] = red + (filtered[0] - red) * mix + data[at + 1] = green + (filtered[1] - green) * mix + data[at + 2] = blue + (filtered[2] - blue) * mix + data[at + 3] = source[at + 3] + } + return { data, width: pixels.width, height: pixels.height } +} diff --git a/src/features/nodes/__tests__/batch-three.browser.test.ts b/src/features/nodes/__tests__/batch-three.browser.test.ts new file mode 100644 index 0000000..a979f67 --- /dev/null +++ b/src/features/nodes/__tests__/batch-three.browser.test.ts @@ -0,0 +1,602 @@ +/** + * Node batch 3. Each test runs the node in a real pipeline and reads what Output delivered: + * decoded images, parsed JSON and text, and the structure of ICO and PDF files. + */ +import { describe, expect, it } from 'vitest' + +import { base64ToBytes } from '#/lib/base64' +import { + BLUE, + chain, + decodeFile, + fixtureBytes, + near, + noisyPixels, + pixelAt, + pngInput, + RED, + run, + solidPixels, +} from './harness' + +/** The compressed image data of a JPEG, from its start-of-scan marker on. */ +function jpegScan(bytes: Uint8Array) { + for (let index = 2; index < bytes.length - 1; index += 1) { + if (bytes[index] === 0xff && bytes[index + 1] === 0xda) return bytes.slice(index) + } + throw new Error('No scan') +} + +describe('Set copyright / author', () => { + const fields = { artist: 'Grace Hopper', copyright: '© 2026 Grace Hopper' } + + it('writes author and copyright into files without metadata', async () => { + const { files } = await run(chain(['set-copyright', fields]), ['photo.png']) + const image = await decodeFile(files()[0].bytes) + expect(image.exif).toMatchObject(fields) + const before = await decodeFile(await fixtureBytes('photo.png')) + expect(image.pixels.data).toEqual(before.pixels.data) + }) + + it('replaces the fields and keeps the rest without re-encoding', async () => { + const { files } = await run(chain(['set-copyright', fields]), ['photo.jpg']) + const bytes = files()[0].bytes + const image = await decodeFile(bytes) + expect(image.exif).toMatchObject({ ...fields, make: 'Hexlode Test', hasGps: true }) + expect(jpegScan(bytes)).toEqual(jpegScan(await fixtureBytes('photo.jpg'))) + }) + + it('leaves a field alone when it is empty', async () => { + const { files } = await run(chain(['set-copyright', { artist: '', copyright: 'CC BY 4.0' }]), [ + 'photo.jpg', + ]) + expect((await decodeFile(files()[0].bytes)).exif).toMatchObject({ + artist: 'Ada Example', + copyright: 'CC BY 4.0', + }) + }) + + it('warns when the format cannot take new metadata', async () => { + const { warnings } = await run(chain(['set-copyright', fields]), ['photo.qoi']) + expect(warnings().map(({ code }) => code)).toEqual(['metadata_dropped']) + }) +}) + +describe('Best format', () => { + it('keeps the smallest of several encodings', async () => { + const noise = await pngInput('noise.png', noisyPixels(64)) + const flat = await pngInput('flat.png', solidPixels(64, 64, [40, 90, 200, 255])) + const { files, records } = await run( + chain(['best-format', { formats: ['jpeg', 'png'], quality: 60 }]), + [noise, flat], + ) + expect(files().map(({ name }) => name)).toEqual(['noise.jpg', 'flat.png']) + const [photo, graphic] = await Promise.all(files().map(({ bytes }) => decodeFile(bytes))) + expect(photo).toMatchObject({ format: 'jpeg', width: 64, height: 64 }) + expect(graphic).toMatchObject({ format: 'png', width: 64, height: 64 }) + const [first] = records('best-format-1') + expect(first.fields).toMatchObject({ format: 'JPEG', jpeg: files()[0].bytes.length }) + expect(Number(first.fields.png)).toBeGreaterThan(Number(first.fields.jpeg)) + }) + + it('keeps the original file when it is smaller, if asked', async () => { + const { files } = await run(chain(['best-format', { formats: ['png'], keepOriginal: true }]), [ + 'photo.webp', + ]) + expect(files()[0].bytes).toEqual(await fixtureBytes('photo.webp')) + }) + + it('writes the metadata into the file it keeps', async () => { + const { files } = await run(chain(['best-format', { formats: ['webp'] }]), ['photo.jpg']) + expect((await decodeFile(files()[0].bytes)).exif.copyright).toBe('(c) 2026 Ada Example') + }) +}) + +const text = (bytes: Uint8Array) => new TextDecoder().decode(bytes) + +describe('Palette', () => { + /** Three quarters red, one quarter blue. */ + const redAndBlue = () => + pngInput( + 'flag.png', + solidPixels(40, 10, (x) => (x < 30 ? [255, 0, 0, 255] : [0, 0, 255, 255])), + ) + + it('extracts the main colours as JSON, largest share first', async () => { + const { files } = await run(chain(['palette', { count: 4, format: 'json' }]), [ + await redAndBlue(), + ]) + const [file] = files() + expect(file.name).toBe('flag.palette.json') + expect(JSON.parse(text(file.bytes))).toEqual({ + image: 'flag.png', + colours: [ + { hex: '#ff0000', share: 0.75 }, + { hex: '#0000ff', share: 0.25 }, + ], + }) + }) + + it('writes CSS variables named after the image', async () => { + const { files } = await run(chain(['palette', { count: 2, format: 'css' }]), [ + await redAndBlue(), + ]) + const [file] = files() + expect(file.name).toBe('flag.palette.css') + expect(text(file.bytes)).toBe(':root {\n --flag-1: #ff0000;\n --flag-2: #0000ff;\n}\n') + }) + + it('ignores transparent pixels', async () => { + const input = await pngInput( + 'mark.png', + solidPixels(10, 10, (_x, y) => (y < 5 ? [0, 255, 0, 0] : [255, 0, 0, 255])), + ) + const { files } = await run(chain(['palette']), [input]) + expect(JSON.parse(text(files()[0].bytes)).colours).toEqual([{ hex: '#ff0000', share: 1 }]) + }) +}) + +/** Decodes a BlurHash to RGB pixels, written from the specification at blurha.sh. */ +function decodeBlurhash(hash: string, width: number, height: number) { + const digits = + '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~' + const number = (part: string) => [...part].reduce((value, c) => value * 83 + digits.indexOf(c), 0) + const linear = (value: number) => { + const v = value / 255 + return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4 + } + const stored = (value: number) => { + const v = Math.max(0, Math.min(1, value)) + return Math.round((v <= 0.0031308 ? v * 12.92 : 1.055 * v ** (1 / 2.4) - 0.055) * 255) + } + const size = number(hash[0]) + const across = (size % 9) + 1 + const down = Math.floor(size / 9) + 1 + const maximum = (number(hash[1]) + 1) / 166 + const dc = number(hash.slice(2, 6)) + const factors = [[linear(dc >> 16), linear((dc >> 8) & 255), linear(dc & 255)]] + for (let index = 1; index < across * down; index += 1) { + const value = number(hash.slice(4 + index * 2, 6 + index * 2)) + const ac = (q: number) => Math.sign(q - 9) * ((q - 9) / 9) ** 2 * maximum + factors.push([ac(Math.floor(value / 361)), ac(Math.floor(value / 19) % 19), ac(value % 19)]) + } + return (x: number, y: number) => { + const sum = [0, 0, 0] + for (let j = 0; j < down; j += 1) { + for (let i = 0; i < across; i += 1) { + const basis = Math.cos((Math.PI * x * i) / width) * Math.cos((Math.PI * y * j) / height) + for (let c = 0; c < 3; c += 1) sum[c] += factors[i + j * across][c] * basis + } + } + return sum.map(stored) + } +} + +describe('Rename with data and documents', () => { + it('renames data and documents and keeps their own extension', async () => { + const { files } = await run( + chain(['palette', { format: 'css' }], ['rename', { template: '{name}-colours' }]), + ['photo.png'], + ) + expect(files().map(({ name }) => name)).toEqual(['photo.palette-colours.css']) + const pdf = await run( + chain(['images-to-pdf'], ['rename', { template: 'Album {name}', replaceSpaces: true }]), + ['photo.png'], + ) + expect(pdf.files().map(({ name }) => name)).toEqual(['Album-images.pdf']) + }) +}) + +describe('Placeholder', () => { + it('makes a tiny image as a data URI with the size and average colour', async () => { + const { files } = await run(chain(['placeholder', { method: 'data-uri', size: 16 }]), [ + 'photo.png', + ]) + const [file] = files() + expect(file.name).toBe('photo.placeholder.json') + const value = JSON.parse(text(file.bytes)) + expect(value).toMatchObject({ image: 'photo.png', width: 48, height: 32, colour: '#800080' }) + const [prefix, data] = value.dataUri.split(',') + expect(prefix).toBe('data:image/webp;base64') + const tiny = await decodeFile(base64ToBytes(data)) + expect(tiny).toMatchObject({ format: 'webp', width: 16, height: 11 }) + expect(near(pixelAt(tiny, 2, 5), RED, 64)).toBe(true) + expect(near(pixelAt(tiny, 13, 5), BLUE, 64)).toBe(true) + }) + + it('encodes a BlurHash that decodes to the same picture', async () => { + const { files } = await run( + chain(['placeholder', { method: 'blurhash', componentsX: 4, componentsY: 3 }]), + ['photo.png'], + ) + const { blurhash } = JSON.parse(text(files()[0].bytes)) + expect(blurhash).toHaveLength(28) + expect(blurhash[0]).toBe('L') + const at = decodeBlurhash(blurhash, 48, 32) + const [leftRed, , leftBlue] = at(6, 16) + const [rightRed, , rightBlue] = at(42, 16) + expect(leftRed - leftBlue).toBeGreaterThan(80) + expect(rightBlue - rightRed).toBeGreaterThan(80) + }) + + it('measures turned photos upright', async () => { + const { files } = await run(chain(['placeholder']), ['oriented.jpg']) + expect(JSON.parse(text(files()[0].bytes))).toMatchObject({ width: 32, height: 48 }) + }) +}) + +describe('Responsive set', () => { + const wide = () => + pngInput( + 'hero.png', + solidPixels(2000, 1000, (x) => (x < 1000 ? [255, 0, 0, 255] : [0, 0, 255, 255])), + ) + + it('makes each width and a srcset snippet that names them', async () => { + const { files } = await run( + chain([ + 'responsive-set', + { + widths: [960, 480, 4000], + format: 'webp', + sizes: '(min-width: 800px) 50vw, 100vw', + path: '/img/', + }, + ]), + [await wide()], + ) + expect(files().map(({ name }) => name)).toEqual([ + 'hero-480w.webp', + 'hero-960w.webp', + 'hero-2000w.webp', + 'hero.html', + ]) + const sizes = await Promise.all( + files() + .slice(0, 3) + .map(async ({ bytes }) => { + const image = await decodeFile(bytes) + return [image.format, image.width, image.height] + }), + ) + expect(sizes).toEqual([ + ['webp', 480, 240], + ['webp', 960, 480], + ['webp', 2000, 1000], + ]) + expect(text(files()[3].bytes)).toBe( + [ + '', + '', + ].join('\n'), + ) + }) + + it('keeps the source format and makes one size for small images', async () => { + const { files } = await run(chain(['responsive-set', { format: 'original' }]), ['photo.jpg']) + expect(files().map(({ name }) => name)).toEqual(['photo-48w.jpg', 'photo.html']) + const image = await decodeFile(files()[0].bytes) + expect(image).toMatchObject({ format: 'jpeg', width: 48, height: 32 }) + expect(image.exif.copyright).toBe('(c) 2026 Ada Example') + }) +}) + +/** The images in an ICO file, read from its directory: each entry's size and stored bytes. */ +function icoEntries(bytes: Uint8Array) { + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength) + expect([view.getUint16(0, true), view.getUint16(2, true)]).toEqual([0, 1]) + return Array.from({ length: view.getUint16(4, true) }, (_, index) => { + const at = 6 + index * 16 + const length = view.getUint32(at + 8, true) + const offset = view.getUint32(at + 12, true) + return { + width: bytes[at] || 256, + height: bytes[at + 1] || 256, + bitCount: view.getUint16(at + 6, true), + data: bytes.slice(offset, offset + length), + } + }) +} + +describe('Favicon / App icons', () => { + const favicon = (settings: Record = {}) => + run( + chain([ + 'favicon', + { name: 'Hexlode', background: '#ffffff', themeColour: '#112233', ...settings }, + ]), + ['alpha.png'], + ) + + it('makes every icon size in a folder named after the image', async () => { + const { files } = await favicon() + expect(files().map(({ name }) => name)).toEqual([ + 'alpha/favicon.ico', + 'alpha/icon-512.png', + 'alpha/icon-maskable-512.png', + 'alpha/icon-192.png', + 'alpha/apple-touch-icon.png', + 'alpha/favicon-32x32.png', + 'alpha/favicon-16x16.png', + 'alpha/site.webmanifest', + 'alpha/favicon.html', + ]) + const sizes = await Promise.all( + files() + .slice(1, 7) + .map(async ({ bytes }) => { + const image = await decodeFile(bytes) + return [image.format, image.width, image.height] + }), + ) + expect(sizes).toEqual([ + ['png', 512, 512], + ['png', 512, 512], + ['png', 192, 192], + ['png', 180, 180], + ['png', 32, 32], + ['png', 16, 16], + ]) + }) + + it('packs 16, 32 and 48 pixel PNGs into the ICO file', async () => { + const { files } = await favicon() + const entries = icoEntries(files()[0].bytes) + expect(entries.map(({ width, height, bitCount }) => [width, height, bitCount])).toEqual([ + [16, 16, 32], + [32, 32, 32], + [48, 48, 32], + ]) + for (const entry of entries) { + expect(await decodeFile(entry.data)).toMatchObject({ + format: 'png', + width: entry.width, + height: entry.height, + }) + } + }) + + it('keeps transparency in icons and fills it for Apple and maskable icons', async () => { + const { files } = await favicon() + const byName = (name: string) => + files().find((file) => file.name === `alpha/${name}`)?.bytes as Uint8Array + const icon = await decodeFile(byName('icon-192.png')) + expect(pixelAt(icon, 96, 2)[3]).toBe(0) + expect(near(pixelAt(icon, 40, 150), RED)).toBe(true) + const apple = await decodeFile(byName('apple-touch-icon.png')) + expect(pixelAt(apple, 0, 0)).toEqual([255, 255, 255, 255]) + const maskable = await decodeFile(byName('icon-maskable-512.png')) + expect(pixelAt(maskable, 256, 40)).toEqual([255, 255, 255, 255]) + }) + + it('writes a web app manifest and the HTML tags', async () => { + const { files } = await favicon({ path: '/static/' }) + const manifest = JSON.parse(text(files()[7].bytes)) + expect(manifest).toMatchObject({ + name: 'Hexlode', + theme_color: '#112233', + background_color: '#ffffff', + icons: [ + { src: '/static/icon-192.png', sizes: '192x192', type: 'image/png' }, + { src: '/static/icon-512.png', sizes: '512x512', type: 'image/png' }, + { + src: '/static/icon-maskable-512.png', + sizes: '512x512', + type: 'image/png', + purpose: 'maskable', + }, + ], + }) + const html = text(files()[8].bytes) + expect(html).toContain('') + expect(html).toContain('') + expect(html).toContain('') + expect(html).toContain('') + }) +}) + +describe('Contact sheet', () => { + const GREEN = [0, 255, 0, 255] + const BLACK = [0, 0, 0, 255] + const squares = () => + Promise.all( + [RED, GREEN, BLUE, RED, GREEN].map((colour, index) => + pngInput(`square-${index}.png`, solidPixels(10, 10, colour)), + ), + ) + const sheet = (settings: Record) => ({ + columns: 2, + cell: 20, + gap: 4, + background: '#000000', + format: 'png', + ...settings, + }) + + // Images are resampled into their cells, which can move a level by one or two. + it('combines every image into one grid, in order', async () => { + const { files, statuses } = await run(chain(['contact-sheet', sheet({})]), await squares()) + expect(statuses('contact-sheet-1')).toEqual(['processed']) + const [file] = files() + expect(file.name).toBe('contact-sheet.png') + const image = await decodeFile(file.bytes) + // Two columns and three rows of 20-pixel cells with 4-pixel gaps around them. + expect(image).toMatchObject({ format: 'png', width: 52, height: 76 }) + expect(pixelAt(image, 2, 2)).toEqual(BLACK) + expect(near(pixelAt(image, 14, 14), RED, 2)).toBe(true) + expect(near(pixelAt(image, 38, 14), GREEN, 2)).toBe(true) + expect(near(pixelAt(image, 14, 38), BLUE, 2)).toBe(true) + expect(near(pixelAt(image, 38, 38), RED, 2)).toBe(true) + expect(near(pixelAt(image, 14, 62), GREEN, 2)).toBe(true) + expect(pixelAt(image, 38, 62)).toEqual(BLACK) + }) + + it('fits images inside their cell or fills it', async () => { + const contained = await run( + chain(['contact-sheet', sheet({ columns: 1, cell: 48, fit: 'contain' })]), + ['photo.png'], + ) + const inside = await decodeFile(contained.files()[0].bytes) + expect(inside).toMatchObject({ width: 56, height: 56 }) + expect(pixelAt(inside, 10, 6)).toEqual(BLACK) + expect(near(pixelAt(inside, 10, 14), RED, 2)).toBe(true) + const covered = await run( + chain(['contact-sheet', sheet({ columns: 1, cell: 48, fit: 'cover' })]), + ['photo.png'], + ) + const filled = await decodeFile(covered.files()[0].bytes) + expect(near(pixelAt(filled, 10, 6), RED, 4)).toBe(true) + }) + + it('starts a new sheet after the chosen number of rows', async () => { + const { files } = await run( + chain(['contact-sheet', sheet({ rows: 1, name: 'proofs' })]), + await squares(), + ) + expect(files().map(({ name }) => name)).toEqual([ + 'proofs-1.png', + 'proofs-2.png', + 'proofs-3.png', + ]) + const last = await decodeFile(files()[2].bytes) + expect(last).toMatchObject({ width: 52, height: 28 }) + expect(near(pixelAt(last, 14, 14), GREEN, 2)).toBe(true) + }) + + it('writes file names under the images when asked', async () => { + const plain = await decodeFile( + ( + await run(chain(['contact-sheet', sheet({ columns: 1, cell: 120 })]), ['photo.png']) + ).files()[0].bytes, + ) + const labelled = await decodeFile( + ( + await run(chain(['contact-sheet', sheet({ columns: 1, cell: 120, labels: true })]), [ + 'photo.png', + ]) + ).files()[0].bytes, + ) + expect(labelled.height).toBeGreaterThan(plain.height) + let bright = 0 + for (let y = 124; y < labelled.height - 4; y += 1) { + for (let x = 4; x < 124; x += 1) if (pixelAt(labelled, x, y)[0] > 128) bright += 1 + } + expect(bright).toBeGreaterThan(10) + }) +}) + +/** A PDF read as text, its image streams and the checks every valid file passes. */ +function readPdf(bytes: Uint8Array) { + const source = new TextDecoder('latin1').decode(bytes) + expect(source.startsWith('%PDF-1.')).toBe(true) + expect(source.trimEnd().endsWith('%%EOF')).toBe(true) + const startxref = Number(/startxref\s+(\d+)/.exec(source)?.[1]) + expect(source.slice(startxref, startxref + 4)).toBe('xref') + const [, first, count] = /xref\s+(\d+) (\d+)/.exec(source.slice(startxref)) ?? [] + const entries = source.slice(startxref).match(/(\d{10}) (\d{5}) n/g) ?? [] + expect(entries).toHaveLength(Number(count) - 1) + for (const [index, entry] of entries.entries()) { + const offset = Number(entry.slice(0, 10)) + expect(source.slice(offset).startsWith(`${Number(first) + index + 1} 0 obj`)).toBe(true) + } + const images = [...source.matchAll(/\/Subtype \/Image([\s\S]*?)>>\s*stream\r?\n/g)].map( + (match) => { + const dictionary = match[1] + const length = Number(/\/Length (\d+)/.exec(dictionary)?.[1]) + const start = (match.index ?? 0) + match[0].length + return { + filter: /\/Filter \/(\w+)/.exec(dictionary)?.[1], + width: Number(/\/Width (\d+)/.exec(dictionary)?.[1]), + height: Number(/\/Height (\d+)/.exec(dictionary)?.[1]), + data: bytes.slice(start, start + length), + } + }, + ) + return { + pages: source.match(/\/Type \/Page(?!s)/g)?.length ?? 0, + mediaBoxes: [...source.matchAll(/\/MediaBox \[([^\]]+)\]/g)].map((match) => match[1]), + images, + } +} + +function contains(haystack: Uint8Array, needle: Uint8Array) { + outer: for (let start = 0; start <= haystack.length - needle.length; start += 1) { + for (let index = 0; index < needle.length; index += 1) { + if (haystack[start + index] !== needle[index]) continue outer + } + return true + } + return false +} + +async function inflate(data: Uint8Array) { + const stream = new Blob([data.slice()]).stream().pipeThrough(new DecompressionStream('deflate')) + return new Uint8Array(await new Response(stream).arrayBuffer()) +} + +describe('Images to PDF', () => { + it('combines images into one PDF, one page each, in order', async () => { + const { files, statuses } = await run(chain(['images-to-pdf', { name: 'album' }]), [ + 'photo.jpg', + 'photo.png', + 'oriented.jpg', + ]) + expect(statuses('images-to-pdf-1')).toEqual(['processed']) + const [file] = files() + expect(file.name).toBe('album.pdf') + const pdf = readPdf(file.bytes) + expect(pdf.pages).toBe(3) + expect(pdf.images.map(({ width, height }) => [width, height])).toEqual([ + [48, 32], + [48, 32], + [32, 48], + ]) + }) + + it('embeds JPEG files as they are and encodes other formats as JPEG', async () => { + const { files } = await run(chain(['images-to-pdf']), ['photo.jpg', 'photo.png']) + const pdf = readPdf(files()[0].bytes) + expect(pdf.images.map(({ filter }) => filter)).toEqual(['DCTDecode', 'DCTDecode']) + expect(contains(files()[0].bytes, await fixtureBytes('photo.jpg'))).toBe(true) + const encoded = await decodeFile(pdf.images[1].data) + expect(encoded).toMatchObject({ format: 'jpeg', width: 48, height: 32 }) + expect(near(pixelAt(encoded, 4, 16), RED)).toBe(true) + }) + + it('keeps pixels exactly when asked for lossless images', async () => { + const { files } = await run(chain(['images-to-pdf', { compression: 'lossless' }]), [ + 'photo.png', + ]) + const [image] = readPdf(files()[0].bytes).images + expect(image.filter).toBe('FlateDecode') + const rgb = await inflate(image.data) + expect(rgb).toHaveLength(48 * 32 * 3) + expect([...rgb.subarray(0, 3)]).toEqual([255, 0, 0]) + expect([...rgb.subarray(47 * 3, 48 * 3)]).toEqual([0, 0, 255]) + }) + + it('sizes pages to paper, turning wide images landscape, or to the image', async () => { + const paper = await run(chain(['images-to-pdf', { pageSize: 'a4' }]), [ + 'photo.png', + 'oriented.jpg', + ]) + expect(readPdf(paper.files()[0].bytes).mediaBoxes).toEqual([ + '0 0 841.89 595.28', + '0 0 595.28 841.89', + ]) + const fitted = await run(chain(['images-to-pdf', { pageSize: 'image', margin: 0 }]), [ + 'photo.png', + ]) + // 48 by 32 pixels at 96 pixels per inch is 36 by 24 points. + expect(readPdf(fitted.files()[0].bytes).mediaBoxes).toEqual(['0 0 36 24']) + }) +}) diff --git a/src/features/nodes/__tests__/batch-two.browser.test.ts b/src/features/nodes/__tests__/batch-two.browser.test.ts new file mode 100644 index 0000000..7b21fcd --- /dev/null +++ b/src/features/nodes/__tests__/batch-two.browser.test.ts @@ -0,0 +1,664 @@ +/** + * Node batch 2. Each test runs the node in a real pipeline, decodes what Output delivered and + * checks format, dimensions and pixels. + */ +import { describe, expect, it } from 'vitest' + +import { createMemoryStepCache } from '#/features/engine/memory-step-cache' +import { jsquashCodecs } from '#/features/images/codecs' +import { bytesToBase64 } from '#/lib/base64' + +import { + BLUE, + chain, + decodeFile, + fixtureBytes, + near, + noisyPixels, + pixelAt, + pngInput, + RED, + run, + solidPixels, +} from './harness' + +describe('Background', () => { + it('replaces transparency with a colour and keeps opaque pixels', async () => { + const { files } = await run(chain(['background', { colour: '#ffffff' }]), ['alpha.png']) + const image = await decodeFile(files()[0].bytes) + expect(image).toMatchObject({ format: 'png', width: 48, height: 32 }) + expect(pixelAt(image, 4, 4)).toEqual([255, 255, 255, 255]) + expect(pixelAt(image, 4, 28)).toEqual(RED) + expect(pixelAt(image, 44, 28)).toEqual(BLUE) + }) + + it('blends partly transparent pixels with the colour', async () => { + const input = await pngInput('half.png', solidPixels(4, 4, [255, 0, 0, 128])) + const { files } = await run(chain(['background', { colour: '#ffffff' }]), [input]) + expect(pixelAt(await decodeFile(files()[0].bytes), 1, 1)).toEqual([255, 127, 127, 255]) + }) + + it('passes opaque images on unchanged', async () => { + const { files } = await run(chain(['background', { colour: '#000000' }]), ['photo.jpg']) + expect(files()[0].bytes.length).toBe(666) + }) +}) + +describe('Adjust', () => { + const grey = (level: number) => + pngInput('grey.png', solidPixels(4, 4, [level, level, level, 255])) + + async function adjusted( + settings: Record, + input: { name: string; bytes: Uint8Array }, + ) { + const { files } = await run(chain(['adjust', settings]), [input]) + return pixelAt(await decodeFile(files()[0].bytes), 1, 1) + } + + it('removes saturation down to the luminance of each pixel', async () => { + const { files } = await run(chain(['adjust', { saturation: -100 }]), ['photo.png']) + const image = await decodeFile(files()[0].bytes) + // Rec. 709 luminance: red 0.2126 × 255, blue 0.0722 × 255. + expect(pixelAt(image, 4, 4)).toEqual([54, 54, 54, 255]) + expect(pixelAt(image, 44, 4)).toEqual([18, 18, 18, 255]) + }) + + it('shifts brightness by up to 128 levels', async () => { + expect(await adjusted({ brightness: 50 }, await grey(100))).toEqual([164, 164, 164, 255]) + expect(await adjusted({ brightness: -100 }, await grey(100))).toEqual([0, 0, 0, 255]) + }) + + it('spreads levels away from the middle with contrast', async () => { + expect(await adjusted({ contrast: 50 }, await grey(96))).toEqual([80, 80, 80, 255]) + expect(await adjusted({ contrast: -100 }, await grey(30))).toEqual([128, 128, 128, 255]) + }) + + it('doubles the light with one stop of exposure', async () => { + // sRGB 128 is 0.2159 linear; doubled, 0.4317 linear is sRGB 175.6. + const [red] = await adjusted({ exposure: 1 }, await grey(128)) + expect(Math.abs(red - 176)).toBeLessThanOrEqual(1) + }) + + it('passes images on unchanged when every setting is zero', async () => { + const { files } = await run(chain(['adjust']), ['photo.jpg']) + expect(files()[0].bytes.length).toBe(666) + }) +}) + +describe('Filters', () => { + async function filtered( + settings: Record, + input: string | { name: string; bytes: Uint8Array } = 'photo.png', + ) { + const { files } = await run(chain(['filters', settings]), [input]) + return decodeFile(files()[0].bytes) + } + + it('turns images grey by luminance', async () => { + const image = await filtered({ filter: 'grayscale' }) + expect(pixelAt(image, 4, 4)).toEqual([54, 54, 54, 255]) + expect(pixelAt(image, 44, 4)).toEqual([18, 18, 18, 255]) + }) + + it('tones images sepia with the classic matrix', async () => { + // Red through the sepia matrix: 0.393, 0.349 and 0.272 of 255. + expect(pixelAt(await filtered({ filter: 'sepia' }), 4, 4)).toEqual([100, 89, 69, 255]) + }) + + it('inverts colours, blended by strength', async () => { + expect(pixelAt(await filtered({ filter: 'invert' }), 4, 4)).toEqual([0, 255, 255, 255]) + expect(pixelAt(await filtered({ filter: 'invert', strength: 25 }), 4, 4)).toEqual([ + 191, 64, 64, 255, + ]) + }) + + it('maps shadows and highlights to two colours with duotone', async () => { + const input = await pngInput( + 'steps.png', + solidPixels(2, 1, (x) => (x === 0 ? [0, 0, 0, 255] : [255, 255, 255, 255])), + ) + const image = await filtered( + { filter: 'duotone', shadows: '#102030', highlights: '#f0e0d0' }, + input, + ) + expect(pixelAt(image, 0, 0)).toEqual([16, 32, 48, 255]) + expect(pixelAt(image, 1, 0)).toEqual([240, 224, 208, 255]) + }) + + it('keeps transparency', async () => { + const image = await filtered({ filter: 'invert' }, 'alpha.png') + expect(pixelAt(image, 4, 4)[3]).toBe(0) + expect(pixelAt(image, 4, 28)).toEqual([0, 255, 255, 255]) + }) +}) + +describe('Sharpen / Blur', () => { + it('blurs across edges and leaves flat areas alone', async () => { + const { files } = await run(chain(['sharpen-blur', { effect: 'blur', radius: 3 }]), [ + 'photo.png', + ]) + const image = await decodeFile(files()[0].bytes) + expect(image).toMatchObject({ width: 48, height: 32 }) + const [red, , blue] = pixelAt(image, 23, 16) + expect(blue).toBeGreaterThan(60) + expect(red).toBeLessThan(200) + expect(near(pixelAt(image, 1, 16), RED, 2)).toBe(true) + }) + + it('does not darken edges next to transparent pixels', async () => { + const input = await pngInput( + 'edge.png', + solidPixels(8, 16, (_x, y) => (y < 8 ? [0, 0, 0, 0] : [255, 255, 255, 255])), + ) + const { files } = await run(chain(['sharpen-blur', { effect: 'blur', radius: 2 }]), [input]) + const [red, green, blue, alpha] = pixelAt(await decodeFile(files()[0].bytes), 4, 8) + expect(alpha).toBeGreaterThan(0) + expect(alpha).toBeLessThan(255) + expect(Math.min(red, green, blue)).toBeGreaterThanOrEqual(250) + }) + + it('blurs only the chosen area', async () => { + const input = await pngInput('noise.png', noisyPixels(64)) + const { files } = await run( + chain([ + 'sharpen-blur', + { effect: 'blur', radius: 4, region: 'area', area: { x: 0, y: 0, width: 50, height: 50 } }, + ]), + [input], + ) + const before = await decodeFile(input.bytes) + const after = await decodeFile(files()[0].bytes) + expect(pixelAt(after, 10, 10)).not.toEqual(pixelAt(before, 10, 10)) + expect(pixelAt(after, 40, 40)).toEqual(pixelAt(before, 40, 40)) + expect(pixelAt(after, 10, 40)).toEqual(pixelAt(before, 10, 40)) + }) + + it('sharpens by pushing levels apart at edges', async () => { + const input = await pngInput( + 'step.png', + solidPixels(16, 4, (x) => (x < 8 ? [100, 100, 100, 255] : [150, 150, 150, 255])), + ) + const { files } = await run( + chain(['sharpen-blur', { effect: 'sharpen', radius: 1, amount: 100 }]), + [input], + ) + const image = await decodeFile(files()[0].bytes) + expect(pixelAt(image, 7, 2)[0]).toBeLessThan(100) + expect(pixelAt(image, 8, 2)[0]).toBeGreaterThan(150) + expect(pixelAt(image, 0, 2)[0]).toBe(100) + expect(pixelAt(image, 15, 2)[0]).toBe(150) + }) +}) + +describe('Auto-trim', () => { + const WHITE = [255, 255, 255, 255] + + /** A red 16x4 block inside a border painted by `border`: 3 left, 1 right, 2 top, 4 bottom. */ + function framed(border: number[] | ((x: number, y: number) => number[])) { + return solidPixels(20, 10, (x, y) => + x >= 3 && x < 19 && y >= 2 && y < 6 + ? RED + : typeof border === 'function' + ? border(x, y) + : border, + ) + } + + it('removes a plain-colour border', async () => { + const input = await pngInput('framed.png', framed(WHITE)) + const { files } = await run(chain(['auto-trim']), [input]) + const image = await decodeFile(files()[0].bytes) + expect(image).toMatchObject({ width: 16, height: 4 }) + expect(pixelAt(image, 0, 0)).toEqual(RED) + expect(pixelAt(image, 15, 3)).toEqual(RED) + }) + + it('removes transparent borders', async () => { + const { files } = await run(chain(['auto-trim']), ['alpha.png']) + const image = await decodeFile(files()[0].bytes) + expect(image).toMatchObject({ width: 48, height: 16 }) + expect(pixelAt(image, 4, 0)).toEqual(RED) + }) + + it('treats colours within the tolerance as border and keeps padding', async () => { + // A paper-white border that varies between 250 and 255. + const paper = (x: number, y: number) => ((x + y) % 2 === 0 ? [250, 250, 250, 255] : WHITE) + const input = await pngInput('framed.png', framed(paper)) + const strict = await run(chain(['auto-trim', { tolerance: 0 }]), [input]) + expect(await decodeFile(strict.files()[0].bytes)).toMatchObject({ width: 20, height: 10 }) + const padded = await run(chain(['auto-trim', { tolerance: 5, padding: 1 }]), [input]) + const image = await decodeFile(padded.files()[0].bytes) + expect(image).toMatchObject({ width: 18, height: 6 }) + expect(pixelAt(image, 0, 0)).toEqual(paper(2, 1)) + expect(pixelAt(image, 1, 1)).toEqual(RED) + }) + + it('leaves an image of one colour unchanged', async () => { + const input = await pngInput('plain.png', solidPixels(6, 6, WHITE)) + const { files } = await run(chain(['auto-trim']), [input]) + expect(files()[0].bytes).toEqual(input.bytes) + }) +}) + +describe('Pad / Extend', () => { + const WHITE = [255, 255, 255, 255] + + it('adds colour around the image to reach an aspect ratio', async () => { + const { files } = await run(chain(['pad', { aspect: '1:1', colour: '#ffffff' }]), ['photo.png']) + const image = await decodeFile(files()[0].bytes) + expect(image).toMatchObject({ format: 'png', width: 48, height: 48 }) + expect(pixelAt(image, 4, 7)).toEqual(WHITE) + expect(pixelAt(image, 4, 8)).toEqual(RED) + expect(pixelAt(image, 44, 39)).toEqual(BLUE) + expect(pixelAt(image, 44, 40)).toEqual(WHITE) + }) + + it('places the image at the chosen position', async () => { + const { files } = await run( + chain(['pad', { aspect: '9:16', colour: '#000000', position: 'top' }]), + ['photo.png'], + ) + const image = await decodeFile(files()[0].bytes) + expect(image).toMatchObject({ width: 48, height: 85 }) + expect(pixelAt(image, 4, 0)).toEqual(RED) + expect(pixelAt(image, 4, 32)).toEqual([0, 0, 0, 255]) + }) + + it('fills the space with a blurred copy of the image', async () => { + const { files } = await run(chain(['pad', { aspect: '1:1', fill: 'blur' }]), ['photo.png']) + const image = await decodeFile(files()[0].bytes) + expect(image).toMatchObject({ width: 48, height: 48 }) + expect(near(pixelAt(image, 4, 2), RED, 60)).toBe(true) + expect(near(pixelAt(image, 44, 2), BLUE, 60)).toBe(true) + expect(pixelAt(image, 4, 24)).toEqual(RED) + }) + + it('turns photos upright first', async () => { + const { files } = await run(chain(['pad', { aspect: '1:1' }]), ['oriented.jpg']) + const image = await decodeFile(files()[0].bytes) + expect(image).toMatchObject({ format: 'jpeg', width: 48, height: 48 }) + expect(image.exif.orientation).toBe(1) + }) + + it('passes images that already have the ratio on unchanged', async () => { + const { files } = await run(chain(['pad', { aspect: '3:2' }]), ['photo.jpg']) + expect(files()[0].bytes.length).toBe(666) + }) +}) + +describe('Pixel-art upscale', () => { + const BLACK = [0, 0, 0, 255] + const WHITE = [255, 255, 255, 255] + /** A white diagonal from the top-left corner on black. */ + const diagonal = () => + pngInput( + 'diagonal.png', + solidPixels(3, 3, (x, y) => (x === y ? WHITE : BLACK)), + ) + + it('enlarges with hard pixel edges', async () => { + const { files } = await run(chain(['pixel-art', { scale: 3 }]), ['photo.png']) + const image = await decodeFile(files()[0].bytes) + expect(image).toMatchObject({ format: 'png', width: 144, height: 96 }) + expect(pixelAt(image, 71, 0)).toEqual(RED) + expect(pixelAt(image, 72, 0)).toEqual(BLUE) + }) + + it('smooths diagonals without blurring when asked', async () => { + const nearest = await run(chain(['pixel-art', { scale: 2, method: 'nearest' }]), [ + await diagonal(), + ]) + const smooth = await run(chain(['pixel-art', { scale: 2, method: 'smooth' }]), [ + await diagonal(), + ]) + expect(pixelAt(await decodeFile(nearest.files()[0].bytes), 2, 1)).toEqual(BLACK) + const image = await decodeFile(smooth.files()[0].bytes) + expect(image).toMatchObject({ width: 6, height: 6 }) + expect(pixelAt(image, 2, 1)).toEqual(WHITE) + expect(pixelAt(image, 5, 0)).toEqual(BLACK) + // Every pixel is one of the source colours. + for (let index = 0; index < image.pixels.data.length; index += 4) { + expect([0, 255]).toContain(image.pixels.data[index]) + } + }) + + it('smooths at 3x and 4x', async () => { + for (const scale of [3, 4]) { + const { files } = await run(chain(['pixel-art', { scale, method: 'smooth' }]), [ + await diagonal(), + ]) + expect(await decodeFile(files()[0].bytes)).toMatchObject({ + width: 3 * scale, + height: 3 * scale, + }) + } + }) +}) + +describe('Split / Tile', () => { + it('cuts one image into a grid, numbered left to right and top to bottom', async () => { + const { files } = await run(chain(['split', { columns: 2, rows: 2 }]), ['photo.png']) + const delivered = files() + expect(delivered.map(({ name }) => name)).toEqual([ + 'photo-1.png', + 'photo-2.png', + 'photo-3.png', + 'photo-4.png', + ]) + const tiles = await Promise.all(delivered.map(({ bytes }) => decodeFile(bytes))) + for (const tile of tiles) expect(tile).toMatchObject({ format: 'png', width: 24, height: 16 }) + expect(pixelAt(tiles[0], 23, 15)).toEqual(RED) + expect(pixelAt(tiles[1], 0, 0)).toEqual(BLUE) + expect(pixelAt(tiles[2], 0, 0)).toEqual(RED) + expect(pixelAt(tiles[3], 23, 15)).toEqual(BLUE) + }) + + it('spreads uneven pixels across the tiles', async () => { + const { files } = await run(chain(['split', { columns: 5, rows: 1 }]), ['photo.png']) + const widths = await Promise.all( + files().map(async ({ bytes }) => (await decodeFile(bytes)).width), + ) + expect(widths).toEqual([10, 9, 10, 9, 10]) + }) + + it('pads numbers so tiles sort in order', async () => { + const { files } = await run(chain(['split', { columns: 4, rows: 3 }]), ['photo.png']) + expect( + files() + .map(({ name }) => name) + .slice(0, 2), + ).toEqual(['photo-01.png', 'photo-02.png']) + expect(files().at(-1)?.name).toBe('photo-12.png') + }) + + it('turns photos upright first and keeps their metadata', async () => { + const { files } = await run(chain(['split', { columns: 1, rows: 2 }]), ['oriented.jpg']) + const tiles = await Promise.all(files().map(({ bytes }) => decodeFile(bytes))) + expect(tiles).toHaveLength(2) + for (const tile of tiles) { + expect(tile).toMatchObject({ format: 'jpeg', width: 32, height: 24 }) + expect(tile.exif).toMatchObject({ orientation: 1, copyright: '(c) 2026 Ada Example' }) + } + }) +}) + +describe('Border / Rounded corners', () => { + const BLACK = [0, 0, 0, 255] + + it('adds a frame around the image', async () => { + const { files } = await run( + chain(['border', { unit: 'pixels', border: 4, borderColour: '#000000', radius: 0 }]), + ['photo.png'], + ) + const image = await decodeFile(files()[0].bytes) + expect(image).toMatchObject({ format: 'png', width: 56, height: 40 }) + expect(pixelAt(image, 0, 0)).toEqual(BLACK) + expect(pixelAt(image, 3, 20)).toEqual(BLACK) + expect(pixelAt(image, 4, 4)).toEqual(RED) + expect(pixelAt(image, 51, 35)).toEqual(BLUE) + expect(pixelAt(image, 55, 39)).toEqual(BLACK) + }) + + it('measures in percent of the shorter edge', async () => { + const { files } = await run(chain(['border', { unit: 'percent', border: 10, radius: 0 }]), [ + 'photo.png', + ]) + expect(await decodeFile(files()[0].bytes)).toMatchObject({ width: 54, height: 38 }) + }) + + it('rounds the corners into transparency', async () => { + const { files } = await run( + chain(['border', { unit: 'pixels', border: 0, radius: 8, corners: 'transparent' }]), + ['photo.png'], + ) + const image = await decodeFile(files()[0].bytes) + expect(image).toMatchObject({ width: 48, height: 32 }) + expect(pixelAt(image, 0, 0)[3]).toBe(0) + expect(pixelAt(image, 1, 1)[3]).toBe(0) + expect(pixelAt(image, 47, 31)[3]).toBe(0) + expect(pixelAt(image, 8, 8)).toEqual(RED) + expect(pixelAt(image, 0, 16)).toEqual(RED) + // The centre of pixel (1, 3) is 7.9 pixels from the centre of the 8-pixel arc. + const edge = pixelAt(image, 1, 3)[3] + expect(edge).toBeGreaterThan(0) + expect(edge).toBeLessThan(255) + }) + + it('fills rounded corners with a colour', async () => { + const { files } = await run( + chain([ + 'border', + { unit: 'pixels', border: 0, radius: 8, corners: 'colour', cornerColour: '#00ff00' }, + ]), + ['photo.jpg'], + ) + const image = await decodeFile(files()[0].bytes) + expect(image.format).toBe('jpeg') + expect(near(pixelAt(image, 0, 0), [0, 255, 0, 255])).toBe(true) + expect(near(pixelAt(image, 10, 16), RED)).toBe(true) + }) +}) + +/** How many pixels in an area are brighter than `level` in their red channel. */ +function brightPixels( + image: { pixels: { data: Uint8ClampedArray; width: number } }, + area: { x: number; y: number; width: number; height: number }, + level = 100, +) { + let count = 0 + for (let y = area.y; y < area.y + area.height; y += 1) { + for (let x = area.x; x < area.x + area.width; x += 1) { + if (pixelAt(image, x, y)[0] > level) count += 1 + } + } + return count +} + +const quadrants = { + topLeft: { x: 0, y: 0, width: 100, height: 50 }, + topRight: { x: 100, y: 0, width: 100, height: 50 }, + bottomLeft: { x: 0, y: 50, width: 100, height: 50 }, + bottomRight: { x: 100, y: 50, width: 100, height: 50 }, +} + +const blackInput = () => pngInput('black.png', solidPixels(200, 100, [0, 0, 0, 255])) + +describe('Text watermark', () => { + async function watermarked(settings: Record) { + const { files } = await run(chain(['text-watermark', settings]), [await blackInput()]) + return decodeFile(files()[0].bytes) + } + + it('places text at the chosen position and leaves the rest alone', async () => { + const image = await watermarked({ text: 'HEXLODE', colour: '#ffffff', opacity: 100, size: 20 }) + expect(image).toMatchObject({ format: 'png', width: 200, height: 100 }) + expect(brightPixels(image, quadrants.bottomRight)).toBeGreaterThan(20) + expect(brightPixels(image, quadrants.topLeft, 0)).toBe(0) + const left = await watermarked({ + text: 'HEXLODE', + position: 'top-left', + opacity: 100, + size: 20, + }) + expect(brightPixels(left, quadrants.topLeft)).toBeGreaterThan(20) + expect(brightPixels(left, quadrants.bottomRight, 0)).toBe(0) + }) + + it('blends the text by its opacity', async () => { + const image = await watermarked({ text: 'HEXLODE', colour: '#ffffff', opacity: 50, size: 30 }) + let brightest = 0 + for (let index = 0; index < image.pixels.data.length; index += 4) { + brightest = Math.max(brightest, image.pixels.data[index]) + } + // White at half opacity over black. + expect(Math.abs(brightest - 128)).toBeLessThanOrEqual(2) + }) + + it('repeats the text across the image as a tile', async () => { + const image = await watermarked({ text: 'HEXLODE', opacity: 100, tile: true, size: 12 }) + for (const area of Object.values(quadrants)) { + expect(brightPixels(image, area)).toBeGreaterThan(10) + } + }) + + it('passes images on unchanged when there is no text', async () => { + const { files } = await run(chain(['text-watermark', { text: ' ' }]), ['photo.jpg']) + expect(files()[0].bytes.length).toBe(666) + }) +}) + +describe('Image watermark', () => { + const GREEN = [0, 255, 0, 255] + + /** The logo setting for a 10x10 green PNG, as the Studio stores it. */ + async function greenLogo() { + const { bytes } = await pngInput('logo.png', solidPixels(10, 10, GREEN)) + return { name: 'logo.png', data: bytesToBase64(bytes), width: 10, height: 10 } + } + + async function watermarked(settings: Record) { + const result = await run(chain(['image-watermark', settings]), [await blackInput()]) + return { image: await decodeFile(result.files()[0].bytes), warnings: result.warnings() } + } + + it('places the logo scaled to a share of the image width', async () => { + const { image } = await watermarked({ + logo: await greenLogo(), + position: 'top-left', + scale: 10, + margin: 0, + opacity: 100, + }) + expect(image).toMatchObject({ format: 'png', width: 200, height: 100 }) + // The logo is resampled, which can move a level by one or two. + expect(near(pixelAt(image, 0, 0), GREEN, 2)).toBe(true) + expect(near(pixelAt(image, 19, 19), GREEN, 2)).toBe(true) + expect(pixelAt(image, 20, 20)).toEqual([0, 0, 0, 255]) + }) + + it('keeps a margin from the edge at the chosen position', async () => { + const { image } = await watermarked({ + logo: await greenLogo(), + position: 'bottom-right', + scale: 10, + margin: 5, + opacity: 100, + }) + // 20 pixels wide, 5 pixels from the right and bottom edges. + expect(near(pixelAt(image, 175, 75), GREEN, 2)).toBe(true) + expect(near(pixelAt(image, 194, 94), GREEN, 2)).toBe(true) + expect(pixelAt(image, 174, 75)).toEqual([0, 0, 0, 255]) + expect(pixelAt(image, 195, 95)).toEqual([0, 0, 0, 255]) + }) + + it('blends the logo by its opacity', async () => { + const { image } = await watermarked({ + logo: await greenLogo(), + position: 'top-left', + scale: 10, + margin: 0, + opacity: 50, + }) + expect(near(pixelAt(image, 5, 5), [0, 128, 0, 255], 2)).toBe(true) + }) + + it('passes images on unchanged with a warning when no logo is chosen', async () => { + const { files, warnings } = await run(chain(['image-watermark']), ['photo.jpg']) + expect(files()[0].bytes.length).toBe(666) + expect(warnings().map(({ code }) => code)).toEqual(['no_logo']) + }) +}) + +describe('Deduplicate', () => { + /** Files, Deduplicate, then Output for unique items and another for duplicates. */ + function dedupe(settings: Record = {}) { + const pipeline = chain(['deduplicate', settings]) + pipeline.nodes.push({ id: 'dupes', type: 'output', settings: {}, position: { x: 0, y: 0 } }) + pipeline.connections.push({ + id: 'dupes', + source: 'deduplicate-1', + sourcePort: 'duplicates', + target: 'dupes', + }) + return pipeline + } + + const noise = () => pngInput('noise.png', noisyPixels(48)) + + it('drops near duplicates by fingerprint and keeps the first', async () => { + const { files } = await run(dedupe({ mode: 'near' }), [ + 'photo.jpg', + await noise(), + 'photo.png', + 'photo.webp', + ]) + expect(files('out').map(({ name }) => name)).toEqual(['photo.jpg', 'noise.png']) + expect(files('dupes').map(({ name }) => name)).toEqual(['photo.png', 'photo.webp']) + expect(files('out')[0].bytes).toEqual(await fixtureBytes('photo.jpg')) + }) + + it('tells different photos of similar scenes apart from resaved and resized copies', async () => { + const photo = async (name: string) => { + const response = await fetch(new URL(`../../../../public/home/${name}`, import.meta.url)) + return { name, bytes: new Uint8Array(await response.arrayBuffer()) } + } + const dawn = await photo('photo-dawn.webp') + const desert = await photo('photo-desert.webp') + const dusk = await photo('photo-dusk.webp') + const pixels = (await decodeFile(dusk.bytes)).pixels + const resaved = { + name: 'dusk-resaved.jpg', + bytes: await jsquashCodecs.encode( + { format: 'jpeg', options: { quality: 30, progressive: false, chromaSubsampling: '420' } }, + pixels, + ), + } + const half = await pngInput( + 'dusk-half.png', + await jsquashCodecs.resize(pixels, pixels.width / 2, pixels.height / 2, 'lanczos3'), + ) + const { files } = await run(dedupe({ mode: 'near' }), [dawn, desert, dusk, resaved, half]) + expect(files('out').map(({ name }) => name)).toEqual([ + 'photo-dawn.webp', + 'photo-desert.webp', + 'photo-dusk.webp', + ]) + expect(files('dupes').map(({ name }) => name)).toEqual(['dusk-resaved.jpg', 'dusk-half.png']) + }) + + it('drops exact duplicates by their pixels', async () => { + const copy = { name: 'copy.png', bytes: await fixtureBytes('photo.png') } + const { files } = await run(dedupe({ mode: 'exact' }), ['photo.png', 'photo.jpg', copy]) + expect(files('out').map(({ name }) => name)).toEqual(['photo.png', 'photo.jpg']) + expect(files('dupes').map(({ name }) => name)).toEqual(['copy.png']) + }) + + it('keeps the largest of near duplicates when asked', async () => { + const twice = await pngInput( + 'large.png', + solidPixels(96, 64, (x) => (x < 48 ? RED : BLUE)), + ) + const { files } = await run(dedupe({ mode: 'near', keep: 'largest' }), ['photo.png', twice]) + expect(files('out').map(({ name }) => name)).toEqual(['large.png']) + expect(files('dupes').map(({ name }) => name)).toEqual(['photo.png']) + }) + + it('lets kept items report which file they came from', async () => { + const { events } = await run(dedupe(), ['photo.jpg', await noise(), 'photo.png']) + const sources = events.flatMap((event) => + event.type === 'node-item' && event.nodeId === 'out' ? [event.source] : [], + ) + expect(sources).toEqual([0, 1]) + }) + + it('reuses its step cache and still delivers the kept items', async () => { + const cache = createMemoryStepCache() + const inputs = ['photo.jpg', await noise(), 'photo.png'] + await run(dedupe(), inputs, { cache }) + const again = await run(dedupe(), inputs, { cache }) + expect(again.statuses('deduplicate-1')).toEqual(['cached']) + expect(again.files('out').map(({ name }) => name)).toEqual(['photo.jpg', 'noise.png']) + expect(again.files('out')[0].bytes).toEqual(await fixtureBytes('photo.jpg')) + }) +}) diff --git a/src/features/nodes/__tests__/harness.ts b/src/features/nodes/__tests__/harness.ts index d5b89cb..094a97e 100644 --- a/src/features/nodes/__tests__/harness.ts +++ b/src/features/nodes/__tests__/harness.ts @@ -3,6 +3,7 @@ import { fileKey } from '#/features/engine/keys' import { runPipeline, type SourceItem } from '#/features/engine/runner' import type { StepCache } from '#/features/engine/step-cache' import type { Pipeline, RunEvent } from '#/features/engine/types' +import { fileKind } from '#/features/file-items/file-item' import { inspectImageHeader } from '#/features/image-input/validators' import { jsquashCodecs } from '#/features/images/codecs' import { imageKind, loadImageItem } from '#/features/images/image-item' @@ -76,7 +77,7 @@ export async function run( const host = createInlineHost({ registry: productRegistry, services: { codecs: jsquashCodecs }, - kinds: { image: imageKind }, + kinds: { image: imageKind, data: fileKind, document: fileKind }, cache: options.cache, }) const events: RunEvent[] = [] @@ -176,6 +177,33 @@ export function noisyPixels(size = 256) { return { data, width: size, height: size } } +/** Pixels of one colour, or of the colour `paint` returns for each position. */ +export function solidPixels( + width: number, + height: number, + paint: number[] | ((x: number, y: number) => number[]), +) { + const data = new Uint8ClampedArray(width * height * 4) + for (let y = 0; y < height; y += 1) { + for (let x = 0; x < width; x += 1) { + data.set(typeof paint === 'function' ? paint(x, y) : paint, (y * width + x) * 4) + } + } + return { data, width, height } +} + +/** A lossless PNG input made from pixels. */ +export async function pngInput( + name: string, + pixels: { data: Uint8ClampedArray; width: number; height: number }, +) { + const bytes = await jsquashCodecs.encode( + { format: 'png', options: { optimisationLevel: 0, interlace: false } }, + pixels, + ) + return { name, bytes } +} + /** Reads every entry of a ZIP (stored, as client-zip writes it) through its central directory. */ export async function unzip(archive: Blob) { const bytes = new Uint8Array(await archive.arrayBuffer()) diff --git a/src/features/nodes/__tests__/node-pair-matrix-1.browser.test.ts b/src/features/nodes/__tests__/node-pair-matrix-1.browser.test.ts new file mode 100644 index 0000000..d9dd05b --- /dev/null +++ b/src/features/nodes/__tests__/node-pair-matrix-1.browser.test.ts @@ -0,0 +1,3 @@ +import { describePairMatrix } from './node-pair-matrix' + +describePairMatrix(1) diff --git a/src/features/nodes/__tests__/node-pair-matrix-2.browser.test.ts b/src/features/nodes/__tests__/node-pair-matrix-2.browser.test.ts new file mode 100644 index 0000000..6eed90a --- /dev/null +++ b/src/features/nodes/__tests__/node-pair-matrix-2.browser.test.ts @@ -0,0 +1,3 @@ +import { describePairMatrix } from './node-pair-matrix' + +describePairMatrix(2) diff --git a/src/features/nodes/__tests__/node-pair-matrix-3.browser.test.ts b/src/features/nodes/__tests__/node-pair-matrix-3.browser.test.ts new file mode 100644 index 0000000..8ec653f --- /dev/null +++ b/src/features/nodes/__tests__/node-pair-matrix-3.browser.test.ts @@ -0,0 +1,3 @@ +import { describePairMatrix } from './node-pair-matrix' + +describePairMatrix(3) diff --git a/src/features/nodes/__tests__/node-pair-matrix-4.browser.test.ts b/src/features/nodes/__tests__/node-pair-matrix-4.browser.test.ts new file mode 100644 index 0000000..3d56769 --- /dev/null +++ b/src/features/nodes/__tests__/node-pair-matrix-4.browser.test.ts @@ -0,0 +1,3 @@ +import { describePairMatrix } from './node-pair-matrix' + +describePairMatrix(4) diff --git a/src/features/nodes/__tests__/node-pair-matrix.browser.test.ts b/src/features/nodes/__tests__/node-pair-matrix.ts similarity index 61% rename from src/features/nodes/__tests__/node-pair-matrix.browser.test.ts rename to src/features/nodes/__tests__/node-pair-matrix.ts index bd7fde8..8a3db5e 100644 --- a/src/features/nodes/__tests__/node-pair-matrix.browser.test.ts +++ b/src/features/nodes/__tests__/node-pair-matrix.ts @@ -1,6 +1,7 @@ /** * Connects every pair of node types and checks that the Studio's accept or refuse decision matches * what the engine does when it runs that pair. Every node added to the registry joins the matrix. + * The pairs run as interleaved shards, one test file each, so browsers run them in parallel. */ import { describe, expect, it } from 'vitest' @@ -9,13 +10,20 @@ import { createInlineHost } from '#/features/engine/inline-host' import { createMemoryStepCache } from '#/features/engine/memory-step-cache' import { runPipeline } from '#/features/engine/runner' import type { NodeRegistry, Pipeline, RunEvent } from '#/features/engine/types' +import { fileKind } from '#/features/file-items/file-item' import { jsquashCodecs } from '#/features/images/codecs' import { imageKind } from '#/features/images/image-item' -import { BATCH_ONE, createRegistry } from '#/features/nodes/registry' -import { ALL_FORMAT_FIXTURES, fixtureBytes, sourceFromBytes } from './harness' +import { createRegistry, PRODUCT_NODES } from '#/features/nodes/registry' +import { + ALL_FORMAT_FIXTURES, + fixtureBytes, + noisyPixels, + pngInput, + sourceFromBytes, +} from './harness' import { TEST_ONLY_NODES } from './test-only-nodes' -const registry: NodeRegistry = createRegistry([...BATCH_ONE, ...TEST_ONLY_NODES]) +const registry: NodeRegistry = createRegistry([...PRODUCT_NODES, ...TEST_ONLY_NODES]) const types = registry.list().map((node) => node.type) const cache = createMemoryStepCache() @@ -73,12 +81,22 @@ function pairPipeline(source: string, target: string, port: string) { return { pipeline, candidate } } -async function runCounts(pipeline: Pipeline) { - const sources = await Promise.all( - ALL_FORMAT_FIXTURES.map(async (name, index) => - sourceFromBytes(name, await fixtureBytes(name), index), - ), +/** + * One fixture per format, all the same picture, then one different picture. Nodes that route by + * content, such as Deduplicate, then send several formats down each of their outputs. + */ +async function matrixSources() { + const fixtures = await Promise.all( + ALL_FORMAT_FIXTURES.map(async (name) => ({ name, bytes: await fixtureBytes(name) })), ) + const different = await pngInput('noise.png', noisyPixels(48)) + return Promise.all( + [...fixtures, different].map(({ name, bytes }, index) => sourceFromBytes(name, bytes, index)), + ) +} + +async function runCounts(pipeline: Pipeline) { + const sources = await matrixSources() const events: RunEvent[] = [] await runPipeline({ pipeline, @@ -88,7 +106,7 @@ async function runCounts(pipeline: Pipeline) { registry, cache, services: { codecs: jsquashCodecs }, - kinds: { image: imageKind }, + kinds: { image: imageKind, data: fileKind, document: fileKind }, }), onEvent: (event) => events.push(event), }) @@ -108,22 +126,28 @@ const pairs = types.flatMap((source) => { return ports.flatMap((port) => types.map((target) => [source, port, target] as const)) }) -describe('node pair matrix', () => { - it.each(pairs)('%s (%s) → %s', async (source, port, target) => { - const { pipeline, candidate } = pairPipeline(source, target, port) - const decision: ConnectionCheck = checkConnection(pipeline, registry, candidate) - if (target === 'files') { - expect(decision.status).toBe('refused') - return - } - pipeline.connections.push({ id: 'pair', ...candidate }) - const counts = await runCounts(pipeline) - expect(counts.failed).toBe(0) - if (decision.status === 'refused') expect(counts.entered).toBe(0) - if (decision.status === 'ok') { - expect(counts.skipped).toBe(0) - expect(counts.entered).toBeGreaterThan(0) - } - if (decision.status === 'narrows') expect(counts.entered).toBeGreaterThan(0) +export const MATRIX_SHARDS = 4 + +/** Runs every `MATRIX_SHARDS`th pair, starting at `shard`, so costly nodes spread evenly. */ +export function describePairMatrix(shard: number) { + const mine = pairs.filter((_pair, index) => index % MATRIX_SHARDS === shard - 1) + describe(`node pair matrix, shard ${shard} of ${MATRIX_SHARDS}`, () => { + it.each(mine)('%s (%s) → %s', async (source, port, target) => { + const { pipeline, candidate } = pairPipeline(source, target, port) + const decision: ConnectionCheck = checkConnection(pipeline, registry, candidate) + if (target === 'files') { + expect(decision.status).toBe('refused') + return + } + pipeline.connections.push({ id: 'pair', ...candidate }) + const counts = await runCounts(pipeline) + expect(counts.failed).toBe(0) + if (decision.status === 'refused') expect(counts.entered).toBe(0) + if (decision.status === 'ok') { + expect(counts.skipped).toBe(0) + expect(counts.entered).toBeGreaterThan(0) + } + if (decision.status === 'narrows') expect(counts.entered).toBeGreaterThan(0) + }) }) -}) +} diff --git a/src/features/nodes/__tests__/settings.test.ts b/src/features/nodes/__tests__/settings.test.ts new file mode 100644 index 0000000..78c60f7 --- /dev/null +++ b/src/features/nodes/__tests__/settings.test.ts @@ -0,0 +1,55 @@ +/** Settings rules every product node follows, so saved pipelines and analytics stay stable. */ +import { describe, expect, it } from 'vitest' + +import { PRODUCT_NODES } from '#/features/nodes/registry' + +const byType = new Map(PRODUCT_NODES.map((node) => [node.type, node])) +const COLOUR_SETTINGS = [ + ['background', 'colour'], + ['pad', 'colour'], + ['filters', 'shadows'], + ['text-watermark', 'colour'], + ['border', 'borderColour'], + ['favicon', 'background'], + ['contact-sheet', 'background'], +] as const + +describe('node settings', () => { + it('has all 33 nodes of the catalogue, once each', () => { + expect(PRODUCT_NODES).toHaveLength(33) + expect(byType.size).toBe(33) + }) + + it.each( + PRODUCT_NODES.map((node) => [node.type, node] as const), + )('%s fills every setting from defaults and reads its own output back unchanged', (_type, node) => { + expect(node.parseSettings({})).toEqual(node.defaults) + expect(node.parseSettings(node.defaults)).toEqual(node.defaults) + expect(node.parseSettings(JSON.parse(JSON.stringify(node.defaults)))).toEqual(node.defaults) + }) + + it.each(COLOUR_SETTINGS)('%s takes %s as a hex colour, with or without alpha', (type, key) => { + const node = byType.get(type) + if (!node) throw new Error(`No node ${type}`) + expect(node.parseSettings({ [key]: '#AABBCC' })[key]).toBe('#aabbcc') + expect(node.parseSettings({ [key]: '#aabbcc80' })[key]).toBe('#aabbcc80') + for (const invalid of ['red', '#abc', 'aabbcc', '#gggggg']) { + expect(() => node.parseSettings({ [key]: invalid })).toThrow() + } + }) + + it('keeps every setting the user types out of analytics', () => { + const typed = { + 'text-watermark': ['text'], + 'image-watermark': ['logo'], + 'set-copyright': ['artist', 'copyright'], + 'responsive-set': ['sizes', 'path'], + favicon: ['name', 'path'], + 'contact-sheet': ['name'], + 'images-to-pdf': ['name'], + } + for (const [type, keys] of Object.entries(typed)) { + expect(byType.get(type)?.privateSettings).toEqual(keys) + } + }) +}) diff --git a/src/features/nodes/constants.ts b/src/features/nodes/constants.ts index 8ff6f48..015ccc9 100644 --- a/src/features/nodes/constants.ts +++ b/src/features/nodes/constants.ts @@ -1,12 +1,14 @@ -import { ALL_IMAGE_TYPES, typesOf } from '#/features/engine/item-types' +import { ALL_IMAGE_TYPES, ALL_ITEM_TYPES, typesOf } from '#/features/engine/item-types' import type { ItemType } from '#/features/engine/types' -export { ALL_IMAGE_TYPES } +export { ALL_IMAGE_TYPES, ALL_ITEM_TYPES } /** Formats whose encoders take a quality setting. */ export const LOSSY_FORMATS = ['jpeg', 'webp', 'avif', 'jxl'] as const export const LOSSY_IMAGE_TYPES = typesOf('image', LOSSY_FORMATS) export const PNG_TYPES = new Set(['image:png']) +export const JSON_TYPES = new Set(['data:json']) +export const TEXT_TYPES = new Set(['data:text']) export const OUTPUT_PORT = [{ id: 'out', label: 'Output' }] @@ -16,3 +18,12 @@ export const DEFAULT_TARGET_KILOBYTES = 200 export const COMPRESS_SEARCH_STEPS = 7 export const COMPRESS_MIN_QUALITY = 1 export const COMPRESS_MAX_QUALITY = 95 + +/** Logos are kept in the pipeline itself, so they must stay small. */ +export const MAX_LOGO_BYTES = 512 * 1024 +/** SVG logos are drawn at this longest edge when chosen. */ +export const SVG_LOGO_EDGE = 1024 + +/** Contact sheets stay within these limits, and start a new sheet when one is full. */ +export const MAX_SHEET_EDGE = 16_000 +export const MAX_SHEET_PIXELS = 50_000_000 diff --git a/src/features/nodes/definitions/adjust.ts b/src/features/nodes/definitions/adjust.ts new file mode 100644 index 0000000..9a853f0 --- /dev/null +++ b/src/features/nodes/definitions/adjust.ts @@ -0,0 +1,39 @@ +import { z } from 'zod' + +import { asImage, codecsOf, pixelsOf, withPixels } from '#/features/images/image-item' +import { adjustTone, isNeutral } from '#/features/images/tone' +import { ALL_IMAGE_TYPES, OUTPUT_PORT } from '#/features/nodes/constants' +import { PIXEL_OP_MS_PER_MEGAPIXEL, pixelCost } from '#/features/nodes/cost' +import { defineNode } from '#/features/nodes/define-node' + +const level = z.number().min(-100).max(100).default(0) + +export const adjustNode = defineNode({ + type: 'adjust', + label: 'Adjust', + category: 'colour', + description: 'Changes brightness, contrast, saturation and exposure.', + schema: z.object({ + brightness: level, + contrast: level, + saturation: level, + exposure: z.number().min(-3).max(3).default(0), + }), + ports: () => OUTPUT_PORT, + accepts: () => ALL_IMAGE_TYPES, + produces: (_settings, input) => input, + simulate: (settings, meta) => [ + { port: 'out', meta: isNeutral(settings) ? meta : { ...meta, size: undefined } }, + ], + cost: (settings, meta) => + isNeutral(settings) + ? { ms: 0, encodes: 0, needsPixels: false } + : pixelCost(meta, PIXEL_OP_MS_PER_MEGAPIXEL), + async run(input, settings, context) { + if (input.mode !== 'each') throw new Error('Adjust runs per item.') + const item = asImage(input.item) + if (isNeutral(settings)) return [{ port: 'out', item }] + const pixels = await pixelsOf(item, codecsOf(context)) + return [{ port: 'out', item: withPixels(item, adjustTone(pixels, settings)) }] + }, +}) diff --git a/src/features/nodes/definitions/auto-trim.ts b/src/features/nodes/definitions/auto-trim.ts new file mode 100644 index 0000000..551ef63 --- /dev/null +++ b/src/features/nodes/definitions/auto-trim.ts @@ -0,0 +1,38 @@ +import { z } from 'zod' + +import { asImage, codecsOf, pixelsOf, withPixels } from '#/features/images/image-item' +import { crop } from '#/features/images/pixel-ops' +import { padArea, trimBounds } from '#/features/images/shape' +import { ALL_IMAGE_TYPES, OUTPUT_PORT } from '#/features/nodes/constants' +import { PIXEL_OP_MS_PER_MEGAPIXEL, pixelCost } from '#/features/nodes/cost' +import { defineNode } from '#/features/nodes/define-node' + +export const autoTrimNode = defineNode({ + type: 'auto-trim', + label: 'Auto-trim', + category: 'size', + description: 'Removes plain-colour or transparent borders.', + schema: z.object({ + /** How far a colour may be from the corner's colour and still count as border, 0 to 100. */ + tolerance: z.number().min(0).max(100).default(10), + /** Border pixels to keep on every side. */ + padding: z.number().int().min(0).max(1000).default(0), + }), + ports: () => OUTPUT_PORT, + accepts: () => ALL_IMAGE_TYPES, + produces: (_settings, input) => input, + cost: (_settings, meta) => pixelCost(meta, PIXEL_OP_MS_PER_MEGAPIXEL), + async run(input, settings, context) { + if (input.mode !== 'each') throw new Error('Auto-trim runs per item.') + const item = asImage(input.item) + const pixels = await pixelsOf(item, codecsOf(context)) + const bounds = trimBounds(pixels, settings.tolerance) + if (!bounds) return [{ port: 'out', item }] + const area = padArea(bounds, settings.padding, pixels.width, pixels.height) + if (area.width === pixels.width && area.height === pixels.height) { + return [{ port: 'out', item }] + } + const trimmed = crop(pixels, area.x, area.y, area.width, area.height) + return [{ port: 'out', item: withPixels(item, trimmed) }] + }, +}) diff --git a/src/features/nodes/definitions/background.ts b/src/features/nodes/definitions/background.ts new file mode 100644 index 0000000..0625839 --- /dev/null +++ b/src/features/nodes/definitions/background.ts @@ -0,0 +1,34 @@ +import { z } from 'zod' +import { rgbaOf } from '#/features/images/colour' +import { flatten } from '#/features/images/compose' +import { asImage, codecsOf, pixelsOf, withPixels } from '#/features/images/image-item' +import { hasTransparency } from '#/features/images/pixel-ops' +import { ALL_IMAGE_TYPES, OUTPUT_PORT } from '#/features/nodes/constants' +import { PIXEL_OP_MS_PER_MEGAPIXEL, pixelCost } from '#/features/nodes/cost' +import { defineNode } from '#/features/nodes/define-node' +import { colour } from '#/features/nodes/validators' + +export const backgroundNode = defineNode({ + type: 'background', + label: 'Background', + category: 'colour', + description: 'Replaces transparency with a colour.', + schema: z.object({ colour: colour('#ffffff') }), + ports: () => OUTPUT_PORT, + accepts: () => ALL_IMAGE_TYPES, + produces: (_settings, input) => input, + simulate: (_settings, meta) => [{ port: 'out', meta }], + cost: (_settings, meta) => + meta.format === 'jpeg' + ? { ms: 0, encodes: 0, needsPixels: false } + : pixelCost(meta, PIXEL_OP_MS_PER_MEGAPIXEL), + async run(input, settings, context) { + if (input.mode !== 'each') throw new Error('Background runs per item.') + const item = asImage(input.item) + // JPEG has no transparency to replace. + if (item.meta.format === 'jpeg') return [{ port: 'out', item }] + const pixels = await pixelsOf(item, codecsOf(context)) + if (!hasTransparency(pixels)) return [{ port: 'out', item }] + return [{ port: 'out', item: withPixels(item, flatten(pixels, rgbaOf(settings.colour))) }] + }, +}) diff --git a/src/features/nodes/definitions/best-format.ts b/src/features/nodes/definitions/best-format.ts new file mode 100644 index 0000000..6ca33fb --- /dev/null +++ b/src/features/nodes/definitions/best-format.ts @@ -0,0 +1,110 @@ +import { z } from 'zod' + +import { typesOf, union } from '#/features/engine/item-types' +import type { ImageFormat, NodeWarning } from '#/features/engine/types' +import { + asImage, + codecsOf, + encodeImage, + encodeWithQuality, + FORMAT_NAMES, + replaceExtension, +} from '#/features/images/image-item' +import type { ImageItem } from '#/features/images/types' +import { ALL_IMAGE_TYPES, OUTPUT_PORT } from '#/features/nodes/constants' +import { encodeCost, predictedSize } from '#/features/nodes/cost' +import { defineNode } from '#/features/nodes/define-node' + +export const CANDIDATE_FORMATS = ['webp', 'avif', 'jpeg', 'jxl', 'png'] as const + +const schema = z.object({ + formats: z.array(z.enum(CANDIDATE_FORMATS)).min(1).max(5).default(['webp', 'avif']), + /** Quality for the lossy formats. PNG is always lossless. */ + quality: z.number().int().min(1).max(100).default(75), + /** Also consider the file as it arrived, and keep it when nothing beats it. */ + keepOriginal: z.boolean().default(false), +}) + +type BestFormatSettings = z.infer + +function uniqueFormats(settings: BestFormatSettings) { + return [...new Set(settings.formats)] +} + +export const bestFormatNode = defineNode({ + type: 'best-format', + label: 'Best format', + category: 'output', + description: 'Encodes several formats and keeps the smallest.', + schema, + ports: () => OUTPUT_PORT, + accepts: () => ALL_IMAGE_TYPES, + produces: (settings, input) => { + const encoded = typesOf('image', uniqueFormats(settings)) + return settings.keepOriginal ? union([encoded, input]) : encoded + }, + simulate: (settings, meta) => { + const sizes = uniqueFormats(settings).map((format) => ({ + format, + size: predictedSize(meta, format), + })) + const smallest = sizes.reduce((best, entry) => (entry.size < best.size ? entry : best)) + if (settings.keepOriginal && meta.size !== undefined && meta.size <= smallest.size) { + return [{ port: 'out', meta }] + } + return [ + { + port: 'out', + meta: { + ...meta, + format: smallest.format, + name: replaceExtension(meta.name, smallest.format), + size: smallest.size, + }, + }, + ] + }, + cost: (settings, meta) => { + const costs = uniqueFormats(settings).map((format) => encodeCost(meta, format)) + return { + ms: costs.reduce((total, cost) => total + cost.ms, 0), + encodes: costs.length, + needsPixels: true, + } + }, + async run(input, settings, context) { + if (input.mode !== 'each') throw new Error('Best format runs per item.') + const item = asImage(input.item) + const codecs = codecsOf(context) + const sizes: Record = {} + let best: { item: ImageItem; warnings: NodeWarning[] } | undefined + const original = + settings.keepOriginal && item.payload.encoded && !item.payload.metadataChanged + ? item.payload.encoded.byteLength + : undefined + if (original !== undefined) sizes.original = original + for (const format of uniqueFormats(settings)) { + const warnings: NodeWarning[] = [] + const encoded = await encodeImage( + item, + encodeWithQuality(format, settings.quality), + codecs, + (warning) => warnings.push(warning), + ) + const size = encoded.meta.size ?? Number.POSITIVE_INFINITY + sizes[format] = size + if (!best || size < (best.item.meta.size ?? Number.POSITIVE_INFINITY)) { + best = { item: encoded, warnings } + } + } + const keepsOriginal = + original !== undefined && (!best || original <= (best.item.meta.size ?? 0)) + const chosen = keepsOriginal || !best ? item : best.item + if (!keepsOriginal) for (const warning of best?.warnings ?? []) context.warn(warning) + context.record({ + name: item.meta.name, + fields: { format: FORMAT_NAMES[chosen.meta.format as ImageFormat], ...sizes }, + }) + return [{ port: 'out', item: chosen }] + }, +}) diff --git a/src/features/nodes/definitions/border.ts b/src/features/nodes/definitions/border.ts new file mode 100644 index 0000000..f6221c0 --- /dev/null +++ b/src/features/nodes/definitions/border.ts @@ -0,0 +1,76 @@ +import { z } from 'zod' + +import type { ItemMeta } from '#/features/engine/types' +import { rgbaOf } from '#/features/images/colour' +import { canvas, drawOver, roundCorners } from '#/features/images/compose' +import { asImage, codecsOf, pixelsOf, withPixels } from '#/features/images/image-item' +import { ALL_IMAGE_TYPES, OUTPUT_PORT } from '#/features/nodes/constants' +import { PIXEL_OP_MS_PER_MEGAPIXEL, pixelCost } from '#/features/nodes/cost' +import { defineNode } from '#/features/nodes/define-node' +import { colour } from '#/features/nodes/validators' + +const schema = z.object({ + /** Border and radius in percent of the shorter edge, or in pixels. */ + unit: z.enum(['percent', 'pixels']).default('percent'), + border: z.number().min(0).max(1000).default(3), + borderColour: colour('#ffffff'), + radius: z.number().min(0).max(1000).default(0), + /** What shows outside rounded corners. Transparency needs a format that keeps it. */ + corners: z.enum(['transparent', 'colour']).default('transparent'), + cornerColour: colour('#ffffff'), +}) + +type BorderSettings = z.infer + +/** Border width and corner radius in pixels for an image of this size. */ +function borderPixels(settings: BorderSettings, meta: Pick) { + const shorter = Math.min(meta.width ?? 0, meta.height ?? 0) + const toPixels = (value: number) => (settings.unit === 'pixels' ? value : (value / 100) * shorter) + return { border: Math.round(toPixels(settings.border)), radius: toPixels(settings.radius) } +} + +export const borderNode = defineNode({ + type: 'border', + label: 'Border / Rounded corners', + category: 'overlay', + description: 'Adds a frame or rounds the corners.', + schema, + ports: () => OUTPUT_PORT, + accepts: () => ALL_IMAGE_TYPES, + produces: (_settings, input) => input, + simulate: (settings, meta) => { + const { border } = borderPixels(settings, meta) + return [ + { + port: 'out', + meta: { + ...meta, + width: (meta.width ?? 0) + border * 2, + height: (meta.height ?? 0) + border * 2, + size: undefined, + }, + }, + ] + }, + cost: (_settings, meta) => pixelCost(meta, PIXEL_OP_MS_PER_MEGAPIXEL), + async run(input, settings, context) { + if (input.mode !== 'each') throw new Error('Border / Rounded corners runs per item.') + const item = asImage(input.item) + const { border, radius } = borderPixels(settings, item.meta) + if (border === 0 && radius <= 0) return [{ port: 'out', item }] + let pixels = await pixelsOf(item, codecsOf(context)) + if (border > 0) { + const framed = canvas( + pixels.width + border * 2, + pixels.height + border * 2, + rgbaOf(settings.borderColour), + ) + pixels = drawOver(framed, pixels, border, border) + } + if (radius > 0) { + const fill = settings.corners === 'colour' ? rgbaOf(settings.cornerColour) : null + pixels = roundCorners(pixels, radius, fill) + } + return [{ port: 'out', item: withPixels(item, pixels) }] + }, +}) diff --git a/src/features/nodes/definitions/contact-sheet.ts b/src/features/nodes/definitions/contact-sheet.ts new file mode 100644 index 0000000..0478eac --- /dev/null +++ b/src/features/nodes/definitions/contact-sheet.ts @@ -0,0 +1,176 @@ +import { z } from 'zod' + +import { itemType } from '#/features/engine/item-types' +import { stemOf } from '#/features/file-items/file-item' +import { lumaOf, type Rgba, rgbaOf } from '#/features/images/colour' +import { canvas, drawOver } from '#/features/images/compose' +import { + asImage, + codecsOf, + DEFAULT_ENCODE, + encodeImage, + uprightPixelsOf, +} from '#/features/images/image-item' +import { crop } from '#/features/images/pixel-ops' +import { renderText } from '#/features/images/text' +import type { Codecs, ImageItem, Pixels } from '#/features/images/types' +import { + ALL_IMAGE_TYPES, + MAX_SHEET_EDGE, + MAX_SHEET_PIXELS, + OUTPUT_PORT, +} from '#/features/nodes/constants' +import { defineNode } from '#/features/nodes/define-node' +import { colour } from '#/features/nodes/validators' + +const schema = z.object({ + columns: z.number().int().min(1).max(20).default(4), + /** The side of each square cell, in pixels. */ + cell: z.number().int().min(16).max(1024).default(320), + gap: z.number().int().min(0).max(200).default(8), + /** Images fit inside their cell, or fill it and lose their edges. */ + fit: z.enum(['contain', 'cover']).default('contain'), + background: colour('#ffffff'), + /** Writes each file name under its image. */ + labels: z.boolean().default(false), + /** Rows per sheet before a new sheet starts. 0 fits as many as the size limits allow. */ + rows: z.number().int().min(0).max(1000).default(0), + format: z.enum(['jpeg', 'png', 'webp']).default('jpeg'), + name: z.string().max(120).default('contact-sheet'), +}) + +type ContactSheetSettings = z.infer + +function layout(settings: ContactSheetSettings) { + const fontSize = Math.max(9, Math.round(settings.cell * 0.08)) + const labelHeight = settings.labels ? Math.round(fontSize * 1.5) : 0 + const width = settings.columns * (settings.cell + settings.gap) + settings.gap + const rowHeight = settings.cell + labelHeight + settings.gap + const fitting = Math.max( + 1, + Math.min( + Math.floor((MAX_SHEET_EDGE - settings.gap) / rowHeight), + Math.floor(MAX_SHEET_PIXELS / (width * rowHeight)), + ), + ) + const rows = settings.rows > 0 ? Math.min(settings.rows, fitting) : fitting + return { fontSize, labelHeight, width, rowHeight, perSheet: rows * settings.columns } +} + +/** The image scaled to fit inside, or to fill, a square cell. */ +async function fitCell(pixels: Pixels, cell: number, fit: 'contain' | 'cover', codecs: Codecs) { + const scale = + fit === 'contain' + ? Math.min(cell / pixels.width, cell / pixels.height) + : Math.max(cell / pixels.width, cell / pixels.height) + const width = Math.max(1, Math.round(pixels.width * scale)) + const height = Math.max(1, Math.round(pixels.height * scale)) + const scaled = + width === pixels.width && height === pixels.height + ? pixels + : await codecs.resize(pixels, width, height, 'lanczos3') + if (fit === 'contain') return scaled + const side = Math.min(cell, width, height) + return crop(scaled, Math.floor((width - side) / 2), Math.floor((height - side) / 2), side, side) +} + +/** The file name, shortened with an ellipsis until it fits the cell. */ +function label(name: string, cell: number, fontSize: number, colour: Rgba) { + const text = name.slice(name.lastIndexOf('/') + 1) + const options = { size: fontSize, colour, family: 'sans-serif' as const, bold: false } + let rendered = renderText(text, options) + for (let keep = text.length - 1; rendered.width > cell && keep > 0; keep -= 1) { + rendered = renderText(`${text.slice(0, keep)}…`, options) + } + return rendered +} + +export const contactSheetNode = defineNode({ + type: 'contact-sheet', + label: 'Contact sheet', + category: 'output', + description: 'Combines many images into one grid image.', + mode: 'all', + privateSettings: ['name'], + schema, + ports: () => OUTPUT_PORT, + accepts: () => ALL_IMAGE_TYPES, + produces: (settings) => new Set([itemType('image', settings.format)]), + async run(input, settings, context) { + if (input.mode !== 'all') throw new Error('Contact sheet waits for every item.') + const codecs = codecsOf(context) + const plan = layout(settings) + const background = rgbaOf(settings.background) + const ink: Rgba = + lumaOf(background[0], background[1], background[2]) < 128 + ? [255, 255, 255, 255] + : [51, 51, 51, 255] + const sheets = Math.max(1, Math.ceil(input.count / plan.perSheet)) + const base = stemOf(settings.name.trim() || 'contact-sheet') + const outputs: { port: string; item: ImageItem }[] = [] + let sheet: Pixels | undefined + let placed = 0 + let bytesIn = 0 + + const finish = async () => { + if (!sheet) return + const number = outputs.length + 1 + const name = `${base}${sheets > 1 ? `-${number}` : ''}.${settings.format}` + const item: ImageItem = { + meta: { + kind: 'image', + format: settings.format, + name, + width: sheet.width, + height: sheet.height, + source: { size: bytesIn, format: settings.format }, + }, + payload: { pixels: sheet, metadata: {}, metadataChanged: false }, + } + outputs.push({ + port: 'out', + item: await encodeImage(item, DEFAULT_ENCODE[settings.format], codecs), + }) + sheet = undefined + } + + for await (const entry of input.items) { + const item = asImage(entry) + const slot = placed % plan.perSheet + if (slot === 0) { + await finish() + const left = input.count - placed + const rows = Math.ceil(Math.min(left, plan.perSheet) / settings.columns) + sheet = canvas(plan.width, rows * plan.rowHeight + settings.gap, background) + } + const x = settings.gap + (slot % settings.columns) * (settings.cell + settings.gap) + const y = settings.gap + Math.floor(slot / settings.columns) * plan.rowHeight + const pixels = await fitCell( + await uprightPixelsOf(item, codecs), + settings.cell, + settings.fit, + codecs, + ) + const target = sheet as Pixels + drawOver( + target, + pixels, + x + Math.floor((settings.cell - pixels.width) / 2), + y + Math.floor((settings.cell - pixels.height) / 2), + ) + if (settings.labels) { + const text = label(item.meta.name, settings.cell, plan.fontSize, ink) + drawOver( + target, + text, + x + Math.floor((settings.cell - text.width) / 2), + y + settings.cell + Math.floor((plan.labelHeight - text.height) / 2), + ) + } + bytesIn += item.meta.size ?? 0 + placed += 1 + } + await finish() + return outputs + }, +}) diff --git a/src/features/nodes/definitions/crop.ts b/src/features/nodes/definitions/crop.ts index 89d275a..247ff2e 100644 --- a/src/features/nodes/definitions/crop.ts +++ b/src/features/nodes/definitions/crop.ts @@ -30,16 +30,20 @@ export const CROP_POSITIONS = [ 'bottom-right', ] as const -const schema = z.object({ +/** The aspect and position settings Crop shares with Pad / Extend. */ +export const aspectFields = { aspect: z.enum([...ASPECT_PRESETS, 'custom']).default('1:1'), customWidth: z.number().positive().max(1000).default(1), customHeight: z.number().positive().max(1000).default(1), position: z.enum(CROP_POSITIONS).default('center'), -}) +} + +const schema = z.object(aspectFields) type CropSettings = z.infer -function ratioOf(settings: CropSettings) { +/** The width to height ratio of an aspect setting, preset or custom. */ +export function ratioOf(settings: Pick) { if (settings.aspect === 'custom') return settings.customWidth / settings.customHeight const [width, height] = settings.aspect.split(':').map(Number) return width / height @@ -50,9 +54,12 @@ export function planCrop(settings: CropSettings, width: number, height: number) const ratio = ratioOf(settings) const cropWidth = Math.max(1, Math.min(width, Math.round(height * ratio))) const cropHeight = Math.max(1, Math.min(height, Math.round(cropWidth / ratio))) - const spareX = width - cropWidth - const spareY = height - cropHeight - const position = settings.position + const { x, y } = placeAt(settings.position, width - cropWidth, height - cropHeight) + return { x, y, width: cropWidth, height: cropHeight } +} + +/** Where something sits when `spareX` by `spareY` pixels are left over around it. */ +export function placeAt(position: (typeof CROP_POSITIONS)[number], spareX: number, spareY: number) { const x = position.includes('left') ? 0 : position.includes('right') @@ -63,7 +70,7 @@ export function planCrop(settings: CropSettings, width: number, height: number) : position.includes('bottom') ? spareY : Math.floor(spareY / 2) - return { x, y, width: cropWidth, height: cropHeight } + return { x, y } } export const cropNode = defineNode({ diff --git a/src/features/nodes/definitions/deduplicate.ts b/src/features/nodes/definitions/deduplicate.ts new file mode 100644 index 0000000..27acd5b --- /dev/null +++ b/src/features/nodes/definitions/deduplicate.ts @@ -0,0 +1,90 @@ +import { z } from 'zod' +import type { Port } from '#/features/engine/types' +import { differenceHash, exactHash, hashDistance } from '#/features/images/fingerprint' +import { asImage, codecsOf, pixelsOf } from '#/features/images/image-item' +import { ALL_IMAGE_TYPES } from '#/features/nodes/constants' +import { PIXEL_OP_MS_PER_MEGAPIXEL, pixelCost } from '#/features/nodes/cost' +import { defineNode } from '#/features/nodes/define-node' + +const DUPLICATES_PORT = 'duplicates' + +const PORTS: Port[] = [ + { id: 'out', label: 'Unique' }, + { id: DUPLICATES_PORT, label: 'Duplicates' }, +] + +const schema = z.object({ + /** Exact compares every pixel; near compares fingerprints that survive re-encoding. */ + mode: z.enum(['exact', 'near']).default('near'), + /** + * Near duplicates: the share of fingerprint bits that may differ, in percent. Resaved and + * resized copies differ by under 5%, different photos of similar scenes by over 12%. + */ + difference: z.number().min(0).max(30).default(8), + /** Which image of a group stays: the first to arrive or the one with the most pixels. */ + keep: z.enum(['first', 'largest']).default('first'), +}) + +interface Group { + fingerprint: string | Uint32Array + kept: number + pixels: number + name: string +} + +export const deduplicateNode = defineNode({ + type: 'deduplicate', + label: 'Deduplicate', + category: 'input', + description: 'Drops exact duplicates, or near duplicates by image fingerprint.', + mode: 'all', + schema, + ports: () => PORTS, + accepts: () => ALL_IMAGE_TYPES, + produces: (_settings, input) => input, + // For estimates: every item counts as unique and leaves by Unique, unchanged. + simulate: (_settings, meta) => [{ port: 'out', meta }], + cost: (_settings, meta) => pixelCost(meta, PIXEL_OP_MS_PER_MEGAPIXEL), + async run(input, settings, context) { + if (input.mode !== 'all') throw new Error('Deduplicate waits for every item.') + const codecs = codecsOf(context) + const groups: Group[] = [] + const groupOf: number[] = [] + const names: string[] = [] + let index = 0 + for await (const entry of input.items) { + const item = asImage(entry) + const pixels = await pixelsOf(item, codecs) + const fingerprint = + settings.mode === 'exact' ? exactHash(pixels) : await differenceHash(pixels, codecs) + const area = pixels.width * pixels.height + const limit = settings.difference / 100 + const match = groups.findIndex((group) => + typeof fingerprint === 'string' + ? group.fingerprint === fingerprint + : hashDistance(group.fingerprint as Uint32Array, fingerprint) <= limit, + ) + names.push(item.meta.name) + if (match === -1) { + groups.push({ fingerprint, kept: index, pixels: area, name: item.meta.name }) + groupOf.push(groups.length - 1) + } else { + groupOf.push(match) + const group = groups[match] + if (settings.keep === 'largest' && area > group.pixels) { + group.kept = index + group.pixels = area + group.name = item.meta.name + } + } + index += 1 + } + return groupOf.map((group, position) => { + const kept = groups[group].kept === position + if (!kept) { + context.record({ name: names[position], fields: { duplicateOf: groups[group].name } }) + } + return { port: kept ? 'out' : DUPLICATES_PORT, input: position } + }) + }, +}) diff --git a/src/features/nodes/definitions/favicon.ts b/src/features/nodes/definitions/favicon.ts new file mode 100644 index 0000000..63e25e7 --- /dev/null +++ b/src/features/nodes/definitions/favicon.ts @@ -0,0 +1,211 @@ +import { z } from 'zod' + +import { union } from '#/features/engine/item-types' +import type { Item, ItemMeta, ItemType } from '#/features/engine/types' +import { fileItem, jsonItem, stemOf, textItem } from '#/features/file-items/file-item' +import { packIco } from '#/features/file-items/ico' +import { type Rgba, rgbaOf } from '#/features/images/colour' +import { canvas, drawOver } from '#/features/images/compose' +import { asImage, codecsOf, uprightPixelsOf } from '#/features/images/image-item' +import { crop } from '#/features/images/pixel-ops' +import type { Codecs, EncodeOptions, ImageItem, Pixels } from '#/features/images/types' +import { ALL_IMAGE_TYPES, JSON_TYPES, PNG_TYPES, TEXT_TYPES } from '#/features/nodes/constants' +import { ENCODE_MS_PER_MEGAPIXEL } from '#/features/nodes/cost' +import { defineNode } from '#/features/nodes/define-node' +import { colour } from '#/features/nodes/validators' + +const schema = z.object({ + /** Non-square images fit inside the square, or fill it and lose their sides. */ + fit: z.enum(['contain', 'cover']).default('contain'), + /** Space around the image in each icon, in percent of the icon size. */ + padding: z.number().min(0).max(30).default(0), + /** Fills the Apple and maskable icons, which must be opaque. */ + background: colour('#ffffff'), + themeColour: colour('#ffffff'), + /** The app name in the web app manifest. The image's name when empty. */ + name: z.string().max(80).default(''), + /** Put before each file name in the manifest and HTML, such as `/icons/`. */ + path: z.string().max(300).default('/'), +}) + +type FaviconSettings = z.infer + +const ICO_SIZES = [16, 32, 48] +/** Maskable icons keep their content inside the middle 80% circle, so pad by over a fifth. */ +const MASKABLE_PADDING = 22 + +interface IconSpec { + file: string + size: number + /** Apple and maskable icons must not be transparent. */ + opaque?: boolean + padding?: number +} + +/** The PNG icons, largest first so the first one previews best. */ +const PNG_ICONS: IconSpec[] = [ + { file: 'icon-512.png', size: 512 }, + { file: 'icon-maskable-512.png', size: 512, opaque: true, padding: MASKABLE_PADDING }, + { file: 'icon-192.png', size: 192 }, + { file: 'apple-touch-icon.png', size: 180, opaque: true }, + { file: 'favicon-32x32.png', size: 32 }, + { file: 'favicon-16x16.png', size: 16 }, +] + +const ICO_TYPES = new Set(['document:ico']) + +/** Oxipng's fast level: on icons it is well over twice as quick for files under 1% larger. */ +const ICON_ENCODE: EncodeOptions = { + format: 'png', + options: { optimisationLevel: 1, interlace: false }, +} + +/** A square icon: the image fitted inside the padding, on transparency or a colour. */ +async function drawIcon( + source: Pixels, + size: number, + padding: number, + fill: Rgba | undefined, + fit: FaviconSettings['fit'], + codecs: Codecs, +) { + const box = Math.max(1, Math.round(size * (1 - (2 * padding) / 100))) + let image = source + if (fit === 'cover') { + const side = Math.min(source.width, source.height) + const x = Math.floor((source.width - side) / 2) + image = crop(source, x, Math.floor((source.height - side) / 2), side, side) + } + const scale = Math.min(box / image.width, box / image.height) + const width = Math.max(1, Math.round(image.width * scale)) + const height = Math.max(1, Math.round(image.height * scale)) + const scaled = + width === image.width && height === image.height + ? image + : await codecs.resize(image, width, height, 'lanczos3') + const x = Math.floor((size - width) / 2) + return drawOver(canvas(size, size, fill), scaled, x, Math.floor((size - height) / 2)) +} + +function manifest(settings: FaviconSettings, meta: ItemMeta) { + const name = settings.name.trim() || stemOf(meta.name) + const src = (file: string) => `${settings.path}${file}` + return { + name, + short_name: name, + icons: [ + { src: src('icon-192.png'), sizes: '192x192', type: 'image/png' }, + { src: src('icon-512.png'), sizes: '512x512', type: 'image/png' }, + { + src: src('icon-maskable-512.png'), + sizes: '512x512', + type: 'image/png', + purpose: 'maskable', + }, + ], + theme_color: settings.themeColour, + background_color: settings.background, + display: 'standalone', + } +} + +function html(settings: FaviconSettings) { + const href = (file: string) => `${settings.path}${file}`.replaceAll('"', '"') + return [ + ``, + ``, + ``, + ``, + ``, + ``, + '', + ].join('\n') +} + +function pngItem( + name: string, + size: number, + bytes: Uint8Array, + source: ItemMeta['source'], +): ImageItem { + return { + meta: { + kind: 'image', + format: 'png', + name, + size: bytes.byteLength, + width: size, + height: size, + source, + }, + payload: { encoded: bytes, metadata: {}, metadataChanged: false, encode: ICON_ENCODE }, + } +} + +export const faviconNode = defineNode({ + type: 'favicon', + label: 'Favicon / App icons', + category: 'output', + description: 'Makes every favicon and app icon size from one image.', + privateSettings: ['name', 'path'], + schema, + ports: () => [{ id: 'out', label: 'Icons and files' }], + accepts: () => ALL_IMAGE_TYPES, + produces: () => union([ICO_TYPES, PNG_TYPES, JSON_TYPES, TEXT_TYPES]), + cost: () => { + const pngSides = PNG_ICONS.map((spec) => spec.size) + const sides = [...pngSides, ...ICO_SIZES.filter((size) => !pngSides.includes(size))] + const megapixels = sides.reduce((total, side) => total + (side * side) / 1e6, 0) + return { + ms: ENCODE_MS_PER_MEGAPIXEL.png * megapixels, + encodes: sides.length, + needsPixels: true, + } + }, + async run(input, settings, context) { + if (input.mode !== 'each') throw new Error('Favicon / App icons runs per item.') + const item = asImage(input.item) + const codecs = codecsOf(context) + const pixels = await uprightPixelsOf(item, codecs) + const { name, source } = item.meta + const folder = `${name.slice(0, name.lastIndexOf('/') + 1)}${stemOf(name)}/` + const background = rgbaOf(settings.background) + // Plain icons of the same size are identical, so the ICO reuses the PNG favicons. + const plain = new Map>() + const png = async (size: number, spec?: IconSpec) => { + const isPlain = !spec?.opaque && spec?.padding === undefined + const cached = isPlain ? plain.get(size) : undefined + if (cached) return cached + const encoded = drawIcon( + pixels, + size, + spec?.padding ?? settings.padding, + spec?.opaque ? background : undefined, + settings.fit, + codecs, + ).then((icon) => codecs.encode(ICON_ENCODE, icon)) + if (isPlain) plain.set(size, encoded) + return encoded + } + + const icons: Item[] = [] + for (const spec of PNG_ICONS) { + icons.push(pngItem(`${folder}${spec.file}`, spec.size, await png(spec.size, spec), source)) + } + const icoImages = [] + for (const size of ICO_SIZES) + icoImages.push({ width: size, height: size, png: await png(size) }) + const outputs: Item[] = [ + fileItem( + { kind: 'document', format: 'ico' }, + `${folder}favicon.ico`, + packIco(icoImages), + source, + ), + ...icons, + ] + outputs.push(jsonItem(`${folder}site.webmanifest`, manifest(settings, item.meta), source)) + outputs.push(textItem('text', `${folder}favicon.html`, html(settings), source)) + return outputs.map((output) => ({ port: 'out', item: output })) + }, +}) diff --git a/src/features/nodes/definitions/filters.ts b/src/features/nodes/definitions/filters.ts new file mode 100644 index 0000000..2bd5131 --- /dev/null +++ b/src/features/nodes/definitions/filters.ts @@ -0,0 +1,44 @@ +import { z } from 'zod' + +import { rgbaOf } from '#/features/images/colour' +import { asImage, codecsOf, pixelsOf, withPixels } from '#/features/images/image-item' +import { applyFilter } from '#/features/images/tone' +import { ALL_IMAGE_TYPES, OUTPUT_PORT } from '#/features/nodes/constants' +import { PIXEL_OP_MS_PER_MEGAPIXEL, pixelCost } from '#/features/nodes/cost' +import { defineNode } from '#/features/nodes/define-node' +import { colour } from '#/features/nodes/validators' + +const FILTERS = ['grayscale', 'sepia', 'duotone', 'invert'] as const + +export const filtersNode = defineNode({ + type: 'filters', + label: 'Filters', + category: 'colour', + description: 'Applies grayscale, sepia, duotone or invert.', + schema: z.object({ + filter: z.enum(FILTERS).default('grayscale'), + strength: z.number().min(0).max(100).default(100), + shadows: colour('#1e1b4b'), + highlights: colour('#fbbf24'), + }), + ports: () => OUTPUT_PORT, + accepts: () => ALL_IMAGE_TYPES, + produces: (_settings, input) => input, + simulate: (settings, meta) => [ + { port: 'out', meta: settings.strength === 0 ? meta : { ...meta, size: undefined } }, + ], + cost: (_settings, meta) => pixelCost(meta, PIXEL_OP_MS_PER_MEGAPIXEL), + async run(input, settings, context) { + if (input.mode !== 'each') throw new Error('Filters runs per item.') + const item = asImage(input.item) + if (settings.strength === 0) return [{ port: 'out', item }] + const pixels = await pixelsOf(item, codecsOf(context)) + const result = applyFilter(pixels, { + filter: settings.filter, + strength: settings.strength, + shadows: rgbaOf(settings.shadows), + highlights: rgbaOf(settings.highlights), + }) + return [{ port: 'out', item: withPixels(item, result) }] + }, +}) diff --git a/src/features/nodes/definitions/image-watermark.ts b/src/features/nodes/definitions/image-watermark.ts new file mode 100644 index 0000000..d147a07 --- /dev/null +++ b/src/features/nodes/definitions/image-watermark.ts @@ -0,0 +1,94 @@ +import { z } from 'zod' + +import { inspectImageHeader } from '#/features/image-input/validators' +import { drawOver } from '#/features/images/compose' +import { + asImage, + codecsOf, + uprightMeta, + uprightPixelsOf, + withUprightPixels, +} from '#/features/images/image-item' +import type { Codecs, Pixels } from '#/features/images/types' +import { ALL_IMAGE_TYPES, MAX_LOGO_BYTES, OUTPUT_PORT } from '#/features/nodes/constants' +import { PIXEL_OP_MS_PER_MEGAPIXEL, pixelCost } from '#/features/nodes/cost' +import { defineNode } from '#/features/nodes/define-node' +import { CROP_POSITIONS, placeAt } from '#/features/nodes/definitions/crop' +import { base64ToBytes } from '#/lib/base64' + +/** A logo chosen in the Studio: PNG or WebP bytes in base64. SVG logos are drawn to PNG. */ +const logoSchema = z.object({ + name: z.string().max(200), + data: z.string().max(Math.ceil(MAX_LOGO_BYTES / 3) * 4), + width: z.number().int().positive(), + height: z.number().int().positive(), +}) + +export type Logo = z.infer + +const schema = z.object({ + logo: logoSchema.nullable().default(null), + position: z.enum(CROP_POSITIONS).default('bottom-right'), + /** Logo width in percent of the image width. */ + scale: z.number().min(1).max(100).default(20), + opacity: z.number().min(0).max(100).default(80), + /** Space between the logo and the edge, in percent of the shorter edge. */ + margin: z.number().min(0).max(50).default(3), +}) + +/** The last logo this worker decoded, so a batch decodes it once. */ +let decoded: { data: string; pixels: Promise } | undefined + +function logoPixels(logo: Logo, codecs: Codecs) { + if (decoded?.data !== logo.data) { + const bytes = base64ToBytes(logo.data) + const { format } = inspectImageHeader(bytes.slice().buffer) + decoded = { data: logo.data, pixels: codecs.decode(format, bytes) } + } + return decoded.pixels +} + +export const imageWatermarkNode = defineNode({ + type: 'image-watermark', + label: 'Image watermark', + category: 'overlay', + description: 'Places a logo (PNG, WebP or SVG) with position, scale and opacity.', + privateSettings: ['logo'], + schema, + ports: () => OUTPUT_PORT, + accepts: () => ALL_IMAGE_TYPES, + produces: (_settings, input) => input, + simulate: (settings, meta) => [{ port: 'out', meta: settings.logo ? uprightMeta(meta) : meta }], + cost: (_settings, meta) => pixelCost(meta, PIXEL_OP_MS_PER_MEGAPIXEL), + async run(input, settings, context) { + if (input.mode !== 'each') throw new Error('Image watermark runs per item.') + const item = asImage(input.item) + if (!settings.logo) { + context.warn({ + code: 'no_logo', + message: 'No logo is chosen, so images pass through unchanged.', + }) + return [{ port: 'out', item }] + } + if (settings.opacity === 0) return [{ port: 'out', item }] + const codecs = codecsOf(context) + // Positions follow the image as it is shown, so turned photos are made upright first. + const pixels = await uprightPixelsOf(item, codecs) + const logo = await logoPixels(settings.logo, codecs) + const width = Math.max(1, Math.round((settings.scale / 100) * pixels.width)) + const height = Math.max(1, Math.round((width * logo.height) / logo.width)) + const scaled = + width === logo.width && height === logo.height + ? logo + : await codecs.resize(logo, width, height, 'lanczos3') + const margin = Math.round((settings.margin / 100) * Math.min(pixels.width, pixels.height)) + const spot = placeAt( + settings.position, + Math.max(0, pixels.width - width - margin * 2), + Math.max(0, pixels.height - height - margin * 2), + ) + const base: Pixels = { ...pixels, data: new Uint8ClampedArray(pixels.data) } + drawOver(base, scaled, spot.x + margin, spot.y + margin, settings.opacity / 100) + return [{ port: 'out', item: withUprightPixels(item, base) }] + }, +}) diff --git a/src/features/nodes/definitions/images-to-pdf.ts b/src/features/nodes/definitions/images-to-pdf.ts new file mode 100644 index 0000000..0123a3a --- /dev/null +++ b/src/features/nodes/definitions/images-to-pdf.ts @@ -0,0 +1,135 @@ +import { z } from 'zod' +import type { ItemType } from '#/features/engine/types' +import { fileItem, stemOf } from '#/features/file-items/file-item' +import { createPdfWriter, deflate, jpegComponents, type PdfImage } from '#/features/file-items/pdf' +import { flatten } from '#/features/images/compose' +import { asImage, codecsOf, uprightPixelsOf } from '#/features/images/image-item' +import type { Codecs, ImageItem, Pixels } from '#/features/images/types' +import { ALL_IMAGE_TYPES, OUTPUT_PORT } from '#/features/nodes/constants' +import { defineNode } from '#/features/nodes/define-node' + +const PAPER = { + a4: { width: 595.28, height: 841.89 }, + letter: { width: 612, height: 792 }, +} +/** Points per pixel when a page takes the image's size: 72 points per inch at 96 pixels. */ +const POINTS_PER_PIXEL = 0.75 +const POINTS_PER_MILLIMETRE = 72 / 25.4 +const PDF_TYPES = new Set(['document:pdf']) + +const schema = z.object({ + /** Paper sizes, or each page the size of its image. */ + pageSize: z.enum(['a4', 'letter', 'image']).default('a4'), + /** Auto turns paper sideways for images wider than tall. */ + orientation: z.enum(['auto', 'portrait', 'landscape']).default('auto'), + margin: z.number().min(0).max(50).default(10), + /** JPEG files go in as they are. Others are encoded as JPEG, or kept exact as lossless. */ + compression: z.enum(['jpeg', 'lossless']).default('jpeg'), + quality: z.number().int().min(1).max(100).default(85), + name: z.string().max(120).default('images'), +}) + +type PdfSettings = z.infer + +/** The JPEG file itself, when a PDF can show it as it is: upright, grey or RGB. */ +function embeddableJpeg(item: ImageItem): PdfImage | undefined { + const { encoded } = item.payload + if (item.meta.format !== 'jpeg' || !encoded || (item.meta.orientation ?? 1) !== 1) + return undefined + const components = jpegComponents(encoded) + if (components !== 1 && components !== 3) return undefined + return { + width: item.meta.width ?? 0, + height: item.meta.height ?? 0, + colourSpace: components === 1 ? 'DeviceGray' : 'DeviceRGB', + filter: 'DCTDecode', + data: encoded, + } +} + +async function encodePixels( + pixels: Pixels, + settings: PdfSettings, + codecs: Codecs, +): Promise { + // Pages are white, so transparency becomes white. + const opaque = flatten(pixels, [255, 255, 255, 255]) + const image = { width: opaque.width, height: opaque.height, colourSpace: 'DeviceRGB' as const } + if (settings.compression === 'jpeg') { + const data = await codecs.encode( + { + format: 'jpeg', + options: { quality: settings.quality, progressive: false, chromaSubsampling: '420' }, + }, + opaque, + ) + return { ...image, filter: 'DCTDecode', data } + } + const rgb = new Uint8Array(opaque.width * opaque.height * 3) + for (let index = 0, at = 0; index < rgb.length; index += 3, at += 4) { + rgb[index] = opaque.data[at] + rgb[index + 1] = opaque.data[at + 1] + rgb[index + 2] = opaque.data[at + 2] + } + return { ...image, filter: 'FlateDecode', data: await deflate(rgb) } +} + +/** The page for an image and the box the image fills, centred inside the margins. */ +function layout(image: PdfImage, settings: PdfSettings) { + const margin = settings.margin * POINTS_PER_MILLIMETRE + if (settings.pageSize === 'image') { + const width = image.width * POINTS_PER_PIXEL + const height = image.height * POINTS_PER_PIXEL + return { + width: width + margin * 2, + height: height + margin * 2, + box: { x: margin, y: margin, width, height }, + } + } + const paper = PAPER[settings.pageSize] + const landscape = + settings.orientation === 'landscape' || + (settings.orientation === 'auto' && image.width > image.height) + const page = landscape ? { width: paper.height, height: paper.width } : paper + const room = { width: page.width - margin * 2, height: page.height - margin * 2 } + const scale = Math.min(room.width / image.width, room.height / image.height) + const width = image.width * scale + const height = image.height * scale + return { + ...page, + box: { x: (page.width - width) / 2, y: (page.height - height) / 2, width, height }, + } +} + +export const imagesToPdfNode = defineNode({ + type: 'images-to-pdf', + label: 'Images to PDF', + category: 'output', + description: 'Combines images into one PDF.', + mode: 'all', + privateSettings: ['name'], + schema, + ports: () => OUTPUT_PORT, + accepts: () => ALL_IMAGE_TYPES, + produces: () => PDF_TYPES, + async run(input, settings, context) { + if (input.mode !== 'all') throw new Error('Images to PDF waits for every item.') + const codecs = codecsOf(context) + const writer = createPdfWriter() + let bytesIn = 0 + for await (const entry of input.items) { + const item = asImage(entry) + const image = + embeddableJpeg(item) ?? + (await encodePixels(await uprightPixelsOf(item, codecs), settings, codecs)) + writer.addPage({ ...layout(image, settings), image }) + bytesIn += item.meta.size ?? 0 + } + const name = `${stemOf(settings.name.trim() || 'images')}.pdf` + const pdf = fileItem({ kind: 'document', format: 'pdf' }, name, writer.finish(), { + size: bytesIn, + format: 'pdf', + }) + return [{ port: 'out', item: pdf }] + }, +}) diff --git a/src/features/nodes/definitions/output.ts b/src/features/nodes/definitions/output.ts index 9863623..77092b4 100644 --- a/src/features/nodes/definitions/output.ts +++ b/src/features/nodes/definitions/output.ts @@ -1,7 +1,8 @@ import { z } from 'zod' import type { ImageFormat } from '#/features/engine/types' +import { fileBytes, isFileItem } from '#/features/file-items/file-item' import { asImage, codecsOf, fileBytesOf } from '#/features/images/image-item' -import { ALL_IMAGE_TYPES, OUTPUT_PORT } from '#/features/nodes/constants' +import { ALL_ITEM_TYPES, OUTPUT_PORT } from '#/features/nodes/constants' import { encodeCost } from '#/features/nodes/cost' import { defineNode } from '#/features/nodes/define-node' @@ -19,19 +20,23 @@ export const outputNode = defineNode({ archiveName: z.string().max(120).default('hexlode'), }), ports: () => OUTPUT_PORT, - accepts: () => ALL_IMAGE_TYPES, + accepts: () => ALL_ITEM_TYPES, produces: (_settings, input) => input, simulate: (_settings, meta) => [{ port: 'out', meta }], cost: (_settings, meta, state) => - state.encoded + state.encoded || meta.kind !== 'image' ? { ms: 0, encodes: 0, needsPixels: false } : encodeCost(meta, meta.format as ImageFormat), async run(input, _settings, context) { if (input.mode !== 'each') throw new Error('Output runs per item.') - const item = asImage(input.item) - const bytes = await fileBytesOf(item, codecsOf(context), context.warn) const sink = context.services.output if (!sink) throw new Error('Output storage is not available.') + if (isFileItem(input.item)) { + await sink.write(context.nodeId, input.item.meta.name, fileBytes(input.item)) + return [{ port: 'out', item: input.item }] + } + const item = asImage(input.item) + const bytes = await fileBytesOf(item, codecsOf(context), context.warn) await sink.write(context.nodeId, item.meta.name, bytes) if (item.payload.encoded === bytes) return [{ port: 'out', item }] // The same item, now in the encoded form Output saved. diff --git a/src/features/nodes/definitions/pad.ts b/src/features/nodes/definitions/pad.ts new file mode 100644 index 0000000..2b77232 --- /dev/null +++ b/src/features/nodes/definitions/pad.ts @@ -0,0 +1,105 @@ +import { z } from 'zod' + +import { gaussianBlur } from '#/features/images/blur' +import { rgbaOf } from '#/features/images/colour' +import { canvas, drawOver } from '#/features/images/compose' +import { + asImage, + codecsOf, + uprightMeta, + uprightPixelsOf, + withUprightPixels, +} from '#/features/images/image-item' +import { crop, displaySize } from '#/features/images/pixel-ops' +import type { Codecs, Pixels } from '#/features/images/types' +import { ALL_IMAGE_TYPES, OUTPUT_PORT } from '#/features/nodes/constants' +import { PIXEL_OP_MS_PER_MEGAPIXEL, pixelCost } from '#/features/nodes/cost' +import { defineNode } from '#/features/nodes/define-node' +import { aspectFields, placeAt, ratioOf } from '#/features/nodes/definitions/crop' +import { colour } from '#/features/nodes/validators' + +const schema = z.object({ + ...aspectFields, + fill: z.enum(['colour', 'blur']).default('colour'), + colour: colour('#ffffff'), +}) + +type PadSettings = z.infer + +/** The blurred fill is built this small, then scaled up, which also softens it. */ +const BLUR_FILL_EDGE = 96 +/** Blur strength of the fill, as a share of its longest edge. */ +const BLUR_FILL_SIGMA = 0.04 + +/** The canvas that gives an upright image the chosen ratio, and where the image sits on it. */ +function planPad(settings: PadSettings, width: number, height: number) { + const ratio = ratioOf(settings) + // Narrower than the ratio: add width. Otherwise add height. + const size = + width / height < ratio + ? { width: Math.max(width, Math.round(height * ratio)), height } + : { width, height: Math.max(height, Math.round(width / ratio)) } + return { ...size, ...placeAt(settings.position, size.width - width, size.height - height) } +} + +/** The image scaled to cover the canvas, blurred. */ +async function blurredFill(pixels: Pixels, width: number, height: number, codecs: Codecs) { + const shrink = Math.min(1, BLUR_FILL_EDGE / Math.max(width, height)) + const small = { + width: Math.max(1, Math.round(width * shrink)), + height: Math.max(1, Math.round(height * shrink)), + } + const cover = Math.max(small.width / pixels.width, small.height / pixels.height) + const scaled = await codecs.resize( + pixels, + Math.max(small.width, Math.round(pixels.width * cover)), + Math.max(small.height, Math.round(pixels.height * cover)), + 'triangle', + ) + const centred = crop( + scaled, + Math.floor((scaled.width - small.width) / 2), + Math.floor((scaled.height - small.height) / 2), + small.width, + small.height, + ) + const blurred = gaussianBlur(centred, Math.max(small.width, small.height) * BLUR_FILL_SIGMA) + return codecs.resize(blurred, width, height, 'triangle') +} + +export const padNode = defineNode({ + type: 'pad', + label: 'Pad / Extend', + category: 'size', + description: + 'Adds space to reach an aspect ratio, filled with a colour or a blurred copy of the image.', + schema, + ports: () => OUTPUT_PORT, + accepts: () => ALL_IMAGE_TYPES, + produces: (_settings, input) => input, + simulate: (settings, meta) => { + const upright = displaySize(meta) + const plan = planPad(settings, upright.width, upright.height) + if (plan.width === upright.width && plan.height === upright.height) { + return [{ port: 'out', meta }] + } + return [{ port: 'out', meta: { ...uprightMeta(meta), width: plan.width, height: plan.height } }] + }, + cost: (_settings, meta) => pixelCost(meta, PIXEL_OP_MS_PER_MEGAPIXEL * 2), + async run(input, settings, context) { + if (input.mode !== 'each') throw new Error('Pad / Extend runs per item.') + const item = asImage(input.item) + const upright = displaySize(item.meta) + const plan = planPad(settings, upright.width, upright.height) + if (plan.width === upright.width && plan.height === upright.height) { + return [{ port: 'out', item }] + } + const codecs = codecsOf(context) + const pixels = await uprightPixelsOf(item, codecs) + const base = + settings.fill === 'blur' + ? await blurredFill(pixels, plan.width, plan.height, codecs) + : canvas(plan.width, plan.height, rgbaOf(settings.colour)) + return [{ port: 'out', item: withUprightPixels(item, drawOver(base, pixels, plan.x, plan.y)) }] + }, +}) diff --git a/src/features/nodes/definitions/palette.ts b/src/features/nodes/definitions/palette.ts new file mode 100644 index 0000000..034b885 --- /dev/null +++ b/src/features/nodes/definitions/palette.ts @@ -0,0 +1,53 @@ +import { z } from 'zod' + +import { typesOf } from '#/features/engine/item-types' +import { jsonItem, nameWithEnding, stemOf, textItem } from '#/features/file-items/file-item' +import { asImage, codecsOf, pixelsOf } from '#/features/images/image-item' +import { extractPalette, type PaletteColour } from '#/features/images/palette' +import { ALL_IMAGE_TYPES, OUTPUT_PORT } from '#/features/nodes/constants' +import { PIXEL_OP_MS_PER_MEGAPIXEL, pixelCost } from '#/features/nodes/cost' +import { defineNode } from '#/features/nodes/define-node' + +const schema = z.object({ + count: z.number().int().min(1).max(16).default(6), + format: z.enum(['json', 'css']).default('json'), +}) + +/** A CSS name from a file name: lowercase letters, digits and dashes. */ +function cssName(name: string) { + return ( + stemOf(name) + .toLowerCase() + .replaceAll(/[^a-z0-9]+/g, '-') + .replaceAll(/^-|-$/g, '') || 'image' + ) +} + +function css(name: string, colours: PaletteColour[]) { + const prefix = cssName(name) + const lines = colours.map((colour, index) => ` --${prefix}-${index + 1}: ${colour.hex};`) + return `:root {\n${lines.join('\n')}\n}\n` +} + +export const paletteNode = defineNode({ + type: 'palette', + label: 'Palette', + category: 'output', + description: 'Extracts the main colours as JSON or CSS variables.', + schema, + ports: () => OUTPUT_PORT, + accepts: () => ALL_IMAGE_TYPES, + produces: (settings) => typesOf('data', [settings.format === 'css' ? 'text' : 'json']), + cost: (_settings, meta) => pixelCost(meta, PIXEL_OP_MS_PER_MEGAPIXEL / 4), + async run(input, settings, context) { + if (input.mode !== 'each') throw new Error('Palette runs per item.') + const item = asImage(input.item) + const colours = extractPalette(await pixelsOf(item, codecsOf(context)), settings.count) + const { name, source } = item.meta + const output = + settings.format === 'css' + ? textItem('text', nameWithEnding(name, '.palette.css'), css(name, colours), source) + : jsonItem(nameWithEnding(name, '.palette.json'), { image: name, colours }, source) + return [{ port: 'out', item: output }] + }, +}) diff --git a/src/features/nodes/definitions/pixel-art.ts b/src/features/nodes/definitions/pixel-art.ts new file mode 100644 index 0000000..0a11550 --- /dev/null +++ b/src/features/nodes/definitions/pixel-art.ts @@ -0,0 +1,47 @@ +import { z } from 'zod' + +import { asImage, codecsOf, pixelsOf, withPixels } from '#/features/images/image-item' +import { scalePixelArt } from '#/features/images/shape' +import { ALL_IMAGE_TYPES, MAX_RESIZE_DIMENSION, OUTPUT_PORT } from '#/features/nodes/constants' +import { PIXEL_OP_MS_PER_MEGAPIXEL, pixelCost } from '#/features/nodes/cost' +import { defineNode } from '#/features/nodes/define-node' + +export const pixelArtNode = defineNode({ + type: 'pixel-art', + label: 'Pixel-art upscale', + category: 'size', + description: 'Enlarges pixel art 2x to 4x with sharp edges.', + schema: z.object({ + scale: z.union([z.literal(2), z.literal(3), z.literal(4)]).default(2), + /** Nearest copies each pixel into a block; smooth also rounds off diagonal steps. */ + method: z.enum(['nearest', 'smooth']).default('nearest'), + }), + ports: () => OUTPUT_PORT, + accepts: () => ALL_IMAGE_TYPES, + produces: (_settings, input) => input, + simulate: (settings, meta) => [ + { + port: 'out', + meta: { + ...meta, + width: (meta.width ?? 0) * settings.scale, + height: (meta.height ?? 0) * settings.scale, + size: undefined, + }, + }, + ], + cost: (settings, meta) => pixelCost(meta, PIXEL_OP_MS_PER_MEGAPIXEL * settings.scale ** 2), + async run(input, settings, context) { + if (input.mode !== 'each') throw new Error('Pixel-art upscale runs per item.') + const item = asImage(input.item) + const longest = Math.max(item.meta.width ?? 0, item.meta.height ?? 0) * settings.scale + if (longest > MAX_RESIZE_DIMENSION) { + throw new Error( + `Enlarged ${settings.scale}x, this image would be ${longest.toLocaleString('en')} pixels long. The limit is ${MAX_RESIZE_DIMENSION.toLocaleString('en')}.`, + ) + } + const pixels = await pixelsOf(item, codecsOf(context)) + const scaled = scalePixelArt(pixels, settings.scale, settings.method) + return [{ port: 'out', item: withPixels(item, scaled) }] + }, +}) diff --git a/src/features/nodes/definitions/placeholder.ts b/src/features/nodes/definitions/placeholder.ts new file mode 100644 index 0000000..a88a6f1 --- /dev/null +++ b/src/features/nodes/definitions/placeholder.ts @@ -0,0 +1,83 @@ +import { z } from 'zod' + +import { jsonItem, nameWithEnding } from '#/features/file-items/file-item' +import { encodeBlurhash } from '#/features/images/blurhash' +import { hexOf } from '#/features/images/colour' +import { flatten } from '#/features/images/compose' +import { asImage, codecsOf, uprightPixelsOf } from '#/features/images/image-item' +import { displaySize } from '#/features/images/pixel-ops' +import { scaleDown } from '#/features/images/thumbnail' +import type { EncodeOptions, Pixels } from '#/features/images/types' +import { ALL_IMAGE_TYPES, JSON_TYPES, OUTPUT_PORT } from '#/features/nodes/constants' +import { PIXEL_OP_MS_PER_MEGAPIXEL, pixelCost } from '#/features/nodes/cost' +import { defineNode } from '#/features/nodes/define-node' +import { bytesToBase64 } from '#/lib/base64' + +const component = z.number().int().min(1).max(9) + +const schema = z.object({ + /** A tiny WebP as a data URI works in plain HTML; a BlurHash needs a small decoder. */ + method: z.enum(['data-uri', 'blurhash']).default('data-uri'), + /** Longest edge of the data URI image, in pixels. */ + size: z.number().int().min(4).max(64).default(16), + componentsX: component.default(4), + componentsY: component.default(3), +}) + +/** BlurHash keeps only coarse shapes, so it is computed from a copy this small. */ +const BLURHASH_EDGE = 64 +const DATA_URI_ENCODE: EncodeOptions = { + format: 'webp', + options: { quality: 50, lossless: false, effort: 4, nearLossless: 100, sharpYuv: false }, +} + +/** The average colour of the visible pixels, weighted by how opaque they are. */ +function averageColour(pixels: Pixels) { + const { data } = pixels + const step = Math.max(1, Math.floor(data.length / 4 / 65_536)) * 4 + const sum = [0, 0, 0] + let weight = 0 + for (let at = 0; at < data.length; at += step) { + const alpha = data[at + 3] + sum[0] += data[at] * alpha + sum[1] += data[at + 1] * alpha + sum[2] += data[at + 2] * alpha + weight += alpha + } + return weight === 0 ? '#000000' : hexOf(sum[0] / weight, sum[1] / weight, sum[2] / weight) +} + +export const placeholderNode = defineNode({ + type: 'placeholder', + label: 'Placeholder', + category: 'output', + description: 'Generates a tiny blurred preview code for websites.', + schema, + ports: () => OUTPUT_PORT, + accepts: () => ALL_IMAGE_TYPES, + produces: () => JSON_TYPES, + cost: (_settings, meta) => pixelCost(meta, PIXEL_OP_MS_PER_MEGAPIXEL), + async run(input, settings, context) { + if (input.mode !== 'each') throw new Error('Placeholder runs per item.') + const item = asImage(input.item) + const codecs = codecsOf(context) + const pixels = await uprightPixelsOf(item, codecs) + const { width, height } = displaySize(item.meta) + const value: Record = { + image: item.meta.name, + width, + height, + colour: averageColour(pixels), + } + if (settings.method === 'data-uri') { + const tiny = await scaleDown(pixels, settings.size, codecs) + const bytes = await codecs.encode(DATA_URI_ENCODE, tiny) + value.dataUri = `data:image/webp;base64,${bytesToBase64(bytes)}` + } else { + const small = flatten(await scaleDown(pixels, BLURHASH_EDGE, codecs), [255, 255, 255, 255]) + value.blurhash = encodeBlurhash(small, settings.componentsX, settings.componentsY) + } + const name = nameWithEnding(item.meta.name, '.placeholder.json') + return [{ port: 'out', item: jsonItem(name, value, item.meta.source) }] + }, +}) diff --git a/src/features/nodes/definitions/rename.ts b/src/features/nodes/definitions/rename.ts index 6c3284b..00f0f92 100644 --- a/src/features/nodes/definitions/rename.ts +++ b/src/features/nodes/definitions/rename.ts @@ -2,7 +2,7 @@ import { z } from 'zod' import type { ImageFormat, ItemMeta } from '#/features/engine/types' import { IMAGE_EXTENSIONS } from '#/features/image-input/validators' import { displaySize } from '#/features/images/pixel-ops' -import { ALL_IMAGE_TYPES, OUTPUT_PORT } from '#/features/nodes/constants' +import { ALL_ITEM_TYPES, OUTPUT_PORT } from '#/features/nodes/constants' import { defineNode } from '#/features/nodes/define-node' export const RENAME_TOKENS = ['{name}', '{width}', '{height}', '{format}'] as const @@ -13,13 +13,14 @@ const schema = z.object({ replaceSpaces: z.boolean().default(false), }) -/** Renders the new name. The extension always follows the item's format. */ +/** Renders the new name. An image's extension follows its format; other items keep theirs. */ export function renderName(meta: ItemMeta, settings: z.infer) { const slash = meta.name.lastIndexOf('/') const folder = slash >= 0 ? meta.name.slice(0, slash + 1) : '' const leaf = meta.name.slice(slash + 1) const dot = leaf.lastIndexOf('.') const stem = dot > 0 ? leaf.slice(0, dot) : leaf + const kept = dot > 0 ? leaf.slice(dot + 1) : meta.format const size = displaySize(meta) let rendered = settings.template .replaceAll('{name}', stem) @@ -30,7 +31,8 @@ export function renderName(meta: ItemMeta, settings: z.infer) { .trim() if (settings.replaceSpaces) rendered = rendered.replaceAll(/\s+/g, '-') if (settings.lowercase) rendered = rendered.toLocaleLowerCase() - const extension = IMAGE_EXTENSIONS[meta.format as ImageFormat] ?? meta.format + const extension = + meta.kind === 'image' ? (IMAGE_EXTENSIONS[meta.format as ImageFormat] ?? meta.format) : kept return `${folder}${rendered || stem}.${extension}` } @@ -43,7 +45,7 @@ export const renameNode = defineNode({ privateSettings: ['template'], schema, ports: () => OUTPUT_PORT, - accepts: () => ALL_IMAGE_TYPES, + accepts: () => ALL_ITEM_TYPES, produces: (_settings, input) => input, simulate: (settings, meta) => [ { port: 'out', meta: { ...meta, name: renderName(meta, settings) } }, diff --git a/src/features/nodes/definitions/responsive-set.ts b/src/features/nodes/definitions/responsive-set.ts new file mode 100644 index 0000000..3b8ab8c --- /dev/null +++ b/src/features/nodes/definitions/responsive-set.ts @@ -0,0 +1,133 @@ +import { z } from 'zod' + +import { itemType, union } from '#/features/engine/item-types' +import type { ImageFormat, ItemTypeSet } from '#/features/engine/types' +import { nameWithEnding, textItem } from '#/features/file-items/file-item' +import { + asImage, + codecsOf, + encodeImage, + encodeWithQuality, + nameWithSuffix, + uprightPixelsOf, + withUprightPixels, +} from '#/features/images/image-item' +import { displaySize } from '#/features/images/pixel-ops' +import { ALL_IMAGE_TYPES, MAX_RESIZE_DIMENSION, TEXT_TYPES } from '#/features/nodes/constants' +import { encodeCost, RESIZE_MS_PER_MEGAPIXEL } from '#/features/nodes/cost' +import { defineNode } from '#/features/nodes/define-node' + +const schema = z.object({ + widths: z + .array(z.number().int().min(16).max(MAX_RESIZE_DIMENSION)) + .min(1) + .max(12) + .default([480, 960, 1440, 1920]), + format: z.enum(['original', 'webp', 'avif', 'jpeg', 'png']).default('webp'), + quality: z.number().int().min(1).max(100).default(80), + /** The `sizes` attribute: how wide the image shows at each screen width. */ + sizes: z.string().max(300).default('100vw'), + /** Put before each file name in the snippet, such as `/images/`. */ + path: z.string().max(300).default(''), +}) + +type ResponsiveSettings = z.infer + +/** Requested widths up to the image's own; when any were larger, the image's own width too. */ +function planWidths(settings: ResponsiveSettings, width: number) { + const fitting = [...new Set(settings.widths)].filter((value) => value < width) + const larger = settings.widths.some((value) => value >= width) + return (larger || fitting.length === 0 ? [...fitting, width] : fitting).sort((a, b) => a - b) +} + +const escapeAttribute = (value: string) => + value + .replaceAll('&', '&') + .replaceAll('"', '"') + .replaceAll('<', '<') + .replaceAll('>', '>') + +const leaf = (name: string) => name.slice(name.lastIndexOf('/') + 1) + +function snippet( + files: { name: string; width: number; height: number }[], + settings: ResponsiveSettings, +) { + const url = (name: string) => escapeAttribute(`${settings.path}${leaf(name)}`) + const largest = files[files.length - 1] + return [ + '', + '', + ].join('\n') +} + +export const responsiveSetNode = defineNode({ + type: 'responsive-set', + label: 'Responsive set', + category: 'output', + description: 'Makes several widths and a ready srcset HTML snippet.', + privateSettings: ['sizes', 'path'], + schema, + ports: () => [{ id: 'out', label: 'Images and HTML' }], + accepts: () => ALL_IMAGE_TYPES, + produces: (settings, input): ItemTypeSet => + union([ + settings.format === 'original' ? input : new Set([itemType('image', settings.format)]), + TEXT_TYPES, + ]), + cost: (settings, meta) => { + const format = settings.format === 'original' ? (meta.format as ImageFormat) : settings.format + const count = planWidths(settings, displaySize(meta).width).length + const encode = encodeCost(meta, format) + return { + ms: + (encode.ms + (RESIZE_MS_PER_MEGAPIXEL * ((meta.width ?? 0) * (meta.height ?? 0))) / 1e6) * + count, + encodes: count, + needsPixels: true, + } + }, + async run(input, settings, context) { + if (input.mode !== 'each') throw new Error('Responsive set runs per item.') + const item = asImage(input.item) + const codecs = codecsOf(context) + const format = + settings.format === 'original' ? (item.meta.format as ImageFormat) : settings.format + const pixels = await uprightPixelsOf(item, codecs) + const outputs = [] + for (const width of planWidths(settings, pixels.width)) { + const height = Math.max(1, Math.round((pixels.height * width) / pixels.width)) + const resized = + width === pixels.width ? pixels : await codecs.resize(pixels, width, height, 'lanczos3') + const sized = withUprightPixels(item, resized) + sized.meta = { ...sized.meta, name: nameWithSuffix(item.meta.name, `-${width}w`) } + outputs.push( + await encodeImage(sized, encodeWithQuality(format, settings.quality), codecs, context.warn), + ) + } + const files = outputs.map((output) => ({ + name: output.meta.name, + width: output.meta.width ?? 0, + height: output.meta.height ?? 0, + })) + const html = textItem( + 'text', + nameWithEnding(item.meta.name, '.html'), + snippet(files, settings), + item.meta.source, + ) + return [ + ...outputs.map((output) => ({ port: 'out', item: output })), + { port: 'out', item: html }, + ] + }, +}) diff --git a/src/features/nodes/definitions/set-copyright.ts b/src/features/nodes/definitions/set-copyright.ts new file mode 100644 index 0000000..d912d8f --- /dev/null +++ b/src/features/nodes/definitions/set-copyright.ts @@ -0,0 +1,51 @@ +import { z } from 'zod' + +import type { ImageFormat } from '#/features/engine/types' +import { asImage, dropWarning, withMetadata } from '#/features/images/image-item' +import { writeMetadata } from '#/features/images/metadata/containers' +import { setExifText } from '#/features/images/metadata/exif' +import { ALL_IMAGE_TYPES, OUTPUT_PORT } from '#/features/nodes/constants' +import { defineNode } from '#/features/nodes/define-node' + +const text = z.string().max(200).default('') + +export const setCopyrightNode = defineNode({ + type: 'set-copyright', + label: 'Set copyright / author', + category: 'metadata', + description: 'Writes author and copyright fields. Empty fields stay as they are.', + privateSettings: ['artist', 'copyright'], + schema: z.object({ artist: text, copyright: text }), + ports: () => OUTPUT_PORT, + accepts: () => ALL_IMAGE_TYPES, + produces: (_settings, input) => input, + simulate: (_settings, meta) => [{ port: 'out', meta }], + cost: () => ({ ms: 2, encodes: 0, needsPixels: false }), + async run(input, settings, context) { + if (input.mode !== 'each') throw new Error('Set copyright / author runs per item.') + const item = asImage(input.item) + const artist = settings.artist.trim() || undefined + const copyright = settings.copyright.trim() || undefined + if (!artist && !copyright) return [{ port: 'out', item }] + const metadata = { + ...item.payload.metadata, + exif: setExifText(item.payload.metadata.exif, { artist, copyright }), + } + const changed = withMetadata(item, metadata) + const { encoded } = item.payload + if (!encoded) return [{ port: 'out', item: changed }] + // Rewrite the container now, without re-encoding, like Strip metadata. + const format = item.meta.format as ImageFormat + const written = await writeMetadata(format, encoded, metadata) + if (written.dropped.length > 0) context.warn(dropWarning(format, written.dropped)) + return [ + { + port: 'out', + item: { + meta: { ...changed.meta, size: written.bytes.byteLength }, + payload: { ...changed.payload, encoded: written.bytes, metadataChanged: false }, + }, + }, + ] + }, +}) diff --git a/src/features/nodes/definitions/sharpen-blur.ts b/src/features/nodes/definitions/sharpen-blur.ts new file mode 100644 index 0000000..c981fe4 --- /dev/null +++ b/src/features/nodes/definitions/sharpen-blur.ts @@ -0,0 +1,106 @@ +import { z } from 'zod' + +import { gaussianBlur, sharpen } from '#/features/images/blur' +import { + asImage, + codecsOf, + pixelsOf, + uprightMeta, + uprightPixelsOf, + withPixels, + withUprightPixels, +} from '#/features/images/image-item' +import { crop } from '#/features/images/pixel-ops' +import type { Pixels } from '#/features/images/types' +import { ALL_IMAGE_TYPES, OUTPUT_PORT } from '#/features/nodes/constants' +import { pixelCost } from '#/features/nodes/cost' +import { defineNode } from '#/features/nodes/define-node' + +const percent = (value: number) => z.number().min(0).max(100).default(value) + +const schema = z.object({ + effect: z.enum(['sharpen', 'blur']).default('sharpen'), + /** The blur's standard deviation in pixels. Sharpening works at the scale of this blur. */ + radius: z.number().min(0.5).max(100).default(1), + /** Sharpening strength in percent of the detail added back. */ + amount: z.number().min(0).max(500).default(100), + region: z.enum(['whole', 'area']).default('whole'), + /** The area as percentages of the upright image. */ + area: z + .object({ x: percent(25), y: percent(25), width: percent(50), height: percent(50) }) + .prefault({}), +}) + +type SharpenBlurSettings = z.infer + +/** Box blurs cost the same at any radius: six passes over every pixel. */ +const BLUR_MS_PER_MEGAPIXEL = 40 + +function effect(pixels: Pixels, settings: SharpenBlurSettings) { + return settings.effect === 'blur' + ? gaussianBlur(pixels, settings.radius) + : sharpen(pixels, settings.radius, settings.amount / 100) +} + +/** Applies the effect to the area only, reading pixels around it so its edges blend. */ +function effectInArea(pixels: Pixels, settings: SharpenBlurSettings) { + const { area } = settings + const x = Math.round((area.x / 100) * pixels.width) + const y = Math.round((area.y / 100) * pixels.height) + const right = Math.min(pixels.width, Math.round(((area.x + area.width) / 100) * pixels.width)) + const bottom = Math.min(pixels.height, Math.round(((area.y + area.height) / 100) * pixels.height)) + if (right <= x || bottom <= y) return pixels + const margin = Math.ceil(settings.radius * 3) + const left = Math.max(0, x - margin) + const top = Math.max(0, y - margin) + const around = crop( + pixels, + left, + top, + Math.min(pixels.width, right + margin) - left, + Math.min(pixels.height, bottom + margin) - top, + ) + const changed = crop(effect(around, settings), x - left, y - top, right - x, bottom - y) + const result = { ...pixels, data: new Uint8ClampedArray(pixels.data) } + // Opaque copy: replace the area's pixels instead of blending over them. + for (let row = 0; row < changed.height; row += 1) { + const from = row * changed.width * 4 + result.data.set( + changed.data.subarray(from, from + changed.width * 4), + ((y + row) * pixels.width + x) * 4, + ) + } + return result +} + +export const sharpenBlurNode = defineNode({ + type: 'sharpen-blur', + label: 'Sharpen / Blur', + category: 'colour', + description: 'Sharpens, or blurs the whole image or a region.', + schema, + ports: () => OUTPUT_PORT, + accepts: () => ALL_IMAGE_TYPES, + produces: (_settings, input) => input, + simulate: (settings, meta) => [ + { + port: 'out', + meta: settings.region === 'area' ? uprightMeta(meta) : { ...meta, size: undefined }, + }, + ], + cost: (_settings, meta) => pixelCost(meta, BLUR_MS_PER_MEGAPIXEL), + async run(input, settings, context) { + if (input.mode !== 'each') throw new Error('Sharpen / Blur runs per item.') + const item = asImage(input.item) + const codecs = codecsOf(context) + if (settings.effect === 'sharpen' && settings.amount === 0) return [{ port: 'out', item }] + if (settings.region === 'whole') { + return [ + { port: 'out', item: withPixels(item, effect(await pixelsOf(item, codecs), settings)) }, + ] + } + // The area is measured on the upright image, so turn it upright first. + const upright = await uprightPixelsOf(item, codecs) + return [{ port: 'out', item: withUprightPixels(item, effectInArea(upright, settings)) }] + }, +}) diff --git a/src/features/nodes/definitions/split.ts b/src/features/nodes/definitions/split.ts new file mode 100644 index 0000000..f50756a --- /dev/null +++ b/src/features/nodes/definitions/split.ts @@ -0,0 +1,81 @@ +import { z } from 'zod' + +import type { ItemMeta } from '#/features/engine/types' +import { + asImage, + codecsOf, + nameWithSuffix, + uprightPixelsOf, + withUprightPixels, +} from '#/features/images/image-item' +import { crop, displaySize } from '#/features/images/pixel-ops' +import { ALL_IMAGE_TYPES, OUTPUT_PORT } from '#/features/nodes/constants' +import { PIXEL_OP_MS_PER_MEGAPIXEL, pixelCost } from '#/features/nodes/cost' +import { defineNode } from '#/features/nodes/define-node' + +const count = z.number().int().min(1).max(20) + +const schema = z.object({ columns: count.default(3), rows: count.default(1) }) + +/** Tile edges along one side, spreading leftover pixels evenly. */ +function edges(length: number, parts: number) { + return Array.from({ length: parts + 1 }, (_, index) => Math.round((index * length) / parts)) +} + +/** Tiles of an upright image, left to right and top to bottom, with their numbered names. */ +function planTiles(settings: z.infer, meta: ItemMeta) { + const { width, height } = displaySize(meta) + const columns = Math.min(settings.columns, width) + const rows = Math.min(settings.rows, height) + const xs = edges(width, columns) + const ys = edges(height, rows) + const total = columns * rows + const digits = String(total).length + return Array.from({ length: total }, (_, index) => { + const column = index % columns + const row = Math.floor(index / columns) + return { + name: nameWithSuffix(meta.name, `-${String(index + 1).padStart(digits, '0')}`), + x: xs[column], + y: ys[row], + width: xs[column + 1] - xs[column], + height: ys[row + 1] - ys[row], + } + }) +} + +export const splitNode = defineNode({ + type: 'split', + label: 'Split / Tile', + category: 'size', + description: 'Cuts one image into a grid of images.', + schema, + ports: () => OUTPUT_PORT, + accepts: () => ALL_IMAGE_TYPES, + produces: (_settings, input) => input, + simulate: (settings, meta) => + planTiles(settings, meta).map((tile) => ({ + port: 'out', + meta: { + ...meta, + name: tile.name, + width: tile.width, + height: tile.height, + orientation: 1, + size: undefined, + }, + })), + cost: (_settings, meta) => pixelCost(meta, PIXEL_OP_MS_PER_MEGAPIXEL), + async run(input, settings, context) { + if (input.mode !== 'each') throw new Error('Split / Tile runs per item.') + const item = asImage(input.item) + const tiles = planTiles(settings, item.meta) + if (tiles.length === 1) return [{ port: 'out', item }] + const upright = await uprightPixelsOf(item, codecsOf(context)) + return tiles.map((tile) => { + const pixels = crop(upright, tile.x, tile.y, tile.width, tile.height) + const result = withUprightPixels(item, pixels) + return { port: 'out', item: { ...result, meta: { ...result.meta, name: tile.name } } } + }) + }, +}) diff --git a/src/features/nodes/definitions/text-watermark.ts b/src/features/nodes/definitions/text-watermark.ts new file mode 100644 index 0000000..233c1da --- /dev/null +++ b/src/features/nodes/definitions/text-watermark.ts @@ -0,0 +1,99 @@ +import { z } from 'zod' + +import { rgbaOf } from '#/features/images/colour' +import { drawOver } from '#/features/images/compose' +import { + asImage, + codecsOf, + uprightMeta, + uprightPixelsOf, + withUprightPixels, +} from '#/features/images/image-item' +import { renderText } from '#/features/images/text' +import type { Pixels } from '#/features/images/types' +import { ALL_IMAGE_TYPES, OUTPUT_PORT } from '#/features/nodes/constants' +import { PIXEL_OP_MS_PER_MEGAPIXEL, pixelCost } from '#/features/nodes/cost' +import { defineNode } from '#/features/nodes/define-node' +import { CROP_POSITIONS, placeAt } from '#/features/nodes/definitions/crop' +import { colour } from '#/features/nodes/validators' + +const schema = z.object({ + text: z.string().max(200).default('© Your name'), + position: z.enum(CROP_POSITIONS).default('bottom-right'), + /** Font size in percent of the image's shorter edge. */ + size: z.number().min(1).max(50).default(4), + opacity: z.number().min(0).max(100).default(60), + colour: colour('#ffffff'), + font: z.enum(['sans-serif', 'serif', 'monospace']).default('sans-serif'), + bold: z.boolean().default(true), + /** Space between the text and the edge, in percent of the shorter edge. */ + margin: z.number().min(0).max(50).default(3), + /** Repeat the text across the whole image instead of placing it once. */ + tile: z.boolean().default(false), + /** Rotation of tiled text in degrees. */ + angle: z.number().min(-90).max(90).default(-30), +}) + +type TextWatermarkSettings = z.infer + +/** Tiled text repeats with this much space between copies, in font sizes. */ +const TILE_GAP = 1.5 + +function placeTiles(base: Pixels, mark: Pixels, gap: number, opacity: number) { + const stepX = mark.width + gap + const stepY = mark.height + gap + for (let row = 0, y = -mark.height / 2; y < base.height; row += 1, y += stepY) { + const shift = row % 2 === 0 ? 0 : stepX / 2 + for (let x = -stepX + shift; x < base.width; x += stepX) { + drawOver(base, mark, Math.round(x), Math.round(y), opacity) + } + } + return base +} + +function watermark(pixels: Pixels, settings: TextWatermarkSettings) { + const shorter = Math.min(pixels.width, pixels.height) + const fontSize = Math.max(1, Math.round((settings.size / 100) * shorter)) + const rgba = rgbaOf(settings.colour) + const opacity = (settings.opacity / 100) * (rgba[3] / 255) + const mark = renderText(settings.text.trim(), { + size: fontSize, + colour: rgba, + family: settings.font, + bold: settings.bold, + angle: settings.tile ? settings.angle : 0, + }) + const base: Pixels = { ...pixels, data: new Uint8ClampedArray(pixels.data) } + if (settings.tile) return placeTiles(base, mark, Math.round(fontSize * TILE_GAP), opacity) + const margin = Math.round((settings.margin / 100) * shorter) + const spot = placeAt( + settings.position, + Math.max(0, pixels.width - mark.width - margin * 2), + Math.max(0, pixels.height - mark.height - margin * 2), + ) + return drawOver(base, mark, spot.x + margin, spot.y + margin, opacity) +} + +export const textWatermarkNode = defineNode({ + type: 'text-watermark', + label: 'Text watermark', + category: 'overlay', + description: 'Places text with position, size, opacity and an optional repeated tile.', + privateSettings: ['text'], + schema, + ports: () => OUTPUT_PORT, + accepts: () => ALL_IMAGE_TYPES, + produces: (_settings, input) => input, + simulate: (settings, meta) => [ + { port: 'out', meta: settings.text.trim() ? uprightMeta(meta) : meta }, + ], + cost: (_settings, meta) => pixelCost(meta, PIXEL_OP_MS_PER_MEGAPIXEL), + async run(input, settings, context) { + if (input.mode !== 'each') throw new Error('Text watermark runs per item.') + const item = asImage(input.item) + if (!settings.text.trim() || settings.opacity === 0) return [{ port: 'out', item }] + // Positions follow the image as it is shown, so turned photos are made upright first. + const pixels = await uprightPixelsOf(item, codecsOf(context)) + return [{ port: 'out', item: withUprightPixels(item, watermark(pixels, settings)) }] + }, +}) diff --git a/src/features/nodes/registry.ts b/src/features/nodes/registry.ts index e2e17a9..459c773 100644 --- a/src/features/nodes/registry.ts +++ b/src/features/nodes/registry.ts @@ -1,16 +1,36 @@ import type { AnyNodeDefinition, NodeRegistry } from '#/features/engine/types' +import { adjustNode } from '#/features/nodes/definitions/adjust' +import { autoTrimNode } from '#/features/nodes/definitions/auto-trim' +import { backgroundNode } from '#/features/nodes/definitions/background' +import { bestFormatNode } from '#/features/nodes/definitions/best-format' +import { borderNode } from '#/features/nodes/definitions/border' import { compressToSizeNode } from '#/features/nodes/definitions/compress-to-size' +import { contactSheetNode } from '#/features/nodes/definitions/contact-sheet' import { convertNode } from '#/features/nodes/definitions/convert' import { cropNode } from '#/features/nodes/definitions/crop' +import { deduplicateNode } from '#/features/nodes/definitions/deduplicate' +import { faviconNode } from '#/features/nodes/definitions/favicon' import { filesNode } from '#/features/nodes/definitions/files' import { filterNode } from '#/features/nodes/definitions/filter' +import { filtersNode } from '#/features/nodes/definitions/filters' +import { imageWatermarkNode } from '#/features/nodes/definitions/image-watermark' +import { imagesToPdfNode } from '#/features/nodes/definitions/images-to-pdf' import { optimizePngNode } from '#/features/nodes/definitions/optimize-png' import { outputNode } from '#/features/nodes/definitions/output' +import { padNode } from '#/features/nodes/definitions/pad' +import { paletteNode } from '#/features/nodes/definitions/palette' import { compareNode, inspectNode } from '#/features/nodes/definitions/passthrough' +import { pixelArtNode } from '#/features/nodes/definitions/pixel-art' +import { placeholderNode } from '#/features/nodes/definitions/placeholder' import { renameNode } from '#/features/nodes/definitions/rename' import { resizeNode } from '#/features/nodes/definitions/resize' +import { responsiveSetNode } from '#/features/nodes/definitions/responsive-set' import { rotateNode } from '#/features/nodes/definitions/rotate' +import { setCopyrightNode } from '#/features/nodes/definitions/set-copyright' +import { sharpenBlurNode } from '#/features/nodes/definitions/sharpen-blur' +import { splitNode } from '#/features/nodes/definitions/split' import { stripMetadataNode } from '#/features/nodes/definitions/strip-metadata' +import { textWatermarkNode } from '#/features/nodes/definitions/text-watermark' /** Node batch 1, in catalogue order. */ export const BATCH_ONE = [ @@ -29,9 +49,40 @@ export const BATCH_ONE = [ compareNode, ] as unknown as AnyNodeDefinition[] +/** Node batch 2, in catalogue order. */ +export const BATCH_TWO = [ + deduplicateNode, + autoTrimNode, + padNode, + pixelArtNode, + splitNode, + adjustNode, + filtersNode, + sharpenBlurNode, + backgroundNode, + textWatermarkNode, + imageWatermarkNode, + borderNode, +] as unknown as AnyNodeDefinition[] + +/** Node batch 3, in catalogue order. */ +export const BATCH_THREE = [ + setCopyrightNode, + bestFormatNode, + responsiveSetNode, + faviconNode, + placeholderNode, + paletteNode, + contactSheetNode, + imagesToPdfNode, +] as unknown as AnyNodeDefinition[] + +/** Every product node. */ +export const PRODUCT_NODES = [...BATCH_ONE, ...BATCH_TWO, ...BATCH_THREE] + export function createRegistry(nodes: AnyNodeDefinition[]): NodeRegistry { const byType = new Map(nodes.map((node) => [node.type, node])) return { get: (type) => byType.get(type), list: () => [...byType.values()] } } -export const productRegistry = createRegistry(BATCH_ONE) +export const productRegistry = createRegistry(PRODUCT_NODES) diff --git a/src/features/nodes/validators.ts b/src/features/nodes/validators.ts new file mode 100644 index 0000000..26bc260 --- /dev/null +++ b/src/features/nodes/validators.ts @@ -0,0 +1,11 @@ +import { z } from 'zod' + +import { HEX_COLOUR } from '#/features/images/colour' + +/** A `#rrggbb` or `#rrggbbaa` colour setting. */ +export const colour = (value: string) => + z + .string() + .regex(HEX_COLOUR) + .transform((hex) => hex.toLowerCase()) + .default(value) diff --git a/src/features/pipelines/__tests__/pipeline-file.test.ts b/src/features/pipelines/__tests__/pipeline-file.test.ts index b94e8ad..d3c8c58 100644 --- a/src/features/pipelines/__tests__/pipeline-file.test.ts +++ b/src/features/pipelines/__tests__/pipeline-file.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import { chain } from '#/features/nodes/__tests__/harness' import { productRegistry } from '#/features/nodes/registry' import { exportPipelineFile, @@ -69,11 +70,11 @@ describe('pipeline file', () => { version: 1, name: 'x', pipeline: { - nodes: [{ id: 'a', type: 'text-watermark', settings: {}, position: { x: 0, y: 0 } }], + nodes: [{ id: 'a', type: 'dither', settings: {}, position: { x: 0, y: 0 } }], connections: [], }, }), - 'This pipeline uses nodes this version of Hexlode does not have: text-watermark.', + 'This pipeline uses nodes this version of Hexlode does not have: dither.', ], [ JSON.stringify({ @@ -111,6 +112,26 @@ describe('pipeline file', () => { }) }) +describe('pipelines with later nodes', () => { + it('keeps a watermark logo and the other settings through export and import', () => { + const logo = { name: 'logo.png', data: 'iVBORw0KGgoAAAANSUhEUg==', width: 64, height: 32 } + const pipeline = chain( + ['image-watermark', { logo, position: 'top-left', opacity: 55 }], + ['responsive-set', { widths: [320, 640], sizes: '50vw' }], + ) + const text = exportPipelineFile({ name: 'Branded', pipeline }) + expect(importPipelineFile(text, productRegistry).pipeline).toEqual(pipeline) + }) + + it('refuses a file whose node settings are not valid', () => { + const pipeline = chain(['background', { colour: 'red' }]) + const text = exportPipelineFile({ name: 'Broken', pipeline }) + expect(() => importPipelineFile(text, productRegistry)).toThrow( + new PipelineFileError('The settings of Background are not valid.'), + ) + }) +}) + describe('templates', () => { it('offers every template whose nodes all exist, with Blank last', () => { expect(availableTemplates(productRegistry).map((template) => template.id)).toEqual([ @@ -119,6 +140,9 @@ describe('templates', () => { 'remove-location', 'square-thumbnails', 'webp-and-avif', + 'responsive-images', + 'watermark-and-compress', + 'instagram-carousel', 'blank', ]) }) diff --git a/src/features/pipelines/__tests__/storage.test.ts b/src/features/pipelines/__tests__/storage.test.ts index 38920cd..c00c1f4 100644 --- a/src/features/pipelines/__tests__/storage.test.ts +++ b/src/features/pipelines/__tests__/storage.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { createPipelineStore } from '#/features/pipelines/storage' +import { createPipelineStore, PipelineStorageFull } from '#/features/pipelines/storage' function memoryStorage() { const values = new Map() @@ -35,6 +35,17 @@ describe('saved pipelines', () => { expect(store.list()).toEqual([]) }) + it('refuses a save the browser has no room for and keeps the list as it was', () => { + const storage = memoryStorage() + const store = createPipelineStore(storage) + const first = store.save({ name: 'Small', pipeline }) + storage.setItem = () => { + throw new DOMException('The quota has been exceeded.', 'QuotaExceededError') + } + expect(() => store.save({ name: 'With a large logo', pipeline })).toThrow(PipelineStorageFull) + expect(store.list()).toEqual([first]) + }) + it('tells subscribers when pipelines change', () => { const store = createPipelineStore(memoryStorage()) let calls = 0 diff --git a/src/features/pipelines/storage.ts b/src/features/pipelines/storage.ts index 181384c..74f3fcb 100644 --- a/src/features/pipelines/storage.ts +++ b/src/features/pipelines/storage.ts @@ -12,6 +12,14 @@ export interface KeyValueStorage { removeItem(key: string): void } +/** The browser has no room left in local storage for the saved pipelines. */ +export class PipelineStorageFull extends Error { + constructor() { + super('This browser has no room left to save it. Export a .hexlode file instead.') + this.name = 'PipelineStorageFull' + } +} + export function createPipelineStore(storage: KeyValueStorage | undefined, now = () => Date.now()) { const listeners = new Set<() => void>() let cached: SavedPipeline[] | undefined @@ -29,8 +37,13 @@ export function createPipelineStore(storage: KeyValueStorage | undefined, now = } const write = (pipelines: SavedPipeline[]) => { + try { + storage?.setItem(SAVED_PIPELINES_KEY, JSON.stringify(pipelines)) + } catch { + // Pipelines can hold a watermark logo, so storage can fill up. + throw new PipelineStorageFull() + } cached = pipelines - storage?.setItem(SAVED_PIPELINES_KEY, JSON.stringify(pipelines)) for (const listener of listeners) listener() } diff --git a/src/features/pipelines/templates.ts b/src/features/pipelines/templates.ts index c9c6f08..5411327 100644 --- a/src/features/pipelines/templates.ts +++ b/src/features/pipelines/templates.ts @@ -125,6 +125,52 @@ export const TEMPLATES: Template[] = [ ], ), }, + { + id: 'responsive-images', + name: 'Responsive image set', + description: + 'Turns photos upright, keeps only copyright, and saves WebP at four widths with a ready srcset snippet.', + pipeline: line([ + ['files', 'files'], + ['rotate', 'rotate', { auto: true }], + ['strip', 'strip-metadata', { mode: 'copyright' }], + [ + 'responsive', + 'responsive-set', + { widths: [480, 960, 1440, 1920], format: 'webp', quality: 80 }, + ], + ['output', 'output', { archiveName: 'responsive' }], + ]), + }, + { + id: 'watermark-and-compress', + name: 'Watermark and compress', + description: + 'Turns photos upright, fits them in 2048 pixels, adds a text watermark and keeps each JPEG under 300 KB.', + pipeline: line([ + ['files', 'files'], + ['rotate', 'rotate', { auto: true }], + ['resize', 'resize', { mode: 'longestEdge', longestEdge: 2048 }], + ['watermark', 'text-watermark', { text: '© Your name' }], + ['compress', 'compress-to-size', { targetKilobytes: 300, format: 'jpeg' }], + ['output', 'output', { archiveName: 'watermarked' }], + ]), + }, + { + id: 'instagram-carousel', + name: 'Instagram carousel', + description: + 'Cuts a wide photo into three 4:5 slides that swipe as one picture, at most 1080 × 1350.', + pipeline: line([ + ['files', 'files'], + ['rotate', 'rotate', { auto: true }], + ['crop', 'crop', { aspect: 'custom', customWidth: 12, customHeight: 5 }], + ['split', 'split', { columns: 3, rows: 1 }], + ['resize', 'resize', { mode: 'box', width: 1080, height: 1350, fit: 'fit' }], + ['convert', 'convert', { format: 'jpeg', jpeg: { quality: 90 } }], + ['output', 'output', { archiveName: 'carousel' }], + ]), + }, { id: 'blank', name: 'Blank', diff --git a/src/features/previews/__tests__/previewer.browser.test.ts b/src/features/previews/__tests__/previewer.browser.test.ts index 429210d..30a85a2 100644 --- a/src/features/previews/__tests__/previewer.browser.test.ts +++ b/src/features/previews/__tests__/previewer.browser.test.ts @@ -64,4 +64,31 @@ describe('live previews', () => { expect(results.slice(0, -1).filter((result) => result !== null).length).toBeLessThanOrEqual(1) previewer.dispose() }) + + it('shows the text of data a node makes, and a thumbnail when it also makes images', async () => { + const previewer = createPreviewer(createWorker) + await previewer.setSample(new Blob([await fixtureBytes('photo.png')]), 'photo.png') + const palette = (await previewer.render(chain(['palette', { count: 2, format: 'css' }]))) ?? [] + const colours = palette.find((preview) => preview.nodeId === 'palette-1') + expect(colours).toMatchObject({ status: 'processed', format: 'text' }) + expect(colours?.thumbnail).toBeUndefined() + expect(colours?.text).toContain('--photo-1:') + const set = (await previewer.render(chain(['responsive-set', { widths: [24] }]))) ?? [] + const responsive = set.find((preview) => preview.nodeId === 'responsive-set-1') + expect(responsive).toMatchObject({ format: 'webp', width: 24, height: 16 }) + expect(responsive?.thumbnail).toBeInstanceOf(Blob) + expect(responsive?.text).toContain('srcset=') + previewer.dispose() + }) + + it('previews combining nodes on the sample', async () => { + const previewer = createPreviewer(createWorker) + await previewer.setSample(new Blob([await fixtureBytes('photo.png')]), 'photo.png') + const result = (await previewer.render(chain(['images-to-pdf']))) ?? [] + expect(result.find((preview) => preview.nodeId === 'images-to-pdf-1')).toMatchObject({ + status: 'processed', + format: 'pdf', + }) + previewer.dispose() + }) }) diff --git a/src/features/previews/preview.worker.ts b/src/features/previews/preview.worker.ts index 992670c..831be0c 100644 --- a/src/features/previews/preview.worker.ts +++ b/src/features/previews/preview.worker.ts @@ -7,12 +7,14 @@ import { PREVIEW_CACHE_BUDGET_BYTES, PREVIEW_COMPARE_EDGE, PREVIEW_SAMPLE_EDGE, + PREVIEW_TEXT_CHARACTERS, PREVIEW_THUMBNAIL_EDGE, } from '#/features/engine/constants' import { createFlowPlan, flowCombining, flowSource } from '#/features/engine/flow' import { createMemoryStepCache } from '#/features/engine/memory-step-cache' import { createMemorySpillStore } from '#/features/engine/stores' import type { Item, Pipeline } from '#/features/engine/types' +import { fileBytes, fileKind } from '#/features/file-items/file-item' import { jsquashCodecs } from '#/features/images/codecs' import { asImage, imageKind, loadImageItem, pixelsOf } from '#/features/images/image-item' import { displayBlob, scaleDown } from '#/features/images/thumbnail' @@ -83,7 +85,7 @@ async function render(pipeline: Pipeline): Promise { services: { codecs: jsquashCodecs, output: { write: async () => undefined } }, spill: createMemorySpillStore(), cache, - kinds: { image: imageKind }, + kinds: { image: imageKind, data: fileKind, document: fileKind }, signal: new AbortController().signal, emit: (event: Parameters[0]['emit']>[0]) => { if (event.type === 'node-item' && (event.status === 'skipped' || event.status === 'failed')) { @@ -97,6 +99,20 @@ async function render(pipeline: Pipeline): Promise { }, observe: async (nodeId: string, _port: string, item: Item, status: 'processed' | 'cached') => { const preview = entry(nodeId) + if (item.meta.kind !== 'image') { + if (item.meta.kind === 'data') { + preview.text ??= new TextDecoder() + .decode(fileBytes(item)) + .slice(0, PREVIEW_TEXT_CHARACTERS) + } + // An image the node also makes describes it better, so it replaces these. + if (!preview.format) { + preview.status = status + preview.format = item.meta.format + preview.size = item.meta.size + } + return + } if (preview.thumbnail) return const image = asImage(item) preview.status = status diff --git a/src/features/previews/protocol.ts b/src/features/previews/protocol.ts index 748c400..949215c 100644 --- a/src/features/previews/protocol.ts +++ b/src/features/previews/protocol.ts @@ -29,6 +29,8 @@ export const nodePreviewSchema = z.object({ height: z.number().optional(), size: z.number().optional(), thumbnail: blob.optional(), + /** The start of the first data item the node made, such as JSON, CSS or HTML. */ + text: z.string().optional(), /** Compare nodes: the sample as it entered the pipeline, and as it reached the node. */ before: blob.optional(), after: blob.optional(), diff --git a/src/features/quick-tools/__tests__/quick-tools.browser.test.ts b/src/features/quick-tools/__tests__/quick-tools.browser.test.ts index e38908c..6907d91 100644 --- a/src/features/quick-tools/__tests__/quick-tools.browser.test.ts +++ b/src/features/quick-tools/__tests__/quick-tools.browser.test.ts @@ -6,12 +6,15 @@ import { describe, expect, it } from 'vitest' import { ALL_FORMAT_FIXTURES, + BLUE, decodeFile, fixtureBytes, near, pixelAt, + pngInput, RED, run, + solidPixels, } from '#/features/nodes/__tests__/harness' import { productRegistry } from '#/features/nodes/registry' import { availableTemplates } from '#/features/pipelines/templates' @@ -87,8 +90,8 @@ describe('quick tools on every input format', () => { }) /** - * What each template delivers: the format per Output node, whether location is removed, and - * optional fixed dimensions. + * What each template delivers: the format per Output node, whether location is removed, + * optional fixed dimensions, and how many files each input becomes (one unless set). */ const TEMPLATE_RESULTS: Record< string, @@ -96,6 +99,9 @@ const TEMPLATE_RESULTS: Record< outputs: Record removesLocation?: boolean size?: { width: number; height: number } + perInput?: number + /** Each input also gets an HTML snippet. */ + snippets?: boolean } > = { 'web-ready-photos': { outputs: { output: 'webp' }, removesLocation: true }, @@ -103,6 +109,9 @@ const TEMPLATE_RESULTS: Record< 'remove-location': { outputs: { output: 'source' }, removesLocation: true }, 'square-thumbnails': { outputs: { output: 'webp' }, size: { width: 32, height: 32 } }, 'webp-and-avif': { outputs: { 'output-webp': 'webp', 'output-avif': 'avif' } }, + 'responsive-images': { outputs: { output: 'webp' }, removesLocation: true, snippets: true }, + 'watermark-and-compress': { outputs: { output: 'jpeg' } }, + 'instagram-carousel': { outputs: { output: 'jpeg' }, perInput: 3 }, blank: { outputs: {} }, } @@ -131,18 +140,23 @@ describe('templates on every input format', () => { expect(failures).toEqual([]) const outputs = template.pipeline.nodes.filter((node) => node.type === 'output') expect(outputs.map((node) => node.id).sort()).toEqual(Object.keys(expected.outputs).sort()) + const perInput = expected.perInput ?? 1 for (const output of outputs) { - const delivered = files(output.id) - expect(delivered).toHaveLength(TEMPLATE_FIXTURES.length) + const snippets = files(output.id).filter((file) => file.name.endsWith('.html')) + expect(snippets).toHaveLength(expected.snippets ? TEMPLATE_FIXTURES.length : 0) + const delivered = files(output.id).filter((file) => !file.name.endsWith('.html')) + expect(delivered).toHaveLength(TEMPLATE_FIXTURES.length * perInput) for (const [index, file] of delivered.entries()) { const image = await decodeFile(file.bytes) const format = expected.outputs[output.id] - const sourceFormat = EXTENSION_FORMAT[TEMPLATE_FIXTURES[index].split('.').pop() as string] + const fixture = TEMPLATE_FIXTURES[Math.floor(index / perInput)] + const sourceFormat = EXTENSION_FORMAT[fixture.split('.').pop() as string] expect(image.format).toBe(format === 'source' ? sourceFormat : format) if (expected.removesLocation) expect(image.exif.hasGps).toBe(false) if (expected.size) expect(image).toMatchObject(expected.size) } - const upright = delivered.find((file) => file.name.startsWith('oriented.')) as { + if (perInput > 1) continue + const upright = delivered.find((file) => file.name.startsWith('oriented')) as { bytes: Uint8Array } const turned = expected.size ?? { width: 32, height: 48 } @@ -162,4 +176,69 @@ describe('templates on every input format', () => { expect(image.exif).toMatchObject({ copyright: '(c) 2026 Ada Example', make: null }) expect(await fixtureBytes('photo.jpg')).toBeDefined() }) + + it('Watermark and compress marks each photo and keeps it under 300 KB', async () => { + const template = availableTemplates(productRegistry).find( + (t) => t.id === 'watermark-and-compress', + ) + if (!template) throw new Error('missing') + const photo = await pngInput('dark.png', solidPixels(800, 600, [0, 0, 0, 255])) + const { files } = await run(template.pipeline, [photo]) + const [file] = files('output') + expect(file.name).toBe('dark.jpg') + expect(file.bytes.length).toBeLessThanOrEqual(300 * 1024) + const image = await decodeFile(file.bytes) + expect(image).toMatchObject({ format: 'jpeg', width: 800, height: 600 }) + const brightest = (x0: number, y0: number) => { + let level = 0 + for (let y = y0; y < y0 + 100; y += 1) { + for (let x = x0; x < x0 + 200; x += 1) level = Math.max(level, pixelAt(image, x, y)[0]) + } + return level + } + expect(brightest(600, 500)).toBeGreaterThan(100) + expect(brightest(0, 0)).toBeLessThan(16) + }) + + it('Instagram carousel cuts a panorama into three 4:5 slides', async () => { + const template = availableTemplates(productRegistry).find((t) => t.id === 'instagram-carousel') + if (!template) throw new Error('missing') + const panorama = await pngInput( + 'pano.png', + solidPixels(3000, 1000, (x) => (x < 1000 ? RED : x < 2000 ? [0, 255, 0, 255] : BLUE)), + ) + const { files } = await run(template.pipeline, [panorama]) + const slides = files('output') + expect(slides.map(({ name }) => name)).toEqual(['pano-1.jpg', 'pano-2.jpg', 'pano-3.jpg']) + const images = await Promise.all(slides.map(({ bytes }) => decodeFile(bytes))) + for (const image of images) + expect(image).toMatchObject({ format: 'jpeg', width: 800, height: 1000 }) + expect(near(pixelAt(images[0], 400, 500), RED)).toBe(true) + expect(near(pixelAt(images[1], 400, 500), [0, 255, 0, 255])).toBe(true) + expect(near(pixelAt(images[2], 400, 500), BLUE)).toBe(true) + }) + + it('Responsive image set makes each width and a snippet that lists them', async () => { + const template = availableTemplates(productRegistry).find((t) => t.id === 'responsive-images') + if (!template) throw new Error('missing') + const hero = await pngInput('hero.png', solidPixels(1600, 800, [200, 120, 40, 255])) + const { files } = await run(template.pipeline, [hero]) + expect(files('output').map(({ name }) => name)).toEqual([ + 'hero-480w.webp', + 'hero-960w.webp', + 'hero-1440w.webp', + 'hero-1600w.webp', + 'hero.html', + ]) + const widths = await Promise.all( + files('output') + .slice(0, 4) + .map(async ({ bytes }) => (await decodeFile(bytes)).width), + ) + expect(widths).toEqual([480, 960, 1440, 1600]) + const html = new TextDecoder().decode(files('output')[4].bytes) + expect(html).toContain( + 'srcset="hero-480w.webp 480w, hero-960w.webp 960w, hero-1440w.webp 1440w, hero-1600w.webp 1600w"', + ) + }) }) diff --git a/src/features/quick-tools/settings-panels.tsx b/src/features/quick-tools/settings-panels.tsx index 8cbe899..b7208e0 100644 --- a/src/features/quick-tools/settings-panels.tsx +++ b/src/features/quick-tools/settings-panels.tsx @@ -377,7 +377,7 @@ function ResizePanel({ value, onChange, isDisabled }: PanelProps) { - const position = POSITIONS.find((option) => option.value === value.position) +type AspectSettings = Pick + +/** The shape buttons and the custom ratio fields. Crop and Pad / Extend share them. */ +export function AspectFields({ + value, + onChange, + isDisabled, +}: PanelProps) { const isCustom = value.aspect === 'custom' const preset = isCustom ? null : sidesOf(value.aspect as (typeof ASPECT_PRESETS)[number]) const choose = (aspect: (typeof ASPECTS)[number]) => { @@ -415,7 +421,7 @@ function CropPanel({ value, onChange, isDisabled }: PanelProps onChange({ ...value, aspect, customWidth: preset.width, customHeight: preset.height }) } return ( - + <> {ASPECTS.map((aspect) => ( @@ -454,25 +460,49 @@ function CropPanel({ value, onChange, isDisabled }: PanelProps isDisabled={isDisabled || !isCustom} /> + + ) +} + +/** Nine buttons for a place in the image: corners, edges and centre. */ +export function PositionGrid({ + value, + onChange, + isDisabled, +}: PanelProps) { + return ( + + {POSITIONS.map((option) => ( + } + isIconOnly + size="sm" + isPressed={value === option.value} + onPressedChange={() => onChange(option.value)} + isDisabled={isDisabled} + /> + ))} + + ) +} + +function CropPanel({ value, onChange, isDisabled }: PanelProps) { + const position = POSITIONS.find((option) => option.value === value.position) + return ( + + - - {POSITIONS.map((option) => ( - } - isIconOnly - size="sm" - isPressed={value.position === option.value} - onPressedChange={() => onChange({ ...value, position: option.value })} - isDisabled={isDisabled} - /> - ))} - + onChange({ ...value, position: next })} + isDisabled={isDisabled} + /> diff --git a/src/features/studio/__tests__/logo.browser.test.ts b/src/features/studio/__tests__/logo.browser.test.ts new file mode 100644 index 0000000..c2b1e53 --- /dev/null +++ b/src/features/studio/__tests__/logo.browser.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest' + +import { decodeFile, fixtureBytes, near, pixelAt } from '#/features/nodes/__tests__/harness' +import { readLogo } from '#/features/studio/logo' +import { base64ToBytes } from '#/lib/base64' + +describe('choosing a logo', () => { + it('draws an SVG logo to a PNG at 1024 pixels on its longest edge', async () => { + const svg = new File( + [ + '', + ], + 'logo.svg', + { type: 'image/svg+xml' }, + ) + const logo = await readLogo(svg) + expect(logo).toMatchObject({ name: 'logo.svg', width: 1024, height: 512 }) + const image = await decodeFile(base64ToBytes(logo.data)) + expect(image).toMatchObject({ format: 'png', width: 1024, height: 512 }) + // Browsers may round a drawn colour by a level. + expect(near(pixelAt(image, 512, 256), [255, 0, 0, 255], 2)).toBe(true) + }) + + it('keeps PNG and WebP logos as they are', async () => { + const bytes = await fixtureBytes('alpha.webp') + const logo = await readLogo(new File([bytes], 'mark.webp', { type: 'image/webp' })) + expect(logo).toMatchObject({ name: 'mark.webp', width: 48, height: 32 }) + expect(base64ToBytes(logo.data)).toEqual(bytes) + }) + + it('refuses other formats', async () => { + const jpeg = new File([await fixtureBytes('photo.jpg')], 'photo.jpg', { type: 'image/jpeg' }) + await expect(readLogo(jpeg)).rejects.toThrow('Choose a PNG, WebP or SVG logo.') + }) +}) diff --git a/src/features/studio/__tests__/node-summary.test.ts b/src/features/studio/__tests__/node-summary.test.ts index 74d659f..76a86fe 100644 --- a/src/features/studio/__tests__/node-summary.test.ts +++ b/src/features/studio/__tests__/node-summary.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest' import { productRegistry } from '#/features/nodes/registry' import { TEMPLATES } from '#/features/pipelines/templates' import { pipelineSteps, summariseNode } from '#/features/studio/node-summary' +import { NODE_ICONS } from '#/features/studio/node-ui' function summary(type: string, settings: Record = {}) { const definition = productRegistry.get(type) @@ -47,6 +48,83 @@ describe('summariseNode', () => { expect(summary('filter')).toBe('1 rule') }) + it('describes the batch 2 nodes', () => { + expect(summary('deduplicate')).toBe('Near duplicates, keeps the first') + expect(summary('deduplicate', { mode: 'exact', keep: 'largest' })).toBe( + 'Exact duplicates, keeps the largest', + ) + expect(summary('auto-trim', { tolerance: 10 })).toBe('Tolerance 10%') + expect(summary('auto-trim', { tolerance: 0, padding: 4 })).toBe('Tolerance 0%, keeps 4 px') + expect(summary('pad', { aspect: '1:1', colour: '#ffffff' })).toBe('1:1, filled with #ffffff') + expect(summary('pad', { aspect: '9:16', fill: 'blur' })).toBe('9:16, blurred fill') + expect(summary('pixel-art', { scale: 3, method: 'smooth' })).toBe('3x, smooth diagonals') + expect(summary('pixel-art')).toBe('2x, hard pixels') + expect(summary('split', { columns: 3, rows: 1 })).toBe('3 × 1 grid') + expect(summary('adjust')).toBe('No change') + expect(summary('adjust', { brightness: 20, contrast: -10, exposure: 0.5 })).toBe( + 'Brightness +20, contrast −10, exposure +0.5', + ) + expect(summary('filters', { filter: 'sepia' })).toBe('Sepia') + expect(summary('filters', { filter: 'invert', strength: 40 })).toBe('Invert at 40%') + expect(summary('sharpen-blur', { effect: 'sharpen', amount: 150, radius: 1 })).toBe( + 'Sharpen 150% at 1 px', + ) + expect(summary('sharpen-blur', { effect: 'blur', radius: 6, region: 'area' })).toBe( + 'Blur 6 px, in an area', + ) + expect(summary('background', { colour: '#000000' })).toBe('Fills transparency with #000000') + expect(summary('text-watermark', { text: '© Ada', position: 'bottom-right' })).toBe( + '“© Ada”, bottom right', + ) + expect(summary('text-watermark', { text: 'Draft', tile: true })).toBe('“Draft”, tiled') + expect(summary('image-watermark')).toBe('No logo chosen') + expect( + summary('image-watermark', { + logo: { name: 'logo.svg', data: 'AA==', width: 1, height: 1 }, + position: 'top-left', + }), + ).toBe('logo.svg, top left') + expect(summary('border', { unit: 'pixels', border: 8, radius: 0 })).toBe('8 px border') + expect(summary('border', { unit: 'percent', border: 0, radius: 5 })).toBe('5% corners') + expect(summary('border', { border: 0, radius: 0 })).toBe('No change') + }) + + it('describes the batch 3 nodes', () => { + expect(summary('set-copyright', { copyright: '© 2026 Ada' })).toBe('© 2026 Ada') + expect(summary('set-copyright', { artist: 'Ada', copyright: '© 2026 Ada' })).toBe( + 'Ada, © 2026 Ada', + ) + expect(summary('set-copyright')).toBe('Nothing set') + expect(summary('best-format')).toBe('Smallest of WebP and AVIF') + expect(summary('best-format', { formats: ['jpeg', 'png', 'jxl'], keepOriginal: true })).toBe( + 'Smallest of JPEG, PNG, JPEG XL or the original', + ) + expect(summary('responsive-set')).toBe('4 widths as WebP') + expect(summary('responsive-set', { widths: [800], format: 'original' })).toBe( + '1 width, same format', + ) + expect(summary('favicon')).toBe('Fits each icon') + expect(summary('favicon', { fit: 'cover', padding: 10 })).toBe('Fills each icon, 10% padding') + expect(summary('placeholder')).toBe('Data URI, 16 px') + expect(summary('placeholder', { method: 'blurhash' })).toBe('BlurHash, 4 × 3') + expect(summary('palette')).toBe('6 colours as JSON') + expect(summary('palette', { count: 1, format: 'css' })).toBe('1 colour as CSS variables') + expect(summary('contact-sheet')).toBe('4 columns of 320 px, JPEG') + expect(summary('images-to-pdf')).toBe('A4 pages, images.pdf') + expect(summary('images-to-pdf', { pageSize: 'image', name: 'album' })).toBe( + 'Pages fit each image, album.pdf', + ) + }) + + it('describes every node that has settings, and every node has an icon', () => { + const withoutSettings = ['files', 'inspect', 'compare'] + for (const node of productRegistry.list()) { + expect(NODE_ICONS[node.type], node.type).toBeDefined() + if (withoutSettings.includes(node.type)) continue + expect(summariseNode(node.type, node.defaults), node.type).not.toBe('') + } + }) + it('returns nothing for nodes without settings', () => { expect(summary('files')).toBe('') }) diff --git a/src/features/studio/logo.ts b/src/features/studio/logo.ts new file mode 100644 index 0000000..63e7f8c --- /dev/null +++ b/src/features/studio/logo.ts @@ -0,0 +1,63 @@ +/** + * Turns a chosen logo file into the Image watermark setting. PNG and WebP are kept as they are; + * SVG is drawn to a PNG here, because workers cannot draw SVG. + */ +import { inspectImageHeader } from '#/features/image-input/validators' +import { MAX_LOGO_BYTES, SVG_LOGO_EDGE } from '#/features/nodes/constants' +import type { Logo } from '#/features/nodes/definitions/image-watermark' +import { bytesToBase64 } from '#/lib/base64' + +export const LOGO_ACCEPT = 'image/png,image/webp,image/svg+xml,.svg' + +const TOO_LARGE = `Logos must be under ${MAX_LOGO_BYTES / 1024} KB.` + +function isSvg(file: File) { + return file.type === 'image/svg+xml' || file.name.toLowerCase().endsWith('.svg') +} + +async function drawSvg(file: File, edge: number) { + const url = URL.createObjectURL(file) + try { + const image = new Image() + image.src = url + await image.decode() + // An SVG without a size draws as a square. + const natural = { + width: image.naturalWidth || edge, + height: image.naturalHeight || edge, + } + const scale = edge / Math.max(natural.width, natural.height) + const width = Math.max(1, Math.round(natural.width * scale)) + const height = Math.max(1, Math.round(natural.height * scale)) + const canvas = new OffscreenCanvas(width, height) + const context = canvas.getContext('2d') + if (!context) throw new Error('This browser cannot draw SVG logos.') + context.drawImage(image, 0, 0, width, height) + const blob = await canvas.convertToBlob({ type: 'image/png' }) + return { bytes: new Uint8Array(await blob.arrayBuffer()), width, height } + } finally { + URL.revokeObjectURL(url) + } +} + +export async function readLogo(file: File): Promise { + if (isSvg(file)) { + let drawn = await drawSvg(file, SVG_LOGO_EDGE) + // Detailed drawings can be large at full size; half the size is still plenty for a logo. + if (drawn.bytes.length > MAX_LOGO_BYTES) drawn = await drawSvg(file, SVG_LOGO_EDGE / 2) + if (drawn.bytes.length > MAX_LOGO_BYTES) throw new Error(TOO_LARGE) + return { + name: file.name, + data: bytesToBase64(drawn.bytes), + width: drawn.width, + height: drawn.height, + } + } + if (file.size > MAX_LOGO_BYTES) throw new Error(TOO_LARGE) + const bytes = new Uint8Array(await file.arrayBuffer()) + const info = inspectImageHeader(bytes.slice().buffer, file.type, file.size) + if (info.format !== 'png' && info.format !== 'webp') { + throw new Error('Choose a PNG, WebP or SVG logo.') + } + return { name: file.name, data: bytesToBase64(bytes), width: info.width, height: info.height } +} diff --git a/src/features/studio/node-editors.tsx b/src/features/studio/node-editors.tsx new file mode 100644 index 0000000..a8123a1 --- /dev/null +++ b/src/features/studio/node-editors.tsx @@ -0,0 +1,1108 @@ +/** Settings forms for node batches 2 and 3. */ +import { Button } from '@astryxdesign/core/Button' +import { FileInput } from '@astryxdesign/core/FileInput' +import { Grid } from '@astryxdesign/core/Grid' +import { NumberInput } from '@astryxdesign/core/NumberInput' +import { SegmentedControl, SegmentedControlItem } from '@astryxdesign/core/SegmentedControl' +import { Selector } from '@astryxdesign/core/Selector' +import { Slider } from '@astryxdesign/core/Slider' +import { HStack, VStack } from '@astryxdesign/core/Stack' +import { Switch } from '@astryxdesign/core/Switch' +import { Text } from '@astryxdesign/core/Text' +import { TextInput } from '@astryxdesign/core/TextInput' +import { type ReactNode, useEffect, useState } from 'react' + +import { MAX_LOGO_BYTES, MAX_RESIZE_DIMENSION } from '#/features/nodes/constants' +import { CANDIDATE_FORMATS } from '#/features/nodes/definitions/best-format' +import type { Logo } from '#/features/nodes/definitions/image-watermark' +import { AspectFields, Field, FORMAT_OPTIONS } from '#/features/quick-tools/settings-panels' +import type { CropToolSettings } from '#/features/quick-tools/tools' +import { LOGO_ACCEPT, readLogo } from '#/features/studio/logo' +import { + ColourField, + type EditorProps, + MoreOptions, + PositionField, +} from '#/features/studio/settings-fields' + +type Editor = (props: EditorProps) => ReactNode + +const formatLabel = (format: string) => + FORMAT_OPTIONS.find((option) => option.value === format)?.label ?? format + +/** A segmented choice with a visible label. */ +function Choice({ + label, + value, + options, + onChange, + isDisabled, + disabledMessage, + hint, +}: { + label: string + value: unknown + options: { value: string; label: string }[] + onChange: (value: string) => void + isDisabled?: boolean + disabledMessage?: string + hint?: string +}) { + return ( + + + {options.map((option) => ( + + ))} + + + ) +} + +/** A percentage slider that shows its value. */ +function Percent({ + label, + value, + onChange, + min = 0, + max = 100, + isDisabled, + disabledMessage, + description, +}: { + label: string + value: unknown + onChange: (value: number) => void + min?: number + max?: number + isDisabled?: boolean + disabledMessage?: string + description?: string +}) { + return ( + `${level}%`} + onChange={(level: number) => onChange(level)} + isDisabled={isDisabled} + disabledMessage={disabledMessage} + /> + ) +} + +const DeduplicateEditor: Editor = ({ settings, onChange, isDisabled }) => ( + + onChange({ mode })} + isDisabled={isDisabled} + hint={ + settings.mode === 'near' + ? 'Finds copies that were resized, re-saved or lightly edited.' + : 'Finds images with identical pixels, whatever their format or metadata.' + } + /> + onChange({ difference })} + isDisabled={isDisabled || settings.mode !== 'near'} + disabledMessage="Exact duplicates must match every pixel." + /> + onChange({ keep })} + isDisabled={isDisabled} + /> + + Waits for every image. Duplicates leave by the Duplicates output, so connect it to keep them. + + +) + +const AutoTrimEditor: Editor = ({ settings, onChange, isDisabled }) => ( + + onChange({ tolerance })} + isDisabled={isDisabled} + /> + onChange({ padding })} + isDisabled={isDisabled} + /> + +) + +const PadEditor: Editor = ({ settings, onChange, isDisabled }) => { + const isBlur = settings.fill === 'blur' + return ( + + onChange(value as unknown as Record)} + isDisabled={isDisabled} + /> + onChange({ fill })} + isDisabled={isDisabled} + /> + onChange({ colour })} + isDisabled={isDisabled || isBlur} + disabledMessage="The blurred image fills the space." + /> + onChange({ position })} + isDisabled={isDisabled} + hint="Photos are turned upright first." + /> + + ) +} + +const PixelArtEditor: Editor = ({ settings, onChange, isDisabled }) => ( + + onChange({ scale: Number(scale) })} + isDisabled={isDisabled} + /> + onChange({ method })} + isDisabled={isDisabled} + hint="Smooth diagonals round off stair steps but never blend colours." + /> + +) + +const SplitEditor: Editor = ({ settings, onChange, isDisabled }) => ( + + + {(['columns', 'rows'] as const).map((key) => ( + onChange({ [key]: value })} + isDisabled={isDisabled} + /> + ))} + + + Tiles are numbered left to right, top to bottom. Photos are turned upright first. + + +) + +const ADJUSTMENTS = [ + { key: 'brightness', label: 'Brightness' }, + { key: 'contrast', label: 'Contrast' }, + { key: 'saturation', label: 'Saturation' }, +] as const + +const AdjustEditor: Editor = ({ settings, onChange, isDisabled }) => ( + + {ADJUSTMENTS.map(({ key, label }) => ( + onChange({ [key]: value })} + isDisabled={isDisabled} + /> + ))} + value.toFixed(1)} + onChange={(exposure: number) => onChange({ exposure: Math.round(exposure * 10) / 10 })} + isDisabled={isDisabled} + /> + +) + +const FiltersEditor: Editor = ({ settings, onChange, isDisabled }) => { + const isDuotone = settings.filter === 'duotone' + return ( + + onChange({ filter })} + options={[ + { value: 'grayscale', label: 'Grayscale' }, + { value: 'sepia', label: 'Sepia' }, + { value: 'duotone', label: 'Duotone' }, + { value: 'invert', label: 'Invert' }, + ]} + isDisabled={isDisabled} + /> + onChange({ strength })} + isDisabled={isDisabled} + /> + {(['shadows', 'highlights'] as const).map((key) => ( + onChange({ [key]: value })} + isDisabled={isDisabled || !isDuotone} + disabledMessage="Only duotone uses these colours." + /> + ))} + + ) +} + +const AREA_FIELDS = [ + { key: 'x', label: 'From left' }, + { key: 'y', label: 'From top' }, + { key: 'width', label: 'Width' }, + { key: 'height', label: 'Height' }, +] as const + +const SharpenBlurEditor: Editor = ({ settings, onChange, isDisabled }) => { + const isBlur = settings.effect === 'blur' + const area = settings.area as Record + const isWhole = settings.region === 'whole' + return ( + + onChange({ effect })} + isDisabled={isDisabled} + /> + onChange({ radius })} + isDisabled={isDisabled} + /> + onChange({ amount })} + isDisabled={isDisabled || isBlur} + disabledMessage="Blur has no amount; change the radius." + /> + onChange({ region })} + isDisabled={isDisabled} + hint="The area is in percent of the upright image." + /> + + {AREA_FIELDS.map(({ key, label }) => ( + onChange({ area: { ...area, [key]: value } })} + isDisabled={isDisabled || isWhole} + disabledMessage="Choose An area to set it." + /> + ))} + + + ) +} + +const BackgroundEditor: Editor = ({ settings, onChange, isDisabled }) => ( + + onChange({ colour })} + isDisabled={isDisabled} + /> + JPEG images have no transparency, so they pass through unchanged. + +) + +const TextWatermarkEditor: Editor = ({ settings, onChange, isDisabled }) => { + const isTile = settings.tile === true + return ( + + onChange({ text })} + isDisabled={isDisabled} + /> + onChange({ size })} + isDisabled={isDisabled} + /> + onChange({ opacity })} + isDisabled={isDisabled} + /> + onChange({ colour })} + isDisabled={isDisabled} + /> + onChange({ tile })} + isDisabled={isDisabled} + /> + onChange({ position })} + isDisabled={isDisabled || isTile} + hint={isTile ? 'Repeated text covers the whole image.' : undefined} + /> + + onChange({ font })} + options={[ + { value: 'sans-serif', label: 'Sans serif' }, + { value: 'serif', label: 'Serif' }, + { value: 'monospace', label: 'Monospace' }, + ]} + isDisabled={isDisabled} + /> + onChange({ bold })} + isDisabled={isDisabled} + /> + onChange({ margin })} + isDisabled={isDisabled || isTile} + disabledMessage="Repeated text has no margin." + /> + `${angle}°`} + onChange={(angle: number) => onChange({ angle })} + isDisabled={isDisabled || !isTile} + disabledMessage="Only repeated text is turned." + /> + + + ) +} + +function LogoField({ settings, onChange, isDisabled }: EditorProps) { + const logo = settings.logo as Logo | null + const [error, setError] = useState(null) + const [isLoading, setLoading] = useState(false) + if (logo) { + return ( + + + + {`${logo.name} · ${logo.width} × ${logo.height}`} + +