// Browser-side codecs for cortiq's dependency-free media outputs: // - P6 PPM → canvas (cortiq imagine links no image library) // - MJPEG+PCM AVI → frame list + canvas player synced to the .wav // (cortiq animate never touches ffmpeg; neither do we) /// Parse binary P6 PPM into {width, height, rgb: Uint8Array}. export function parsePpm(buf) { const b = new Uint8Array(buf); // header: "P6" width height maxval raster let pos = 0; const isWs = (c) => c === 32 || c === 9 || c === 10 || c === 13; function token() { while (pos < b.length && (isWs(b[pos]) || b[pos] === 35 /* # comment */)) { if (b[pos] === 35) while (pos < b.length && b[pos] !== 10) pos++; else pos++; } let start = pos; while (pos < b.length && !isWs(b[pos])) pos++; return String.fromCharCode(...b.slice(start, pos)); } if (token() !== "P6") throw new Error("not a P6 PPM"); const width = parseInt(token()); const height = parseInt(token()); const maxval = parseInt(token()); pos++; // single whitespace after maxval if (!width || !height || !maxval) throw new Error("bad PPM header"); return { width, height, rgb: b.subarray(pos, pos + width * height * 3) }; } /// Draw a parsed PPM onto a (new or given) canvas. Returns the canvas. export function ppmToCanvas(buf, canvas) { const { width, height, rgb } = parsePpm(buf); const c = canvas || document.createElement("canvas"); c.width = width; c.height = height; const ctx = c.getContext("2d"); const img = ctx.createImageData(width, height); for (let i = 0, j = 0; i < width * height; i++, j += 3) { img.data[i * 4] = rgb[j]; img.data[i * 4 + 1] = rgb[j + 1]; img.data[i * 4 + 2] = rgb[j + 2]; img.data[i * 4 + 3] = 255; } ctx.putImageData(img, 0, 0); return c; } /// Rasterize an uploaded image file to WxH (stretched, as the engine does with /// the first frame) and return it as base64-encoded binary P6 PPM. export async function fileToPpmB64(file, width, height) { const bmp = await createImageBitmap(file); const c = document.createElement("canvas"); c.width = width; c.height = height; const ctx = c.getContext("2d"); ctx.drawImage(bmp, 0, 0, width, height); const img = ctx.getImageData(0, 0, width, height).data; const header = `P6\n${width} ${height}\n255\n`; const out = new Uint8Array(header.length + width * height * 3); for (let i = 0; i < header.length; i++) out[i] = header.charCodeAt(i); let p = header.length; for (let i = 0; i < width * height; i++) { out[p++] = img[i * 4]; out[p++] = img[i * 4 + 1]; out[p++] = img[i * 4 + 2]; } // chunked btoa — the frame can be a few MB let bin = ""; for (let i = 0; i < out.length; i += 0x8000) bin += String.fromCharCode.apply(null, out.subarray(i, i + 0x8000)); return btoa(bin); } /// Extract JPEG frames from an MJPEG AVI (RIFF): walk chunks from 'movi', /// collect '00dc'/'00dj' payloads as Blobs. export function aviJpegFrames(buf) { const b = new Uint8Array(buf); const dv = new DataView(buf); const fourcc = (o) => String.fromCharCode(b[o], b[o + 1], b[o + 2], b[o + 3]); if (fourcc(0) !== "RIFF" || fourcc(8) !== "AVI ") throw new Error("not an AVI"); // find the 'movi' LIST let movi = -1; for (let o = 12; o + 12 <= b.length; ) { const id = fourcc(o); const size = dv.getUint32(o + 4, true); if (id === "LIST" && fourcc(o + 8) === "movi") { movi = o + 12; break; } o += 8 + size + (size & 1); } if (movi < 0) throw new Error("no movi list"); const frames = []; for (let o = movi; o + 8 <= b.length; ) { const id = fourcc(o); const size = dv.getUint32(o + 4, true); if (id === "00dc" || id === "00dj") { if (size > 0) frames.push(new Blob([b.subarray(o + 8, o + 8 + size)], { type: "image/jpeg" })); } else if (id === "LIST") { o += 12; // descend (rec lists) continue; } else if (id === "idx1") { break; } o += 8 + size + (size & 1); } return frames; } /// Canvas player for extracted MJPEG frames at a fixed fps, synced to an /// optional