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.
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 —
icojsandheic-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/heifas atargetFormat. - Any new converter registration must be added to both
src/app.jsandsrc/worker.js— they independently duplicate the registration call list. - Local test runs must use
.env.local-equivalent inline env vars, never load real.envprod credentials (seeCLAUDE.md). - Prisma schema changes use the shadow-db-free fallback (
--from-schema-datasource/--to-schema-datamodel) documented inCLAUDE.md—prisma migrate devdoes 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 detectedextis always'heic'per the installedfile-typev22 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(exportsharpFormatName, it is currently private) - Create:
src/converters/ico.js - Test:
test/converters/ico.test.js - Modify:
package.json(addicojsdependency)
Interfaces:
-
Consumes:
sharpFormatName(format)andbuildFormatOptions(targetFormat, quality), both now exported fromsrc/converters/image.js. -
Produces:
registerIcoConverter()— registersico -> XandX -> icofor everyXin['jpg', 'jpeg', 'png', 'webp', 'gif', 'tiff', 'avif'].encodeIcoFromInput(input, size)— exported helper,inputmay be a file path or a Buffer (anythingsharp()accepts);sizedefaults toDEFAULT_ICON_SIZE(256). Also exportsDEFAULT_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
sharpFormatNamefromimage.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(addheic-convertdependency)
Interfaces:
-
Consumes:
sharpFormatName,buildFormatOptionsfromsrc/converters/image.js(Task 2);encodeIcoFromInput,DEFAULT_ICON_SIZEfromsrc/converters/ico.js(Task 2). -
Produces:
registerHeicConverter()— registersheic -> Xandheif -> Xfor everyXin['jpg', 'jpeg', 'png', 'webp', 'gif', 'tiff', 'avif', 'ico']. Registers nothing withheic/heifas atargetFormat. -
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 toicon_sizecolumn,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 usingjobSelect(getJobByUuid,findPendingJobs,findExpiredJobs) returnsjob.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/jobsaccepts aniconSizesform field (JSON array, same shape/validation pattern asqualities); per-fileiconSizeis validated againstVALID_ICON_SIZES = [16, 32, 48, 256, 512]and passed tocreateJob. -
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:
processJobnow callsentry.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/formatsalready returnsicodynamically once registered). -
Produces: each
pendingFilesitem gains aniconSizefield;uploadFilessends aniconSizesarray alongsidetargetFormats/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, selecticoas 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.icoat the chosen size (open it or re-run theicojsdecode check on the downloaded file). -
Upload a
.heicfile (e.g.test/fixtures/sample.heicrenamed with a real.heicextension), confirmheicis not offered as a target format anywhere, and that converting it to.jpg/.pngsucceeds. -
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.