fix: show real image dimensions in right pane for DNG/RAW files

The preview endpoint now returns X-Original-Width/Height headers with
dimensions read from Sharp metadata (or ExifTool for RAW files). The
frontend stores these in FileEntry and the ImageViewer prefers them over
the browser's naturalWidth/naturalHeight, which reflects the resized
preview rather than the original sensor dimensions.
This commit is contained in:
SnapOtter
2026-05-12 22:13:58 +08:00
parent 8c1526559b
commit 2d17fd4903
7 changed files with 105 additions and 14 deletions
+28
View File
@@ -135,6 +135,34 @@ export async function writeMetadata(
} }
} }
export async function readImageDimensions(
buffer: Buffer,
ext?: string,
): Promise<{ width: number; height: number } | null> {
try {
const bin = await findExiftool();
const suffix = ext ? `.${ext.replace(/^\./, "")}` : ".jpg";
const id = randomUUID();
const tempPath = join(tmpdir(), `exif-dim-${id}${suffix}`);
try {
await writeFile(tempPath, buffer);
const { stdout } = await execFileAsync(
bin,
["-json", "-ImageWidth", "-ImageHeight", tempPath],
{ timeout: 10_000 },
);
const [data] = JSON.parse(stdout);
if (!data?.ImageWidth || !data?.ImageHeight) return null;
return { width: data.ImageWidth, height: data.ImageHeight };
} finally {
await rm(tempPath, { force: true }).catch(() => {});
}
} catch {
return null;
}
}
/** Settings shape that buildTagArgs accepts */ /** Settings shape that buildTagArgs accepts */
export interface EditMetadataSettings { export interface EditMetadataSettings {
title?: string; title?: string;
+21 -2
View File
@@ -3,6 +3,7 @@ import { readFile, stat, writeFile } from "node:fs/promises";
import { extname, join } from "node:path"; import { extname, join } from "node:path";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import sharp from "sharp"; import sharp from "sharp";
import { readImageDimensions } from "../lib/exiftool.js";
import { validateImageBuffer } from "../lib/file-validation.js"; import { validateImageBuffer } from "../lib/file-validation.js";
import { sanitizeFilename } from "../lib/filename.js"; import { sanitizeFilename } from "../lib/filename.js";
import { decodeToSharpCompat, needsCliDecode } from "../lib/format-decoders.js"; import { decodeToSharpCompat, needsCliDecode } from "../lib/format-decoders.js";
@@ -135,7 +136,9 @@ export async function fileRoutes(app: FastifyInstance): Promise<void> {
if (!data) { if (!data) {
return reply.status(400).send({ error: "No file provided" }); return reply.status(400).send({ error: "No file provided" });
} }
let buffer = await data.toBuffer(); const originalBuffer = await data.toBuffer();
let buffer = originalBuffer;
const ext = data.filename?.split(".").pop()?.toLowerCase();
const validation = await validateImageBuffer(buffer, data.filename); const validation = await validateImageBuffer(buffer, data.filename);
if (!validation.valid) { if (!validation.valid) {
@@ -168,11 +171,27 @@ export async function fileRoutes(app: FastifyInstance): Promise<void> {
} }
try { try {
const preMeta = await sharp(buffer).metadata();
let origWidth = preMeta.width ?? 0;
let origHeight = preMeta.height ?? 0;
if (validation.format === "raw") {
const dims = await readImageDimensions(originalBuffer, ext);
if (dims) {
origWidth = dims.width;
origHeight = dims.height;
}
}
const webp = await sharp(buffer) const webp = await sharp(buffer)
.resize(1200, 1200, { fit: "inside", withoutEnlargement: true }) .resize(1200, 1200, { fit: "inside", withoutEnlargement: true })
.webp({ quality: 80 }) .webp({ quality: 80 })
.toBuffer(); .toBuffer();
return reply.header("Content-Type", "image/webp").send(webp); return reply
.header("Content-Type", "image/webp")
.header("X-Original-Width", String(origWidth))
.header("X-Original-Height", String(origHeight))
.send(webp);
} catch { } catch {
return reply.status(422).send({ return reply.status(422).send({
error: `Failed to generate preview for ${validation.format.toUpperCase()} file`, error: `Failed to generate preview for ${validation.format.toUpperCase()} file`,
@@ -19,6 +19,8 @@ interface ImageViewerProps {
src: string; src: string;
filename: string; filename: string;
fileSize: number; fileSize: number;
originalWidth?: number | null;
originalHeight?: number | null;
cssRotate?: number; cssRotate?: number;
cssFlipH?: boolean; cssFlipH?: boolean;
cssFlipV?: boolean; cssFlipV?: boolean;
@@ -34,6 +36,8 @@ export function ImageViewer({
src, src,
filename, filename,
fileSize, fileSize,
originalWidth,
originalHeight,
cssRotate, cssRotate,
cssFlipH, cssFlipH,
cssFlipV, cssFlipV,
@@ -298,9 +302,9 @@ export function ImageViewer({
<div className="flex items-center justify-between px-3 py-1.5 border-t border-border text-xs text-muted-foreground shrink-0"> <div className="flex items-center justify-between px-3 py-1.5 border-t border-border text-xs text-muted-foreground shrink-0">
<span className="truncate mr-2">{filename}</span> <span className="truncate mr-2">{filename}</span>
<div className="flex items-center gap-3 shrink-0"> <div className="flex items-center gap-3 shrink-0">
{naturalWidth != null && naturalHeight != null && ( {(originalWidth || naturalWidth) != null && (originalHeight || naturalHeight) != null && (
<span> <span>
{naturalWidth} x {naturalHeight} {originalWidth || naturalWidth} x {originalHeight || naturalHeight}
</span> </span>
)} )}
<span>{formatFileSize(fileSize)}</span> <span>{formatFileSize(fileSize)}</span>
+16 -2
View File
@@ -65,7 +65,13 @@ export function needsServerPreview(file: File): boolean {
return SERVER_PREVIEW_EXTENSIONS.has(ext); return SERVER_PREVIEW_EXTENSIONS.has(ext);
} }
export async function fetchDecodedPreview(file: File): Promise<string | null> { export interface DecodedPreview {
url: string;
originalWidth: number | null;
originalHeight: number | null;
}
export async function fetchDecodedPreview(file: File): Promise<DecodedPreview | null> {
try { try {
const formData = new FormData(); const formData = new FormData();
formData.append("file", file); formData.append("file", file);
@@ -75,8 +81,16 @@ export async function fetchDecodedPreview(file: File): Promise<string | null> {
body: formData, body: formData,
}); });
if (!res.ok) return null; if (!res.ok) return null;
const w = Number(res.headers.get("X-Original-Width"));
const h = Number(res.headers.get("X-Original-Height"));
const blob = await res.blob(); const blob = await res.blob();
return URL.createObjectURL(blob); return {
url: URL.createObjectURL(blob),
originalWidth: w > 0 ? w : null,
originalHeight: h > 0 ? h : null,
};
} catch { } catch {
return null; return null;
} }
+2
View File
@@ -631,6 +631,8 @@ export function ToolPage() {
src={originalBlobUrl} src={originalBlobUrl}
filename={fname} filename={fname}
fileSize={fsize} fileSize={fsize}
originalWidth={currentEntry?.originalWidth}
originalHeight={currentEntry?.originalHeight}
{...(isLivePreview && previewTransform {...(isLivePreview && previewTransform
? { ? {
cssRotate: previewTransform.rotate, cssRotate: previewTransform.rotate,
+2 -2
View File
@@ -141,7 +141,7 @@ export const useCollageStore = create<CollageState>((set, get) => ({
for (const img of newImages) { for (const img of newImages) {
if (img.previewLoading) { if (img.previewLoading) {
const imgId = img.id; const imgId = img.id;
fetchDecodedPreview(img.file).then((url) => { fetchDecodedPreview(img.file).then((result) => {
const current = get(); const current = get();
const idx = current.images.findIndex((i) => i.id === imgId); const idx = current.images.findIndex((i) => i.id === imgId);
if (idx === -1) return; if (idx === -1) return;
@@ -149,7 +149,7 @@ export const useCollageStore = create<CollageState>((set, get) => ({
updated[idx] = { updated[idx] = {
...updated[idx], ...updated[idx],
previewLoading: false, previewLoading: false,
...(url ? { previewBlobUrl: url } : {}), ...(result ? { previewBlobUrl: result.url } : {}),
}; };
set({ images: updated }); set({ images: updated });
}); });
+30 -6
View File
@@ -10,6 +10,8 @@ export interface FileEntry {
processedFilename: string | null; processedFilename: string | null;
processedSize: number | null; processedSize: number | null;
originalSize: number; originalSize: number;
originalWidth: number | null;
originalHeight: number | null;
status: "pending" | "processing" | "completed" | "failed"; status: "pending" | "processing" | "completed" | "failed";
error: string | null; error: string | null;
serverFileId?: string; serverFileId?: string;
@@ -29,6 +31,8 @@ function createEntry(file: File): FileEntry {
processedFilename: null, processedFilename: null,
processedSize: null, processedSize: null,
originalSize: file.size, originalSize: file.size,
originalWidth: null,
originalHeight: null,
status: "pending", status: "pending",
error: null, error: null,
serverFileId: undefined, serverFileId: undefined,
@@ -147,13 +151,23 @@ export const useFileStore = create<FileState>((set, get) => ({
for (let i = 0; i < entries.length; i++) { for (let i = 0; i < entries.length; i++) {
if (needsServerPreview(entries[i].file)) { if (needsServerPreview(entries[i].file)) {
const file = entries[i].file; const file = entries[i].file;
fetchDecodedPreview(file).then((url) => { fetchDecodedPreview(file).then((result) => {
const state = get(); const state = get();
if (state.entries[i]?.file !== file) return; if (state.entries[i]?.file !== file) return;
const updated = [...state.entries]; const updated = [...state.entries];
const oldBlobUrl = updated[i].blobUrl; const oldBlobUrl = updated[i].blobUrl;
updated[i] = { ...updated[i], previewLoading: false, ...(url ? { blobUrl: url } : {}) }; updated[i] = {
if (url && oldBlobUrl) URL.revokeObjectURL(oldBlobUrl); ...updated[i],
previewLoading: false,
...(result
? {
blobUrl: result.url,
originalWidth: result.originalWidth,
originalHeight: result.originalHeight,
}
: {}),
};
if (result && oldBlobUrl) URL.revokeObjectURL(oldBlobUrl);
set({ entries: updated, ...deriveSelected(updated, state.selectedIndex) }); set({ entries: updated, ...deriveSelected(updated, state.selectedIndex) });
}); });
} }
@@ -171,13 +185,23 @@ export const useFileStore = create<FileState>((set, get) => ({
const i = oldLen + j; const i = oldLen + j;
if (needsServerPreview(newEntries[j].file)) { if (needsServerPreview(newEntries[j].file)) {
const file = newEntries[j].file; const file = newEntries[j].file;
fetchDecodedPreview(file).then((url) => { fetchDecodedPreview(file).then((result) => {
const state = get(); const state = get();
if (state.entries[i]?.file !== file) return; if (state.entries[i]?.file !== file) return;
const updated = [...state.entries]; const updated = [...state.entries];
const oldBlobUrl = updated[i].blobUrl; const oldBlobUrl = updated[i].blobUrl;
updated[i] = { ...updated[i], previewLoading: false, ...(url ? { blobUrl: url } : {}) }; updated[i] = {
if (url && oldBlobUrl) URL.revokeObjectURL(oldBlobUrl); ...updated[i],
previewLoading: false,
...(result
? {
blobUrl: result.url,
originalWidth: result.originalWidth,
originalHeight: result.originalHeight,
}
: {}),
};
if (result && oldBlobUrl) URL.revokeObjectURL(oldBlobUrl);
set({ entries: updated, ...deriveSelected(updated, state.selectedIndex) }); set({ entries: updated, ...deriveSelected(updated, state.selectedIndex) });
}); });
} }