diff --git a/docs/superpowers/plans/2026-07-31-heic-heif-ico-image-formats.md b/docs/superpowers/plans/2026-07-31-heic-heif-ico-image-formats.md new file mode 100644 index 0000000..8f925c1 --- /dev/null +++ b/docs/superpowers/plans/2026-07-31-heic-heif-ico-image-formats.md @@ -0,0 +1,1162 @@ +# HEIC/HEIF/ICO Image Format Support Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add ICO (bidirectional) and HEIC/HEIF (source-only decode) as supported image conversion formats. + +**Architecture:** Two new converter modules (`src/converters/ico.js`, `src/converters/heic.js`) register into the existing generic `sourceFormat -> targetFormat` registry, following the exact pattern `src/converters/imageToPdf.js` already uses. ICO conversion uses `icojs` (pure JS, both decode and encode). HEIC/HEIF decode uses `heic-convert` (pure JS wrapper around the WASM `libheif-js`) to produce a PNG buffer, which then flows through the same sharp pipeline as every other image conversion. A new nullable `iconSize` column on `ConversionJob` carries the user's chosen icon resolution through the job pipeline, parallel to the existing `quality` column. + +**Tech Stack:** Node.js (ESM), Express, Prisma/MySQL, sharp, vitest+supertest, React (frontend). + +## Global Constraints + +- No dependency with native/compiled bindings unless it ships a prebuilt binary for o2switch's platform/arch — `icojs` and `heic-convert` (→ `heic-decode` → `libheif-js`, a WASM build) are pure JS/WASM with no compile step, so this is satisfied. +- HDR (Radiance `.hdr`) is explicitly **out of scope** — dropped during design (no viable library either direction). +- HEIC/HEIF are **source-only** — never register anything with `heic`/`heif` as a `targetFormat`. +- Any new converter registration must be added to **both** `src/app.js` and `src/worker.js` — they independently duplicate the registration call list. +- Local test runs must use `.env.local`-equivalent inline env vars, never load real `.env` prod credentials (see `CLAUDE.md`). +- Prisma schema changes use the shadow-db-free fallback (`--from-schema-datasource` / `--to-schema-datamodel`) documented in `CLAUDE.md` — `prisma migrate dev` does not work against the local dev DB. + +--- + +### Task 1: `mime.js` — ICO output MIME type and HEIF→HEIC extension alias + +**Files:** +- Modify: `src/mime.js:3-19` (OUTPUT_MIME_TYPES), `src/mime.js:41-43` (normalizeFormat) +- Test: `test/mime.test.js` + +**Interfaces:** +- Consumes: nothing new. +- Produces: `outputMimeType('ico')` returns `'image/x-icon'`. `resolveInputFormat(path, 'heif')` now succeeds for real HEIC/HEIF-family content (whose detected `ext` is always `'heic'` per the installed `file-type` v22 source, regardless of container brand). + +- [ ] **Step 1: Write the failing tests** + +Add to `test/mime.test.js`: + +```js +describe('outputMimeType — ico', () => { + it('returns image/x-icon for ico', () => { + expect(outputMimeType('ico')).toBe('image/x-icon'); + }); +}); + +describe('resolveInputFormat — heic/heif alias', () => { + it('accepts a HEIC-family file declared as heic', async () => { + const fixturePath = path.join(import.meta.dirname, 'fixtures', 'sample.heic'); + const result = await resolveInputFormat(fixturePath, 'heic'); + expect(result.valid).toBe(true); + }); + + it('accepts the same HEIC-family content declared as heif', async () => { + const fixturePath = path.join(import.meta.dirname, 'fixtures', 'sample.heif'); + const result = await resolveInputFormat(fixturePath, 'heif'); + expect(result.valid).toBe(true); + }); +}); +``` + +(`test/fixtures/sample.heic` and `test/fixtures/sample.heif` already exist — added and committed earlier in this project's history, see `test/fixtures/HEIC_ATTRIBUTION.md`.) + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run test/mime.test.js` +Expected: the `ico` case fails with a thrown error (`No known MIME type for target format "ico"`); the `heif` case fails with `result.valid` being `false`. + +- [ ] **Step 3: Implement** + +In `src/mime.js`, add `ico` to `OUTPUT_MIME_TYPES`: + +```js +const OUTPUT_MIME_TYPES = { + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + png: 'image/png', + webp: 'image/webp', + gif: 'image/gif', + tiff: 'image/tiff', + avif: 'image/avif', + bmp: 'image/bmp', + ico: 'image/x-icon', + pdf: 'application/pdf', + html: 'text/html', + txt: 'text/plain', + docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + md: 'text/markdown', + csv: 'text/csv', + xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', +}; +``` + +Update `normalizeFormat`: + +```js +function normalizeFormat(format) { + if (format === 'jpg') return 'jpeg'; + if (format === 'heif') return 'heic'; + return format; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run test/mime.test.js` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/mime.js test/mime.test.js +git commit -m "feat: add ico MIME type and heif->heic extension alias" +``` + +--- + +### Task 2: ICO converter (`src/converters/ico.js`) + +**Files:** +- Modify: `src/converters/image.js:6-8` (export `sharpFormatName`, it is currently private) +- Create: `src/converters/ico.js` +- Test: `test/converters/ico.test.js` +- Modify: `package.json` (add `icojs` dependency) + +**Interfaces:** +- Consumes: `sharpFormatName(format)` and `buildFormatOptions(targetFormat, quality)`, both now exported from `src/converters/image.js`. +- Produces: `registerIcoConverter()` — registers `ico -> X` and `X -> ico` for every `X` in `['jpg', 'jpeg', 'png', 'webp', 'gif', 'tiff', 'avif']`. `encodeIcoFromInput(input, size)` — exported helper, `input` may be a file path or a Buffer (anything `sharp()` accepts); `size` defaults to `DEFAULT_ICON_SIZE` (`256`). Also exports `DEFAULT_ICON_SIZE`. + +- [ ] **Step 1: Install the dependency** + +```bash +npm install icojs +``` + +Verify `package.json`'s `dependencies` now includes `"icojs"` at whatever version was resolved (do not hand-edit the version string — let `npm install` write it). + +- [ ] **Step 2: Export `sharpFormatName` from `image.js`** + +In `src/converters/image.js`, change: +```js +function sharpFormatName(format) { +``` +to: +```js +export function sharpFormatName(format) { +``` + +- [ ] **Step 3: Write the failing tests** + +Create `test/converters/ico.test.js`: + +```js +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; +import sharp from 'sharp'; +import { registerIcoConverter, encodeIcoFromInput, DEFAULT_ICON_SIZE } from '../../src/converters/ico.js'; +import { resolve, listTargetFormats } from '../../src/converters/registry.js'; +import { detectInputMime } from '../../src/mime.js'; + +let tmpDir; + +beforeAll(async () => { + registerIcoConverter(); + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'converter-ico-')); +}); + +afterAll(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); +}); + +describe('ICO converter registration', () => { + it('registers ico as both a source and a target for every image format', () => { + expect(listTargetFormats('ico')).toContain('png'); + expect(listTargetFormats('ico')).toContain('jpg'); + expect(listTargetFormats('png')).toContain('ico'); + expect(listTargetFormats('jpg')).toContain('ico'); + }); +}); + +describe('encodeIcoFromInput', () => { + it('defaults to a 256x256 icon when no size is given', async () => { + const inputPath = path.join(import.meta.dirname, '..', 'fixtures', 'sample.png'); + const icoBuffer = await encodeIcoFromInput(inputPath); + + const outputPath = path.join(tmpDir, 'default-size.ico'); + await fs.writeFile(outputPath, icoBuffer); + const detected = await detectInputMime(outputPath); + expect(detected.mime).toBe('image/x-icon'); + }); + + it('produces an icon at the requested size', async () => { + const inputPath = path.join(import.meta.dirname, '..', 'fixtures', 'sample.png'); + const icoBuffer = await encodeIcoFromInput(inputPath, 32); + + const { decodeIco } = await import('icojs'); + const [image] = await decodeIco(icoBuffer, 'image/png'); + expect(image.width).toBe(32); + expect(image.height).toBe(32); + }); +}); + +describe('ico -> image conversion', () => { + it('converts an ICO fixture to PNG, picking the largest embedded image', async () => { + const source = path.join(import.meta.dirname, '..', 'fixtures', 'sample.png'); + const icoPath = path.join(tmpDir, 'multi.ico'); + + const small = await sharp(source).resize(16, 16).png().toBuffer(); + const large = await sharp(source).resize(48, 48).png().toBuffer(); + const { encodeIco } = await import('icojs'); + await fs.writeFile(icoPath, Buffer.from(await encodeIco([{ buffer: small }, { buffer: large }]))); + + const outputPath = path.join(tmpDir, 'from-ico.png'); + const entry = resolve('ico', 'png'); + await entry.convert(icoPath, outputPath); + + const meta = await sharp(outputPath).metadata(); + expect(meta.width).toBe(48); + expect(meta.height).toBe(48); + }); +}); + +describe('image -> ico conversion', () => { + it('converts a PNG fixture to a valid ICO at the requested size', async () => { + const inputPath = path.join(import.meta.dirname, '..', 'fixtures', 'sample.png'); + const outputPath = path.join(tmpDir, 'output.ico'); + const entry = resolve('png', 'ico'); + + await entry.convert(inputPath, outputPath, { iconSize: 48 }); + + const detected = await detectInputMime(outputPath); + expect(detected.mime).toBe('image/x-icon'); + + const { decodeIco } = await import('icojs'); + const [image] = await decodeIco(await fs.readFile(outputPath), 'image/png'); + expect(image.width).toBe(48); + }); + + it('defaults to 256px when no iconSize is given', async () => { + const inputPath = path.join(import.meta.dirname, '..', 'fixtures', 'sample.png'); + const outputPath = path.join(tmpDir, 'output-default.ico'); + const entry = resolve('png', 'ico'); + + await entry.convert(inputPath, outputPath); + + const { decodeIco } = await import('icojs'); + const [image] = await decodeIco(await fs.readFile(outputPath), 'image/png'); + expect(image.width).toBe(DEFAULT_ICON_SIZE); + }); +}); +``` + +- [ ] **Step 4: Run tests to verify they fail** + +Run: `DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run test/converters/ico.test.js` +Expected: FAIL — `Cannot find module '../../src/converters/ico.js'` + +- [ ] **Step 5: Implement `src/converters/ico.js`** + +```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'); + return images.reduce((a, b) => (a.width * a.height >= b.width * b.height ? a : b)); +} + +export function registerIcoConverter() { + for (const format of IMAGE_FORMATS) { + register({ + family: 'image', + sourceFormat: 'ico', + targetFormat: format, + convert: async (inputPath, outputPath, { quality } = {}) => { + const largest = await decodeIcoLargestImage(inputPath); + await sharp(Buffer.from(largest.buffer)) + .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); + }, + }); + } +} +``` + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run test/converters/ico.test.js` +Expected: PASS + +- [ ] **Step 7: Commit** + +```bash +git add package.json package-lock.json src/converters/image.js src/converters/ico.js test/converters/ico.test.js +git commit -m "feat: add bidirectional ICO image converter" +``` + +--- + +### Task 3: HEIC/HEIF source-only converter (`src/converters/heic.js`) + +**Files:** +- Create: `src/converters/heic.js` +- Test: `test/converters/heic.test.js` +- Modify: `package.json` (add `heic-convert` dependency) + +**Interfaces:** +- Consumes: `sharpFormatName`, `buildFormatOptions` from `src/converters/image.js` (Task 2); `encodeIcoFromInput`, `DEFAULT_ICON_SIZE` from `src/converters/ico.js` (Task 2). +- Produces: `registerHeicConverter()` — registers `heic -> X` and `heif -> X` for every `X` in `['jpg', 'jpeg', 'png', 'webp', 'gif', 'tiff', 'avif', 'ico']`. Registers nothing with `heic`/`heif` as a `targetFormat`. + +- [ ] **Step 1: Install the dependency** + +```bash +npm install heic-convert +``` + +Verify `package.json`'s `dependencies` now includes `"heic-convert"`. + +- [ ] **Step 2: Write the failing tests** + +Create `test/converters/heic.test.js`: + +```js +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; +import sharp from 'sharp'; +import { registerHeicConverter } from '../../src/converters/heic.js'; +import { resolve, listTargetFormats } from '../../src/converters/registry.js'; +import { detectInputMime } from '../../src/mime.js'; + +let tmpDir; + +beforeAll(async () => { + registerHeicConverter(); + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'converter-heic-')); +}); + +afterAll(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); +}); + +describe('HEIC/HEIF converter registration', () => { + it('registers heic and heif as source formats for every image target plus ico', () => { + expect(listTargetFormats('heic')).toEqual( + expect.arrayContaining(['jpg', 'jpeg', 'png', 'webp', 'gif', 'tiff', 'avif', 'ico']) + ); + expect(listTargetFormats('heif')).toEqual( + expect.arrayContaining(['jpg', 'jpeg', 'png', 'webp', 'gif', 'tiff', 'avif', 'ico']) + ); + }); + + it('never registers heic or heif as a target format', () => { + expect(listTargetFormats('png')).not.toContain('heic'); + expect(listTargetFormats('png')).not.toContain('heif'); + }); +}); + +describe('heic -> image conversion', () => { + it('converts a HEIC fixture to PNG', async () => { + const inputPath = path.join(import.meta.dirname, '..', 'fixtures', 'sample.heic'); + const outputPath = path.join(tmpDir, 'output.png'); + const entry = resolve('heic', 'png'); + + await entry.convert(inputPath, outputPath); + + const detected = await detectInputMime(outputPath); + expect(detected.mime).toBe('image/png'); + }); + + it('converts a HEIC fixture to JPG', async () => { + const inputPath = path.join(import.meta.dirname, '..', 'fixtures', 'sample.heic'); + const outputPath = path.join(tmpDir, 'output.jpg'); + const entry = resolve('heic', 'jpg'); + + await entry.convert(inputPath, outputPath); + + const detected = await detectInputMime(outputPath); + expect(detected.mime).toBe('image/jpeg'); + }); + + it('converts a HEIC fixture to ICO at the requested size', async () => { + const inputPath = path.join(import.meta.dirname, '..', 'fixtures', 'sample.heic'); + const outputPath = path.join(tmpDir, 'output.ico'); + const entry = resolve('heic', 'ico'); + + await entry.convert(inputPath, outputPath, { iconSize: 32 }); + + const detected = await detectInputMime(outputPath); + expect(detected.mime).toBe('image/x-icon'); + + const { decodeIco } = await import('icojs'); + const [image] = await decodeIco(await fs.readFile(outputPath), 'image/png'); + expect(image.width).toBe(32); + }); +}); + +describe('heif -> image conversion', () => { + it('converts a HEIF-declared fixture to PNG', async () => { + const inputPath = path.join(import.meta.dirname, '..', 'fixtures', 'sample.heif'); + const outputPath = path.join(tmpDir, 'output-from-heif.png'); + const entry = resolve('heif', 'png'); + + await entry.convert(inputPath, outputPath); + + const detected = await detectInputMime(outputPath); + expect(detected.mime).toBe('image/png'); + }); +}); +``` + +- [ ] **Step 3: Run tests to verify they fail** + +Run: `DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run test/converters/heic.test.js` +Expected: FAIL — `Cannot find module '../../src/converters/heic.js'` + +- [ ] **Step 4: Implement `src/converters/heic.js`** + +```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); + }, + }); + } +} +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run test/converters/heic.test.js` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add package.json package-lock.json src/converters/heic.js test/converters/heic.test.js +git commit -m "feat: add source-only HEIC/HEIF image converter" +``` + +--- + +### Task 4: Prisma schema — add `iconSize` column + +**Files:** +- Modify: `prisma/schema.prisma` +- Create: `prisma/migrations/_add_icon_size/migration.sql` + +**Interfaces:** +- Produces: `ConversionJob.iconSize` (`Int?`, mapped to `icon_size` column, `UnsignedSmallInt`) — consumed by Task 5. + +- [ ] **Step 1: Edit `prisma/schema.prisma`** + +Add `iconSize` to the `ConversionJob` model, directly after `quality`: + +```prisma +model ConversionJob { + id Int @id @default(autoincrement()) @db.UnsignedInt + uuid String @unique(map: "uniq_uuid") @db.Char(36) + status JobStatus @default(pending) + family String @db.VarChar(32) + sourceFormat String @map("source_format") @db.VarChar(16) + targetFormat String @map("target_format") @db.VarChar(16) + originalFilename String @map("original_filename") @db.VarChar(255) + inputPath String @map("input_path") @db.VarChar(255) + outputPath String? @map("output_path") @db.VarChar(255) + inputMimeType String @map("input_mime_type") @db.VarChar(128) + outputMimeType String? @map("output_mime_type") @db.VarChar(128) + inputSizeBytes Int @map("input_size_bytes") @db.UnsignedInt + outputSizeBytes Int? @map("output_size_bytes") @db.UnsignedInt + quality Int? @db.UnsignedSmallInt + iconSize Int? @map("icon_size") @db.UnsignedSmallInt + conversionDurationSeconds Decimal? @map("conversion_duration_seconds") @db.Decimal(10, 3) + errorMessage String? @map("error_message") @db.VarChar(255) + errorLog String? @map("error_log") @db.Text + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(0) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.DateTime(0) + expiresAt DateTime @map("expires_at") @db.DateTime(0) + cleanedAt DateTime? @map("cleaned_at") @db.DateTime(0) + + @@index([status], map: "idx_status") + @@index([expiresAt], map: "idx_expires_at") + @@map("conversion_jobs") +} +``` + +- [ ] **Step 2: Generate the migration SQL (shadow-db-free)** + +```bash +DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage \ + DATABASE_URL=$(node scripts/printDatabaseUrl.js) npx prisma migrate diff \ + --from-schema-datasource prisma/schema.prisma --to-schema-datamodel prisma/schema.prisma --script > /tmp/migration.sql +cat /tmp/migration.sql +``` + +Expected output: a single `ALTER TABLE conversion_jobs ADD COLUMN icon_size SMALLINT UNSIGNED NULL;` statement (column name/type must match exactly what's declared in Step 1 — verify before proceeding). + +- [ ] **Step 3: Create the migration folder** + +```bash +mkdir -p "prisma/migrations/$(date +%Y%m%d%H%M%S)_add_icon_size" +``` + +Copy the contents of `/tmp/migration.sql` into `prisma/migrations//migration.sql`, then delete `/tmp/migration.sql`: + +```bash +rm /tmp/migration.sql +``` + +- [ ] **Step 4: Apply it to the local dev DB and mark it resolved** + +```bash +DATABASE_URL=$(node scripts/printDatabaseUrl.js) npx prisma db execute --schema prisma/schema.prisma --file "prisma/migrations//migration.sql" +DATABASE_URL=$(node scripts/printDatabaseUrl.js) npx prisma migrate resolve --applied "" +``` + +- [ ] **Step 5: Regenerate the Prisma client** + +```bash +npm run prisma-generate +``` + +- [ ] **Step 6: Verify** + +```bash +DATABASE_URL=$(node scripts/printDatabaseUrl.js) npx prisma migrate status +``` + +Expected: no pending migrations; `` listed as applied. + +- [ ] **Step 7: Commit** + +```bash +git add prisma/schema.prisma "prisma/migrations/" +git commit -m "feat: add iconSize column to ConversionJob" +``` + +--- + +### Task 5: `jobRepository.js` — thread `iconSize` through job creation/retrieval + +**Files:** +- Modify: `src/jobs/jobRepository.js:1-22` (jobSelect), `src/jobs/jobRepository.js:24-39` (createJob) +- Test: `test/jobs/jobRepository.test.js` + +**Interfaces:** +- Consumes: `ConversionJob.iconSize` (Task 4). +- Produces: `createJob(prisma, { ..., iconSize })` persists it; every query using `jobSelect` (`getJobByUuid`, `findPendingJobs`, `findExpiredJobs`) returns `job.iconSize`. + +- [ ] **Step 1: Write the failing test** + +Add to `test/jobs/jobRepository.test.js`, after the existing `'stores and retrieves a numeric quality value'` test: + +```js +it('stores and retrieves a numeric iconSize value', async () => { + await createJob( + prisma, + baseJob({ uuid: '77777777-7777-4777-8777-777777777777', targetFormat: 'ico', iconSize: 48 }) + ); + + const job = await getJobByUuid(prisma, '77777777-7777-4777-8777-777777777777'); + expect(job.iconSize).toBe(48); +}); +``` + +Also add `expect(job.iconSize).toBeNull();` to the existing `'creates and retrieves a pending job'` test, next to the existing `expect(job.quality).toBeNull();` line. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run test/jobs/jobRepository.test.js` +Expected: FAIL — `job.iconSize` is `undefined`, not `null`/`48`. + +- [ ] **Step 3: Implement** + +In `src/jobs/jobRepository.js`, add `iconSize: true,` to `jobSelect` (next to `quality: true,`): + +```js +const jobSelect = { + id: true, + uuid: true, + status: true, + family: true, + sourceFormat: true, + targetFormat: true, + originalFilename: true, + inputPath: true, + outputPath: true, + inputMimeType: true, + outputMimeType: true, + inputSizeBytes: true, + outputSizeBytes: true, + quality: true, + iconSize: true, + conversionDurationSeconds: true, + errorMessage: true, + createdAt: true, + updatedAt: true, + expiresAt: true, + cleanedAt: true, +}; +``` + +Add `iconSize: job.iconSize ?? null,` to `createJob`'s `data` object (next to `quality: job.quality ?? null,`): + +```js +export async function createJob(prisma, job) { + await prisma.conversionJob.create({ + data: { + uuid: job.uuid, + family: job.family, + sourceFormat: job.sourceFormat, + targetFormat: job.targetFormat, + originalFilename: job.originalFilename, + inputPath: job.inputPath, + inputMimeType: job.inputMimeType, + inputSizeBytes: job.inputSizeBytes, + expiresAt: job.expiresAt, + quality: job.quality ?? null, + iconSize: job.iconSize ?? null, + }, + }); +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run test/jobs/jobRepository.test.js` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/jobs/jobRepository.js test/jobs/jobRepository.test.js +git commit -m "feat: thread iconSize through jobRepository" +``` + +--- + +### Task 6: `app.js` — register converters, validate `iconSize`, reject `quality` for ico + +**Files:** +- Modify: `src/app.js:16-32` (isValidQuality, registerAllConverters), `src/app.js:1-14` (imports), `src/app.js:68-151` (POST /api/jobs handler) +- Test: `test/api/jobs.test.js` + +**Interfaces:** +- Consumes: `registerIcoConverter` (Task 2), `registerHeicConverter` (Task 3). +- Produces: `POST /api/jobs` accepts an `iconSizes` form field (JSON array, same shape/validation pattern as `qualities`); per-file `iconSize` is validated against `VALID_ICON_SIZES = [16, 32, 48, 256, 512]` and passed to `createJob`. + +- [ ] **Step 1: Write the failing tests** + +Add to `test/api/jobs.test.js`, inside the `describe('POST /api/jobs', ...)` block: + +```js +it('creates a pending job with an iconSize for an ico target', async () => { + const fixturePath = path.join(import.meta.dirname, '..', 'fixtures', 'sample.png'); + + const response = await request(app) + .post('/api/jobs') + .field('targetFormats', JSON.stringify(['ico'])) + .field('iconSizes', JSON.stringify([48])) + .attach('files', fixturePath, 'photo.png'); + + expect(response.status).toBe(201); + expect(response.body.jobs[0].status).toBe('pending'); + + const job = await getJobByUuid(prisma, response.body.jobs[0].id); + expect(job.iconSize).toBe(48); +}); + +it('rejects an iconSize outside the fixed set', async () => { + const fixturePath = path.join(import.meta.dirname, '..', 'fixtures', 'sample.png'); + + const response = await request(app) + .post('/api/jobs') + .field('targetFormats', JSON.stringify(['ico'])) + .field('iconSizes', JSON.stringify([100])) + .attach('files', fixturePath, 'photo.png'); + + expect(response.status).toBe(201); + expect(response.body.jobs[0].error).toMatch(/Invalid iconSize/); +}); + +it('rejects an iconSize for a non-ico target', async () => { + const fixturePath = path.join(import.meta.dirname, '..', 'fixtures', 'sample.png'); + + const response = await request(app) + .post('/api/jobs') + .field('targetFormats', JSON.stringify(['webp'])) + .field('iconSizes', JSON.stringify([48])) + .attach('files', fixturePath, 'photo.png'); + + expect(response.status).toBe(201); + expect(response.body.jobs[0].error).toMatch(/Invalid iconSize/); +}); + +it('rejects a quality value for an ico target', async () => { + const fixturePath = path.join(import.meta.dirname, '..', 'fixtures', 'sample.png'); + + const response = await request(app) + .post('/api/jobs') + .field('targetFormats', JSON.stringify(['ico'])) + .field('qualities', JSON.stringify([50])) + .attach('files', fixturePath, 'photo.png'); + + expect(response.status).toBe(201); + expect(response.body.jobs[0].error).toMatch(/Invalid quality/); +}); + +it('creates a pending job converting a HEIC upload to jpg', async () => { + const fixturePath = path.join(import.meta.dirname, '..', 'fixtures', 'sample.heic'); + + const response = await request(app) + .post('/api/jobs') + .field('targetFormats', JSON.stringify(['jpg'])) + .attach('files', fixturePath, 'photo.heic'); + + expect(response.status).toBe(201); + expect(response.body.jobs[0].status).toBe('pending'); + + const job = await getJobByUuid(prisma, response.body.jobs[0].id); + expect(job.sourceFormat).toBe('heic'); +}); + +it('creates a pending job converting a HEIF-declared upload to png', async () => { + const fixturePath = path.join(import.meta.dirname, '..', 'fixtures', 'sample.heif'); + + const response = await request(app) + .post('/api/jobs') + .field('targetFormats', JSON.stringify(['png'])) + .attach('files', fixturePath, 'photo.heif'); + + expect(response.status).toBe(201); + expect(response.body.jobs[0].status).toBe('pending'); +}); +``` + +Add to `describe('GET /api/formats', ...)`: + +```js +it('lists ico as a target for png, and does not list heic/heif as a target for anything', async () => { + const icoTargets = await request(app).get('/api/formats').query({ source: 'png' }); + expect(icoTargets.body.targets).toContain('ico'); + + const heicTargets = await request(app).get('/api/formats').query({ source: 'heic' }); + expect(heicTargets.body.targets).toContain('png'); + + const pngTargets = await request(app).get('/api/formats').query({ source: 'png' }); + expect(pngTargets.body.targets).not.toContain('heic'); + expect(pngTargets.body.targets).not.toContain('heif'); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run test/api/jobs.test.js` +Expected: FAIL — `ico`/`heic`/`heif` conversions are rejected with `Unsupported conversion` (converters not yet registered in `app.js`), and `iconSizes` is silently ignored (no validation exists yet). + +- [ ] **Step 3: Implement** + +In `src/app.js`, add imports (next to the existing converter imports): + +```js +import { registerIcoConverter } from './converters/ico.js'; +import { registerHeicConverter } from './converters/heic.js'; +``` + +Update `registerAllConverters`: + +```js +function registerAllConverters() { + if (convertersRegistered) return; + registerImageConverters(); + registerImageToPdfConverter(); + registerDocumentConverters(); + registerIcoConverter(); + registerHeicConverter(); + convertersRegistered = true; +} +``` + +Update `isValidQuality` to reject `ico` (no quality knob), and add `isValidIconSize`: + +```js +const VALID_ICON_SIZES = [16, 32, 48, 256, 512]; + +function isValidQuality(targetFormat, quality) { + if (quality === null || quality === undefined) return true; + if (!Number.isInteger(quality)) return false; + if (targetFormat === 'gif' || targetFormat === 'ico') return false; + if (targetFormat === 'png') return quality >= 0 && quality <= 9; + return quality >= 1 && quality <= 100; +} + +function isValidIconSize(targetFormat, iconSize) { + if (iconSize === null || iconSize === undefined) return true; + if (targetFormat !== 'ico') return false; + return VALID_ICON_SIZES.includes(iconSize); +} +``` + +In the `POST /api/jobs` handler, parse `iconSizes` the same way `qualities` is parsed (add right after the existing `qualities` parsing block, before the per-file loop): + +```js +let iconSizes; +try { + iconSizes = JSON.parse(req.body.iconSizes ?? '[]'); +} catch { + return res.status(400).json({ error: 'iconSizes must be a JSON array' }); +} + +if (!Array.isArray(iconSizes)) { + return res.status(400).json({ error: 'iconSizes must be a JSON array' }); +} + +if (iconSizes.length > 0 && iconSizes.length !== req.files.length) { + return res.status(400).json({ error: 'iconSizes must have one entry per uploaded file, or be omitted' }); +} +``` + +Inside the per-file loop, after the existing `requestedQuality`/`isValidQuality` block and before the `createJob` call, add: + +```js +const requestedIconSize = iconSizes[i] ?? null; +if (!isValidIconSize(targetFormat, requestedIconSize)) { + await deleteIfExists(file.path); + results.push({ + file: file.originalname, + error: `Invalid iconSize for target format ${targetFormat}`, + }); + continue; +} +``` + +Add `iconSize: requestedIconSize,` to the `createJob(prisma, { ... })` call, next to `quality: requestedQuality,`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run test/api/jobs.test.js` +Expected: PASS + +- [ ] **Step 5: Run the full test suite to check for regressions** + +Run: `DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run` +Expected: PASS, except the two pre-existing unrelated failures documented in `CLAUDE.md` (`test/cleanup.test.js` and `test/jobs/jobRepository.test.js`'s expired-jobs clock/timezone case) — verify against `main` first if any other new failure shows up. + +- [ ] **Step 6: Commit** + +```bash +git add src/app.js test/api/jobs.test.js +git commit -m "feat: wire ICO/HEIC/HEIF converters and iconSize validation into the API" +``` + +--- + +### Task 7: `worker.js` — register converters, pass `iconSize` through + +**Files:** +- Modify: `src/worker.js:1-11` (imports), `src/worker.js:23-53` (processJob), `src/worker.js:70-81` (main) +- Test: `test/worker.test.js` + +**Interfaces:** +- Consumes: `registerIcoConverter`, `registerHeicConverter` (same as Task 6). +- Produces: `processJob` now calls `entry.convert(inputFilePath, outputFilePath, { quality: job.quality, iconSize: job.iconSize })`. + +- [ ] **Step 1: Write the failing test** + +`test/worker.test.js` already has a `createPendingImageJob(uuid, sourceFormat, targetFormat)` helper that copies `test/fixtures/sample.png` into place and calls `createJob` without a `quality`/`iconSize` field (see the existing `'converts a pending image job to done'` test). It doesn't support passing `iconSize`, so write this test using `createJob` directly, following the same manual-setup style already used by the file's `'passes the job quality through to the converter...'` test. Add this inside the existing `describe('processPendingJobs', ...)` block in `test/worker.test.js`: + +```js +it('processes a png -> ico job honoring iconSize', async () => { + const { decodeIco } = await import('icojs'); + const uuid = 'bbbbbbbb-1111-4bbb-8bbb-bbbbbbbbbbb1'; + const fixturePath = path.join(import.meta.dirname, 'fixtures', 'sample.png'); + const inputFilePath = uploadPath(config, uuid, 'png'); + await fs.copyFile(fixturePath, inputFilePath); + const { size: inputSizeBytes } = await fs.stat(inputFilePath); + + await createJob(prisma, { + uuid, + family: 'image', + sourceFormat: 'png', + targetFormat: 'ico', + originalFilename: 'photo.png', + inputPath: `${uuid}.png`, + inputMimeType: 'image/png', + inputSizeBytes, + expiresAt: new Date(Date.now() + 3600 * 1000), + iconSize: 32, + }); + + await processPendingJobs(prisma, config); + + const job = await getJobByUuid(prisma, uuid); + expect(job.status).toBe('done'); + + const outputFilePath = outputPath(config, uuid, 'ico'); + const [image] = await decodeIco(await fs.readFile(outputFilePath), 'image/png'); + expect(image.width).toBe(32); +}); +``` + +This test file's `beforeAll` only calls `registerImageConverters()` — it will need `registerIcoConverter()` too. Add that import and call to `test/worker.test.js`'s existing `beforeAll`: + +```js +import { registerIcoConverter } from '../src/converters/ico.js'; +``` + +```js +beforeAll(async () => { + registerImageConverters(); + registerIcoConverter(); + config = { ...loadConfig(), storageDir: await fs.mkdtemp(path.join(os.tmpdir(), 'converter-worker-')) }; + await ensureStorageDirs(config); + prisma = getPrismaClient(config); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run test/worker.test.js` +Expected: FAIL — `No converter registered for png -> ico`. + +- [ ] **Step 3: Implement** + +In `src/worker.js`, add imports: + +```js +import { registerIcoConverter } from './converters/ico.js'; +import { registerHeicConverter } from './converters/heic.js'; +``` + +In `processJob`, pass `iconSize` through: + +```js +await withTimeout( + entry.convert(inputFilePath, outputFilePath, { quality: job.quality, iconSize: job.iconSize }), + JOB_TIMEOUT_MS +); +``` + +In `main`, register the two new converters: + +```js +registerImageConverters(); +registerImageToPdfConverter(); +registerDocumentConverters(); +registerIcoConverter(); +registerHeicConverter(); +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run test/worker.test.js` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/worker.js test/worker.test.js +git commit -m "feat: register ICO/HEIC/HEIF converters in the worker and pass iconSize through" +``` + +--- + +### Task 8: Frontend — ICO size picker + +**Files:** +- Modify: `frontend/src/App.jsx` +- Modify: `frontend/src/api.js` + +**Interfaces:** +- Consumes: nothing new from the backend beyond what Task 6 already exposes (`/api/formats` already returns `ico` dynamically once registered). +- Produces: each `pendingFiles` item gains an `iconSize` field; `uploadFiles` sends an `iconSizes` array alongside `targetFormats`/`qualities`. + +- [ ] **Step 1: Update `frontend/src/App.jsx`** + +Add an icon-size constant near the top, alongside `QUALITY_FORMATS`: + +```js +const ICON_SIZES = [16, 32, 48, 256, 512]; +const DEFAULT_ICON_SIZE = 256; +``` + +Update `defaultQualityFor` usage sites so `iconSize` is set independently of `quality`. In `handleFilesSelected`, change the mapped object: + +```js +const withTargets = await Promise.all( + files.map(async (file) => { + const targets = await fetchFormats(extensionOf(file.name)); + const targetFormat = targets[0] ?? null; + return { + file, + targets, + targetFormat, + quality: defaultQualityFor(targetFormat), + iconSize: targetFormat === 'ico' ? DEFAULT_ICON_SIZE : null, + }; + }) +); +``` + +Update `updateTargetFormat` to reset `iconSize` the same way it resets `quality`: + +```js +function updateTargetFormat(index, targetFormat) { + setPendingFiles((current) => + current.map((item, i) => + i === index + ? { + ...item, + targetFormat, + quality: defaultQualityFor(targetFormat), + iconSize: targetFormat === 'ico' ? DEFAULT_ICON_SIZE : null, + } + : item + ) + ); +} +``` + +Add an `updateIconSize` setter, mirroring `updateQuality`: + +```js +function updateIconSize(index, iconSize) { + setPendingFiles((current) => current.map((item, i) => (i === index ? { ...item, iconSize } : item))); +} +``` + +Add a conditional control in the JSX, alongside the existing png/pdf blocks: + +```jsx +{item.targetFormat === 'ico' && ( + +)} +``` + +- [ ] **Step 2: Update `frontend/src/api.js`** + +```js +export async function uploadFiles(items) { + const formData = new FormData(); + const targetFormats = []; + const qualities = []; + const iconSizes = []; + for (const item of items) { + formData.append('files', item.file); + targetFormats.push(item.targetFormat); + qualities.push(item.quality ?? null); + iconSizes.push(item.iconSize ?? null); + } + formData.append('targetFormats', JSON.stringify(targetFormats)); + formData.append('qualities', JSON.stringify(qualities)); + formData.append('iconSizes', JSON.stringify(iconSizes)); + + const response = await fetch('/api/jobs', { method: 'POST', body: formData }); + const data = await response.json(); + return data.jobs; +} +``` + +- [ ] **Step 3: Manual browser verification** + +Per `CLAUDE.md`, check for pre-existing `npm run dev` / `node src/server.js` / `node src/worker.js` processes before starting your own (`Get-CimInstance Win32_Process -Filter "Name='node.exe'" | Select-Object ProcessId,CommandLine"`). If a pre-existing worker is running, it will not have this session's converter code loaded — either ask the user to restart their worker, or start your own throwaway worker instance for this check (do not kill/restart processes you didn't start yourself). Then: +- Upload a `.png`, select `ico` as the target, confirm the size dropdown appears and defaults to 256px, submit, confirm the download works and the file is a valid multi-... actually single-resolution `.ico` at the chosen size (open it or re-run the `icojs` decode check on the downloaded file). +- Upload a `.heic` file (e.g. `test/fixtures/sample.heic` renamed with a real `.heic` extension), confirm `heic` is not offered as a target format anywhere, and that converting it to `.jpg`/`.png` succeeds. + +- [ ] **Step 4: Commit** + +```bash +git add frontend/src/App.jsx frontend/src/api.js +git commit -m "feat: add ICO size picker to the upload UI" +``` + +--- + +### Task 9: Full regression pass + +**Files:** none (verification only) + +- [ ] **Step 1: Run the full backend test suite** + +Run: `DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run` +Expected: PASS, except the two pre-existing unrelated failures documented in `CLAUDE.md`. + +- [ ] **Step 2: Confirm no unintended target-format regressions** + +This exact check (`GET /api/formats?source=heic`/`?source=heif` contains every image format plus `ico`; `?source=png` does not contain `heic`/`heif`) is already asserted automatically in the `test/api/jobs.test.js` test added in Task 6 (`'lists ico as a target for png, and does not list heic/heif as a target for anything'`), which Step 1's full run already covers — no separate manual check is needed here. + +If a server is already running per the pre-existing-process check noted in Task 8 (check with `Get-CimInstance Win32_Process -Filter "Name='node.exe'"` first, per `CLAUDE.md`), a real-world spot check is still worthwhile: `curl localhost:3000/api/formats?source=heic` and confirm the JSON `targets` array matches expectations. + +- [ ] **Step 3: Report to the user** + +Summarize: all new tests passing, list of files changed, note that o2switch deployment will need `npm install` (to fetch `icojs`/`heic-convert`) and `npm run prisma-migration` (to apply the `icon_size` column) before the new formats work in production — per `CLAUDE.md`'s deployment process, do not run these against production yourself; hand off to the user.