CMMS fork: robust board-photo encoding for the AI bridge

read_board_image reported "no board photos available" - buildProjectImages
was returning [] (both photos failed to encode) with no visibility.

- split encodeImage into downscale() + blobToB64(); on any failure
  (createImageBitmap / canvas.toBlob returning null / EXIF-heavy JPEGs)
  fall back to sending the raw blob base64 if it's under ~3MB.
- coerce a non-Blob img.blob through new Blob([...]).
- per-image [cmms-pcb-bridge] tracing: "downscaled ok" / "downscale
  failed: <msg> - trying raw" / "raw encode also failed" etc.
This commit is contained in:
Stuart 2026-09-03 15:31:06 +10:00
commit 8976c52f42

View file

@ -162,40 +162,69 @@
// The board photos live in pcb-retrace's IndexedDB (imported by the user or
// unpacked from the .pcbretrace.zip), NOT as CMMS attachments, so the
// assistant can't reach them with read_attachment_image. Downscale + JPEG
// each and hand them along with the buffer so a `read_board_image` tool can
// surface them. Long edge ~1400px, quality 0.8 - a phone photo lands well
// under 300KB.
async function encodeImage(blob, maxEdge) {
const bmp = await createImageBitmap(blob);
const s = Math.min(1, maxEdge / Math.max(bmp.width, bmp.height));
const w = Math.max(1, Math.round(bmp.width * s));
const h = Math.max(1, Math.round(bmp.height * s));
const c = document.createElement('canvas');
c.width = w; c.height = h;
c.getContext('2d').drawImage(bmp, 0, 0, w, h);
bmp.close();
const out = await new Promise(res => c.toBlob(res, 'image/jpeg', 0.8));
const b64 = await new Promise((res, rej) => {
// assistant can't reach them with read_attachment_image. Hand them along
// with the buffer so a `read_board_image` tool can surface them.
const MAX_IMG_B64 = 4 * 1024 * 1024; // ~3MB raw; matches message.php's cap
function blobToB64(blob) {
return new Promise((res, rej) => {
const r = new FileReader();
r.onload = () => res(String(r.result).split(',', 2)[1] || '');
r.onerror = rej;
r.readAsDataURL(out);
r.onerror = () => rej(new Error('FileReader failed'));
r.readAsDataURL(blob);
});
return { w, h, b64 };
}
// Downscale a photo to ~maxEdge on the long side as JPEG. Returns a Blob.
async function downscale(blob, maxEdge) {
const bmp = await createImageBitmap(blob);
try {
const s = Math.min(1, maxEdge / Math.max(bmp.width, bmp.height));
const w = Math.max(1, Math.round(bmp.width * s));
const h = Math.max(1, Math.round(bmp.height * s));
const c = document.createElement('canvas');
c.width = w; c.height = h;
c.getContext('2d').drawImage(bmp, 0, 0, w, h);
const out = await new Promise(r => c.toBlob(r, 'image/jpeg', 0.8));
if (!out) throw new Error('canvas.toBlob returned null');
return out;
} finally {
try { bmp.close(); } catch (e) {}
}
}
async function buildProjectImages() {
if (!currentBomId || !bomImages.length) return [];
const out = [];
for (const img of bomImages.slice(0, 6)) {
try {
const enc = await encodeImage(img.blob, 1400);
if (enc.b64.length > 3 * 1024 * 1024) { trace('image too large after encode, skipping', img.id); continue; }
out.push({ id: img.id, name: img.name || (img.id + '.jpg'), mime: 'image/jpeg', data_base64: enc.b64 });
} catch (e) {
trace('image encode failed', img.id, e && e.message);
let blob = img.blob;
if (!(blob instanceof Blob)) {
try { blob = new Blob([blob], { type: (img.type || 'image/jpeg') }); }
catch (e) { trace('image', img.id, 'has no usable blob'); continue; }
}
let b64 = null;
try {
b64 = await blobToB64(await downscale(blob, 1400));
trace('image', img.id, 'downscaled ok');
} catch (e) {
trace('image', img.id, 'downscale failed:', e && e.message, '- trying raw');
// Fall back to the raw blob (some builds / EXIF-heavy JPEGs break
// createImageBitmap or toBlob). Only if it's not huge.
try {
if (blob.size <= 3 * 1024 * 1024) b64 = await blobToB64(blob);
else trace('image', img.id, 'raw blob too large (', blob.size, ')');
} catch (e2) {
trace('image', img.id, 'raw encode also failed:', e2 && e2.message);
}
}
if (!b64) continue;
if (b64.length > MAX_IMG_B64) { trace('image', img.id, 'over size cap after encode'); continue; }
out.push({
id: img.id,
name: img.name || (img.id + '.jpg'),
mime: (blob.type && blob.type.indexOf('image/') === 0) ? blob.type : 'image/jpeg',
data_base64: b64,
});
}
trace('buildProjectImages:', out.length, 'of', bomImages.length, 'photos encoded');
return out;