From c7ae5366546f22363cd45c1742c7fbb40187b49b Mon Sep 17 00:00:00 2001 From: Anthony GAEREMYNCK <1@anthony.sh> Date: Thu, 30 Jul 2026 23:32:01 +0200 Subject: [PATCH] Add implementation plan for per-format image compression level Co-Authored-By: Claude Sonnet 5 --- .../2026-07-30-image-compression-level.md | 1043 +++++++++++++++++ 1 file changed, 1043 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-30-image-compression-level.md diff --git a/docs/superpowers/plans/2026-07-30-image-compression-level.md b/docs/superpowers/plans/2026-07-30-image-compression-level.md new file mode 100644 index 0000000..70b78d5 --- /dev/null +++ b/docs/superpowers/plans/2026-07-30-image-compression-level.md @@ -0,0 +1,1043 @@ +# Image Compression Level 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:** Let users choose a compression/quality level for image conversions, applied per uploaded file and interpreted according to the destination format's own semantics (sharp `quality` for jpeg/webp/avif/tiff, sharp `compressionLevel` for png, an opt-in JPEG-embed quality for image→pdf, no control for gif). + +**Architecture:** A `quality` value flows: frontend slider (`App.jsx`) → `qualities` JSON array alongside `targetFormats` (`api.js`) → parsed and validated per-file in `POST /api/jobs` (`app.js`) → stored on the job row (`jobRepository.js`) → read by the worker and passed as a third `options` argument to each converter's `convert(inputPath, outputPath, options)` (`worker.js` → `image.js` / `imageToPdf.js`). + +**Tech Stack:** Node.js (ESM), Express, `mariadb` driver, `sharp@0.35.3`, `pdf-lib@1.17.1`, Vitest + Supertest for backend tests, React 19 (no test framework) for the frontend. + +## Global Constraints + +- No new dependency may be added — everything needed (`sharp`, `pdf-lib`, `mariadb`) is already installed. +- No validation library exists in this project (no joi/zod/express-validator) — new validation must be plain inline JS, matching the existing style in `src/app.js`. +- `db/schema.sql` is the source of truth for schema but is applied by hand — there is no migration runner. Any schema change requires a manual `ALTER TABLE` against the local dev DB (and later against the o2switch DB at deploy time). +- Local tests must run against `.env.local` values passed as inline env vars, never against `.env` (production creds). Use: + `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` +- Two pre-existing test failures are unrelated to this work and must not be "fixed" as part of it: `test/cleanup.test.js` ("deletes an expired pending job...") and `test/jobs/jobRepository.test.js` ("finds expired jobs and allows deleting them"). Verify against `main` if they reappear. +- Spec reference: `docs/superpowers/specs/2026-07-30-image-compression-level-design.md`. + +--- + +### Task 1: Database column and job repository support + +**Files:** +- Modify: `db/schema.sql` +- Modify: `src/jobs/jobRepository.js:1-108` +- Test: `test/jobs/jobRepository.test.js` + +**Interfaces:** +- Produces: `createJob(pool, job)` now accepts an optional `job.quality` (number or omitted/null); rows returned by `getJobByUuid`, `findPendingJobs`, `findExpiredJobs` gain a `quality` field (number or `null`), used by Task 4 (`worker.js`) and Task 5 (`app.js`). + +- [ ] **Step 1: Write the failing tests** + +Edit `test/jobs/jobRepository.test.js`. In the existing "creates and retrieves a pending job" test, add one assertion right after `expect(job.cleanedAt).toBeNull();`: + +```js + expect(job.quality).toBeNull(); +``` + +Then add a new test right after that same test (after its closing `});`): + +```js + it('stores and retrieves a numeric quality value', async () => { + await createJob(pool, baseJob({ uuid: '66666666-6666-4666-8666-666666666666', quality: 82 })); + + const job = await getJobByUuid(pool, '66666666-6666-4666-8666-666666666666'); + expect(job.quality).toBe(82); + }); +``` + +- [ ] **Step 2: Run the 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.quality` is `undefined` in both tests (`expected undefined to be null` and `expected undefined to be 82`), since `createJob`/`toCamelJob` don't read or write a `quality` field yet. + +- [ ] **Step 3: Alter the local database** + +Run this against the local dev DB described in `CLAUDE.md` (`.env.local` credentials), using the project's own `mariadb` driver so no separate DB client needs to be installed: + +```bash +DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter node -e " +const mariadb = require('mariadb'); +const pool = mariadb.createPool({ host: process.env.DB_HOST, user: process.env.DB_USER, password: process.env.DB_PASSWORD, database: process.env.DB_NAME }); +pool.query('ALTER TABLE conversion_jobs ADD COLUMN quality SMALLINT UNSIGNED NULL') + .then(() => { console.log('quality column added'); return pool.end(); }) + .catch((err) => { console.error(err); process.exit(1); }); +" +``` + +Expected output: `quality column added`. + +- [ ] **Step 4: Update `db/schema.sql`** + +In `db/schema.sql`, add the column right after `output_size_bytes`: + +```sql + output_size_bytes INT UNSIGNED NULL, + quality SMALLINT UNSIGNED NULL, + conversion_duration_seconds DECIMAL(10,3) NULL, +``` + +- [ ] **Step 5: Update `src/jobs/jobRepository.js`** + +Replace `toCamelJob`: + +```js +function toCamelJob(row) { + if (!row) return null; + return { + id: row.id, + uuid: row.uuid, + status: row.status, + family: row.family, + sourceFormat: row.source_format, + targetFormat: row.target_format, + originalFilename: row.original_filename, + inputPath: row.input_path, + outputPath: row.output_path, + inputMimeType: row.input_mime_type, + outputMimeType: row.output_mime_type, + inputSizeBytes: row.input_size_bytes, + outputSizeBytes: row.output_size_bytes, + quality: row.quality, + conversionDurationSeconds: row.conversion_duration_seconds, + errorMessage: row.error_message, + createdAt: row.created_at, + updatedAt: row.updated_at, + expiresAt: row.expires_at, + cleanedAt: row.cleaned_at, + }; +} +``` + +Replace `createJob`: + +```js +export async function createJob(pool, job) { + await pool.query( + `INSERT INTO conversion_jobs + (uuid, status, family, source_format, target_format, original_filename, input_path, input_mime_type, input_size_bytes, expires_at, quality) + VALUES (?, 'pending', ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + job.uuid, + job.family, + job.sourceFormat, + job.targetFormat, + job.originalFilename, + job.inputPath, + job.inputMimeType, + job.inputSizeBytes, + job.expiresAt, + job.quality ?? null, + ] + ); +} +``` + +Replace the column list in `getJobByUuid`: + +```js +export async function getJobByUuid(pool, uuid) { + const rows = await pool.query( + `SELECT id, uuid, status, family, source_format, target_format, original_filename, + input_path, output_path, input_mime_type, output_mime_type, + input_size_bytes, output_size_bytes, quality, conversion_duration_seconds, + error_message, created_at, updated_at, expires_at, cleaned_at + FROM conversion_jobs WHERE uuid = ?`, + [uuid] + ); + return toCamelJob(rows[0]); +} +``` + +Replace the column list in `findPendingJobs`: + +```js +export async function findPendingJobs(pool, limit) { + const rows = await pool.query( + `SELECT id, uuid, status, family, source_format, target_format, original_filename, + input_path, output_path, input_mime_type, output_mime_type, + input_size_bytes, output_size_bytes, quality, conversion_duration_seconds, + error_message, created_at, updated_at, expires_at, cleaned_at + FROM conversion_jobs WHERE status = 'pending' ORDER BY created_at ASC LIMIT ?`, + [limit] + ); + return rows.map(toCamelJob); +} +``` + +Replace the column list in `findExpiredJobs`: + +```js +export async function findExpiredJobs(pool) { + const rows = await pool.query( + `SELECT id, uuid, status, family, source_format, target_format, original_filename, + input_path, output_path, input_mime_type, output_mime_type, + input_size_bytes, output_size_bytes, quality, conversion_duration_seconds, + error_message, created_at, updated_at, expires_at, cleaned_at + FROM conversion_jobs WHERE expires_at < NOW() AND cleaned_at IS NULL` + ); + return rows.map(toCamelJob); +} +``` + +`markProcessing`, `markDone`, `markFailed`, `getJobErrorLog`, `markCleaned` are unchanged. + +- [ ] **Step 6: Run the 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 for all tests except the pre-existing, unrelated failure noted in Global Constraints ("finds expired jobs and allows deleting them"). + +- [ ] **Step 7: Commit** + +```bash +git add db/schema.sql src/jobs/jobRepository.js test/jobs/jobRepository.test.js +git commit -m "feat: add quality column to conversion_jobs" +``` + +--- + +### Task 2: Quality/compressionLevel support in image-to-image conversion + +**Files:** +- Modify: `src/converters/image.js:1-25` +- Test: `test/converters/image.test.js` + +**Interfaces:** +- Consumes: nothing new from Task 1. +- Produces: `buildFormatOptions(targetFormat, quality)` (exported pure function) and every registered `convert(inputPath, outputPath, options = {})` now accepts an optional third argument shaped `{ quality }`, consumed by Task 4 (`worker.js`). + +- [ ] **Step 1: Write the failing tests** + +Replace the full contents of `test/converters/image.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 { registerImageConverters, buildFormatOptions } from '../../src/converters/image.js'; +import { resolve, listTargetFormats } from '../../src/converters/registry.js'; +import { detectInputMime } from '../../src/mime.js'; + +let tmpDir; +let noisyPngPath; + +beforeAll(async () => { + registerImageConverters(); + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'converter-image-')); + noisyPngPath = path.join(tmpDir, 'noisy.png'); + await sharp({ + create: { + width: 256, + height: 256, + channels: 3, + noise: { type: 'gaussian', mean: 128, sigma: 40 }, + }, + }) + .png() + .toFile(noisyPngPath); +}); + +afterAll(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); +}); + +describe('buildFormatOptions', () => { + it('returns an empty object when quality is not set', () => { + expect(buildFormatOptions('jpeg', null)).toEqual({}); + }); + + it('maps quality to the quality option for jpeg/webp/avif/tiff', () => { + expect(buildFormatOptions('jpeg', 40)).toEqual({ quality: 40 }); + expect(buildFormatOptions('webp', 40)).toEqual({ quality: 40 }); + expect(buildFormatOptions('avif', 40)).toEqual({ quality: 40 }); + expect(buildFormatOptions('tiff', 40)).toEqual({ quality: 40 }); + }); + + it('maps quality to compressionLevel for png', () => { + expect(buildFormatOptions('png', 3)).toEqual({ compressionLevel: 3 }); + }); + + it('ignores quality for gif', () => { + expect(buildFormatOptions('gif', 5)).toEqual({}); + }); +}); + +describe('image converters', () => { + it('registers every pair among the supported formats', () => { + const targets = listTargetFormats('png').sort(); + expect(targets).toEqual(['avif', 'gif', 'jpeg', 'jpg', 'tiff', 'webp']); + }); + + it('converts a PNG fixture to WebP', async () => { + const inputPath = path.join(import.meta.dirname, '..', 'fixtures', 'sample.png'); + const outputPath = path.join(tmpDir, 'output.webp'); + const entry = resolve('png', 'webp'); + + await entry.convert(inputPath, outputPath); + + const detected = await detectInputMime(outputPath); + expect(detected.mime).toBe('image/webp'); + }); + + it('converts a PNG fixture to JPG', async () => { + const inputPath = path.join(import.meta.dirname, '..', 'fixtures', 'sample.png'); + const outputPath = path.join(tmpDir, 'output.jpg'); + const entry = resolve('png', 'jpg'); + + await entry.convert(inputPath, outputPath); + + const detected = await detectInputMime(outputPath); + expect(detected.mime).toBe('image/jpeg'); + }); + + it('produces a smaller JPG at lower quality than at higher quality', async () => { + const lowPath = path.join(tmpDir, 'low.jpg'); + const highPath = path.join(tmpDir, 'high.jpg'); + const entry = resolve('png', 'jpg'); + + await entry.convert(noisyPngPath, lowPath, { quality: 10 }); + await entry.convert(noisyPngPath, highPath, { quality: 95 }); + + const [lowStat, highStat] = await Promise.all([fs.stat(lowPath), fs.stat(highPath)]); + expect(lowStat.size).toBeLessThan(highStat.size); + }); + + it('produces a smaller-or-equal PNG at a higher compressionLevel', async () => { + const jpgPath = path.join(tmpDir, 'noisy.jpg'); + await sharp(noisyPngPath).jpeg({ quality: 90 }).toFile(jpgPath); + + const fastPath = path.join(tmpDir, 'fast.png'); + const slowPath = path.join(tmpDir, 'slow.png'); + const entry = resolve('jpg', 'png'); + + await entry.convert(jpgPath, fastPath, { quality: 0 }); + await entry.convert(jpgPath, slowPath, { quality: 9 }); + + const [fastStat, slowStat] = await Promise.all([fs.stat(fastPath), fs.stat(slowPath)]); + expect(slowStat.size).toBeLessThanOrEqual(fastStat.size); + }); +}); +``` + +- [ ] **Step 2: Run the 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/image.test.js` + +Expected: FAIL — either a module-load error because `buildFormatOptions` is not an exported member of `src/converters/image.js` yet, or (depending on how Vitest's ESM interop resolves the missing export) `buildFormatOptions is not a function` at the first `describe('buildFormatOptions', ...)` test. The two new size-comparison tests at the bottom would also fail on their own once the module loads, because `entry.convert` currently ignores its third argument. + +- [ ] **Step 3: Implement** + +Replace the full contents of `src/converters/image.js`: + +```js +import sharp from 'sharp'; +import { register } from './registry.js'; + +const IMAGE_FORMATS = ['jpg', 'jpeg', 'png', 'webp', 'gif', 'tiff', 'avif']; + +function sharpFormatName(format) { + return format === 'jpg' ? 'jpeg' : format; +} + +export function buildFormatOptions(targetFormat, quality) { + if (quality == null) return {}; + if (targetFormat === 'png') return { compressionLevel: quality }; + if (targetFormat === 'gif') return {}; + return { quality }; +} + +export function registerImageConverters() { + for (const sourceFormat of IMAGE_FORMATS) { + for (const targetFormat of IMAGE_FORMATS) { + if (sourceFormat === targetFormat) continue; + + register({ + family: 'image', + sourceFormat, + targetFormat, + convert: async (inputPath, outputPath, { quality } = {}) => { + await sharp(inputPath) + .toFormat(sharpFormatName(targetFormat), buildFormatOptions(targetFormat, quality)) + .toFile(outputPath); + }, + }); + } + } +} +``` + +- [ ] **Step 4: Run the 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/image.test.js` + +Expected: PASS (all tests in this file). + +- [ ] **Step 5: Commit** + +```bash +git add src/converters/image.js test/converters/image.test.js +git commit -m "feat: support quality/compressionLevel options in image converters" +``` + +--- + +### Task 3: Opt-in JPEG-embed quality for image-to-PDF conversion + +**Files:** +- Modify: `src/converters/imageToPdf.js:1-32` +- Test: `test/converters/imageToPdf.test.js` + +**Interfaces:** +- Consumes: nothing new from Task 1 or 2 (independent converter module). +- Produces: `convert(inputPath, outputPath, options = {})` for the `*->pdf` registry entries now accepts `{ quality }`, consumed by Task 4 (`worker.js`). When `quality` is `null`/omitted, behavior is byte-for-byte the same as before (PNG embed). + +- [ ] **Step 1: Write the failing test** + +Replace the full contents of `test/converters/imageToPdf.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 { registerImageToPdfConverter } from '../../src/converters/imageToPdf.js'; +import { resolve, listTargetFormats } from '../../src/converters/registry.js'; +import { detectInputMime } from '../../src/mime.js'; + +let tmpDir; + +beforeAll(async () => { + registerImageToPdfConverter(); + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'converter-image-to-pdf-')); +}); + +afterAll(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); +}); + +describe('image to PDF converter', () => { + it('registers pdf as a target for every image format', () => { + expect(listTargetFormats('png')).toContain('pdf'); + expect(listTargetFormats('jpg')).toContain('pdf'); + }); + + it('converts a PNG fixture into a valid PDF', async () => { + const inputPath = path.join(import.meta.dirname, '..', 'fixtures', 'sample.png'); + const outputPath = path.join(tmpDir, 'output.pdf'); + const entry = resolve('png', 'pdf'); + + await entry.convert(inputPath, outputPath); + + const detected = await detectInputMime(outputPath); + expect(detected.mime).toBe('application/pdf'); + }); + + it('embeds a JPEG instead of a PNG when a quality is given, shrinking the output for a noisy image', async () => { + const inputPath = path.join(tmpDir, 'noisy.png'); + await sharp({ + create: { + width: 256, + height: 256, + channels: 3, + noise: { type: 'gaussian', mean: 128, sigma: 40 }, + }, + }) + .png() + .toFile(inputPath); + + const defaultOutputPath = path.join(tmpDir, 'default.pdf'); + const compressedOutputPath = path.join(tmpDir, 'compressed.pdf'); + const entry = resolve('png', 'pdf'); + + await entry.convert(inputPath, defaultOutputPath); + await entry.convert(inputPath, compressedOutputPath, { quality: 20 }); + + const compressedDetected = await detectInputMime(compressedOutputPath); + expect(compressedDetected.mime).toBe('application/pdf'); + + const [defaultStat, compressedStat] = await Promise.all([ + fs.stat(defaultOutputPath), + fs.stat(compressedOutputPath), + ]); + expect(compressedStat.size).toBeLessThan(defaultStat.size); + }); +}); +``` + +- [ ] **Step 2: Run the 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/converters/imageToPdf.test.js` + +Expected: FAIL on the new test — `compressedStat.size` is not less than `defaultStat.size` because `quality` is currently ignored (both paths embed the same PNG). + +- [ ] **Step 3: Implement** + +Replace the full contents of `src/converters/imageToPdf.js`: + +```js +import fs from 'node:fs/promises'; +import sharp from 'sharp'; +import { PDFDocument } from 'pdf-lib'; +import { register } from './registry.js'; + +const IMAGE_FORMATS = ['jpg', 'jpeg', 'png', 'webp', 'gif', 'tiff', 'avif']; + +async function convert(inputPath, outputPath, { quality } = {}) { + const imageBuffer = + quality == null + ? await sharp(inputPath).png().toBuffer() + : await sharp(inputPath).jpeg({ quality }).toBuffer(); + const metadata = await sharp(imageBuffer).metadata(); + + const pdfDoc = await PDFDocument.create(); + const page = pdfDoc.addPage([metadata.width, metadata.height]); + const embeddedImage = + quality == null ? await pdfDoc.embedPng(imageBuffer) : await pdfDoc.embedJpg(imageBuffer); + + page.drawImage(embeddedImage, { + x: 0, + y: 0, + width: metadata.width, + height: metadata.height, + }); + + const pdfBytes = await pdfDoc.save(); + await fs.writeFile(outputPath, pdfBytes); +} + +export function registerImageToPdfConverter() { + for (const sourceFormat of IMAGE_FORMATS) { + register({ family: 'image', sourceFormat, targetFormat: 'pdf', convert }); + } +} +``` + +- [ ] **Step 4: Run the 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/converters/imageToPdf.test.js` + +Expected: PASS (all tests in this file). + +- [ ] **Step 5: Commit** + +```bash +git add src/converters/imageToPdf.js test/converters/imageToPdf.test.js +git commit -m "feat: opt-in JPEG-embed quality for image-to-PDF conversion" +``` + +--- + +### Task 4: Thread job quality through the worker + +**Files:** +- Modify: `src/worker.js:36` +- Test: `test/worker.test.js` + +**Interfaces:** +- Consumes: `job.quality` from Task 1 (`jobRepository.js`), `convert(inputPath, outputPath, { quality })` from Tasks 2 and 3. +- Produces: nothing new for later tasks — this is the last hop before the converter. + +- [ ] **Step 1: Write the failing test** + +Add `import sharp from 'sharp';` to the top of `test/worker.test.js`, alongside the other imports. + +Add this test to `test/worker.test.js`, right after the "converts a pending image job to done" test (inside the `describe('processPendingJobs', ...)` block): + +```js + it('passes the job quality through to the converter, shrinking output for a low quality value', async () => { + const lowUuid = 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee'; + const defaultUuid = 'ffffffff-ffff-4fff-8fff-ffffffffffff'; + + const noisyBuffer = await sharp({ + create: { + width: 256, + height: 256, + channels: 3, + noise: { type: 'gaussian', mean: 128, sigma: 40 }, + }, + }) + .png() + .toBuffer(); + + for (const uuid of [lowUuid, defaultUuid]) { + await fs.writeFile(uploadPath(config, uuid, 'png'), noisyBuffer); + } + + await createJob(pool, { + uuid: lowUuid, + family: 'image', + sourceFormat: 'png', + targetFormat: 'jpg', + originalFilename: 'noisy.png', + inputPath: `${lowUuid}.png`, + inputMimeType: 'image/png', + inputSizeBytes: noisyBuffer.length, + expiresAt: new Date(Date.now() + 3600 * 1000), + quality: 5, + }); + await createJob(pool, { + uuid: defaultUuid, + family: 'image', + sourceFormat: 'png', + targetFormat: 'jpg', + originalFilename: 'noisy.png', + inputPath: `${defaultUuid}.png`, + inputMimeType: 'image/png', + inputSizeBytes: noisyBuffer.length, + expiresAt: new Date(Date.now() + 3600 * 1000), + }); + + await processPendingJobs(pool, { ...config, workerConcurrency: 2 }); + + const lowJob = await getJobByUuid(pool, lowUuid); + const defaultJob = await getJobByUuid(pool, defaultUuid); + expect(lowJob.status).toBe('done'); + expect(defaultJob.status).toBe('done'); + expect(lowJob.outputSizeBytes).toBeLessThan(defaultJob.outputSizeBytes); + }); +``` + +- [ ] **Step 2: Run the 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 — `lowJob.outputSizeBytes` is not less than `defaultJob.outputSizeBytes`, because `worker.js:36` calls `entry.convert(inputFilePath, outputFilePath)` without forwarding `job.quality`, so both jobs are encoded at sharp's default JPEG quality (80) regardless of the `quality` column value. + +- [ ] **Step 3: Implement** + +In `src/worker.js`, change line 36 from: + +```js + await withTimeout(entry.convert(inputFilePath, outputFilePath), JOB_TIMEOUT_MS); +``` + +to: + +```js + await withTimeout(entry.convert(inputFilePath, outputFilePath, { quality: job.quality }), JOB_TIMEOUT_MS); +``` + +- [ ] **Step 4: Run the 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 (all tests in this file). + +- [ ] **Step 5: Commit** + +```bash +git add src/worker.js test/worker.test.js +git commit -m "feat: pass job quality through to converters in the worker" +``` + +--- + +### Task 5: Parse and validate `qualities` in the job creation API + +**Files:** +- Modify: `src/app.js:60-117` +- Test: `test/api/jobs.test.js` + +**Interfaces:** +- Consumes: `createJob(pool, { ..., quality })` from Task 1. +- Produces: nothing new for later tasks — this is the last backend hop before storage; Task 6 (frontend) is the consumer of this endpoint's contract (`qualities` field on `POST /api/jobs`). + +- [ ] **Step 1: Write the failing tests** + +In `test/api/jobs.test.js`, add this assertion to the existing "creates a pending job for a valid image upload" test, right after `expect(job.inputSizeBytes).toBeGreaterThan(0);`: + +```js + expect(job.quality).toBeNull(); +``` + +Then add these new tests inside `describe('POST /api/jobs', ...)`, after the "creates a pending job for a valid image upload" test: + +```js + it('creates a pending job with a quality level for a supported format', async () => { + const fixturePath = path.join(import.meta.dirname, '..', 'fixtures', 'sample.png'); + + const response = await request(app) + .post('/api/jobs') + .field('targetFormats', JSON.stringify(['jpg'])) + .field('qualities', JSON.stringify([45])) + .attach('files', fixturePath, 'photo.png'); + + expect(response.status).toBe(201); + expect(response.body.jobs[0].status).toBe('pending'); + + const job = await getJobByUuid(pool, response.body.jobs[0].id); + expect(job.quality).toBe(45); + }); + + it('rejects an out-of-range quality without failing the whole batch', async () => { + const fixturePath = path.join(import.meta.dirname, '..', 'fixtures', 'sample.png'); + + const response = await request(app) + .post('/api/jobs') + .field('targetFormats', JSON.stringify(['jpg'])) + .field('qualities', JSON.stringify([500])) + .attach('files', fixturePath, 'photo.png'); + + expect(response.status).toBe(201); + expect(response.body.jobs[0].error).toMatch(/Invalid quality/); + }); + + it('rejects a quality value for a format that does not support one', async () => { + const fixturePath = path.join(import.meta.dirname, '..', 'fixtures', 'sample.png'); + + const response = await request(app) + .post('/api/jobs') + .field('targetFormats', JSON.stringify(['gif'])) + .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('returns 400 when qualities length does not match the number of files', async () => { + const fixturePath = path.join(import.meta.dirname, '..', 'fixtures', 'sample.png'); + + const response = await request(app) + .post('/api/jobs') + .field('targetFormats', JSON.stringify(['jpg'])) + .field('qualities', JSON.stringify([10, 20])) + .attach('files', fixturePath, 'photo.png'); + + expect(response.status).toBe(400); + }); +``` + +- [ ] **Step 2: Run the 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 on the three new tests — `job.quality` is `null` instead of `45` in the first (since `app.js` never reads `req.body.qualities` yet, so `createJob` always receives no `quality` and defaults it to `null` per Task 1), and `response.body.jobs[0].error` is `undefined` instead of matching `/Invalid quality/` in the other two (the out-of-range and gif+quality cases both succeed as pending jobs today because nothing validates `qualities` yet). The pre-existing test's new `expect(job.quality).toBeNull()` assertion already passes at this point — that's expected, it's a regression guard for Task 1's default, not a new behavior. + +- [ ] **Step 3: Implement** + +In `src/app.js`, add this helper function above `createApp` (after the imports, before `let convertersRegistered`): + +```js +function isValidQuality(targetFormat, quality) { + if (quality === null || quality === undefined) return true; + if (!Number.isInteger(quality)) return false; + if (targetFormat === 'gif') return false; + if (targetFormat === 'png') return quality >= 0 && quality <= 9; + return quality >= 1 && quality <= 100; +} +``` + +In the `POST /api/jobs` handler, right after the existing `targetFormats` length check (`if (!Array.isArray(targetFormats) || targetFormats.length !== req.files.length) { ... }`), add: + +```js + let qualities; + try { + qualities = JSON.parse(req.body.qualities ?? '[]'); + } catch { + return res.status(400).json({ error: 'qualities must be a JSON array' }); + } + + if (!Array.isArray(qualities)) { + return res.status(400).json({ error: 'qualities must be a JSON array' }); + } + + if (qualities.length > 0 && qualities.length !== req.files.length) { + return res.status(400).json({ error: 'qualities must have one entry per uploaded file, or be omitted' }); + } +``` + +In the per-file loop, right after the `registryEntry` unsupported-conversion check (after its `continue;` and closing `}`), add: + +```js + const requestedQuality = qualities[i] ?? null; + if (!isValidQuality(targetFormat, requestedQuality)) { + await deleteIfExists(file.path); + results.push({ + file: file.originalname, + error: `Invalid quality for target format ${targetFormat}`, + }); + continue; + } +``` + +Finally, add `quality: requestedQuality` to the `createJob(pool, { ... })` call: + +```js + await createJob(pool, { + uuid, + family: registryEntry.family, + sourceFormat, + targetFormat, + originalFilename: file.originalname, + inputPath: file.filename, + inputMimeType: mime, + inputSizeBytes: file.size, + expiresAt, + quality: requestedQuality, + }); +``` + +- [ ] **Step 4: Run the 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 (all tests in this file). + +- [ ] **Step 5: 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 listed in Global Constraints. + +- [ ] **Step 6: Commit** + +```bash +git add src/app.js test/api/jobs.test.js +git commit -m "feat: validate and persist per-file quality in POST /api/jobs" +``` + +--- + +### Task 6: Frontend compression controls + +**Files:** +- Modify: `frontend/src/App.jsx:1-81` +- Modify: `frontend/src/api.js:1-29` + +**Interfaces:** +- Consumes: the `qualities` field accepted by `POST /api/jobs` from Task 5. +- Produces: nothing further downstream — this is the last task. + +There is no frontend test framework in this repo (`frontend/package.json` has no vitest/RTL/jest), so this task is verified manually in the browser instead of with an automated test cycle. + +- [ ] **Step 1: Update `frontend/src/api.js`** + +Replace `uploadFiles`: + +```js +export async function uploadFiles(items) { + const formData = new FormData(); + const targetFormats = []; + const qualities = []; + for (const item of items) { + formData.append('files', item.file); + targetFormats.push(item.targetFormat); + qualities.push(item.quality ?? null); + } + formData.append('targetFormats', JSON.stringify(targetFormats)); + formData.append('qualities', JSON.stringify(qualities)); + + const response = await fetch('/api/jobs', { method: 'POST', body: formData }); + const data = await response.json(); + return data.jobs; +} +``` + +- [ ] **Step 2: Update `frontend/src/App.jsx`** + +Replace the full contents of `frontend/src/App.jsx`: + +```jsx +import { useState } from 'react'; +import { fetchFormats, uploadFiles } from './api.js'; +import { FileCard } from './FileCard.jsx'; +import './App.css'; + +const DEFAULT_QUALITY = { + jpg: 80, + jpeg: 80, + webp: 80, + avif: 50, + tiff: 80, + png: 6, +}; + +const QUALITY_FORMATS = ['jpg', 'jpeg', 'webp', 'avif', 'tiff']; + +function extensionOf(fileName) { + return fileName.split('.').pop().toLowerCase(); +} + +function defaultQualityFor(targetFormat) { + return DEFAULT_QUALITY[targetFormat] ?? null; +} + +export default function App() { + const [pendingFiles, setPendingFiles] = useState([]); + const [submittedJobs, setSubmittedJobs] = useState([]); + + async function handleFilesSelected(fileList) { + const files = Array.from(fileList); + 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) }; + }) + ); + setPendingFiles(withTargets); + } + + function updateTargetFormat(index, targetFormat) { + setPendingFiles((current) => + current.map((item, i) => + i === index ? { ...item, targetFormat, quality: defaultQualityFor(targetFormat) } : item + ) + ); + } + + function updateQuality(index, quality) { + setPendingFiles((current) => current.map((item, i) => (i === index ? { ...item, quality } : item))); + } + + async function handleConvert() { + const validItems = pendingFiles.filter((item) => item.targetFormat); + const jobs = await uploadFiles(validItems); + setSubmittedJobs((current) => [...current, ...jobs]); + setPendingFiles([]); + } + + return ( +
+

Convertisseur de fichiers

+ + handleFilesSelected(event.target.files)} /> + + {pendingFiles.length > 0 && ( +
+
    + {pendingFiles.map((item, index) => ( +
  • + {item.file.name} + {item.targets.length > 0 ? ( + <> + + + {QUALITY_FORMATS.includes(item.targetFormat) && ( + + )} + + {item.targetFormat === 'png' && ( + + )} + + {item.targetFormat === 'pdf' && ( + + )} + + ) : ( + Format non supporté + )} +
  • + ))} +
+ +
+ )} + + +
+ ); +} +``` + +- [ ] **Step 3: Manual verification in the browser** + +Start the backend (in one terminal): + +```bash +DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 node src/server.js +``` + +Start the worker (in a second terminal): + +```bash +DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 node src/worker.js +``` + +Start the frontend dev server, which proxies `/api` to `localhost:3000` per `frontend/vite.config.js` (in a third terminal): + +```bash +npm run dev --prefix frontend +``` + +Open the printed local URL and check: +- Uploading a `.png` and selecting `jpg` as the target shows a 1-100 quality slider defaulted to 80; dragging it changes the displayed number. +- Selecting `png` as the target shows a 0-9 compression slider defaulted to 6. +- Selecting `gif` as the target shows no slider. +- Selecting `pdf` as the target shows an unchecked "Compresser en JPEG" checkbox with no slider until checked; checking it reveals a 1-100 slider defaulted to 90. +- Switching the target format after adjusting the slider resets the slider to the new format's default (or hides it). +- Clicking "Convertir" completes the job and the download succeeds, for at least one case with a non-default slider value. + +- [ ] **Step 4: Commit** + +```bash +git add frontend/src/App.jsx frontend/src/api.js +git commit -m "feat: add per-format compression controls to the frontend" +``` + +--- + +## Deployment note + +After all tasks are merged, the same `ALTER TABLE conversion_jobs ADD COLUMN quality SMALLINT UNSIGNED NULL;` from Task 1 Step 3 must be run against the o2switch production database before deploying the new backend code, since `db/schema.sql` is not auto-applied there.