| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735 |
- /*
- rawdecoder.js — client side camera RAW decoder for the ArozOS Raw Editor
- Responsibilities:
- - Detect the container type from the extension / magic bytes.
- - For ordinary images (jpg/png/webp/tiff) draw them onto a work canvas.
- - For camera RAW (ARW/DNG/NEF/CR2/ORF/RW2 ... — all TIFF/IFD based)
- parse the TIFF structure, locate the CFA (Bayer) plane and demosaic it
- into an RGB image, applying black/white levels and camera / gray-world
- white balance.
- - When the raw payload uses a compression we cannot decode, gracefully
- fall back to the full size JPEG preview that virtually every RAW file
- embeds, so the user always sees their photo.
- The decoder always resolves to a common structure consumed by the editor:
- {
- width, height, // working resolution (long edge capped)
- data: Float32Array, // RGBA, scene linear, range ~[0,1]
- meta: { camera, iso, shutter, aperture, focal, temp, tint, wb:[r,g,b] },
- source: 'raw-demosaic' | 'embedded-preview' | 'image'
- }
- Everything downstream (WebGL develop pipeline) treats "data" as linear RGBA.
- */
- const RawDecoder = (function () {
- // Longest edge (px) of the working buffer used for interactive editing and
- // export. Keeps memory / GPU upload bounded on multi-megapixel sensors.
- const MAX_WORK_EDGE = 2560;
- const RAW_EXTS = ["arw", "dng", "nef", "cr2", "cr3", "orf", "raf", "rw2", "pef", "srw"];
- function extOf(name) {
- const i = (name || "").lastIndexOf(".");
- return i < 0 ? "" : name.substring(i + 1).toLowerCase();
- }
- // ---- sRGB <-> linear helpers ------------------------------------------
- function srgbToLinear(c) {
- return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
- }
- // Convert an 8bit RGBA ImageData (sRGB) into a linear Float32 RGBA buffer.
- function imageDataToLinear(imgData) {
- const src = imgData.data;
- const out = new Float32Array(src.length);
- // Small lookup table for the 256 possible 8bit values.
- const lut = new Float32Array(256);
- for (let i = 0; i < 256; i++) lut[i] = srgbToLinear(i / 255);
- for (let i = 0; i < src.length; i += 4) {
- out[i] = lut[src[i]];
- out[i + 1] = lut[src[i + 1]];
- out[i + 2] = lut[src[i + 2]];
- out[i + 3] = 1.0;
- }
- return out;
- }
- // Draw an ImageBitmap/Image onto a work canvas (capped) and return linear RGBA.
- function bitmapToWork(bitmap) {
- let w = bitmap.width, h = bitmap.height;
- const scale = Math.min(1, MAX_WORK_EDGE / Math.max(w, h));
- w = Math.max(1, Math.round(w * scale));
- h = Math.max(1, Math.round(h * scale));
- const cv = document.createElement("canvas");
- cv.width = w; cv.height = h;
- const ctx = cv.getContext("2d");
- ctx.drawImage(bitmap, 0, 0, w, h);
- const img = ctx.getImageData(0, 0, w, h);
- return { width: w, height: h, data: imageDataToLinear(img) };
- }
- function decodeBlobAsImage(blob) {
- return new Promise((resolve, reject) => {
- if (window.createImageBitmap) {
- createImageBitmap(blob).then((bmp) => {
- resolve(bitmapToWork(bmp));
- }).catch(reject);
- } else {
- const url = URL.createObjectURL(blob);
- const im = new Image();
- im.onload = () => { URL.revokeObjectURL(url); resolve(bitmapToWork(im)); };
- im.onerror = (e) => { URL.revokeObjectURL(url); reject(e); };
- im.src = url;
- }
- });
- }
- // ======================================================================
- // TIFF / IFD parsing
- // ======================================================================
- const TYPE_SIZE = { 1: 1, 2: 1, 3: 2, 4: 4, 5: 8, 6: 1, 7: 1, 8: 2, 9: 4, 10: 8, 11: 4, 12: 8 };
- function parseTIFF(buf) {
- const dv = new DataView(buf);
- if (dv.byteLength < 8) throw new Error("not a tiff");
- const b0 = dv.getUint8(0), b1 = dv.getUint8(1);
- let little;
- if (b0 === 0x49 && b1 === 0x49) little = true; // II
- else if (b0 === 0x4D && b1 === 0x4D) little = false; // MM
- else throw new Error("not a tiff (byte order)");
- const magic = dv.getUint16(2, little);
- if (magic !== 42 && magic !== 0x4F52 /*ORF 'RO'*/ && magic !== 0x5352) {
- // ORF (Olympus) and some others use a non-42 magic; be lenient.
- }
- const ifds = [];
- const visited = {};
- function readValues(dv, type, count, valOffset, entryOffset) {
- const size = TYPE_SIZE[type] || 1;
- const total = size * count;
- let base;
- if (total <= 4) base = entryOffset; // inline
- else base = valOffset;
- if (base + total > dv.byteLength) return null;
- const vals = [];
- for (let i = 0; i < count; i++) {
- const o = base + i * size;
- switch (type) {
- case 1: case 6: case 7: vals.push(dv.getUint8(o)); break;
- case 2: vals.push(dv.getUint8(o)); break; // ascii byte
- case 3: case 8: vals.push(dv.getUint16(o, little)); break;
- case 4: case 9: vals.push(dv.getUint32(o, little)); break;
- case 5: vals.push(dv.getUint32(o, little) / (dv.getUint32(o + 4, little) || 1)); break;
- case 10: vals.push(dv.getInt32(o, little) / (dv.getInt32(o + 4, little) || 1)); break;
- case 11: vals.push(dv.getFloat32(o, little)); break;
- case 12: vals.push(dv.getFloat64(o, little)); break;
- default: vals.push(dv.getUint8(o));
- }
- }
- return vals;
- }
- function readIFD(offset) {
- if (!offset || offset <= 0 || offset + 2 > dv.byteLength) return null;
- if (visited[offset]) return null;
- visited[offset] = true;
- const count = dv.getUint16(offset, little);
- if (count > 4096) return null; // not a real IFD — bogus offset
- const tags = {};
- const subOffsets = [];
- let p = offset + 2;
- for (let i = 0; i < count; i++, p += 12) {
- if (p + 12 > dv.byteLength) break;
- const tag = dv.getUint16(p, little);
- const type = dv.getUint16(p + 2, little);
- const cnt = dv.getUint32(p + 4, little);
- const valOff = dv.getUint32(p + 8, little);
- tags[tag] = { type: type, count: cnt, valueOffset: valOff, entryOffset: p + 8 };
- // Follow SubIFD (0x014A) and the ExifIFD (0x8769) pointers. Do NOT
- // recurse the MakerNote (0x927C): its bytes are not IFD offsets and
- // treating them as such can be pathologically slow on real files.
- if (tag === 0x014A || tag === 0x8769) {
- const v = readValues(dv, type, cnt, valOff, p + 8);
- if (v) v.forEach((o) => subOffsets.push(o));
- }
- }
- const nextOff = (p + 4 <= dv.byteLength) ? dv.getUint32(p, little) : 0;
- const ifd = {
- tags: tags,
- get: function (tag) {
- const t = tags[tag];
- if (!t) return null;
- return readValues(dv, t.type, t.count, t.valueOffset, t.entryOffset);
- },
- raw: tags
- };
- ifds.push(ifd);
- subOffsets.forEach((so) => readIFD(so));
- return nextOff;
- }
- let ifdOff = dv.getUint32(4, little);
- let guard = 0;
- while (ifdOff && guard++ < 64) {
- ifdOff = readIFD(ifdOff);
- }
- return { dv: dv, little: little, ifds: ifds };
- }
- // Common TIFF/EXIF/DNG tag ids we care about.
- const T = {
- ImageWidth: 0x0100, ImageLength: 0x0101, BitsPerSample: 0x0102,
- Compression: 0x0103, Photometric: 0x0106, StripOffsets: 0x0111,
- RowsPerStrip: 0x0116, StripByteCounts: 0x0117, TileWidth: 0x0142,
- TileLength: 0x0143, TileOffsets: 0x0144, TileByteCounts: 0x0145,
- JPEGOffset: 0x0201, JPEGLength: 0x0202, Make: 0x010F, Model: 0x0110,
- CFAPattern: 0x828E, CFAPatternExif: 0xA302, SubfileType: 0x00FE,
- // EXIF
- ExposureTime: 0x829A, FNumber: 0x829D, ISO: 0x8827, FocalLength: 0x920A,
- // DNG
- BlackLevel: 0xC61A, WhiteLevel: 0xC61D, AsShotNeutral: 0xC628,
- DNGVersion: 0xC612, CFALayout: 0xC61E, LinearizationTable: 0xC618
- };
- function asciiOf(vals) {
- if (!vals) return "";
- let s = "";
- for (let i = 0; i < vals.length; i++) {
- if (vals[i] === 0) break;
- s += String.fromCharCode(vals[i]);
- }
- return s.trim();
- }
- // ======================================================================
- // Embedded JPEG preview extraction (robust fallback)
- // ======================================================================
- // True only for a real, decodable JPEG (SOI at start, EOI at end). This
- // rejects lossless-JPEG-compressed CFA raw streams, which also start with
- // 0xFFD8 but are NOT displayable images.
- function looksLikeJpeg(u8, off, len) {
- return off >= 0 && len > 1000 && off + len <= u8.length &&
- u8[off] === 0xFF && u8[off + 1] === 0xD8 &&
- u8[off + len - 2] === 0xFF && u8[off + len - 1] === 0xD9;
- }
- // Locate the largest embedded preview JPEG, using IFD pointers first then a
- // brute force SOI/EOI scan. CFA / raw IFDs (Photometric 32803) are ignored —
- // their compressed data starts with 0xFFD8 but is not a viewable image.
- // Returns { off, len } into the source bytes or null.
- function findEmbeddedJpegRange(u8, tiff) {
- let best = null;
- if (tiff) {
- tiff.ifds.forEach((ifd) => {
- const photo = ifd.get(T.Photometric);
- if (photo && photo[0] === 32803) return; // CFA raw — never a preview
- const off = ifd.get(T.JPEGOffset);
- const len = ifd.get(T.JPEGLength);
- if (off && len && looksLikeJpeg(u8, off[0], len[0])) {
- if (!best || len[0] > best.len) best = { off: off[0], len: len[0] };
- }
- // Some previews are stored as a full strip with Compression 6/7.
- const comp = ifd.get(T.Compression);
- if (comp && (comp[0] === 6 || comp[0] === 7 || comp[0] === 99)) {
- const so = ifd.get(T.StripOffsets);
- const sc = ifd.get(T.StripByteCounts);
- if (so && sc && so.length === 1 && looksLikeJpeg(u8, so[0], sc[0])) {
- if (!best || sc[0] > best.len) best = { off: so[0], len: sc[0] };
- }
- }
- });
- }
- if (best) return best;
- // Brute force: find the largest FFD8..FFD9 span.
- let bestStart = -1, bestEnd = -1;
- for (let i = 0; i + 1 < u8.length; i++) {
- if (u8[i] === 0xFF && u8[i + 1] === 0xD8 && u8[i + 2] === 0xFF) {
- for (let j = i + 2; j + 1 < u8.length; j++) {
- if (u8[j] === 0xFF && u8[j + 1] === 0xD9) {
- if (j - i > bestEnd - bestStart) { bestStart = i; bestEnd = j + 1; }
- i = j + 1;
- break;
- }
- }
- }
- }
- if (bestStart >= 0 && bestEnd - bestStart > 2000) {
- return { off: bestStart, len: bestEnd - bestStart + 1 };
- }
- return null;
- }
- function extractEmbeddedJpeg(buf, tiff) {
- const u8 = new Uint8Array(buf);
- const r = findEmbeddedJpegRange(u8, tiff);
- return r ? new Blob([u8.subarray(r.off, r.off + r.len)], { type: "image/jpeg" }) : null;
- }
- // ======================================================================
- // EXIF from a JPEG APP1 segment (covers plain JPEGs, embedded previews,
- // and RAWs whose main TIFF structure we could not parse).
- // ======================================================================
- // jbytes: a Uint8Array starting at a JPEG SOI (0xFFD8). Returns meta or null.
- function parseJpegExif(jbytes) {
- if (!jbytes || jbytes.length < 4 || jbytes[0] !== 0xFF || jbytes[1] !== 0xD8) return null;
- let i = 2;
- while (i + 4 < jbytes.length) {
- if (jbytes[i] !== 0xFF) { i++; continue; }
- const marker = jbytes[i + 1];
- if (marker === 0xD9 || marker === 0xDA) break; // EOI / start of scan
- const len = (jbytes[i + 2] << 8) | jbytes[i + 3];
- if (len < 2) break;
- if (marker === 0xE1) {
- const o = i + 4;
- // "Exif\0\0"
- if (jbytes[o] === 0x45 && jbytes[o + 1] === 0x78 && jbytes[o + 2] === 0x69 &&
- jbytes[o + 3] === 0x66 && jbytes[o + 4] === 0 && jbytes[o + 5] === 0) {
- const tiffStart = o + 6;
- const tiffLen = (len - 2) - 6;
- if (tiffLen > 8 && tiffStart + tiffLen <= jbytes.length) {
- try {
- const sub = jbytes.slice(tiffStart, tiffStart + tiffLen).buffer;
- return readMeta(parseTIFF(sub));
- } catch (e) { return null; }
- }
- }
- }
- i += 2 + len;
- }
- return null;
- }
- function metaHasExif(m) {
- return m && (m.iso || m.aperture || m.shutter || m.focal || m.camera);
- }
- function mergeMeta(primary, secondary) {
- if (!secondary) return primary;
- if (!primary) return secondary;
- const out = Object.assign({}, primary);
- ["camera", "iso", "shutter", "aperture", "focal"].forEach((k) => {
- if ((out[k] === "" || out[k] === 0 || out[k] == null) && secondary[k]) out[k] = secondary[k];
- });
- if (!out.wb && secondary.wb) out.wb = secondary.wb;
- if ((!out.temp || out.temp === 5500) && secondary.temp) out.temp = secondary.temp;
- return out;
- }
- // Collect every embedded JPEG (IFD-pointed + brute-force SOI/EOI scan).
- // Sony ARW keeps EXIF in the small thumbnail, not the large preview, so we
- // must be able to inspect all of them — not just the biggest.
- function collectJpegRanges(u8, tiff) {
- const ranges = [];
- const seen = {};
- const add = (off, len) => {
- if (off >= 0 && len > 100 && off + len <= u8.length &&
- u8[off] === 0xFF && u8[off + 1] === 0xD8 && !seen[off]) {
- seen[off] = true; ranges.push({ off: off, len: len });
- }
- };
- if (tiff) {
- tiff.ifds.forEach((ifd) => {
- const photo = ifd.get(T.Photometric);
- if (photo && photo[0] === 32803) return; // skip CFA raw stream
- const off = ifd.get(T.JPEGOffset), len = ifd.get(T.JPEGLength);
- if (off && len) add(off[0], len[0]);
- const so = ifd.get(T.StripOffsets), sc = ifd.get(T.StripByteCounts);
- if (so && sc && so.length === 1) add(so[0], sc[0]);
- });
- }
- // Brute-force SOI/EOI scan, capped so a huge false-positive (e.g. raw
- // data containing 0xFFD8) can't produce a multi-megabyte junk range.
- for (let i = 0; i + 2 < u8.length; i++) {
- if (u8[i] === 0xFF && u8[i + 1] === 0xD8 && u8[i + 2] === 0xFF) {
- for (let j = i + 2; j + 1 < u8.length && j - i < 4000000; j++) {
- if (u8[j] === 0xFF && u8[j + 1] === 0xD9) { add(i, j - i + 1); i = j + 1; break; }
- }
- }
- }
- return ranges;
- }
- // Fill gaps in "meta" from EXIF found in any embedded / whole-file JPEG.
- function enrichMetaFromJpeg(buf, tiff, meta) {
- if (metaHasExif(meta) && meta.iso && meta.aperture && meta.shutter) return meta;
- try {
- const u8 = new Uint8Array(buf);
- if (u8[0] === 0xFF && u8[1] === 0xD8) {
- const em0 = parseJpegExif(u8);
- if (em0) meta = mergeMeta(meta, em0);
- }
- const ranges = collectJpegRanges(u8, tiff);
- for (let k = 0; k < ranges.length; k++) {
- if (meta.iso && meta.aperture && meta.shutter) break;
- const em = parseJpegExif(u8.subarray(ranges[k].off, ranges[k].off + ranges[k].len));
- if (em) meta = mergeMeta(meta, em);
- }
- return meta;
- } catch (e) { return meta; }
- }
- // ======================================================================
- // CFA (Bayer) extraction + demosaic
- // ======================================================================
- // Read raw CFA samples into a Float32 single-channel plane (values kept in
- // sensor code range). Supports uncompressed 16/14/12-bit (packed or not)
- // and Sony ARW2 lossy compression. Returns null when unsupported.
- function readCFAPlane(buf, tiff, ifd) {
- const dv = tiff.dv, little = tiff.little;
- const w = (ifd.get(T.ImageWidth) || [0])[0];
- const h = (ifd.get(T.ImageLength) || [0])[0];
- const bps = (ifd.get(T.BitsPerSample) || [16])[0];
- const comp = (ifd.get(T.Compression) || [1])[0];
- if (!w || !h || w * h > 80e6) return null;
- const plane = new Float32Array(w * h);
- const strips = ifd.get(T.StripOffsets);
- const counts = ifd.get(T.StripByteCounts);
- const rowsPerStrip = (ifd.get(T.RowsPerStrip) || [h])[0];
- if (comp === 1) {
- // Uncompressed. Concatenate strips then unpack bit-by-bit.
- if (!strips) return null;
- let dstPix = 0;
- for (let s = 0; s < strips.length; s++) {
- const off = strips[s];
- const nRows = Math.min(rowsPerStrip, h - s * rowsPerStrip);
- const pixInStrip = nRows * w;
- if (bps === 16) {
- for (let i = 0; i < pixInStrip; i++) {
- const o = off + i * 2;
- if (o + 1 >= dv.byteLength) break;
- plane[dstPix++] = dv.getUint16(o, little);
- }
- } else {
- // Packed bitstream (12 or 14 bit), MSB first per TIFF spec.
- let bitPos = off * 8;
- for (let i = 0; i < pixInStrip; i++) {
- let v = 0;
- for (let b = 0; b < bps; b++) {
- const bytePos = bitPos >> 3;
- if (bytePos >= dv.byteLength) { v = 0; break; }
- const bit = 7 - (bitPos & 7);
- v = (v << 1) | ((dv.getUint8(bytePos) >> bit) & 1);
- bitPos++;
- }
- plane[dstPix++] = v;
- }
- }
- }
- return { plane: plane, width: w, height: h, maxVal: (1 << bps) - 1 };
- }
- if (comp === 32767) {
- // Sony ARW2 lossy compression: 16 pixel blocks, each 128 bits.
- if (!strips || !counts) return null;
- if (decodeSonyARW2(dv, strips, counts, w, h, plane)) {
- return { plane: plane, width: w, height: h, maxVal: 16383 };
- }
- return null;
- }
- return null; // unsupported compression (lossless JPEG etc.)
- }
- // Sony ARW2 block decoder. Each row is stored in 16 pixel groups; a group
- // is 16 bytes = 2x max/min (11bit each) + 4bit shift + 14x 7bit deltas.
- // Reference: dcraw sony_arw2_load_raw. Values are interleaved per Bayer
- // colour (even/odd columns) but we lay them out linearly which is fine for
- // a subsequent generic demosaic.
- function decodeSonyARW2(dv, strips, counts, w, h, plane) {
- try {
- // Build a per-row byte offset table from strips.
- // ARW2 typically uses one strip; bytes-per-row = stripBytes / rows.
- const totalBytes = counts.reduce((a, b) => a + b, 0);
- const bytesPerRow = Math.floor(totalBytes / h);
- if (bytesPerRow < w) return false;
- function bits(bytePos, bitOff, n) {
- let v = 0;
- for (let i = 0; i < n; i++) {
- const bp = bytePos + ((bitOff + i) >> 3);
- if (bp >= dv.byteLength) return v;
- const b = 7 - ((bitOff + i) & 7);
- v = (v << 1) | ((dv.getUint8(bp) >> b) & 1);
- }
- return v;
- }
- let rowBase = strips[0];
- for (let row = 0; row < h; row++) {
- let colByte = rowBase;
- for (let col = 0; col < w; col += 16) {
- // Two interleaved colours -> two passes of 8 pixels.
- for (let phase = 0; phase < 2; phase++) {
- let bitOff = 0;
- const max = bits(colByte, bitOff, 11); bitOff += 11;
- const min = bits(colByte, bitOff, 11); bitOff += 11;
- const imax = bits(colByte, bitOff, 4); bitOff += 4;
- const imin = bits(colByte, bitOff, 4); bitOff += 4;
- let sh = 0;
- for (let s = max; s < 0x800; s <<= 1) sh++;
- for (let i = 0; i < 8; i++) {
- let p;
- if (i === imax) p = max;
- else if (i === imin) p = min;
- else {
- const d = bits(colByte, bitOff, 7); bitOff += 7;
- p = (d << sh) + min;
- if (p > 0x7ff) p = 0x7ff;
- }
- const c = col + i * 2 + phase;
- if (c < w) plane[row * w + c] = p << 1;
- }
- colByte += 16;
- }
- }
- rowBase += bytesPerRow;
- }
- return true;
- } catch (e) {
- return false;
- }
- }
- // Bilinear demosaic of a single CFA plane into linear RGBA float, applying
- // black/white level normalisation and camera / gray-world white balance.
- function demosaic(cfa, pattern, black, white, wbMul) {
- const w = cfa.width, h = cfa.height, p = cfa.plane;
- const out = new Float32Array(w * h * 4);
- const range = Math.max(1, (white - black));
- // pattern: 2x2 colour ids, 0=R 1=G 2=B for (row%2,col%2).
- function colorAt(y, x) { return pattern[(y & 1) * 2 + (x & 1)]; }
- function samp(y, x) {
- if (x < 0) x = 1; if (x >= w) x = w - 2;
- if (y < 0) y = 1; if (y >= h) y = h - 2;
- return p[y * w + x];
- }
- for (let y = 0; y < h; y++) {
- for (let x = 0; x < w; x++) {
- const c = colorAt(y, x);
- let r, g, b;
- const v = samp(y, x);
- if (c === 0) { // red site
- r = v;
- g = (samp(y, x - 1) + samp(y, x + 1) + samp(y - 1, x) + samp(y + 1, x)) * 0.25;
- b = (samp(y - 1, x - 1) + samp(y - 1, x + 1) + samp(y + 1, x - 1) + samp(y + 1, x + 1)) * 0.25;
- } else if (c === 2) { // blue site
- b = v;
- g = (samp(y, x - 1) + samp(y, x + 1) + samp(y - 1, x) + samp(y + 1, x)) * 0.25;
- r = (samp(y - 1, x - 1) + samp(y - 1, x + 1) + samp(y + 1, x - 1) + samp(y + 1, x + 1)) * 0.25;
- } else { // green site
- g = v;
- // Neighbours: horizontal & vertical carry the two other colours.
- const h1 = samp(y, x - 1), h2 = samp(y, x + 1);
- const v1 = samp(y - 1, x), v2 = samp(y + 1, x);
- if (colorAt(y, x - 1) === 0) { r = (h1 + h2) * 0.5; b = (v1 + v2) * 0.5; }
- else { b = (h1 + h2) * 0.5; r = (v1 + v2) * 0.5; }
- }
- const o = (y * w + x) * 4;
- out[o] = Math.max(0, (r - black) / range) * wbMul[0];
- out[o + 1] = Math.max(0, (g - black) / range) * wbMul[1];
- out[o + 2] = Math.max(0, (b - black) / range) * wbMul[2];
- out[o + 3] = 1.0;
- }
- }
- return { data: out, width: w, height: h };
- }
- // Downscale a full-res linear RGBA buffer to the working edge cap (box filter).
- function downscaleLinear(src, w, h) {
- const scale = Math.min(1, MAX_WORK_EDGE / Math.max(w, h));
- if (scale >= 1) return { data: src, width: w, height: h };
- const nw = Math.max(1, Math.round(w * scale));
- const nh = Math.max(1, Math.round(h * scale));
- const out = new Float32Array(nw * nh * 4);
- const sx = w / nw, sy = h / nh;
- for (let y = 0; y < nh; y++) {
- const y0 = Math.floor(y * sy), y1 = Math.min(h, Math.floor((y + 1) * sy));
- for (let x = 0; x < nw; x++) {
- const x0 = Math.floor(x * sx), x1 = Math.min(w, Math.floor((x + 1) * sx));
- let r = 0, g = 0, b = 0, n = 0;
- for (let yy = y0; yy < y1; yy++) {
- for (let xx = x0; xx < x1; xx++) {
- const o = (yy * w + xx) * 4;
- r += src[o]; g += src[o + 1]; b += src[o + 2]; n++;
- }
- }
- const o = (y * nw + x) * 4;
- if (n === 0) n = 1;
- out[o] = r / n; out[o + 1] = g / n; out[o + 2] = b / n; out[o + 3] = 1;
- }
- }
- return { data: out, width: nw, height: nh };
- }
- // Estimate a gray-world white balance from the demosaiced plane, used when
- // the file carries no AsShotNeutral (typical for Sony ARW without makernote).
- function grayWorldMul(cfa, pattern, black) {
- const w = cfa.width, h = cfa.height, p = cfa.plane;
- let sr = 0, sg = 0, sb = 0, nr = 0, ng = 0, nb = 0;
- const step = Math.max(1, Math.floor(Math.min(w, h) / 300));
- for (let y = 0; y < h; y += step) {
- for (let x = 0; x < w; x += step) {
- const v = Math.max(0, p[y * w + x] - black);
- const c = pattern[(y & 1) * 2 + (x & 1)];
- if (c === 0) { sr += v; nr++; }
- else if (c === 2) { sb += v; nb++; }
- else { sg += v; ng++; }
- }
- }
- const ar = sr / Math.max(1, nr), ag = sg / Math.max(1, ng), ab = sb / Math.max(1, nb);
- const g = ag || 1;
- let mr = g / (ar || g), mb = g / (ab || g);
- // clamp to sane range
- mr = Math.min(4, Math.max(0.25, mr));
- mb = Math.min(4, Math.max(0.25, mb));
- return [mr, 1.0, mb];
- }
- // Map a DNG CFAPattern (or default) into our 2x2 colour id layout.
- function resolvePattern(ifd) {
- const raw = ifd.get(T.CFAPattern) || ifd.get(T.CFAPatternExif);
- // CFAPattern values: 0=R 1=G 2=B (Exif) preceded by a 2x2 dim header in
- // some encodings; be defensive and fall back to RGGB.
- if (raw && raw.length >= 4) {
- const last4 = raw.slice(raw.length - 4);
- const ok = last4.every((v) => v >= 0 && v <= 2);
- if (ok) return last4;
- }
- return [0, 1, 1, 2]; // RGGB
- }
- function readMeta(tiff) {
- const meta = { camera: "", iso: 0, shutter: 0, aperture: 0, focal: 0, temp: 5500, tint: 0, wb: null };
- tiff.ifds.forEach((ifd) => {
- const make = asciiOf(ifd.get(T.Make));
- const model = asciiOf(ifd.get(T.Model));
- if (model && !meta.camera) meta.camera = (make ? make + " " : "") + model;
- const iso = ifd.get(T.ISO); if (iso && !meta.iso) meta.iso = iso[0];
- const et = ifd.get(T.ExposureTime); if (et && !meta.shutter) meta.shutter = et[0];
- const fn = ifd.get(T.FNumber); if (fn && !meta.aperture) meta.aperture = fn[0];
- const fl = ifd.get(T.FocalLength); if (fl && !meta.focal) meta.focal = fl[0];
- const asn = ifd.get(T.AsShotNeutral);
- if (asn && asn.length >= 3 && !meta.wb) {
- // AsShotNeutral is the neutral in camera-native space; multiplier is 1/n.
- meta.wb = [1 / (asn[0] || 1), 1 / (asn[1] || 1), 1 / (asn[2] || 1)];
- const g = meta.wb[1] || 1;
- meta.wb = [meta.wb[0] / g, 1, meta.wb[2] / g];
- }
- });
- return meta;
- }
- // Attempt a full CFA demosaic. Returns work-sized linear RGBA or null.
- function tryDemosaic(buf, tiff, meta) {
- // Find the CFA IFD: Photometric 32803, or the largest raw-looking plane.
- let cfaIfd = null, bestPix = 0;
- tiff.ifds.forEach((ifd) => {
- const photo = ifd.get(T.Photometric);
- const w = (ifd.get(T.ImageWidth) || [0])[0];
- const h = (ifd.get(T.ImageLength) || [0])[0];
- const comp = (ifd.get(T.Compression) || [0])[0];
- const isCFA = photo && photo[0] === 32803;
- if ((isCFA || comp === 32767) && w * h > bestPix) { cfaIfd = ifd; bestPix = w * h; }
- });
- if (!cfaIfd) return null;
- const cfa = readCFAPlane(buf, tiff, cfaIfd);
- if (!cfa) return null;
- const pattern = resolvePattern(cfaIfd);
- let black = (cfaIfd.get(T.BlackLevel) || [0])[0] || 0;
- let white = (cfaIfd.get(T.WhiteLevel) || [cfa.maxVal])[0] || cfa.maxVal;
- if (white <= black) white = cfa.maxVal;
- const wb = meta.wb || grayWorldMul(cfa, pattern, black);
- const dem = demosaic(cfa, pattern, black, white, wb);
- // A mild highlight normalise so mid-tones land in a sensible range.
- return downscaleLinear(dem.data, dem.width, dem.height);
- }
- // ======================================================================
- // Public entry point
- // ======================================================================
- function decode(buf, filename) {
- return new Promise((resolve, reject) => {
- const ext = extOf(filename);
- const blob = new Blob([buf]);
- // Plain images: hand straight to the browser, but still read EXIF
- // from the JPEG APP1 segment so shooting info is shown.
- if (["jpg", "jpeg", "png", "webp", "gif", "bmp"].indexOf(ext) >= 0) {
- let imgMeta = emptyMeta();
- try { imgMeta = enrichMetaFromJpeg(buf, null, imgMeta); } catch (e) { imgMeta = emptyMeta(); }
- decodeBlobAsImage(blob).then((work) => {
- resolve({ width: work.width, height: work.height, data: work.data, meta: imgMeta, source: "image" });
- }).catch(reject);
- return;
- }
- let tiff = null;
- try { tiff = parseTIFF(buf); } catch (e) { tiff = null; }
- let meta = tiff ? readMeta(tiff) : emptyMeta();
- // Fill any missing EXIF from the embedded JPEG preview — this rescues
- // shooting info when the RAW's TIFF structure could not be parsed.
- try { meta = enrichMetaFromJpeg(buf, tiff, meta); } catch (e) { /* keep meta */ }
- // For RAW containers, try to demosaic; otherwise fall back to preview.
- const isRaw = RAW_EXTS.indexOf(ext) >= 0 || (tiff && ext !== "tif" && ext !== "tiff");
- function finishWithPreview() {
- const jpeg = tiff ? extractEmbeddedJpeg(buf, tiff) : null;
- if (jpeg) {
- decodeBlobAsImage(jpeg).then((work) => {
- resolve({ width: work.width, height: work.height, data: work.data, meta: meta, source: "embedded-preview" });
- }).catch(() => tryTiffAsImage());
- } else {
- tryTiffAsImage();
- }
- }
- function tryTiffAsImage() {
- // Last resort: maybe the browser can render it (baseline TIFF/DNG w/ preview).
- decodeBlobAsImage(blob).then((work) => {
- resolve({ width: work.width, height: work.height, data: work.data, meta: meta, source: "image" });
- }).catch(() => reject(new Error("Unable to decode this file. The RAW format or its compression is not supported and no embedded preview was found.")));
- }
- if (isRaw && tiff) {
- let dem = null;
- try { dem = tryDemosaic(buf, tiff, meta); } catch (e) { dem = null; }
- if (dem) {
- resolve({ width: dem.width, height: dem.height, data: dem.data, meta: meta, source: "raw-demosaic" });
- return;
- }
- finishWithPreview();
- return;
- }
- // tif/tiff or anything else: try as image, then preview.
- decodeBlobAsImage(blob).then((work) => {
- resolve({ width: work.width, height: work.height, data: work.data, meta: meta, source: "image" });
- }).catch(finishWithPreview);
- });
- }
- function emptyMeta() {
- return { camera: "", iso: 0, shutter: 0, aperture: 0, focal: 0, temp: 5500, tint: 0, wb: null };
- }
- return { decode: decode, MAX_WORK_EDGE: MAX_WORK_EDGE };
- })();
|