fix(raw): fix RAW preview matching, EXIF orientation alignment & studio initial state
This commit is contained in:
@@ -212,13 +212,56 @@ async function cameraPreview(raw: LibRaw): Promise<Uint8Array | null> {
|
||||
return new Uint8Array(thumb.data);
|
||||
}
|
||||
|
||||
// Reads EXIF Orientation tag (1-8) from JPEG byte headers.
|
||||
export function getJpegOrientation(bytes: Uint8Array): number {
|
||||
if (bytes.length < 12 || bytes[0] !== 0xff || bytes[1] !== 0xd8) return 1;
|
||||
let offset = 2;
|
||||
while (offset < bytes.length - 4) {
|
||||
if (bytes[offset] !== 0xff) { offset++; continue; }
|
||||
const marker = bytes[offset + 1];
|
||||
if (marker === 0xe1) {
|
||||
const length = (bytes[offset + 2] << 8) | bytes[offset + 3];
|
||||
if (offset + 4 + length > bytes.length) break;
|
||||
const exifHeader = String.fromCharCode(...bytes.subarray(offset + 4, offset + 10));
|
||||
if (exifHeader === 'Exif\0\0') {
|
||||
const tiffStart = offset + 10;
|
||||
const littleEndian = bytes[tiffStart] === 0x49 && bytes[tiffStart + 1] === 0x49;
|
||||
const read16 = (o: number) => littleEndian ? (bytes[o] | (bytes[o + 1] << 8)) : ((bytes[o] << 8) | bytes[o + 1]);
|
||||
const read32 = (o: number) => littleEndian ? (bytes[o] | (bytes[o + 1] << 8) | (bytes[o + 2] << 16) | (bytes[o + 3] << 24)) : ((bytes[o] << 24) | (bytes[o + 1] << 16) | (bytes[o + 2] << 8) | bytes[o + 3]);
|
||||
if (read16(tiffStart + 2) !== 0x002a) break;
|
||||
const firstIfdOffset = read32(tiffStart + 4);
|
||||
let dirStart = tiffStart + firstIfdOffset;
|
||||
if (dirStart + 2 > bytes.length) break;
|
||||
const entries = read16(dirStart);
|
||||
dirStart += 2;
|
||||
for (let i = 0; i < entries; i++) {
|
||||
const entryOffset = dirStart + i * 12;
|
||||
if (entryOffset + 12 > bytes.length) break;
|
||||
const tag = read16(entryOffset);
|
||||
if (tag === 0x0112) {
|
||||
const val = read16(entryOffset + 8);
|
||||
return val >= 1 && val <= 8 ? val : 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (marker === 0xda || marker === 0xd9) break;
|
||||
const segLen = (bytes[offset + 2] << 8) | bytes[offset + 3];
|
||||
if (segLen < 2) break;
|
||||
offset += 2 + segLen;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Fast pure JS binary scanner to extract embedded JPEG preview from RAW files (ARW, NEF, CR2, CR3, DNG, RAF, RW2, ORF, PEF).
|
||||
// Runs in ~1-3ms without loading full file or initializing LibRaw WebAssembly.
|
||||
// Scans for all embedded JPEGs in the RAW file and returns the largest segment (the full-size camera preview).
|
||||
export function extractEmbeddedJpeg(buf: Uint8Array): Uint8Array | null {
|
||||
const len = buf.length;
|
||||
let largest: Uint8Array | null = null;
|
||||
let maxLen = 0;
|
||||
for (let i = 0; i < len - 4; i++) {
|
||||
if (buf[i] === 0xff && buf[i + 1] === 0xd8 && buf[i + 2] === 0xff) {
|
||||
// Found JPEG SOI marker
|
||||
let pos = i + 2;
|
||||
let eoiPos = -1;
|
||||
let valid = true;
|
||||
@@ -247,7 +290,6 @@ export function extractEmbeddedJpeg(buf: Uint8Array): Uint8Array | null {
|
||||
break;
|
||||
}
|
||||
if (marker === 0xda) {
|
||||
// Start of scan: entropy-coded data until EOI
|
||||
pos += 2 + segLen;
|
||||
while (pos < len - 1) {
|
||||
if (buf[pos] === 0xff) {
|
||||
@@ -267,12 +309,17 @@ export function extractEmbeddedJpeg(buf: Uint8Array): Uint8Array | null {
|
||||
pos += 2 + segLen;
|
||||
}
|
||||
}
|
||||
if (valid && eoiPos > i && (eoiPos - i) > 10000) {
|
||||
return buf.subarray(i, eoiPos);
|
||||
if (valid && eoiPos > i) {
|
||||
const segSize = eoiPos - i;
|
||||
if (segSize > 10000 && segSize > maxLen) {
|
||||
maxLen = segSize;
|
||||
largest = buf.subarray(i, eoiPos);
|
||||
}
|
||||
i = eoiPos - 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
return largest;
|
||||
}
|
||||
|
||||
// The preview on its own, for the catalogue: a folder of RAW files has to show
|
||||
@@ -383,30 +430,70 @@ function previewGrid(jpeg: Uint8Array, w: number, h: number, n = MATCH_GRID): Ui
|
||||
if (!bmp) return null;
|
||||
try {
|
||||
if (bmp.width() < n || bmp.height() < n) return null;
|
||||
const orient = getJpegOrientation(jpeg);
|
||||
const isSwapped = orient === 5 || orient === 6 || orient === 7 || orient === 8;
|
||||
const orientedW = isSwapped ? bmp.height() : bmp.width();
|
||||
const orientedH = isSwapped ? bmp.width() : bmp.height();
|
||||
|
||||
const targetAspect = w / h;
|
||||
const bmpAspect = bmp.width() / bmp.height();
|
||||
let cropW = bmp.width();
|
||||
let cropH = bmp.height();
|
||||
const bmpAspect = orientedW / orientedH;
|
||||
let cropW = orientedW;
|
||||
let cropH = orientedH;
|
||||
if (Math.abs(bmpAspect - targetAspect) > 0.01) {
|
||||
if (bmpAspect > targetAspect) {
|
||||
cropW = Math.round(bmp.height() * targetAspect);
|
||||
cropW = Math.round(orientedH * targetAspect);
|
||||
} else {
|
||||
cropH = Math.round(bmp.width() / targetAspect);
|
||||
cropH = Math.round(orientedW / targetAspect);
|
||||
}
|
||||
}
|
||||
const cropX = Math.round((bmp.width() - cropW) / 2);
|
||||
const cropY = Math.round((bmp.height() - cropH) / 2);
|
||||
const cropX = Math.round((orientedW - cropW) / 2);
|
||||
const cropY = Math.round((orientedH - cropH) / 2);
|
||||
|
||||
const surface = Skia.Surface.MakeOffscreen(n, n) ?? Skia.Surface.Make(n, n);
|
||||
if (!surface) return null;
|
||||
const canvas = surface.getCanvas();
|
||||
canvas.drawImageRectCubic(
|
||||
bmp,
|
||||
Skia.XYWHRect(cropX, cropY, cropW, cropH),
|
||||
Skia.XYWHRect(0, 0, n, n),
|
||||
1 / 3,
|
||||
1 / 3
|
||||
);
|
||||
canvas.save();
|
||||
|
||||
if (orient === 3) {
|
||||
canvas.translate(n, n);
|
||||
canvas.rotate(180, 0, 0);
|
||||
canvas.drawImageRectCubic(
|
||||
bmp,
|
||||
Skia.XYWHRect(bmp.width() - cropX - cropW, bmp.height() - cropY - cropH, cropW, cropH),
|
||||
Skia.XYWHRect(0, 0, n, n),
|
||||
1 / 3,
|
||||
1 / 3
|
||||
);
|
||||
} else if (orient === 6) {
|
||||
canvas.translate(n, 0);
|
||||
canvas.rotate(90, 0, 0);
|
||||
canvas.drawImageRectCubic(
|
||||
bmp,
|
||||
Skia.XYWHRect(cropY, bmp.height() - cropX - cropW, cropH, cropW),
|
||||
Skia.XYWHRect(0, 0, n, n),
|
||||
1 / 3,
|
||||
1 / 3
|
||||
);
|
||||
} else if (orient === 8) {
|
||||
canvas.translate(0, n);
|
||||
canvas.rotate(-90, 0, 0);
|
||||
canvas.drawImageRectCubic(
|
||||
bmp,
|
||||
Skia.XYWHRect(bmp.width() - cropY - cropH, cropX, cropH, cropW),
|
||||
Skia.XYWHRect(0, 0, n, n),
|
||||
1 / 3,
|
||||
1 / 3
|
||||
);
|
||||
} else {
|
||||
canvas.drawImageRectCubic(
|
||||
bmp,
|
||||
Skia.XYWHRect(cropX, cropY, cropW, cropH),
|
||||
Skia.XYWHRect(0, 0, n, n),
|
||||
1 / 3,
|
||||
1 / 3
|
||||
);
|
||||
}
|
||||
canvas.restore();
|
||||
surface.flush();
|
||||
const px = canvas.readPixels(0, 0, {
|
||||
width: n,
|
||||
@@ -471,9 +558,13 @@ export async function developRaw(bytes: Uint8Array): Promise<Uint8Array> {
|
||||
const raw = new LibRaw();
|
||||
let preview: Uint8Array | null = null;
|
||||
try {
|
||||
// Extract largest embedded JPEG preview from file bytes first
|
||||
preview = extractEmbeddedJpeg(bytes);
|
||||
// LibRaw copies the buffer it is handed, so the caller's bytes stay intact.
|
||||
await raw.open(bytes as unknown as BufferSource, SETTINGS);
|
||||
preview = await cameraPreview(raw);
|
||||
if (!preview) {
|
||||
preview = await cameraPreview(raw);
|
||||
}
|
||||
const meta = await raw.metadata(true);
|
||||
const img = await raw.imageData();
|
||||
const cd = meta?.color_data;
|
||||
|
||||
Reference in New Issue
Block a user