// encode.js — slice encoding (plan Step 3 + red-team PLAN-06) // // Rules baked in: // - ONE drawImage per slice: crop and resize in a single operation from the // original source bitmap. Two-pass crop-then-resize double-resamples and // creates visible seams between adjacent carousel slides (guardrail 8). // - Integer pixel boundaries, identical output dimensions for all N slices // (guardrail 9/10 — the carousel frame must not resize mid-swipe). // - WebP quality binary search in [0.6, 0.95], <= 6 iterations, landing // under maxBytes (2,000,000 by default). // - LONG-EDGE REDUCTION IS GLOBAL (red-team PLAN-06): if ANY slice still // exceeds maxBytes at q=0.6, reduce the SHARED long edge by 10% (max 3 // reductions) and re-encode ALL slices at the new size. Per-slice // variation is allowed in quality only — never in dimensions. // - Never upscale: targetLongEdge is a cap (CDN min:true semantics, // guardrail 7 / fixture F4). export const DEFAULT_MAX_BYTES = 2000000; export const DEFAULT_TARGET_LONG_EDGE = 2000; export const MIN_QUALITY = 0.6; export const MAX_QUALITY = 0.95; export const MAX_QUALITY_ITERATIONS = 6; export const MAX_LONG_EDGE_REDUCTIONS = 3; /** * Create a canvas in the current environment. OffscreenCanvas when * available (browser), else document canvas, else throws — tests inject * @napi-rs/canvas via canvasFactory. */ export function defaultCanvasFactory(width, height) { if (typeof OffscreenCanvas !== 'undefined') { return new OffscreenCanvas(width, height); } // Environment capability check (SSR guard). // oxlint-disable-next-line anti-slop/no-runtime-typeof if (typeof document !== 'undefined' && typeof document.createElement === 'function') { const canvas = document.createElement('canvas'); canvas.width = width; canvas.height = height; return canvas; } throw new Error( 'No canvas implementation available; pass canvasFactory in non-browser environments' ); } /** * Encode a canvas to a WebP Blob at the given quality. * Supports convertToBlob (OffscreenCanvas) and toBuffer (node canvases). */ async function encodeWebP(canvas, quality) { // Prefer toBuffer when present: node canvases (tests) return a Buffer // whose arrayBuffer() is reliable under any Blob polyfill. Browser // canvases (OffscreenCanvas / ) never define toBuffer, so the // convertToBlob path below is the only one they take. // Capability check: node-canvas vs browser canvas. // oxlint-disable-next-line anti-slop/no-runtime-typeof if (typeof canvas.toBuffer === 'function') { const buffer = canvas.toBuffer('image/webp', { quality }); // Duck-typed Blob: size/type/arrayBuffer. Works in tests (node canvas) // and is compatible with uploadBlob in the browser path (real Blob). return { size: buffer.length, type: 'image/webp', arrayBuffer: () => Promise.resolve( buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength) ), }; } // Capability check: browser canvas path. // oxlint-disable-next-line anti-slop/no-runtime-typeof if (typeof canvas.convertToBlob === 'function') { return canvas.convertToBlob({ type: 'image/webp', quality }); } throw new Error('Canvas has neither convertToBlob nor toBuffer'); } /** * Compute final slice output dimensions for a source rect and a target * long edge. Scales down to fit the long edge; NEVER upscales. * Returns integer pixel dimensions. */ export function computeOutputSize(rect, targetLongEdge) { const rectLongEdge = Math.max(rect.sw, rect.sh); const scale = Math.min(1, targetLongEdge / rectLongEdge); return { width: Math.max(1, Math.round(rect.sw * scale)), height: Math.max(1, Math.round(rect.sh * scale)), }; } /** * Encode ONE slice from a source bitmap. * * @param {Object} args * @param {ImageBitmap|CanvasImageSource} args.source original decoded bitmap * @param {{sx:number,sy:number,sw:number,sh:number}} args.rect slice rect in * SOURCE coordinates (from geometry.computeSliceGrid) * @param {number} [args.targetLongEdge=2000] cap on the output long edge * @param {number} [args.maxBytes=2000000] blob size limit * @param {(w:number,h:number)=>any} [args.canvasFactory] injectable canvas * @returns {Promise<{blob:Blob,width:number,height:number,quality:number,over:boolean}>} * over=true when even q=0.6 exceeds maxBytes (caller decides reduction). */ export async function encodeSlice({ source, rect, targetLongEdge = DEFAULT_TARGET_LONG_EDGE, maxBytes = DEFAULT_MAX_BYTES, canvasFactory = defaultCanvasFactory, minQuality = MIN_QUALITY, maxQuality = MAX_QUALITY, maxQualityIterations = MAX_QUALITY_ITERATIONS, }) { const { width, height } = computeOutputSize(rect, targetLongEdge); const canvas = canvasFactory(width, height); const ctx = canvas.getContext('2d'); ctx.imageSmoothingEnabled = true; ctx.imageSmoothingQuality = 'high'; // ONE drawImage: source rect -> final slice canvas (guardrail 8). ctx.drawImage(source, rect.sx, rect.sy, rect.sw, rect.sh, 0, 0, width, height); // Binary search the highest quality that fits under maxBytes. let lo = minQuality; let hi = maxQuality; let best = null; for (let i = 0; i < maxQualityIterations; i++) { const q = (lo + hi) / 2; const blob = await encodeWebP(canvas, q); if (blob.size <= maxBytes) { best = { blob, quality: q }; lo = q; } else { hi = q; } } if (best) { return { blob: best.blob, width, height, quality: best.quality, over: false }; } // Even q=0.6 exceeds maxBytes: return the smallest blob so the caller // can measure it, flagged over=true. const blob = await encodeWebP(canvas, minQuality); return { blob, width, height, quality: minQuality, over: true }; } /** * Encode ALL slices with GLOBAL dimension consistency (red-team PLAN-06). * * @param {Object} args * @param {ImageBitmap|CanvasImageSource} args.source * @param {Array<{sx:number,sy:number,sw:number,sh:number}>} args.sliceRects * must all share identical sw/sh (geometry invariant) * @param {number} [args.targetLongEdge=2000] * @param {number} [args.maxBytes=2000000] * @param {number} [args.maxReductions=3] * @param {(w:number,h:number)=>any} [args.canvasFactory] * @returns {Promise<{ * blobs: Blob[], width: number, height: number, longEdge: number, * qualities: number[], overLimit: boolean * }>} overLimit=true when the last attempt still exceeds maxBytes — * publish must be blocked (UI handles the warning). */ export async function encodeAllSlices({ source, sliceRects, targetLongEdge = DEFAULT_TARGET_LONG_EDGE, maxBytes = DEFAULT_MAX_BYTES, maxReductions = MAX_LONG_EDGE_REDUCTIONS, canvasFactory = defaultCanvasFactory, minQuality = MIN_QUALITY, maxQuality = MAX_QUALITY, maxQualityIterations = MAX_QUALITY_ITERATIONS, }) { if (!Array.isArray(sliceRects) || sliceRects.length < 5) { throw new RangeError('encodeAllSlices needs at least 5 slice rects'); } const first = sliceRects[0]; const identical = sliceRects.every( (r) => r.sw === first.sw && r.sh === first.sh ); if (!identical) { throw new Error('sliceRects must all have identical dimensions (geometry invariant)'); } const srcLongEdge = Math.max(first.sw, first.sh); let longEdge = Math.min(targetLongEdge, srcLongEdge); // never upscale for (let reduction = 0; reduction <= maxReductions; reduction++) { const results = await Promise.all( sliceRects.map((rect) => encodeSlice({ source, rect, targetLongEdge: longEdge, maxBytes, canvasFactory, minQuality, maxQuality, maxQualityIterations, }) ) ); const allFit = results.every((r) => !r.over); if (allFit || reduction === maxReductions) { return { blobs: results.map((r) => r.blob), width: results[0].width, height: results[0].height, longEdge, qualities: results.map((r) => r.quality), overLimit: !allFit, }; } longEdge = Math.round(longEdge * 0.9); } // Unreachable: the loop always returns on reduction === maxReductions. throw new Error('encodeAllSlices loop exhausted unexpectedly'); } /** * Decode a blob into a drawable bitmap, honoring EXIF orientation * (guardrail 11 — prevents sideways phone panoramas). * Browser-only: prefers createImageBitmap with imageOrientation * 'from-image'; falls back to an Image element via object URL. */ export async function decodeImage(blob, { imageOrientation = 'from-image' } = {}) { // Capability check: createImageBitmap may be absent in some runtimes. // oxlint-disable-next-line anti-slop/no-runtime-typeof if (typeof createImageBitmap === 'function') { return createImageBitmap(blob, { imageOrientation }); } if (typeof Image !== 'undefined') { const url = URL.createObjectURL(blob); try { const img = new Image(); img.src = url; await img.decode(); return img; } finally { URL.revokeObjectURL(url); } } throw new Error('decodeImage requires createImageBitmap or an Image element'); }