Files
convert/docs/superpowers/specs/2026-07-30-image-compression-level-design.md
anthonyandClaude Sonnet 5 b826338a61 Add design spec for per-format image compression level
Covers data model, backend threading through registry/image/imageToPdf/worker, frontend controls, validation, and testing for a new quality/compressionLevel option on image conversions.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 22:26:00 +02:00

7.7 KiB

Compression level for image conversion

Date: 2026-07-30

Goal

Let users specify a compression/quality level for image conversions, when the destination format supports it. Currently src/converters/image.js calls sharp(...).toFormat(target) with no options at all — every conversion uses sharp's built-in defaults, and there is no way for a user to trade file size against quality.

Scope

  • Image → image conversions (jpeg/jpg, webp, avif, tiff, png, gif) via src/converters/image.js.
  • Image → PDF conversions via src/converters/imageToPdf.js.
  • Out of scope: document converters (src/converters/document.js), any non-image conversion family.

Data model & value semantics

Compression is per-file, per-selected-target-format. The frontend sends a qualities array parallel to the existing targetFormats array (one entry per uploaded file; entries may be null).

Target format Parameter passed to sharp Range Sharp default Sent when user hasn't touched the control?
jpeg / jpg quality 1-100 80 yes, pre-filled with 80
webp quality 1-100 80 yes, pre-filled with 80
avif quality 1-100 50 yes, pre-filled with 50
tiff quality 1-100 80 yes, pre-filled with 80
png compressionLevel 0-9 6 yes, pre-filled with 6
gif — (no relevant sharp option) no control shown; always null
pdf (image→pdf) quality (drives a JPEG embed) 1-100 no default — opt-in no; null unless the user explicitly enables it

For pdf: quality: null (the default, untouched state) preserves today's behavior exactly — sharp(inputPath).png().toBuffer() + pdfDoc.embedPng(...). Any non-null quality switches that one job to sharp(inputPath).jpeg({ quality }).toBuffer() + pdfDoc.embedJpg(...). This is a deliberate, opt-in behavior change: PDF output only becomes lossy if the user explicitly asks for a quality level.

Database

conversion_jobs gets one new nullable column:

ALTER TABLE conversion_jobs ADD COLUMN quality SMALLINT UNSIGNED NULL;

The column stores the raw value in whichever unit applies to that job's target_format (0-100 for quality-based formats and pdf, 0-9 for png's compressionLevel, NULL for gif or an untouched pdf job). This project has no migration runner — db/schema.sql is the source of truth and is applied by hand (see .env.local / local MariaDB setup in CLAUDE.md). This ALTER must be run manually against the local dev DB after implementation, and against the o2switch DB at deploy time.

Backend flow

src/converters/registry.js: convert functions gain an optional third options parameter: convert(inputPath, outputPath, options). register() / resolve() signatures are unchanged — only the shape of the stored convert function changes.

src/converters/image.js: introduce a small pure helper that maps (targetFormat, quality) to the sharp options object:

function buildFormatOptions(targetFormat, quality) {
  if (quality == null) return {};
  if (targetFormat === 'png') return { compressionLevel: quality };
  if (targetFormat === 'gif') return {};
  return { quality }; // jpeg/jpg, webp, avif, tiff
}

Each registered convert becomes:

convert: async (inputPath, outputPath, { quality } = {}) => {
  await sharp(inputPath)
    .toFormat(sharpFormatName(targetFormat), buildFormatOptions(targetFormat, quality))
    .toFile(outputPath);
}

src/converters/imageToPdf.js: convert(inputPath, outputPath, { quality } = {}).

  • quality == null: unchanged — PNG buffer + pdfDoc.embedPng(...).
  • quality set: sharp(inputPath).jpeg({ quality }).toBuffer() + pdfDoc.embedJpg(...). Page dimensions still come from sharp(...).metadata() on the produced buffer.

src/app.js (POST /api/jobs): parse qualities the same way targetFormats is parsed today:

let qualities;
try {
  qualities = JSON.parse(req.body.qualities ?? '[]');
} catch {
  return res.status(400).json({ error: 'qualities must be a JSON array' });
}
  • If req.body.qualities is absent, this defaults to []; treat a shorter/empty array as "all null" rather than a hard length-mismatch error, so older frontend builds that never send qualities keep working unmodified.
  • If qualities is provided non-empty, it must have one entry per uploaded file (same length check pattern as targetFormats).
  • Per-file validation happens inline, in the same per-file loop that already resolves the converter: given the file's resolved targetFormat, check the corresponding entry is null or an integer in the valid range (1-100 for quality formats and pdf, 0-9 for png, always null-only for gif). An invalid entry pushes a per-file error into results (mirroring the existing "Unsupported conversion" branch) rather than failing the whole batch.
  • The validated quality is included in the createJob(pool, { ... }) call.

src/jobs/jobRepository.js: add quality to:

  • the INSERT column list and params in createJob
  • the SELECT column lists in getJobByUuid, findPendingJobs, findExpiredJobs
  • toCamelJob

src/worker.js: entry.convert(inputFilePath, outputFilePath, { quality: job.quality }).

Frontend

frontend/src/App.jsx:

  • Each pendingFiles item gains a quality field, set whenever targetFormat is initialized or changed via updateTargetFormat — pre-filled to the format's sharp default (see table above), or null for gif/pdf.
  • Next to the existing target-format <select>, conditionally render:
    • <input type="range" min="1" max="100"> for jpeg/jpg/webp/avif/tiff, with the current numeric value displayed alongside.
    • <input type="range" min="0" max="9"> for png.
    • Nothing for gif.
    • For pdf: an opt-in control (e.g. a checkbox "Compresser en JPEG" that reveals a 1-100 slider when checked) — reflects that pdf's default must stay null until the user explicitly opts in.
  • Changing targetFormat resets quality to the new format's default/null — a slider value tuned for one format must not silently carry over to another.

frontend/src/api.js: uploadFiles builds and appends a qualities array (item.quality ?? null per item), JSON-stringified, alongside the existing targetFormats append.

Validation & edge cases

  • The frontend resets quality on format change, but the backend independently re-validates every quality value against the resolved target format — the frontend is not trusted as the sole guard.
  • Omitting qualities entirely (old client) behaves identically to sending an array of nulls.
  • Out-of-range or wrong-type quality for one file produces a per-file error in the POST /api/jobs response, without blocking the other files in the same batch.

Testing

  • test/converters/image.test.js: add cases asserting that quality/compressionLevel actually affects output — e.g. convert the same PNG fixture to jpg at quality 10 vs 95 and assert the low-quality output is smaller (sharp doesn't surface the applied quality in output metadata, so file-size comparison is the practical assertion).
  • test/converters/imageToPdf.test.js: add a case with quality set, asserting the produced file is a valid PDF and is smaller than the default (no-quality) PNG-embed output for the same source image.
  • test/api/jobs.test.js: extend to cover qualities parsing — valid array, mismatched length, out-of-range value, omitted field entirely.
  • test/jobs/jobRepository.test.js: extend the createJob/getJobByUuid round-trip test to include quality.
  • No frontend test framework exists in frontend/ today (confirmed via frontend/package.json — no vitest/RTL/jest). The UI changes get a manual browser check only, consistent with how targetFormat selection is tested today.