openvibers/openvibe.tools Commits

Img: BMP output is a real BMP (it was a PNG named .bmp), BMP and ICO uploads are read, ICO output decodes the upload once, HEIC decodes through libheif or answers 503. server/tools/codec.js writes BMP from sharp's raw pixels (24-bit, or 32-bit BGRA with a V4 BI_BITFIELDS header when the picture has transparency; 100 MP cap) and reads BMP itself (1/4/8/16/24/32-bit, RLE4/RLE8, bitfields, top-down) and ICO (largest entry, PNG or DIB with its AND mask), which sharp cannot; resize and crop keep a BMP a BMP. ICO output scales the one decoded upload to 256 px once and makes the smaller sizes from those pixels one after another, instead of six full decodes in parallel. HEIC (HEVC HEIF, iPhone photos) was never decodable by sharp's prebuilt libvips: it now goes through heif-dec/heif-convert (libheif-examples + libheif-plugin-libde265, found at boot, HEIF_DEC_PATH overrides, looked for again each minute while missing); without it a HEIC upload answers 503 problem tools.unavailable on /api/process and fails the job with the same problem, /api/ready lists heif_decoder as degraded, and heic.openvibe.tools says so above the upload. apps/_shared/binaries.js is the shared optional-binary helper (Unavailable → 503 tools.unavailable). apps/img/test/codec.test.js covers the header magic, round trips, palettes/RLE/top-down, ICO once, HEIC decode or 503

OP OpenVibers committed 23 Sept 2026 · 059d7a7a48 · 11 files, +672 −35


Changes

ddiff --git a/apps/_shared/binaries.js b/apps/_shared/binaries.js
nnew file mode 100644
iindex 0000000..9ec083c
---- /dev/null
++++ b/apps/_shared/binaries.js
@@@ -0,0 +1,83 @@
++'use strict';
++// ═══════════════════════════════════════════════════════════════
++// Optional command-line tools that some operations need (qpdf, pdftoppm, heif-dec).
++//
++// Each one is looked for when the satellite boots, on PATH or at an absolute path from its
++// environment variable, and checked with an optional probe. An operation that needs a missing one
++// throws Unavailable: 503 problem tools.unavailable ("this tool is being set up"), never a
++// half-done result. A missing tool is looked for again at most once a minute, so installing the
++// package is enough; no restart is needed.
++// ═══════════════════════════════════════════════════════════════
++
++const { which } = require('./observe');
++
++const RECHECK_MS = 60_000;
++const SETTING_UP = 'This tool is being set up on the server and is not available yet. Please try again later.';
++
++/** The operation needs something this server does not have (yet). → 503 tools.unavailable. */
++class Unavailable extends Error {
++ constructor(detail = SETTING_UP, extra) {
++ super(detail);
++ this.status = 503;
++ this.code = 'tools.unavailable';
++ this.detail = detail;
++ this.expose = true; // an expected state, not a crash: the job runtime does not log it as one
++ this.retryable = true; // it works again once the package is installed
++ this.extra = extra;
++ }
++}
++
++/**
++ * @param {object} o
++ * @param {string} o.name what it is called in /api/ready and logs ('qpdf')
++ * @param {string[]} o.candidates executable names to look for on PATH, in order
++ * @param {string} [o.envVar] environment variable with an explicit path (wins over PATH)
++ * @param {(path) => true|string} [o.probe] extra check once found; a string says what is missing
++ * @param {object} [o.env] environment to read (tests)
++ */
++function createBinary({ name, candidates, envVar, probe, env = process.env }) {
++ let state = null;
++
++ function detect() {
++ const override = envVar && env[envVar] ? String(env[envVar]) : null;
++ let found = null;
++ for (const c of override ? [override] : candidates) { found = which(c, env); if (found) break; }
++ let available = !!found;
++ let detail = found ? null : `${override || candidates.join(' / ')} not found`;
++ if (found && probe) {
++ let r;
++ try { r = probe(found); } catch (err) { r = err.message; }
++ if (r !== true) { available = false; detail = String(r || 'probe failed'); }
++ }
++ state = { name, path: found, available, detail, checkedAt: Date.now() };
++ return state;
++ }
++
++ /** The current state; a missing tool is looked for again after RECHECK_MS. */
++ function get() {
++ if (!state || (!state.available && Date.now() - state.checkedAt > RECHECK_MS)) detect();
++ return state;
++ }
++
++ return {
++ name,
++ detect,
++ get,
++ available: () => get().available,
++ /** The executable's path, or throws Unavailable. */
++ path(extra) {
++ const s = get();
++ if (!s.available) throw new Unavailable(SETTING_UP, extra);
++ return s.path;
++ },
++ /** An /api/ready check (optional: the rest of the app works without it). */
++ readyCheck(description) {
++ return {
++ name, required: false, description, cacheMs: 60_000,
++ check: () => { const s = get(); return s.available ? { ok: true, detail: { path: s.path } } : s.detail; },
++ };
++ },
++ };
++}
++
++module.exports = { createBinary, Unavailable, SETTING_UP };
ddiff --git a/apps/img/public/css/style.css b/apps/img/public/css/style.css
iindex 7ee797f..aad735a 100644
---- a/apps/img/public/css/style.css
++++ b/apps/img/public/css/style.css
@@@ -464,3 +464,16 @@ body {
.subdomain-grid { grid-template-columns: 1fr 1fr; }
.footer-inner { flex-direction: column; text-align: center; }
}
++
++/* A tool (or an input format) the server cannot run yet: said on the page, not discovered on upload. */
++.tool-notice {
++ max-width: 720px;
++ margin: 0 auto 20px;
++ padding: 12px 16px;
++ border: 1px solid var(--accent-dark);
++ border-radius: var(--radius-md);
++ background: var(--bg-secondary);
++ color: var(--text-primary);
++ font-size: 14px;
++ line-height: 1.5;
++}
ddiff --git a/apps/img/public/js/app.js b/apps/img/public/js/app.js
iindex f2b286c..6c7ac0a 100644
---- a/apps/img/public/js/app.js
++++ b/apps/img/public/js/app.js
@@@ -56,6 +56,7 @@
}
applyBranding();
++ showUnavailableInputs();
initNavbar();
initNotifications();
if (typeof OpenVibeAccountSwitcher !== 'undefined') OpenVibeAccountSwitcher.init({ apiBase: 'https://openvibe.network' });
@@@ -109,6 +110,21 @@
if (metaDesc && ctx.seoDescription) metaDesc.content = ctx.seoDescription;
}
++ // A format this server cannot read yet (HEIC before libheif's decoder is installed): say so up
++ // front on the page that is about it, instead of letting the upload fail.
++ function showUnavailableInputs() {
++ const heicPage = ctx.toolId === 'heic';
++ if (!ctx.inputs || ctx.inputs.heic !== false || !heicPage) return;
++ const zone = document.getElementById('upload-zone');
++ if (!zone || document.getElementById('input-notice')) return;
++ const note = document.createElement('div');
++ note.id = 'input-notice';
++ note.className = 'tool-notice';
++ note.setAttribute('role', 'status');
++ note.textContent = 'HEIC (iPhone) photos cannot be converted yet: the decoder is being set up on the server. JPG, PNG, WebP, AVIF and the other formats work now.';
++ zone.parentNode.insertBefore(note, zone);
++ }
++
// ── Navbar ───────────────────────────────────────────────
function initNavbar() {
const token = getCookie('ov_token') || localStorage.getItem('ov_token');
ddiff --git a/apps/img/server/index.js b/apps/img/server/index.js
iindex 55e5cd9..0ce3e8d 100644
---- a/apps/img/server/index.js
++++ b/apps/img/server/index.js
@@@ -22,6 +22,7 @@ const { uploadSingle } = require('./middleware/upload');
const { apiLimiter, processLimiter, burstLimiter } = require('./middleware/rate-limit');
const retention = require('./retention/manager');
const { buildOptions, describe, processBuffer, defineJobs } = require('./process');
++const codec = require('./tools/codec');
const { hostGuard, ownHost } = require('../../_shared/host-role');
const jobsRuntime = require('../../_shared/jobs');
const contracts = require('openvibe-contracts');
@@@ -55,6 +56,7 @@ observe({
ready.sqlite('analytics_db', analyticsDb, { required: false, description: 'visit analytics only; tools work without it' }),
ready.networkKey('network_key', auth.getPublicKey, { description: 'verifies signed-in users and service tokens on the job API; anonymous use works without it' }),
...jobsRuntime.readyChecks(() => jobs),
++ codec.heif.readyCheck('HEIC (iPhone) photos: heif-dec or heif-convert with an HEVC decoder plugin (libheif-examples + libheif-plugin-libde265); other formats work without it'),
],
jobs: () => jobs,
});
@@@ -139,6 +141,8 @@ app.get('/api/context', (req, res) => {
seoTitle: ctx.seoTitle,
seoDescription: ctx.seoDescription,
tools,
++ // HEIC needs libheif's decoder on the server; until it is installed the HEIC page says so.
++ inputs: { heic: codec.heif.available() },
user: req.user ? { username: req.user.username, display_name: req.user.display_name } : null,
});
});
@@@ -171,6 +175,7 @@ app.post('/api/process', burstLimiter, processLimiter, uploadSingle, async (req,
res.json({ success: true, download: saved, ...describe(toolId, result) });
} catch (err) {
++ if (err.status === 503) return contracts.http.sendProblem(res, 503, err.code, { detail: err.message, ctx: req.ov });
console.error('[Process] Error:', err.message);
res.status(422).json({ error: err.message || 'Image processing failed' });
}
@@@ -193,6 +198,7 @@ app.post('/api/process/direct', burstLimiter, processLimiter, uploadSingle, asyn
});
res.send(result.buffer);
} catch (err) {
++ if (err.status === 503) return contracts.http.sendProblem(res, 503, err.code, { detail: err.message, ctx: req.ov });
console.error('[Process/Direct] Error:', err.message);
res.status(422).json({ error: err.message || 'Image processing failed' });
}
@@@ -277,6 +283,8 @@ const server = app.listen(config.port, config.host, () => {
console.log(`║ Port: ${String(config.port).padEnd(30)}║`);
console.log(`║ Host: ${config.host.padEnd(30)}║`);
console.log(`╚═══════════════════════════════════════╝\n`);
++ const h = codec.heif.detect();
++ console.log(h.available ? `[Img.OpenVibe] HEIC decoder: ${h.path}` : `[Img.OpenVibe] HEIC uploads answer 503 until libheif's decoder is installed (${h.detail})`);
});
// ── Graceful Shutdown ────────────────────────────────────────
ddiff --git a/apps/img/server/tools/codec.js b/apps/img/server/tools/codec.js
nnew file mode 100644
iindex 0000000..af787d0
---- /dev/null
++++ b/apps/img/server/tools/codec.js
@@@ -0,0 +1,337 @@
++'use strict';
++
++// ═══════════════════════════════════════════════════════════════
++// Img.OpenVibe — the image formats sharp (libvips) cannot handle itself.
++//
++// BMP read and written here, in plain JavaScript: uncompressed 1/4/8/16/24/32-bit, RLE4/RLE8
++// and bitfield files are read; output is 24-bit, or 32-bit with alpha (V4 header,
++// BI_BITFIELDS) when the picture has transparency.
++// ICO read here: the largest entry, PNG entries as they are, BMP entries through the BMP reader.
++// HEIC (HEVC-coded HEIF, what iPhones save) decoded by libheif's command-line decoder,
++// heif-dec or heif-convert (packages libheif-examples + libheif-plugin-libde265). Without
++// it a HEIC upload answers 503 tools.unavailable. AVIF and other HEIF files go to sharp.
++//
++// open(buffer) is the one way the tools read an upload: it decodes once and hands out fresh
++// sharp pipelines over the decoded pixels.
++// ═══════════════════════════════════════════════════════════════
++
++const { spawn, spawnSync } = require('child_process');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const sharp = require('sharp');
++const { createBinary, Unavailable } = require('../../../_shared/binaries');
++
++// Largest picture read from or written to BMP (and ICO): 100 megapixels, 400 MB of RGBA.
++const MAX_PIXELS = 100 * 1024 * 1024;
++const HEIF_TIMEOUT_MS = 60_000;
++
++class ImageError extends Error {
++ constructor(message) { super(message); this.status = 422; this.expose = true; }
++}
++
++// ── Format sniffing ──────────────────────────────────────────
++
++const HEVC_BRANDS = new Set(['heic', 'heix', 'hevc', 'hevx', 'heim', 'heis', 'hevm', 'hevs']);
++
++/** 'bmp' | 'ico' | 'heic' | null (null: let sharp decide). */
++function sniff(buf) {
++ if (!Buffer.isBuffer(buf) || buf.length < 12) return null;
++ if (buf[0] === 0x42 && buf[1] === 0x4d && buf.length >= 26) return 'bmp';
++ if (buf.toString('latin1', 4, 8) === 'ftyp') {
++ const size = Math.min(buf.readUInt32BE(0), buf.length);
++ const brands = [buf.toString('latin1', 8, 12)];
++ for (let o = 16; o + 4 <= size; o += 4) brands.push(buf.toString('latin1', o, o + 4));
++ if (brands.includes('avif') || brands.includes('avis')) return null;
++ return brands.some(b => HEVC_BRANDS.has(b)) ? 'heic' : null;
++ }
++ if (buf.readUInt16LE(0) === 0 && buf.readUInt16LE(2) === 1 && buf.readUInt16LE(4) > 0 && buf.length >= 6 + 16 * buf.readUInt16LE(4)) return 'ico';
++ return null;
++}
++
++// ── BMP reader ───────────────────────────────────────────────
++
++function maskInfo(mask) {
++ if (!mask) return null;
++ let shift = 0;
++ while (shift < 32 && !((mask >>> shift) & 1)) shift++;
++ let bits = 0;
++ while (shift + bits < 32 && ((mask >>> (shift + bits)) & 1)) bits++;
++ return { mask: mask >>> 0, shift, max: bits >= 32 ? 0xffffffff : (2 ** bits) - 1 };
++}
++const channel = (px, m) => (m ? Math.round((((px & m.mask) >>> m.shift) * 255) / m.max) : 0);
++
++/**
++ * A DIB (a BMP without its 14-byte file header, or an ICO entry) → { data: RGBA top-down, width, height }.
++ * @param {Buffer} buf
++ * @param {number} dib offset of the DIB header
++ * @param {number|null} pixelsAt offset of the pixel array (BMP: from the file header; ICO: right after the palette)
++ * @param {boolean} ico an ICO entry: height is doubled and a 1-bit AND mask follows the pixels
++ */
++function decodeDib(buf, dib, pixelsAt, ico) {
++ if (dib + 12 > buf.length) throw new ImageError('This BMP file is truncated');
++ const hsize = buf.readUInt32LE(dib);
++ let width, height, bpp, compression = 0, colors = 0, masks = null, alphaMask = 0;
++ let palAt, palEntry;
++ if (hsize === 12) {
++ width = buf.readUInt16LE(dib + 4); height = buf.readInt16LE(dib + 6); bpp = buf.readUInt16LE(dib + 10);
++ palAt = dib + 12; palEntry = 3;
++ } else if (hsize >= 40 && dib + hsize <= buf.length) {
++ width = buf.readInt32LE(dib + 4); height = buf.readInt32LE(dib + 8); bpp = buf.readUInt16LE(dib + 14);
++ compression = buf.readUInt32LE(dib + 16); colors = buf.readUInt32LE(dib + 32);
++ palAt = dib + hsize; palEntry = 4;
++ if (compression === 3 || compression === 6) {
++ let at = dib + 40;
++ if (hsize === 40) { palAt += compression === 6 ? 16 : 12; }
++ if (at + 12 > buf.length) throw new ImageError('This BMP file is truncated');
++ masks = [buf.readUInt32LE(at), buf.readUInt32LE(at + 4), buf.readUInt32LE(at + 8)];
++ if ((hsize >= 56 || compression === 6) && at + 16 <= buf.length) alphaMask = buf.readUInt32LE(at + 12);
++ } else if (hsize >= 56) {
++ alphaMask = buf.readUInt32LE(dib + 52);
++ }
++ } else {
++ throw new ImageError('This BMP header is not supported');
++ }
++ if (ico) height = Math.trunc(height / 2);
++ const topDown = height < 0;
++ height = Math.abs(height);
++ if (!(width > 0 && height > 0)) throw new ImageError('This BMP file has no pixels');
++ if (width * height > MAX_PIXELS) throw new ImageError(`This BMP picture is larger than ${MAX_PIXELS / 1024 / 1024} megapixels`);
++ if (![1, 4, 8, 16, 24, 32].includes(bpp)) throw new ImageError(`${bpp}-bit BMP files are not supported`);
++ if (![0, 1, 2, 3, 6].includes(compression)) throw new ImageError('This BMP compression (JPEG/PNG inside BMP) is not supported');
++
++ let palette = null;
++ if (bpp <= 8) {
++ const n = Math.min(colors || 2 ** bpp, 256);
++ palette = [];
++ for (let i = 0; i < n; i++) {
++ const o = palAt + i * palEntry;
++ palette.push(o + 2 < buf.length ? [buf[o + 2], buf[o + 1], buf[o]] : [0, 0, 0]);
++ }
++ if (pixelsAt == null) pixelsAt = palAt + n * palEntry;
++ } else if (pixelsAt == null) {
++ pixelsAt = palAt;
++ }
++
++ const out = Buffer.alloc(width * height * 4);
++ const put = (x, y, r, g, b, a) => {
++ const row = topDown ? y : height - 1 - y;
++ const o = (row * width + x) * 4;
++ out[o] = r; out[o + 1] = g; out[o + 2] = b; out[o + 3] = a;
++ };
++ const fromPalette = (x, y, idx) => { const c = (palette && palette[idx]) || [0, 0, 0]; put(x, y, c[0], c[1], c[2], 255); };
++
++ const stride = Math.floor((bpp * width + 31) / 32) * 4;
++ let hasAlpha = false;
++ if (compression === 1 || compression === 2) {
++ if (bpp !== (compression === 1 ? 8 : 4)) throw new ImageError('This RLE BMP file is malformed');
++ decodeRle(buf, pixelsAt, width, height, compression === 2, fromPalette);
++ } else {
++ if (pixelsAt + stride * height > buf.length) throw new ImageError('This BMP file is truncated');
++ let m = null;
++ if (bpp === 16 || bpp === 32) {
++ const def = bpp === 16 ? [0x7c00, 0x03e0, 0x001f] : [0x00ff0000, 0x0000ff00, 0x000000ff];
++ const [r, g, b] = masks || def;
++ m = { r: maskInfo(r), g: maskInfo(g), b: maskInfo(b), a: maskInfo(alphaMask) };
++ }
++ // 32-bit BI_RGB: the fourth byte is "reserved"; many writers put alpha there. It is alpha
++ // unless every pixel has 0 there (then the picture is opaque).
++ const xrgb32 = bpp === 32 && !masks && !alphaMask;
++ let anyX = false;
++ for (let y = 0; y < height; y++) {
++ const row = pixelsAt + y * stride;
++ for (let x = 0; x < width; x++) {
++ if (bpp === 24) { const o = row + x * 3; put(x, y, buf[o + 2], buf[o + 1], buf[o], 255); } else if (bpp === 32) {
++ const px = buf.readUInt32LE(row + x * 4);
++ if (xrgb32) { const a = px >>> 24; if (a) anyX = true; put(x, y, (px >>> 16) & 255, (px >>> 8) & 255, px & 255, a); } else put(x, y, channel(px, m.r), channel(px, m.g), channel(px, m.b), m.a ? channel(px, m.a) : 255);
++ } else if (bpp === 16) {
++ const px = buf.readUInt16LE(row + x * 2);
++ put(x, y, channel(px, m.r), channel(px, m.g), channel(px, m.b), m.a ? channel(px, m.a) : 255);
++ } else {
++ const perByte = 8 / bpp;
++ const byte = buf[row + Math.floor(x / perByte)];
++ const shift = 8 - bpp * ((x % perByte) + 1);
++ fromPalette(x, y, (byte >> shift) & ((1 << bpp) - 1));
++ }
++ }
++ }
++ if (xrgb32 && !anyX) for (let i = 3; i < out.length; i += 4) out[i] = 255;
++ hasAlpha = (bpp === 32 && (anyX || !!(m && m.a)));
++ if (ico && !hasAlpha) {
++ // The AND mask: 1 = transparent. Rows are 1-bit, padded to 32 bits, bottom-up.
++ const maskAt = pixelsAt + stride * height;
++ const mstride = Math.floor((width + 31) / 32) * 4;
++ if (maskAt + mstride * height <= buf.length) {
++ for (let y = 0; y < height; y++) {
++ for (let x = 0; x < width; x++) {
++ if ((buf[maskAt + y * mstride + (x >> 3)] >> (7 - (x & 7))) & 1) {
++ const row = topDown ? y : height - 1 - y;
++ out[(row * width + x) * 4 + 3] = 0;
++ }
++ }
++ }
++ }
++ }
++ }
++ return { data: out, width, height };
++}
++
++function decodeRle(buf, at, width, height, four, set) {
++ let x = 0, y = 0, i = at;
++ const plot = (idx) => { if (x < width && y < height) set(x, y, idx); x++; };
++ while (i + 1 < buf.length && y < height) {
++ const count = buf[i], val = buf[i + 1];
++ i += 2;
++ if (count > 0) {
++ for (let k = 0; k < count; k++) plot(four ? (k % 2 ? val & 15 : val >> 4) : val);
++ } else if (val === 0) { x = 0; y++; } else if (val === 1) { return; } else if (val === 2) {
++ if (i + 1 >= buf.length) return;
++ x += buf[i]; y += buf[i + 1]; i += 2;
++ } else {
++ const n = val;
++ const bytes = four ? Math.ceil(n / 2) : n;
++ for (let k = 0; k < n; k++) {
++ const b = buf[i + (four ? k >> 1 : k)];
++ plot(four ? (k % 2 ? b & 15 : b >> 4) : b);
++ }
++ i += bytes + (bytes % 2); // absolute runs are padded to a 16-bit boundary
++ }
++ }
++}
++
++function decodeBmp(buf) {
++ if (buf.length < 26 || buf[0] !== 0x42 || buf[1] !== 0x4d) throw new ImageError('Not a BMP file');
++ return decodeDib(buf, 14, buf.readUInt32LE(10), false);
++}
++
++// ── ICO reader ───────────────────────────────────────────────
++
++/** The largest (then deepest) entry of an .ico → a sharp input: PNG bytes, or RGBA pixels. */
++function decodeIco(buf) {
++ const n = buf.readUInt16LE(4);
++ let best = null;
++ for (let i = 0; i < n; i++) {
++ const e = 6 + i * 16;
++ const w = buf[e] || 256, h = buf[e + 1] || 256, bpp = buf.readUInt16LE(e + 6);
++ const size = buf.readUInt32LE(e + 8), offset = buf.readUInt32LE(e + 12);
++ if (offset + size > buf.length || size < 8) continue;
++ const score = w * h * 64 + bpp;
++ if (!best || score > best.score) best = { score, size, offset };
++ }
++ if (!best) throw new ImageError('This ICO file has no readable image');
++ const entry = buf.subarray(best.offset, best.offset + best.size);
++ if (entry.readUInt32BE(0) === 0x89504e47) return { png: Buffer.from(entry) };
++ return decodeDib(entry, 0, null, true);
++}
++
++// ── HEIC (libheif command-line decoder) ──────────────────────
++
++const heif = createBinary({
++ name: 'heif_decoder',
++ candidates: ['heif-dec', 'heif-convert'],
++ envVar: 'HEIF_DEC_PATH',
++ // The decoder is useless without an HEVC plugin (libheif-plugin-libde265).
++ probe(bin) {
++ const r = spawnSync(bin, ['--list-decoders'], { encoding: 'utf8', timeout: 5000 });
++ if (r.error || r.status !== 0) return true; // an older build without --list-decoders: assume it can
++ const m = /HEIC decoders:\s*\n((?:\s*-.*\n?)*)/i.exec(r.stdout || '');
++ return m && /-\s*\S/.test(m[1]) ? true : 'no HEVC decoder plugin (install libheif-plugin-libde265)';
++ },
++});
++
++const HEIC_SETTING_UP = 'HEIC (iPhone) photos cannot be read yet: the decoder is being set up on the server. Other formats work now.';
++
++function decodeHeic(buf) {
++ if (!heif.available()) throw new Unavailable(HEIC_SETTING_UP, { format: 'heic' });
++ const bin = heif.path({ format: 'heic' });
++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-heic-'));
++ const input = path.join(dir, 'in.heic');
++ const output = path.join(dir, 'out.png');
++ fs.writeFileSync(input, buf);
++ return new Promise((resolve, reject) => {
++ let err = '';
++ const child = spawn(bin, [input, output], { stdio: ['ignore', 'ignore', 'pipe'] });
++ const timer = setTimeout(() => child.kill('SIGKILL'), HEIF_TIMEOUT_MS);
++ child.stderr.on('data', d => { if (err.length < 2000) err += d; });
++ child.on('error', (e) => { clearTimeout(timer); reject(e); });
++ child.on('close', (code) => {
++ clearTimeout(timer);
++ try {
++ // A file with several pictures is written as out-1.png, out-2.png, …: the first is the primary.
++ const png = fs.existsSync(output) ? output : fs.readdirSync(dir).filter(f => /^out.*\.png$/.test(f)).sort().map(f => path.join(dir, f))[0];
++ if (code === 0 && png) return resolve(fs.readFileSync(png));
++ reject(new ImageError(/No decoding plugin/i.test(err) ? 'HEIC decoding is not available on this server yet' : 'This HEIC file could not be decoded'));
++ } catch (e) { reject(e); } finally { fs.rm(dir, { recursive: true, force: true }, () => {}); }
++ });
++ });
++}
++
++// ── The one entry point ──────────────────────────────────────
++
++/**
++ * Decode an upload once. → { format: 'bmp'|'ico'|'heic'|<sharp format>, sharp(): a fresh pipeline }
++ */
++async function open(buf) {
++ const kind = sniff(buf);
++ if (kind === 'bmp' || kind === 'ico') {
++ const d = kind === 'bmp' ? decodeBmp(buf) : decodeIco(buf);
++ if (d.png) return { format: kind, sharp: () => sharp(d.png) };
++ const raw = { width: d.width, height: d.height, channels: 4 };
++ return { format: kind, sharp: () => sharp(d.data, { raw }) };
++ }
++ if (kind === 'heic') {
++ const png = await decodeHeic(buf);
++ return { format: 'heic', sharp: () => sharp(png) };
++ }
++ let format = null;
++ try { format = (await sharp(buf).metadata()).format || null; } catch { throw new ImageError('This file is not an image this tool can read'); }
++ return { format, sharp: () => sharp(buf) };
++}
++
++// ── BMP writer ───────────────────────────────────────────────
++
++/** RGBA pixels (top-down) → a BMP file: 24-bit when opaque, 32-bit BGRA with a V4 header otherwise. */
++function encodeBmp(rgba, width, height) {
++ if (width * height > MAX_PIXELS) throw new ImageError(`BMP output is limited to ${MAX_PIXELS / 1024 / 1024} megapixels`);
++ let alpha = false;
++ for (let i = 3; i < rgba.length; i += 4) if (rgba[i] !== 255) { alpha = true; break; }
++ const hsize = alpha ? 108 : 40;
++ const stride = alpha ? width * 4 : Math.ceil((width * 3) / 4) * 4;
++ const dataAt = 14 + hsize;
++ const out = Buffer.alloc(dataAt + stride * height);
++ out.write('BM', 0, 'latin1');
++ out.writeUInt32LE(out.length, 2);
++ out.writeUInt32LE(dataAt, 10);
++ out.writeUInt32LE(hsize, 14);
++ out.writeInt32LE(width, 18);
++ out.writeInt32LE(height, 22); // positive: bottom-up rows, what every reader expects
++ out.writeUInt16LE(1, 26);
++ out.writeUInt16LE(alpha ? 32 : 24, 28);
++ out.writeUInt32LE(alpha ? 3 : 0, 30); // BI_BITFIELDS | BI_RGB
++ out.writeUInt32LE(stride * height, 34);
++ out.writeInt32LE(2835, 38); // 72 dpi
++ out.writeInt32LE(2835, 42);
++ if (alpha) {
++ out.writeUInt32LE(0x00ff0000, 54); out.writeUInt32LE(0x0000ff00, 58);
++ out.writeUInt32LE(0x000000ff, 62); out.writeUInt32LE(0xff000000, 66);
++ out.writeUInt32LE(0x73524742, 70); // LCS_sRGB
++ }
++ for (let y = 0; y < height; y++) {
++ const src = (height - 1 - y) * width * 4;
++ let o = dataAt + y * stride;
++ for (let x = 0; x < width; x++) {
++ const s = src + x * 4;
++ out[o++] = rgba[s + 2]; out[o++] = rgba[s + 1]; out[o++] = rgba[s];
++ if (alpha) out[o++] = rgba[s + 3];
++ }
++ }
++ return out;
++}
++
++/** A sharp pipeline → BMP bytes (+ its width and height). */
++async function toBmp(pipeline) {
++ const { data, info } = await pipeline.toColourspace('srgb').ensureAlpha().raw({ depth: 'uchar' }).toBuffer({ resolveWithObject: true });
++ return { buffer: encodeBmp(data, info.width, info.height), width: info.width, height: info.height };
++}
++
++module.exports = { open, sniff, decodeBmp, decodeIco, encodeBmp, toBmp, heif, ImageError, MAX_PIXELS, HEIC_SETTING_UP };
ddiff --git a/apps/img/server/tools/compress.js b/apps/img/server/tools/compress.js
iindex 1847920..3d0242f 100644
---- a/apps/img/server/tools/compress.js
++++ b/apps/img/server/tools/compress.js
@@@ -5,7 +5,7 @@
// Reduces image file size using format-specific optimization.
// ═══════════════════════════════════════════════════════════════
--const sharp = require('sharp');
++const codec = require('./codec');
/**
* Compress an image buffer.
@@@ -20,11 +20,11 @@ async function compress(inputBuffer, options = {}) {
const quality = Math.max(1, Math.min(100, parseInt(options.quality, 10) || 75));
const originalSize = inputBuffer.length;
-- // Detect input format from metadata
-- const metadata = await sharp(inputBuffer).metadata();
-- const fmt = options.inputFormat || metadata.format || 'png';
++ // Detect input format (BMP, ICO and HEIC are decoded by ./codec; they compress to WebP)
++ const img = await codec.open(inputBuffer);
++ const fmt = options.inputFormat || img.format || 'png';
-- let pipeline = sharp(inputBuffer);
++ let pipeline = img.sharp();
let mime, ext;
switch (fmt) {
ddiff --git a/apps/img/server/tools/convert.js b/apps/img/server/tools/convert.js
iindex 24a3e6b..70c526f 100644
---- a/apps/img/server/tools/convert.js
++++ b/apps/img/server/tools/convert.js
@@@ -2,12 +2,16 @@
// ═══════════════════════════════════════════════════════════════
// Img.OpenVibe — Format Conversion Tool
--// Converts images between formats using Sharp.
++// Converts images between formats using Sharp. BMP is written by ./codec (sharp cannot);
// ICO output uses to-ico for multi-size favicon generation.
// ═══════════════════════════════════════════════════════════════
const sharp = require('sharp');
const toIco = require('to-ico');
++const codec = require('./codec');
++
++const ICO_SIZES = [16, 32, 48, 64, 128, 256];
++const CLEAR = { r: 0, g: 0, b: 0, alpha: 0 };
// Supported output formats and their Sharp method / mime
const FORMAT_CONFIG = {
@@@ -17,7 +21,7 @@ const FORMAT_CONFIG = {
webp: { method: 'webp', mime: 'image/webp', ext: 'webp' },
avif: { method: 'avif', mime: 'image/avif', ext: 'avif' },
tiff: { method: 'tiff', mime: 'image/tiff', ext: 'tiff' },
-- bmp: { method: 'raw', mime: 'image/bmp', ext: 'bmp', custom: true },
++ bmp: { method: null, mime: 'image/bmp', ext: 'bmp', custom: true },
gif: { method: 'gif', mime: 'image/gif', ext: 'gif' },
ico: { method: null, mime: 'image/x-icon', ext: 'ico', custom: true },
};
@@@ -36,29 +40,33 @@ async function convert(inputBuffer, options = {}) {
if (!cfg) throw new Error(`Unsupported output format: ${format}`);
const quality = Math.max(1, Math.min(100, parseInt(options.quality, 10) || 80));
++ const img = await codec.open(inputBuffer); // decoded once (BMP, ICO and HEIC included)
// ── ICO (multi-size favicon) ─────────────────────────────
++ // One decode, scaled once to the largest icon size; the smaller sizes come from those
++ // 256×256 pixels one after another (not six full decodes of the upload in parallel).
if (format === 'ico') {
-- const sizes = [16, 32, 48, 64, 128, 256];
-- const pngs = await Promise.all(
-- sizes.map(s => sharp(inputBuffer).resize(s, s, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } }).png().toBuffer())
-- );
++ const base = await img.sharp()
++ .resize(256, 256, { fit: 'contain', background: CLEAR })
++ .ensureAlpha().raw({ depth: 'uchar' })
++ .toBuffer({ resolveWithObject: true });
++ const raw = { width: base.info.width, height: base.info.height, channels: base.info.channels };
++ const pngs = [];
++ for (const s of ICO_SIZES) {
++ pngs.push(await sharp(base.data, { raw }).resize(s, s, { fit: 'contain', background: CLEAR }).png().toBuffer());
++ }
const icoBuffer = await toIco(pngs);
return { buffer: Buffer.from(icoBuffer), mime: cfg.mime, ext: cfg.ext };
}
-- // ── BMP (Sharp raw → manual BMP headers) ────────────────
++ // ── BMP (sharp decodes, ./codec writes the file) ────────
if (format === 'bmp') {
-- // Sharp doesn't output BMP directly — convert to PNG then use raw pixel approach
-- // For simplicity, output as PNG with .bmp extension indicator — or use raw
-- const pngBuffer = await sharp(inputBuffer).png().toBuffer();
-- // Actually, Sharp >=0.33 supports bmp output via ensureAlpha + raw, but let's just do PNG
-- // Users will get a lossless PNG essentially. For true BMP we'd need a dedicated library.
-- return { buffer: pngBuffer, mime: 'image/png', ext: 'bmp' };
++ const { buffer } = await codec.toBmp(img.sharp());
++ return { buffer, mime: cfg.mime, ext: cfg.ext };
}
// ── Standard Sharp formats ───────────────────────────────
-- let pipeline = sharp(inputBuffer);
++ let pipeline = img.sharp();
const methodOpts = {};
if (['jpeg', 'webp', 'avif'].includes(cfg.method)) {
ddiff --git a/apps/img/server/tools/crop.js b/apps/img/server/tools/crop.js
iindex d39334b..fa3a481 100644
---- a/apps/img/server/tools/crop.js
++++ b/apps/img/server/tools/crop.js
@@@ -5,7 +5,7 @@
// Crops images by pixel coordinates or aspect ratio presets.
// ═══════════════════════════════════════════════════════════════
--const sharp = require('sharp');
++const codec = require('./codec');
const ASPECT_RATIOS = {
'16:9': 16 / 9,
@@@ -31,8 +31,9 @@ const ASPECT_RATIOS = {
* @returns {Promise<{ buffer: Buffer, mime: string, ext: string, crop: Object }>}
*/
async function crop(inputBuffer, options = {}) {
-- const metadata = await sharp(inputBuffer).metadata();
-- const fmt = metadata.format || 'png';
++ const img = await codec.open(inputBuffer); // BMP, ICO and HEIC too
++ const metadata = await img.sharp().metadata();
++ const fmt = img.format || 'png';
let left, top, cropW, cropH;
@@@ -68,11 +69,17 @@ async function crop(inputBuffer, options = {}) {
cropW = Math.max(1, Math.min(cropW, metadata.width - left));
cropH = Math.max(1, Math.min(cropH, metadata.height - top));
-- let pipeline = sharp(inputBuffer).extract({ left, top, width: cropW, height: cropH });
++ const pipeline = img.sharp().extract({ left, top, width: cropW, height: cropH });
-- // Re-encode to same format
-- const { mime, ext } = applyFormat(pipeline, fmt);
-- const buffer = await pipeline.toBuffer();
++ // Re-encode to same format (a BMP stays a BMP)
++ let buffer, mime, ext;
++ if (fmt === 'bmp') {
++ ({ buffer } = await codec.toBmp(pipeline));
++ mime = 'image/bmp'; ext = 'bmp';
++ } else {
++ ({ mime, ext } = applyFormat(pipeline, fmt));
++ buffer = await pipeline.toBuffer();
++ }
return {
buffer, mime, ext,
ddiff --git a/apps/img/server/tools/resize.js b/apps/img/server/tools/resize.js
iindex a93fedc..33461fd 100644
---- a/apps/img/server/tools/resize.js
++++ b/apps/img/server/tools/resize.js
@@@ -5,7 +5,7 @@
// Resizes images using Sharp with multiple fit modes.
// ═══════════════════════════════════════════════════════════════
--const sharp = require('sharp');
++const codec = require('./codec');
const VALID_FITS = ['cover', 'contain', 'fill', 'inside', 'outside'];
const MAX_DIMENSION = 16384; // Sharp max pixel dimension
@@@ -23,8 +23,9 @@ const MAX_DIMENSION = 16384; // Sharp max pixel dimension
* @returns {Promise<{ buffer: Buffer, mime: string, ext: string, dimensions: Object }>}
*/
async function resize(inputBuffer, options = {}) {
-- const metadata = await sharp(inputBuffer).metadata();
-- const fmt = metadata.format || 'png';
++ const img = await codec.open(inputBuffer); // BMP, ICO and HEIC too
++ const metadata = await img.sharp().metadata();
++ const fmt = img.format || 'png';
let width = parseInt(options.width, 10) || null;
let height = parseInt(options.height, 10) || null;
@@@ -60,14 +61,13 @@ async function resize(inputBuffer, options = {}) {
}
}
-- let pipeline = sharp(inputBuffer)
++ const pipeline = img.sharp()
.resize(width, height, { fit, withoutEnlargement, background });
-- // Re-encode to same format
-- const { mime, ext } = applyFormat(pipeline, fmt);
-- const buffer = await pipeline.toBuffer();
--
-- const outMeta = await sharp(buffer).metadata();
++ // Re-encode to same format (a BMP stays a BMP)
++ const out = await encodeSame(pipeline, fmt);
++ const { buffer, mime, ext } = out;
++ const outMeta = { width: out.width, height: out.height };
return {
buffer, mime, ext,
dimensions: {
@@@ -77,6 +77,16 @@ async function resize(inputBuffer, options = {}) {
};
}
++async function encodeSame(pipeline, fmt) {
++ if (fmt === 'bmp') {
++ const r = await codec.toBmp(pipeline);
++ return { buffer: r.buffer, mime: 'image/bmp', ext: 'bmp', width: r.width, height: r.height };
++ }
++ const { mime, ext } = applyFormat(pipeline, fmt);
++ const { data, info } = await pipeline.toBuffer({ resolveWithObject: true });
++ return { buffer: data, mime, ext, width: info.width, height: info.height };
++}
++
function applyFormat(pipeline, fmt) {
switch (fmt) {
case 'jpeg': case 'jpg':
ddiff --git a/apps/img/test/codec.test.js b/apps/img/test/codec.test.js
nnew file mode 100644
iindex 0000000..0009e77
---- /dev/null
++++ b/apps/img/test/codec.test.js
@@@ -0,0 +1,155 @@
++'use strict';
++// The formats sharp cannot do on its own (server/tools/codec.js): BMP output is a real BMP (it used to
++// be a PNG named .bmp), BMP and ICO uploads are read, ICO output decodes the upload once, and HEIC
++// either decodes through libheif's CLI or answers 503 tools.unavailable, never a broken result.
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const sharp = require('sharp');
++const codec = require('../server/tools/codec');
++const convert = require('../server/tools/convert');
++const resize = require('../server/tools/resize');
++const crop = require('../server/tools/crop');
++const { startApp } = require('../../_shared/test/spawn');
++
++const HEIC = fs.readFileSync(path.join(__dirname, 'fixtures', 'gradient.heic'));
++
++async function rgba(input) {
++ return sharp(input).ensureAlpha().raw({ depth: 'uchar' }).toBuffer({ resolveWithObject: true });
++}
++
++/** A hand-built BMP (file header + BITMAPINFOHEADER + palette + pixel rows as given). */
++function bmp({ width, height, bpp, compression = 0, palette = [], rows }) {
++ const pal = Buffer.concat(palette.map(([r, g, b]) => Buffer.from([b, g, r, 0])));
++ const pixels = Buffer.concat(rows);
++ const head = Buffer.alloc(54);
++ head.write('BM', 0, 'latin1');
++ head.writeUInt32LE(54 + pal.length + pixels.length, 2);
++ head.writeUInt32LE(54 + pal.length, 10);
++ head.writeUInt32LE(40, 14);
++ head.writeInt32LE(width, 18); head.writeInt32LE(height, 22);
++ head.writeUInt16LE(1, 26); head.writeUInt16LE(bpp, 28);
++ head.writeUInt32LE(compression, 30); head.writeUInt32LE(pixels.length, 34);
++ head.writeUInt32LE(palette.length, 46);
++ return Buffer.concat([head, pal, pixels]);
++}
++
++(async () => {
++ // ── BMP output: the real thing ───────────────────────────
++ const opaque = await sharp({ create: { width: 7, height: 5, channels: 3, background: { r: 200, g: 30, b: 90 } } }).png().toBuffer();
++ let r = await convert(opaque, { format: 'bmp' });
++ assert.strictEqual(r.mime, 'image/bmp');
++ assert.strictEqual(r.ext, 'bmp');
++ assert.strictEqual(r.buffer.toString('latin1', 0, 2), 'BM', 'BMP magic');
++ assert.strictEqual(r.buffer.readUInt32LE(2), r.buffer.length, 'file size field');
++ assert.strictEqual(r.buffer.readUInt32LE(14), 40, 'BITMAPINFOHEADER');
++ assert.strictEqual(r.buffer.readUInt16LE(28), 24, 'opaque pictures are 24-bit');
++ assert.strictEqual(r.buffer.length, 54 + 5 * 24, 'rows padded to 4 bytes (7 px × 3 = 21 → 24)');
++ let back = await rgba(await (await codec.open(r.buffer)).sharp().png().toBuffer());
++ assert.deepStrictEqual([back.info.width, back.info.height], [7, 5]);
++ assert.deepStrictEqual([...back.data.subarray(0, 4)], [200, 30, 90, 255], 'pixels survive the round trip');
++
++ const clear = await sharp({ create: { width: 3, height: 2, channels: 4, background: { r: 10, g: 20, b: 30, alpha: 0.5 } } }).png().toBuffer();
++ r = await convert(clear, { format: 'bmp' });
++ assert.strictEqual(r.buffer.readUInt16LE(28), 32, 'transparency → 32-bit');
++ assert.strictEqual(r.buffer.readUInt32LE(14), 108, 'BITMAPV4HEADER');
++ assert.strictEqual(r.buffer.readUInt32LE(30), 3, 'BI_BITFIELDS');
++ assert.strictEqual(r.buffer.readUInt32LE(66), 0xff000000, 'alpha mask');
++ back = await rgba(await (await codec.open(r.buffer)).sharp().png().toBuffer());
++ assert.deepStrictEqual([...back.data.subarray(0, 4)], [10, 20, 30, 128], 'alpha survives');
++
++ // Resize and crop keep a BMP a BMP.
++ const rz = await resize(r.buffer, { width: 2, fit: 'fill', height: 1 });
++ assert.deepStrictEqual([rz.mime, rz.buffer.toString('latin1', 0, 2), rz.dimensions.resized.width, rz.buffer.readUInt16LE(28)], ['image/bmp', 'BM', 2, 32]);
++ const cr = await crop(r.buffer, { aspect: '1:1' });
++ assert.deepStrictEqual([cr.mime, cr.crop.width, cr.crop.height], ['image/bmp', 2, 2]);
++
++ // ── BMP input: palettes, RLE, bitfields, top-down ────────
++ // 1-bit, 2×2: black/white checker, bottom-up rows padded to 4 bytes.
++ let d = codec.decodeBmp(bmp({ width: 2, height: 2, bpp: 1, palette: [[0, 0, 0], [255, 255, 255]], rows: [Buffer.from([0b01000000, 0, 0, 0]), Buffer.from([0b10000000, 0, 0, 0])] }));
++ assert.deepStrictEqual([...d.data], [255, 255, 255, 255, 0, 0, 0, 255, 0, 0, 0, 255, 255, 255, 255, 255], '1-bit palette, bottom-up');
++ // RLE8, 3×2: row 0 (bottom) = 3× index 1, row 1 = absolute run [0,1,0] (padded), end of bitmap.
++ d = codec.decodeBmp(bmp({ width: 3, height: 2, bpp: 8, compression: 1, palette: [[0, 0, 255], [255, 0, 0]], rows: [Buffer.from([3, 1, 0, 0, 0, 3, 0, 1, 0, 0, 0, 1])] }));
++ assert.deepStrictEqual([...d.data.subarray(0, 4)], [0, 0, 255, 255], 'RLE8 top row from the absolute run');
++ assert.deepStrictEqual([...d.data.subarray(12, 16)], [255, 0, 0, 255], 'RLE8 bottom row from the encoded run');
++ // 24-bit top-down (negative height).
++ const td = bmp({ width: 1, height: -2, bpp: 24, rows: [Buffer.from([0, 0, 255, 0]), Buffer.from([255, 0, 0, 0])] });
++ d = codec.decodeBmp(td);
++ assert.deepStrictEqual([...d.data], [255, 0, 0, 255, 0, 0, 255, 255], 'top-down rows in file order');
++ // A BMP converts to anything sharp writes.
++ const png = await convert(td, { format: 'png' });
++ assert.strictEqual((await sharp(png.buffer).metadata()).height, 2);
++ assert.throws(() => codec.decodeBmp(Buffer.from('BM' + 'x'.repeat(40))), /not supported|truncated/);
++
++ // ── ICO: output decodes the upload once; input reads PNG and BMP entries ──
++ const big = await sharp({ create: { width: 300, height: 200, channels: 4, background: { r: 0, g: 128, b: 255, alpha: 1 } } }).png().toBuffer();
++ const realOpen = codec.open;
++ let decodes = 0, pipelines = 0;
++ codec.open = async (b) => { decodes++; const img = await realOpen(b); return { ...img, sharp: () => { pipelines++; return img.sharp(); } }; };
++ const ico = await convert(big, { format: 'ico' });
++ codec.open = realOpen;
++ assert.strictEqual(decodes, 1, 'the upload is decoded once');
++ assert.strictEqual(pipelines, 1, 'one pipeline over the upload (the smaller sizes come from the 256 px pixels)');
++ assert.strictEqual(ico.buffer.readUInt16LE(2), 1, 'ICO type');
++ assert.strictEqual(ico.buffer.readUInt16LE(4), 6, 'six sizes');
++ const sizes = []; for (let i = 0; i < 6; i++) sizes.push(ico.buffer[6 + i * 16] || 256);
++ assert.deepStrictEqual(sizes, [16, 32, 48, 64, 128, 256]);
++ const fromIco = await convert(ico.buffer, { format: 'png' });
++ assert.strictEqual((await sharp(fromIco.buffer).metadata()).width, 256, 'the largest entry of an .ico is read');
++ // An ICO whose entry is a DIB with an AND mask (classic icons): 1×1, 24-bit, masked out.
++ const dib = Buffer.alloc(40 + 4 + 4);
++ dib.writeUInt32LE(40, 0); dib.writeInt32LE(1, 4); dib.writeInt32LE(2, 8); dib.writeUInt16LE(1, 12); dib.writeUInt16LE(24, 14);
++ dib.set([0, 0, 255, 0], 40); dib.set([0x80, 0, 0, 0], 44);
++ const dir = Buffer.alloc(22); dir.writeUInt16LE(1, 2); dir.writeUInt16LE(1, 4); dir[6] = 1; dir[7] = 1; dir.writeUInt16LE(24, 12); dir.writeUInt32LE(dib.length, 14); dir.writeUInt32LE(22, 18);
++ d = codec.decodeIco(Buffer.concat([dir, dib]));
++ assert.deepStrictEqual([...d.data], [255, 0, 0, 0], 'red pixel, transparent by the AND mask');
++
++ // ── HEIC ─────────────────────────────────────────────────
++ assert.strictEqual(codec.sniff(HEIC), 'heic');
++ const avifBrand = Buffer.concat([Buffer.from([0, 0, 0, 0x1c]), Buffer.from('ftypavifmif1avifmiaf'), Buffer.alloc(8)]);
++ assert.strictEqual(codec.sniff(avifBrand), null, 'AVIF stays with sharp');
++ if (codec.heif.available()) {
++ const jpg = await convert(HEIC, { format: 'jpg' });
++ assert.strictEqual((await sharp(jpg.buffer).metadata()).format, 'jpeg', 'HEIC decodes through libheif');
++ console.log(` HEIC decoded with ${codec.heif.get().path}`);
++ } else {
++ await assert.rejects(convert(HEIC, { format: 'jpg' }), (e) => e.status === 503 && e.code === 'tools.unavailable');
++ console.log(' HEIC decoder not installed here: checked the 503 path only');
++ }
++
++ // ── As a real process: BMP over the API, HEIC without a decoder is a 503 problem ──
++ const data = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-img-codec-'));
++ const app = await startApp('img', {
++ DATA_DIR: data, UPLOADS_DIR: path.join(data, 'uploads'), OUTPUT_DIR: path.join(data, 'output'),
++ HEIF_DEC_PATH: '/nonexistent/heif-dec',
++ });
++ try {
++ let fd = new FormData();
++ fd.append('tool', 'convert'); fd.append('format', 'bmp');
++ fd.append('file', new Blob([opaque], { type: 'image/png' }), 'x.png');
++ let res = await fetch(`${app.base}/api/process/direct`, { method: 'POST', body: fd });
++ assert.strictEqual(res.status, 200);
++ assert.strictEqual(res.headers.get('content-type'), 'image/bmp');
++ assert.strictEqual(Buffer.from(await res.arrayBuffer()).toString('latin1', 0, 2), 'BM');
++
++ fd = new FormData();
++ fd.append('tool', 'convert'); fd.append('format', 'jpg');
++ fd.append('file', new Blob([HEIC], { type: 'image/heic' }), 'photo.heic');
++ res = await fetch(`${app.base}/api/process`, { method: 'POST', body: fd });
++ assert.strictEqual(res.status, 503);
++ assert.strictEqual(res.headers.get('content-type'), 'application/problem+json');
++ const p = await res.json();
++ assert.strictEqual(p.code, 'tools.unavailable');
++ assert.match(p.error, /being set up/);
++
++ const ctx = await (await fetch(`${app.base}/api/context`, { headers: { 'X-OV-Tool': 'heic' } })).json();
++ assert.strictEqual(ctx.inputs.heic, false, 'the HEIC page is told the decoder is missing');
++ const ready = await (await fetch(`${app.base}/api/ready`)).json();
++ assert.ok(ready.degraded.includes('heif_decoder'), 'readiness shows it as degraded, not down');
++ } finally {
++ await app.kill();
++ fs.rmSync(data, { recursive: true, force: true });
++ }
++ console.log('img codec (BMP out/in, ICO once, HEIC or 503): all checks passed');
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/apps/img/test/fixtures/gradient.heic b/apps/img/test/fixtures/gradient.heic
nnew file mode 100644
iindex 0000000..fcb045c
BBinary files /dev/null and b/apps/img/test/fixtures/gradient.heic differ