Files
convert/docs/superpowers/plans/2026-07-31-heic-heif-ico-image-formats.md
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

43 KiB

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-decodelibheif-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.mdprisma 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:

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:

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:

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
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

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:

function sharpFormatName(format) {

to:

export function sharpFormatName(format) {
  • Step 3: Write the failing tests

Create test/converters/ico.test.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
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
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

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:

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
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
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:

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)
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
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:

rm /tmp/migration.sql
  • Step 4: Apply it to the local dev DB and mark it resolved
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
npm run prisma-generate
  • Step 6: Verify
DATABASE_URL=$(node scripts/printDatabaseUrl.js) npx prisma migrate status

Expected: no pending migrations; <that-folder-name> listed as applied.

  • Step 7: Commit
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:

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,):

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,):

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
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:

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', ...):

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):

import { registerIcoConverter } from './converters/ico.js';
import { registerHeicConverter } from './converters/heic.js';

Update registerAllConverters:

function registerAllConverters() {
  if (convertersRegistered) return;
  registerImageConverters();
  registerImageToPdfConverter();
  registerDocumentConverters();
  registerIcoConverter();
  registerHeicConverter();
  convertersRegistered = true;
}

Update isValidQuality to reject ico (no quality knob), and add isValidIconSize:

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):

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:

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
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:

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:

import { registerIcoConverter } from '../src/converters/ico.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:

import { registerIcoConverter } from './converters/ico.js';
import { registerHeicConverter } from './converters/heic.js';

In processJob, pass iconSize through:

await withTimeout(
  entry.convert(inputFilePath, outputFilePath, { quality: job.quality, iconSize: job.iconSize }),
  JOB_TIMEOUT_MS
);

In main, register the two new converters:

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
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:

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:

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:

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:

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:

{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
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

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.