mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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) });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user