Something went wrong. Try again.
slices images for the bsky.app photo carousel (min 5 images)
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269// 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 / <canvas>) 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');}