Files
convert/docs/superpowers/plans/2026-07-31-heic-heif-ico-image-formats.md
T
anthony 2b2d8a9e6f Add implementation plan for HEIC/HEIF/ICO image format support
9 tasks covering mime.js, the ico/heic converter modules, a new
iconSize column on ConversionJob, app.js/worker.js wiring, and the
frontend size picker. Every code snippet (icojs encode/decode, the
heic-convert ESM import, prisma db execute/migrate status flags) was
verified against the actual installed/resolved packages rather than
guessed.
2026-07-31 09:54:06 +02:00

1163 lines
43 KiB
Markdown

# 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/<timestamp>_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/<that-folder>/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/<that-folder>/migration.sql"
DATABASE_URL=$(node scripts/printDatabaseUrl.js) npx prisma migrate resolve --applied "<that-folder-name>"
```
- [ ] **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; `<that-folder-name>` listed as applied.
- [ ] **Step 7: Commit**
```bash
git add prisma/schema.prisma "prisma/migrations/<that-folder>"
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' && (
<label>
Taille de l'icône
<select
value={item.iconSize ?? DEFAULT_ICON_SIZE}
onChange={(event) => updateIconSize(index, Number(event.target.value))}
>
{ICON_SIZES.map((size) => (
<option key={size} value={size}>
{size}px
</option>
))}
</select>
</label>
)}
```
- [ ] **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.