diff --git a/docs/superpowers/specs/2026-07-31-heic-heif-ico-image-formats-design.md b/docs/superpowers/specs/2026-07-31-heic-heif-ico-image-formats-design.md new file mode 100644 index 0000000..3a63b42 --- /dev/null +++ b/docs/superpowers/specs/2026-07-31-heic-heif-ico-image-formats-design.md @@ -0,0 +1,214 @@ +# HEIC/HEIF/ICO image format support + +Date: 2026-07-31 + +## Goal + +Extend the image converter with three new formats: **ICO** (bidirectional, full support) and **HEIC**/**HEIF** (source-only — decode only). A fourth requested format, **HDR (Radiance .hdr)**, is explicitly dropped: the installed sharp/libvips build has it fully disabled (`sharp.format.rad` reports `input`/`output` all `false`), and the only npm package found (`parse-hdr`) is decode-only, unmaintained since 2016, and built for WebGL textures rather than general conversion. + +## Why HEIC/HEIF are source-only + +The installed sharp build's `heif` codec only supports the `.avif` alias (already supported today); encoding true `.heic`/`.heif` (HEVC) requires an x265 encoder that isn't in prebuilt sharp/libvips builds for licensing reasons. No pure-JS/WASM package fills this gap either — `heic-convert` (built on `heic-decode` → `libheif-js`, a WASM build of libheif) only decodes HEIC/HEIF to JPEG/PNG, with no encode path. So HEIC/HEIF can only be a **source** format: decode into a buffer, then feed that buffer through the existing sharp pipeline. + +## Scope + +- New source formats: `heic`, `heif` → every existing image target (`jpg, jpeg, png, webp, gif, tiff, avif`) plus the new `ico` target. Nothing registers `heic`/`heif` as a *target* — `listTargetFormats` (and therefore the frontend's dropdown) reflects only what's registered, so no target-side special-casing is needed. +- New bidirectional format: `ico` ↔ every existing image format. +- Out of scope: HDR, document converters, any non-image conversion family. + +## New dependencies + +- `icojs` — pure JS, no native bindings. Both decodes (`decodeIco(buffer, 'image/png')` → array of embedded PNG buffers) and encodes (`encodeIco([{ buffer }, ...])` → ICO buffer from PNG buffers). Safe for o2switch (no compile step). +- `heic-convert` — pure JS wrapper around `libheif-js` (WASM build of libheif, no native compile step). `convert({ buffer, format: 'PNG' })` decodes the main image in a HEIC/HEIF file to a PNG buffer. Safe for o2switch for the same reason `sharp`/`puppeteer` already are — the binary is prebuilt/shipped, not compiled locally. + +## ICO size selection + +ICO encoding needs a target resolution; there's no natural single "quality" scalar the way jpeg/png/pdf have. The frontend offers a dropdown of `16, 32, 48, 256, 512` px (default `256`), single choice — not a multi-size bundle. + +This value doesn't reuse the existing generic `quality` column (which stays strictly about quality/compression, matching its use for jpeg/webp/avif/tiff quality and png compression level). Instead, `ConversionJob` gets a new nullable column: + +```prisma +iconSize Int? @map("icon_size") @db.UnsignedSmallInt +``` + +Per the project's documented shadow-db-free migration process (`CLAUDE.md`), generate this with: + +``` +DATABASE_URL=$(node scripts/printDatabaseUrl.js) npx prisma migrate diff \ + --from-schema-datasource prisma/schema.prisma --to-schema-datamodel prisma/schema.prisma --script +``` + +against the schema with `iconSize` added, review the output SQL, then hand-create the migration folder and `prisma migrate resolve --applied`. + +Allowed values: `16, 32, 48, 256, 512`. `null`/omitted defaults to `256` at conversion time. Any other value is a validation error, mirrored the same way an invalid `quality` is today. + +## Backend flow + +**`src/converters/ico.js`** (new): + +```js +import fs from 'node:fs/promises'; +import sharp from 'sharp'; +import { decodeIco, encodeIco } from 'icojs'; +import { register } from './registry.js'; +import { sharpFormatName, buildFormatOptions } from './image.js'; + +const IMAGE_FORMATS = ['jpg', 'jpeg', 'png', 'webp', 'gif', 'tiff', 'avif']; +export const DEFAULT_ICON_SIZE = 256; + +export async function encodeIcoFromInput(input, size = DEFAULT_ICON_SIZE) { + const pngBuffer = await sharp(input).resize(size, size, { fit: 'contain' }).png().toBuffer(); + return Buffer.from(await encodeIco([{ buffer: pngBuffer }])); +} + +async function decodeIcoLargestImage(inputPath) { + const buffer = await fs.readFile(inputPath); + const images = await decodeIco(buffer, 'image/png'); + const largest = images.reduce((a, b) => (a.width * a.height >= b.width * b.height ? a : b)); + return Buffer.from(largest.buffer); +} + +export function registerIcoConverter() { + for (const format of IMAGE_FORMATS) { + register({ + family: 'image', + sourceFormat: 'ico', + targetFormat: format, + convert: async (inputPath, outputPath, { quality } = {}) => { + const pngBuffer = await decodeIcoLargestImage(inputPath); + await sharp(pngBuffer) + .toFormat(sharpFormatName(format), buildFormatOptions(format, quality)) + .toFile(outputPath); + }, + }); + + register({ + family: 'image', + sourceFormat: format, + targetFormat: 'ico', + convert: async (inputPath, outputPath, { iconSize } = {}) => { + const icoBuffer = await encodeIcoFromInput(inputPath, iconSize ?? DEFAULT_ICON_SIZE); + await fs.writeFile(outputPath, icoBuffer); + }, + }); + } +} +``` + +(`sharpFormatName`/`buildFormatOptions` are exported from `image.js` rather than duplicated.) + +**`src/converters/heic.js`** (new): + +```js +import fs from 'node:fs/promises'; +import sharp from 'sharp'; +import convert from 'heic-convert'; +import { register } from './registry.js'; +import { sharpFormatName, buildFormatOptions } from './image.js'; +import { encodeIcoFromInput, DEFAULT_ICON_SIZE } from './ico.js'; + +const IMAGE_FORMATS = ['jpg', 'jpeg', 'png', 'webp', 'gif', 'tiff', 'avif']; +const HEIC_SOURCE_FORMATS = ['heic', 'heif']; + +async function decodeToPng(inputPath) { + const buffer = await fs.readFile(inputPath); + return convert({ buffer, format: 'PNG' }); +} + +export function registerHeicConverter() { + for (const sourceFormat of HEIC_SOURCE_FORMATS) { + for (const targetFormat of IMAGE_FORMATS) { + register({ + family: 'image', + sourceFormat, + targetFormat, + convert: async (inputPath, outputPath, { quality } = {}) => { + const pngBuffer = await decodeToPng(inputPath); + await sharp(pngBuffer) + .toFormat(sharpFormatName(targetFormat), buildFormatOptions(targetFormat, quality)) + .toFile(outputPath); + }, + }); + } + + register({ + family: 'image', + sourceFormat, + targetFormat: 'ico', + convert: async (inputPath, outputPath, { iconSize } = {}) => { + const pngBuffer = await decodeToPng(inputPath); + const icoBuffer = await encodeIcoFromInput(pngBuffer, iconSize ?? DEFAULT_ICON_SIZE); + await fs.writeFile(outputPath, icoBuffer); + }, + }); + } +} +``` + +**`src/converters/image.js`**: export `sharpFormatName` and `buildFormatOptions` (currently only `buildFormatOptions` is exported; `sharpFormatName` is private) so `ico.js`/`heic.js` can reuse them instead of redefining. + +**`src/app.js` / `src/worker.js`**: both currently call `registerImageConverters()`, `registerImageToPdfConverter()`, `registerDocumentConverters()` independently at startup. Add `registerIcoConverter()` and `registerHeicConverter()` to both call sites. + +**`src/mime.js`**: +- `OUTPUT_MIME_TYPES.ico = 'image/x-icon'` — matches what `file-type` itself reports for real ICO content (verified against the installed `file-type` source), keeping input-detection and output-mime symmetric with every other existing entry. +- `normalizeFormat`: add `heif → heic` alongside the existing `jpg → jpeg`. Verified against the installed `file-type` v22 source: its HEIF-family detector always returns `ext: 'heic'` for *any* brand in that family (`mif1`, `msf1`, `heic`, `heix`, `hevc`, `hevx`) — the `mime` field differs (`image/heif` vs `image/heic`) but `ext` never does. Without this alias, uploading a file named `photo.heif` would have `sourceFormat = 'heif'` (from the filename) but `detectInputMime` would report `ext: 'heic'`, failing the `resolveInputFormat` content-matches-extension check. + +**`src/app.js` (`POST /api/jobs`)**: parse a new `iconSizes` array the same way `qualities` is parsed today (JSON array, one entry per file, empty/omitted array = all `null`). Add `isValidIconSize(targetFormat, iconSize)`: + +```js +const VALID_ICON_SIZES = [16, 32, 48, 256, 512]; + +function isValidIconSize(targetFormat, iconSize) { + if (iconSize === null || iconSize === undefined) return true; + if (targetFormat !== 'ico') return false; // only meaningful for ico targets + return VALID_ICON_SIZES.includes(iconSize); +} +``` + +Also extend `isValidQuality` to reject `ico` (no quality knob), mirroring the existing `gif` case: +```js +if (targetFormat === 'gif' || targetFormat === 'ico') return false; +``` + +Both `quality` and `iconSize` get passed to `createJob`. + +**`src/jobs/jobRepository.js`**: add `iconSize` to the `jobSelect` projection and to `createJob`'s `data` object (`iconSize: job.iconSize ?? null`). + +**`src/worker.js`**: `entry.convert(inputFilePath, outputFilePath, { quality: job.quality, iconSize: job.iconSize })`. + +## Frontend + +**`frontend/src/App.jsx`**: +- Each `pendingFiles` item gains an `iconSize` field, defaulted to `256` when `targetFormat` becomes `'ico'` (via `updateTargetFormat`), reset to `null` for every other format. +- New conditional control, alongside the existing png/pdf ones: + ```jsx + {item.targetFormat === 'ico' && ( + + )} + ``` +- New `updateIconSize` setter, mirroring `updateQuality`. + +**`frontend/src/api.js`**: `uploadFiles` builds and appends an `iconSizes` array (`item.iconSize ?? null` per item), JSON-stringified, alongside `targetFormats`/`qualities`. + +## Validation & edge cases + +- Backend independently re-validates `iconSize` against the resolved `targetFormat` — never trusts the frontend default. +- Omitting `iconSizes` entirely (old client) behaves like an array of `null`s; every `ico` conversion then falls back to the 256px default inside the converter itself. +- An `iconSize` provided for a non-`ico` target, or a value outside the fixed set, is a per-file error in the `POST /api/jobs` response (same pattern as invalid `quality`/unsupported conversion pairs today) — it does not fail the rest of the batch. +- `ico → X`: if the source `.ico` contains multiple embedded resolutions, the largest is used (matches "give me the best available image" as the least surprising default; there's no size-selection concern on the decode side, since the destination format has no notion of embedded multi-resolution). + +## Testing + +- `test/converters/ico.test.js`: round-trip test (`png → ico → png`, or similar) confirming pixel dimensions match the requested `iconSize`; a decode test using a multi-resolution ICO (produced by `icojs.encodeIco` in `beforeAll` from several sharp-resized PNGs) confirming the largest embedded image is selected. +- `test/converters/heic.test.js`: decode tests for both `.heic` and `.heif` fixtures → jpg/png/ico. Requires committing small real sample files under `test/fixtures/` (e.g. `test/fixtures/sample.heic`, `test/fixtures/sample.heif`) since nothing in the toolchain can encode HEIC/HEIF to generate these programmatically. +- `test/mime.test.js` (or wherever `normalizeFormat`/`resolveInputFormat` is currently tested): add a case asserting a file named `*.heif` with real HEIF content passes `resolveInputFormat`'s validity check. +- `test/api/jobs.test.js`: extend to cover `iconSizes` parsing (valid array, value on non-ico target, out-of-range value, omitted field). +- `test/jobs/jobRepository.test.js`: extend the `createJob`/`getJobByUuid` round-trip test to include `iconSize`. +- Frontend: manual browser check only (no frontend test framework exists today, consistent with how `quality`/`targetFormat` controls are verified).