From 2d17fd49035f600b40abd9a5a3cae4267aa8d1b7 Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Tue, 12 May 2026 22:13:58 +0800 Subject: [PATCH] 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. --- apps/api/src/lib/exiftool.ts | 28 +++++++++++++++ apps/api/src/routes/files.ts | 23 ++++++++++-- .../src/components/common/image-viewer.tsx | 8 +++-- apps/web/src/lib/image-preview.ts | 18 ++++++++-- apps/web/src/pages/tool-page.tsx | 2 ++ apps/web/src/stores/collage-store.ts | 4 +-- apps/web/src/stores/file-store.ts | 36 +++++++++++++++---- 7 files changed, 105 insertions(+), 14 deletions(-) diff --git a/apps/api/src/lib/exiftool.ts b/apps/api/src/lib/exiftool.ts index 607347a6..f5475e65 100644 --- a/apps/api/src/lib/exiftool.ts +++ b/apps/api/src/lib/exiftool.ts @@ -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 */ export interface EditMetadataSettings { title?: string; diff --git a/apps/api/src/routes/files.ts b/apps/api/src/routes/files.ts index 3f4b4d61..0289c0d9 100644 --- a/apps/api/src/routes/files.ts +++ b/apps/api/src/routes/files.ts @@ -3,6 +3,7 @@ import { readFile, stat, writeFile } from "node:fs/promises"; import { extname, join } from "node:path"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import sharp from "sharp"; +import { readImageDimensions } from "../lib/exiftool.js"; import { validateImageBuffer } from "../lib/file-validation.js"; import { sanitizeFilename } from "../lib/filename.js"; import { decodeToSharpCompat, needsCliDecode } from "../lib/format-decoders.js"; @@ -135,7 +136,9 @@ export async function fileRoutes(app: FastifyInstance): Promise { if (!data) { 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); if (!validation.valid) { @@ -168,11 +171,27 @@ export async function fileRoutes(app: FastifyInstance): Promise { } 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) .resize(1200, 1200, { fit: "inside", withoutEnlargement: true }) .webp({ quality: 80 }) .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 { return reply.status(422).send({ error: `Failed to generate preview for ${validation.format.toUpperCase()} file`, diff --git a/apps/web/src/components/common/image-viewer.tsx b/apps/web/src/components/common/image-viewer.tsx index 6a61d796..5abde160 100644 --- a/apps/web/src/components/common/image-viewer.tsx +++ b/apps/web/src/components/common/image-viewer.tsx @@ -19,6 +19,8 @@ interface ImageViewerProps { src: string; filename: string; fileSize: number; + originalWidth?: number | null; + originalHeight?: number | null; cssRotate?: number; cssFlipH?: boolean; cssFlipV?: boolean; @@ -34,6 +36,8 @@ export function ImageViewer({ src, filename, fileSize, + originalWidth, + originalHeight, cssRotate, cssFlipH, cssFlipV, @@ -298,9 +302,9 @@ export function ImageViewer({
{filename}
- {naturalWidth != null && naturalHeight != null && ( + {(originalWidth || naturalWidth) != null && (originalHeight || naturalHeight) != null && ( - {naturalWidth} x {naturalHeight} + {originalWidth || naturalWidth} x {originalHeight || naturalHeight} )} {formatFileSize(fileSize)} diff --git a/apps/web/src/lib/image-preview.ts b/apps/web/src/lib/image-preview.ts index f9785fdc..7ebafcfb 100644 --- a/apps/web/src/lib/image-preview.ts +++ b/apps/web/src/lib/image-preview.ts @@ -65,7 +65,13 @@ export function needsServerPreview(file: File): boolean { return SERVER_PREVIEW_EXTENSIONS.has(ext); } -export async function fetchDecodedPreview(file: File): Promise { +export interface DecodedPreview { + url: string; + originalWidth: number | null; + originalHeight: number | null; +} + +export async function fetchDecodedPreview(file: File): Promise { try { const formData = new FormData(); formData.append("file", file); @@ -75,8 +81,16 @@ export async function fetchDecodedPreview(file: File): Promise { body: formData, }); 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(); - return URL.createObjectURL(blob); + return { + url: URL.createObjectURL(blob), + originalWidth: w > 0 ? w : null, + originalHeight: h > 0 ? h : null, + }; } catch { return null; } diff --git a/apps/web/src/pages/tool-page.tsx b/apps/web/src/pages/tool-page.tsx index 316398e1..7d404c4b 100644 --- a/apps/web/src/pages/tool-page.tsx +++ b/apps/web/src/pages/tool-page.tsx @@ -631,6 +631,8 @@ export function ToolPage() { src={originalBlobUrl} filename={fname} fileSize={fsize} + originalWidth={currentEntry?.originalWidth} + originalHeight={currentEntry?.originalHeight} {...(isLivePreview && previewTransform ? { cssRotate: previewTransform.rotate, diff --git a/apps/web/src/stores/collage-store.ts b/apps/web/src/stores/collage-store.ts index 335888e5..720ce559 100644 --- a/apps/web/src/stores/collage-store.ts +++ b/apps/web/src/stores/collage-store.ts @@ -141,7 +141,7 @@ export const useCollageStore = create((set, get) => ({ for (const img of newImages) { if (img.previewLoading) { const imgId = img.id; - fetchDecodedPreview(img.file).then((url) => { + fetchDecodedPreview(img.file).then((result) => { const current = get(); const idx = current.images.findIndex((i) => i.id === imgId); if (idx === -1) return; @@ -149,7 +149,7 @@ export const useCollageStore = create((set, get) => ({ updated[idx] = { ...updated[idx], previewLoading: false, - ...(url ? { previewBlobUrl: url } : {}), + ...(result ? { previewBlobUrl: result.url } : {}), }; set({ images: updated }); }); diff --git a/apps/web/src/stores/file-store.ts b/apps/web/src/stores/file-store.ts index b63ea6ff..f600edb9 100644 --- a/apps/web/src/stores/file-store.ts +++ b/apps/web/src/stores/file-store.ts @@ -10,6 +10,8 @@ export interface FileEntry { processedFilename: string | null; processedSize: number | null; originalSize: number; + originalWidth: number | null; + originalHeight: number | null; status: "pending" | "processing" | "completed" | "failed"; error: string | null; serverFileId?: string; @@ -29,6 +31,8 @@ function createEntry(file: File): FileEntry { processedFilename: null, processedSize: null, originalSize: file.size, + originalWidth: null, + originalHeight: null, status: "pending", error: null, serverFileId: undefined, @@ -147,13 +151,23 @@ export const useFileStore = create((set, get) => ({ for (let i = 0; i < entries.length; i++) { if (needsServerPreview(entries[i].file)) { const file = entries[i].file; - fetchDecodedPreview(file).then((url) => { + fetchDecodedPreview(file).then((result) => { const state = get(); if (state.entries[i]?.file !== file) return; const updated = [...state.entries]; const oldBlobUrl = updated[i].blobUrl; - updated[i] = { ...updated[i], previewLoading: false, ...(url ? { blobUrl: url } : {}) }; - if (url && oldBlobUrl) URL.revokeObjectURL(oldBlobUrl); + updated[i] = { + ...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) }); }); } @@ -171,13 +185,23 @@ export const useFileStore = create((set, get) => ({ const i = oldLen + j; if (needsServerPreview(newEntries[j].file)) { const file = newEntries[j].file; - fetchDecodedPreview(file).then((url) => { + fetchDecodedPreview(file).then((result) => { const state = get(); if (state.entries[i]?.file !== file) return; const updated = [...state.entries]; const oldBlobUrl = updated[i].blobUrl; - updated[i] = { ...updated[i], previewLoading: false, ...(url ? { blobUrl: url } : {}) }; - if (url && oldBlobUrl) URL.revokeObjectURL(oldBlobUrl); + updated[i] = { + ...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) }); }); }