mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
fix: unify project on port 1349, improve strip-metadata and UI components
Consolidate all access to localhost:1349 — Vite dev server serves on 1349 and proxies API calls to an internal dev port (13490). Production API defaults to 1349. Also includes strip-metadata improvements, UI component updates, and compress operation fixes.
This commit is contained in:
+3
-3
@@ -1,6 +1,6 @@
|
|||||||
# In dev: API runs on this port, UI on 1349 (Vite proxies /api here)
|
# Server port (used in production / Docker)
|
||||||
# In Docker/production: Fastify serves everything on 1349 (set PORT=1349)
|
# In dev, the API auto-starts on an internal port; you always access localhost:1349
|
||||||
PORT=1350
|
PORT=1349
|
||||||
AUTH_ENABLED=true
|
AUTH_ENABLED=true
|
||||||
DEFAULT_USERNAME=admin
|
DEFAULT_USERNAME=admin
|
||||||
DEFAULT_PASSWORD=admin
|
DEFAULT_PASSWORD=admin
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsx watch src/index.ts",
|
"dev": "PORT=13490 tsx watch src/index.ts",
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"start": "tsx src/index.ts",
|
"start": "tsx src/index.ts",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
@@ -24,6 +24,7 @@
|
|||||||
"better-sqlite3": "^11.7.0",
|
"better-sqlite3": "^11.7.0",
|
||||||
"dotenv": "^16.4.0",
|
"dotenv": "^16.4.0",
|
||||||
"drizzle-orm": "^0.38.0",
|
"drizzle-orm": "^0.38.0",
|
||||||
|
"exif-reader": "^2.0.3",
|
||||||
"fastify": "^5.2.0",
|
"fastify": "^5.2.0",
|
||||||
"jsqr": "^1.4.0",
|
"jsqr": "^1.4.0",
|
||||||
"p-queue": "^9.1.0",
|
"p-queue": "^9.1.0",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
const envSchema = z.object({
|
const envSchema = z.object({
|
||||||
PORT: z.coerce.number().default(1350),
|
PORT: z.coerce.number().default(1349),
|
||||||
AUTH_ENABLED: z
|
AUTH_ENABLED: z
|
||||||
.enum(["true", "false"])
|
.enum(["true", "false"])
|
||||||
.default("true")
|
.default("true")
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { writeFile } from "node:fs/promises";
|
|||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
|
import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import sharp from "sharp";
|
||||||
import { createWorkspace } from "../lib/workspace.js";
|
import { createWorkspace } from "../lib/workspace.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";
|
||||||
@@ -125,9 +126,24 @@ export function createToolRoute<T>(
|
|||||||
return reply.status(400).send({ error: "Settings must be valid JSON" });
|
return reply.status(400).send({ error: "Settings must be valid JSON" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auto-orient based on EXIF metadata before processing.
|
||||||
|
// Camera photos often have EXIF orientation tags (values 2-8) that browsers
|
||||||
|
// respect when displaying, but Sharp does NOT apply by default. Without this,
|
||||||
|
// processed images appear rotated because the output (PNG) strips EXIF data.
|
||||||
|
// Only re-encodes when orientation correction is actually needed.
|
||||||
|
let processBuffer = fileBuffer;
|
||||||
|
try {
|
||||||
|
const meta = await sharp(fileBuffer).metadata();
|
||||||
|
if (meta.orientation && meta.orientation > 1) {
|
||||||
|
processBuffer = await sharp(fileBuffer).rotate().toBuffer();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// If metadata reading fails, proceed with original buffer
|
||||||
|
}
|
||||||
|
|
||||||
// Process the image
|
// Process the image
|
||||||
try {
|
try {
|
||||||
const result = await config.process(fileBuffer, settings, filename);
|
const result = await config.process(processBuffer, settings, filename);
|
||||||
|
|
||||||
// Create workspace and save output
|
// Create workspace and save output
|
||||||
const jobId = randomUUID();
|
const jobId = randomUUID();
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import { z } from "zod";
|
|||||||
import { createToolRoute } from "../tool-factory.js";
|
import { createToolRoute } from "../tool-factory.js";
|
||||||
import { stripMetadata } from "@stirling-image/image-engine";
|
import { stripMetadata } from "@stirling-image/image-engine";
|
||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
import type { FastifyInstance } from "fastify";
|
import exifReader from "exif-reader";
|
||||||
|
import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
|
||||||
|
import { basename } from "node:path";
|
||||||
|
|
||||||
const settingsSchema = z.object({
|
const settingsSchema = z.object({
|
||||||
stripExif: z.boolean().default(false),
|
stripExif: z.boolean().default(false),
|
||||||
@@ -12,7 +14,264 @@ const settingsSchema = z.object({
|
|||||||
stripAll: z.boolean().default(true),
|
stripAll: z.boolean().default(true),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialize a value for JSON — convert Buffers/Dates and drop overly large blobs.
|
||||||
|
*/
|
||||||
|
function sanitizeValue(v: unknown): unknown {
|
||||||
|
if (v instanceof Date) return v.toISOString();
|
||||||
|
if (Buffer.isBuffer(v)) {
|
||||||
|
if (v.length > 256) return `<binary ${v.length} bytes>`;
|
||||||
|
return Array.from(v);
|
||||||
|
}
|
||||||
|
if (Array.isArray(v)) return v.map(sanitizeValue);
|
||||||
|
if (v !== null && typeof v === "object") {
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
for (const [k, val] of Object.entries(v)) {
|
||||||
|
out[k] = sanitizeValue(val);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse GPS coordinates from EXIF GPSInfo into decimal degrees.
|
||||||
|
*/
|
||||||
|
function parseGpsCoordinates(gps: Record<string, unknown>): {
|
||||||
|
latitude: number | null;
|
||||||
|
longitude: number | null;
|
||||||
|
altitude: number | null;
|
||||||
|
} {
|
||||||
|
let latitude: number | null = null;
|
||||||
|
let longitude: number | null = null;
|
||||||
|
let altitude: number | null = null;
|
||||||
|
|
||||||
|
const lat = gps.GPSLatitude as number[] | undefined;
|
||||||
|
const latRef = gps.GPSLatitudeRef as string | undefined;
|
||||||
|
if (lat && lat.length === 3) {
|
||||||
|
latitude = lat[0] + lat[1] / 60 + lat[2] / 3600;
|
||||||
|
if (latRef === "S") latitude = -latitude;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lon = gps.GPSLongitude as number[] | undefined;
|
||||||
|
const lonRef = gps.GPSLongitudeRef as string | undefined;
|
||||||
|
if (lon && lon.length === 3) {
|
||||||
|
longitude = lon[0] + lon[1] / 60 + lon[2] / 3600;
|
||||||
|
if (lonRef === "W") longitude = -longitude;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof gps.GPSAltitude === "number") {
|
||||||
|
altitude = gps.GPSAltitude;
|
||||||
|
if (gps.GPSAltitudeRef === 1) altitude = -altitude;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { latitude, longitude, altitude };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse XMP XML buffer into key-value pairs.
|
||||||
|
*/
|
||||||
|
function parseXmp(xmpBuffer: Buffer): Record<string, string> {
|
||||||
|
const xml = xmpBuffer.toString("utf-8");
|
||||||
|
const result: Record<string, string> = {};
|
||||||
|
|
||||||
|
const attrRegex = /(\w+:\w+)="([^"]+)"/g;
|
||||||
|
let match;
|
||||||
|
while ((match = attrRegex.exec(xml)) !== null) {
|
||||||
|
const key = match[1];
|
||||||
|
if (key.startsWith("xmlns:") || key.startsWith("rdf:")) continue;
|
||||||
|
result[key] = match[2];
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse ICC profile buffer into basic info.
|
||||||
|
*/
|
||||||
|
function parseIccProfile(iccBuffer: Buffer): Record<string, string> {
|
||||||
|
const info: Record<string, string> = {};
|
||||||
|
|
||||||
|
if (iccBuffer.length < 128) return info;
|
||||||
|
|
||||||
|
info["Profile Size"] = `${iccBuffer.length} bytes`;
|
||||||
|
|
||||||
|
const colorSpace = iccBuffer.subarray(16, 20).toString("ascii").trim();
|
||||||
|
if (colorSpace) info["Color Space"] = colorSpace;
|
||||||
|
|
||||||
|
const pcs = iccBuffer.subarray(20, 24).toString("ascii").trim();
|
||||||
|
if (pcs) info["Connection Space"] = pcs;
|
||||||
|
|
||||||
|
const classMap: Record<string, string> = {
|
||||||
|
scnr: "Input (Scanner)",
|
||||||
|
mntr: "Display (Monitor)",
|
||||||
|
prtr: "Output (Printer)",
|
||||||
|
link: "Device Link",
|
||||||
|
spac: "Color Space",
|
||||||
|
abst: "Abstract",
|
||||||
|
nmcl: "Named Color",
|
||||||
|
};
|
||||||
|
const deviceClass = iccBuffer.subarray(12, 16).toString("ascii").trim();
|
||||||
|
if (deviceClass) info["Device Class"] = classMap[deviceClass] ?? deviceClass;
|
||||||
|
|
||||||
|
const major = iccBuffer[8];
|
||||||
|
const minor = (iccBuffer[9] >> 4) & 0xf;
|
||||||
|
if (major) info["Version"] = `${major}.${minor}`;
|
||||||
|
|
||||||
|
// Extract description tag from ICC tag table
|
||||||
|
const tagCount = iccBuffer.readUInt32BE(128);
|
||||||
|
for (let i = 0; i < tagCount && i < 50; i++) {
|
||||||
|
const tagOffset = 132 + i * 12;
|
||||||
|
if (tagOffset + 12 > iccBuffer.length) break;
|
||||||
|
const sig = iccBuffer.subarray(tagOffset, tagOffset + 4).toString("ascii");
|
||||||
|
if (sig === "desc") {
|
||||||
|
const dataOffset = iccBuffer.readUInt32BE(tagOffset + 4);
|
||||||
|
const dataLen = iccBuffer.readUInt32BE(tagOffset + 8);
|
||||||
|
if (dataOffset + dataLen <= iccBuffer.length && dataLen > 12) {
|
||||||
|
const descType = iccBuffer.subarray(dataOffset, dataOffset + 4).toString("ascii");
|
||||||
|
if (descType === "desc") {
|
||||||
|
const strLen = iccBuffer.readUInt32BE(dataOffset + 8);
|
||||||
|
if (strLen > 0 && strLen < 256) {
|
||||||
|
const desc = iccBuffer.subarray(dataOffset + 12, dataOffset + 12 + strLen - 1).toString("ascii");
|
||||||
|
info["Description"] = desc;
|
||||||
|
}
|
||||||
|
} else if (descType === "mluc") {
|
||||||
|
const recCount = iccBuffer.readUInt32BE(dataOffset + 8);
|
||||||
|
if (recCount > 0) {
|
||||||
|
const strOffset = iccBuffer.readUInt32BE(dataOffset + 20);
|
||||||
|
const strLength = iccBuffer.readUInt32BE(dataOffset + 16);
|
||||||
|
if (strOffset && strLength && dataOffset + strOffset + strLength <= iccBuffer.length) {
|
||||||
|
const raw = iccBuffer.subarray(dataOffset + strOffset, dataOffset + strOffset + strLength);
|
||||||
|
// ICC mluc strings are UTF-16BE: swap bytes for Node's utf16le decoder
|
||||||
|
const swapped = Buffer.alloc(raw.length);
|
||||||
|
for (let j = 0; j < raw.length - 1; j += 2) {
|
||||||
|
swapped[j] = raw[j + 1];
|
||||||
|
swapped[j + 1] = raw[j];
|
||||||
|
}
|
||||||
|
const desc = swapped.toString("utf16le");
|
||||||
|
info["Description"] = desc.replace(/\0/g, "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
|
||||||
export function registerStripMetadata(app: FastifyInstance) {
|
export function registerStripMetadata(app: FastifyInstance) {
|
||||||
|
// Inspect endpoint — returns parsed metadata as JSON
|
||||||
|
app.post(
|
||||||
|
"/api/v1/tools/strip-metadata/inspect",
|
||||||
|
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
let fileBuffer: Buffer | null = null;
|
||||||
|
let filename = "image";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parts = request.parts();
|
||||||
|
for await (const part of parts) {
|
||||||
|
if (part.type === "file") {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
for await (const chunk of part.file) {
|
||||||
|
chunks.push(chunk);
|
||||||
|
}
|
||||||
|
fileBuffer = Buffer.concat(chunks);
|
||||||
|
filename = basename(part.filename ?? "image");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
return reply.status(400).send({
|
||||||
|
error: "Failed to parse multipart request",
|
||||||
|
details: err instanceof Error ? err.message : String(err),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fileBuffer || fileBuffer.length === 0) {
|
||||||
|
return reply.status(400).send({ error: "No image file provided" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const metadata = await sharp(fileBuffer).metadata();
|
||||||
|
|
||||||
|
const result: Record<string, unknown> = {
|
||||||
|
filename,
|
||||||
|
fileSize: fileBuffer.length,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse EXIF
|
||||||
|
if (metadata.exif) {
|
||||||
|
try {
|
||||||
|
const parsed = exifReader(metadata.exif);
|
||||||
|
const exifData: Record<string, unknown> = {};
|
||||||
|
const gpsData: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
if (parsed.Image) {
|
||||||
|
for (const [k, v] of Object.entries(parsed.Image)) {
|
||||||
|
exifData[k] = sanitizeValue(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.Photo) {
|
||||||
|
for (const [k, v] of Object.entries(parsed.Photo)) {
|
||||||
|
exifData[k] = sanitizeValue(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.Iop) {
|
||||||
|
for (const [k, v] of Object.entries(parsed.Iop)) {
|
||||||
|
exifData[k] = sanitizeValue(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.GPSInfo) {
|
||||||
|
for (const [k, v] of Object.entries(parsed.GPSInfo)) {
|
||||||
|
gpsData[k] = sanitizeValue(v);
|
||||||
|
}
|
||||||
|
const coords = parseGpsCoordinates(parsed.GPSInfo as Record<string, unknown>);
|
||||||
|
if (coords.latitude !== null) gpsData["_latitude"] = coords.latitude;
|
||||||
|
if (coords.longitude !== null) gpsData["_longitude"] = coords.longitude;
|
||||||
|
if (coords.altitude !== null) gpsData["_altitude"] = coords.altitude;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(exifData).length > 0) result.exif = exifData;
|
||||||
|
if (Object.keys(gpsData).length > 0) result.gps = gpsData;
|
||||||
|
} catch {
|
||||||
|
result.exif = null;
|
||||||
|
result.exifError = "Failed to parse EXIF data";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse ICC
|
||||||
|
if (metadata.icc) {
|
||||||
|
try {
|
||||||
|
result.icc = parseIccProfile(metadata.icc);
|
||||||
|
} catch {
|
||||||
|
result.icc = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse XMP
|
||||||
|
if (metadata.xmp) {
|
||||||
|
try {
|
||||||
|
result.xmp = parseXmp(metadata.xmp);
|
||||||
|
} catch {
|
||||||
|
result.xmp = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return reply.send(result);
|
||||||
|
} catch (err) {
|
||||||
|
return reply.status(422).send({
|
||||||
|
error: "Failed to read image metadata",
|
||||||
|
details: err instanceof Error ? err.message : "Unknown error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Strip endpoint — processes and returns cleaned image
|
||||||
createToolRoute(app, {
|
createToolRoute(app, {
|
||||||
toolId: "strip-metadata",
|
toolId: "strip-metadata",
|
||||||
settingsSchema,
|
settingsSchema,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ All configuration is done through environment variables. Every variable has a se
|
|||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `PORT` | `1350` | Port the server listens on. The Docker image overrides this to `1349`. |
|
| `PORT` | `1349` | Port the server listens on. |
|
||||||
| `RATE_LIMIT_PER_MIN` | `100` | Maximum requests per minute per IP. |
|
| `RATE_LIMIT_PER_MIN` | `100` | Maximum requests per minute per IP. |
|
||||||
|
|
||||||
### Authentication
|
### Authentication
|
||||||
|
|||||||
@@ -102,17 +102,11 @@ export function BeforeAfterSlider({
|
|||||||
draggable={false}
|
draggable={false}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* After image (clipped, top layer) — checkerboard background shows transparency */}
|
{/* After image (clipped, top layer) */}
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0"
|
className="absolute inset-0 bg-muted/30"
|
||||||
style={{
|
style={{
|
||||||
clipPath: `inset(0 0 0 ${position}%)`,
|
clipPath: `inset(0 0 0 ${position}%)`,
|
||||||
backgroundImage: `linear-gradient(45deg, #ccc 25%, transparent 25%),
|
|
||||||
linear-gradient(-45deg, #ccc 25%, transparent 25%),
|
|
||||||
linear-gradient(45deg, transparent 75%, #ccc 75%),
|
|
||||||
linear-gradient(-45deg, transparent 75%, #ccc 75%)`,
|
|
||||||
backgroundSize: "16px 16px",
|
|
||||||
backgroundPosition: "0 0, 0 8px, 8px -8px, -8px 0px",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
|
|||||||
@@ -27,15 +27,6 @@ export function SideBySideComparison({
|
|||||||
? ((1 - afterSize / beforeSize) * 100).toFixed(1)
|
? ((1 - afterSize / beforeSize) * 100).toFixed(1)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const checkerboard = {
|
|
||||||
backgroundImage: `linear-gradient(45deg, #ccc 25%, transparent 25%),
|
|
||||||
linear-gradient(-45deg, #ccc 25%, transparent 25%),
|
|
||||||
linear-gradient(45deg, transparent 75%, #ccc 75%),
|
|
||||||
linear-gradient(-45deg, transparent 75%, #ccc 75%)`,
|
|
||||||
backgroundSize: "16px 16px",
|
|
||||||
backgroundPosition: "0 0, 0 8px, 8px -8px, -8px 0px",
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center gap-3 w-full max-w-3xl mx-auto">
|
<div className="flex flex-col items-center gap-3 w-full max-w-3xl mx-auto">
|
||||||
{/* Side-by-side images */}
|
{/* Side-by-side images */}
|
||||||
@@ -45,14 +36,11 @@ export function SideBySideComparison({
|
|||||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||||
Original
|
Original
|
||||||
</span>
|
</span>
|
||||||
<div
|
<div className="w-full rounded-lg border border-border overflow-hidden flex items-center justify-center bg-muted/30 p-2 min-h-[200px] max-h-[60vh]">
|
||||||
className="w-full aspect-video rounded-lg border border-border overflow-hidden flex items-center justify-center"
|
|
||||||
style={checkerboard}
|
|
||||||
>
|
|
||||||
<img
|
<img
|
||||||
src={beforeSrc}
|
src={beforeSrc}
|
||||||
alt="Original"
|
alt="Original"
|
||||||
className="max-w-full max-h-full object-contain"
|
className="max-w-full max-h-[56vh] object-contain rounded-sm"
|
||||||
draggable={false}
|
draggable={false}
|
||||||
onLoad={(e) => {
|
onLoad={(e) => {
|
||||||
const img = e.currentTarget;
|
const img = e.currentTarget;
|
||||||
@@ -70,19 +58,16 @@ export function SideBySideComparison({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Resized */}
|
{/* Processed */}
|
||||||
<div className="flex-1 flex flex-col items-center gap-2">
|
<div className="flex-1 flex flex-col items-center gap-2">
|
||||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||||
Resized
|
Processed
|
||||||
</span>
|
</span>
|
||||||
<div
|
<div className="w-full rounded-lg border border-border overflow-hidden flex items-center justify-center bg-muted/30 p-2 min-h-[200px] max-h-[60vh]">
|
||||||
className="w-full aspect-video rounded-lg border border-border overflow-hidden flex items-center justify-center"
|
|
||||||
style={checkerboard}
|
|
||||||
>
|
|
||||||
<img
|
<img
|
||||||
src={afterSrc}
|
src={afterSrc}
|
||||||
alt="Resized"
|
alt="Processed"
|
||||||
className="max-w-full max-h-full object-contain"
|
className="max-w-full max-h-[56vh] object-contain rounded-sm"
|
||||||
draggable={false}
|
draggable={false}
|
||||||
onLoad={(e) => {
|
onLoad={(e) => {
|
||||||
const img = e.currentTarget;
|
const img = e.currentTarget;
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export function ResizeSettings() {
|
|||||||
const { processFiles, processing, error, downloadUrl, progress } =
|
const { processFiles, processing, error, downloadUrl, progress } =
|
||||||
useToolProcessor("resize");
|
useToolProcessor("resize");
|
||||||
|
|
||||||
const [tab, setTab] = useState<ResizeTab>("presets");
|
const [tab, setTab] = useState<ResizeTab>("custom");
|
||||||
const [selectedPreset, setSelectedPreset] = useState<string | null>(null);
|
const [selectedPreset, setSelectedPreset] = useState<string | null>(null);
|
||||||
const [width, setWidth] = useState<string>("");
|
const [width, setWidth] = useState<string>("");
|
||||||
const [height, setHeight] = useState<string>("");
|
const [height, setHeight] = useState<string>("");
|
||||||
@@ -80,15 +80,15 @@ export function ResizeSettings() {
|
|||||||
{/* Tab selector */}
|
{/* Tab selector */}
|
||||||
<div>
|
<div>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<button type="button" onClick={() => setTab("presets")} className={tabClass("presets")}>
|
|
||||||
Presets
|
|
||||||
</button>
|
|
||||||
<button type="button" onClick={() => setTab("custom")} className={tabClass("custom")}>
|
<button type="button" onClick={() => setTab("custom")} className={tabClass("custom")}>
|
||||||
Custom Size
|
Custom Size
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onClick={() => setTab("scale")} className={tabClass("scale")}>
|
<button type="button" onClick={() => setTab("scale")} className={tabClass("scale")}>
|
||||||
Scale
|
Scale
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" onClick={() => setTab("presets")} className={tabClass("presets")}>
|
||||||
|
Presets
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import { useFileStore } from "@/stores/file-store";
|
import { useFileStore } from "@/stores/file-store";
|
||||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||||
import {
|
import {
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
RotateCw,
|
RotateCw,
|
||||||
FlipHorizontal,
|
FlipHorizontal,
|
||||||
FlipVertical,
|
FlipVertical,
|
||||||
|
RotateCcw as ResetIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { ProgressCard } from "@/components/common/progress-card";
|
import { ProgressCard } from "@/components/common/progress-card";
|
||||||
|
|
||||||
@@ -34,12 +35,26 @@ export function RotateSettings({ onPreviewTransform }: RotateSettingsProps) {
|
|||||||
onPreviewTransform?.({ rotate: angle, flipH, flipV });
|
onPreviewTransform?.({ rotate: angle, flipH, flipV });
|
||||||
}, [angle, flipH, flipV, onPreviewTransform]);
|
}, [angle, flipH, flipV, onPreviewTransform]);
|
||||||
|
|
||||||
const rotateLeft = () => setAngle((a) => (a - 90 + 360) % 360);
|
const rotateLeft = () => setAngle((a) => {
|
||||||
const rotateRight = () => setAngle((a) => (a + 90) % 360);
|
const next = a - 90;
|
||||||
|
return next < -180 ? next + 360 : next;
|
||||||
|
});
|
||||||
|
const rotateRight = () => setAngle((a) => {
|
||||||
|
const next = a + 90;
|
||||||
|
return next > 180 ? next - 360 : next;
|
||||||
|
});
|
||||||
|
|
||||||
|
const setAngleClamped = useCallback((val: number) => {
|
||||||
|
// Clamp to -180..180
|
||||||
|
const clamped = Math.max(-180, Math.min(180, Math.round(val)));
|
||||||
|
setAngle(clamped);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleProcess = () => {
|
const handleProcess = () => {
|
||||||
|
// Convert -180..180 to 0..360 for the backend
|
||||||
|
const backendAngle = angle < 0 ? angle + 360 : angle;
|
||||||
processFiles(files, {
|
processFiles(files, {
|
||||||
angle,
|
angle: backendAngle,
|
||||||
horizontal: flipH,
|
horizontal: flipH,
|
||||||
vertical: flipV,
|
vertical: flipV,
|
||||||
});
|
});
|
||||||
@@ -53,6 +68,12 @@ export function RotateSettings({ onPreviewTransform }: RotateSettingsProps) {
|
|||||||
if (hasFile && hasChanges && !processing) handleProcess();
|
if (hasFile && hasChanges && !processing) handleProcess();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
setAngle(0);
|
||||||
|
setFlipH(false);
|
||||||
|
setFlipV(false);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
{/* Quick rotate buttons */}
|
{/* Quick rotate buttons */}
|
||||||
@@ -65,7 +86,7 @@ export function RotateSettings({ onPreviewTransform }: RotateSettingsProps) {
|
|||||||
className="flex-1 flex items-center justify-center gap-1 py-2 rounded bg-muted text-muted-foreground hover:bg-primary hover:text-primary-foreground transition-colors text-sm"
|
className="flex-1 flex items-center justify-center gap-1 py-2 rounded bg-muted text-muted-foreground hover:bg-primary hover:text-primary-foreground transition-colors text-sm"
|
||||||
>
|
>
|
||||||
<RotateCcw className="h-4 w-4" />
|
<RotateCcw className="h-4 w-4" />
|
||||||
90 Left
|
90° Left
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -73,25 +94,51 @@ export function RotateSettings({ onPreviewTransform }: RotateSettingsProps) {
|
|||||||
className="flex-1 flex items-center justify-center gap-1 py-2 rounded bg-muted text-muted-foreground hover:bg-primary hover:text-primary-foreground transition-colors text-sm"
|
className="flex-1 flex items-center justify-center gap-1 py-2 rounded bg-muted text-muted-foreground hover:bg-primary hover:text-primary-foreground transition-colors text-sm"
|
||||||
>
|
>
|
||||||
<RotateCw className="h-4 w-4" />
|
<RotateCw className="h-4 w-4" />
|
||||||
90 Right
|
90° Right
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Angle slider */}
|
{/* Angle control */}
|
||||||
<div>
|
<div>
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<label className="text-xs text-muted-foreground">Angle</label>
|
<label className="text-xs text-muted-foreground">Fine Angle</label>
|
||||||
<span className="text-xs font-mono text-foreground">{angle} deg</span>
|
<div className="flex items-center gap-1.5">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={angle}
|
||||||
|
onChange={(e) => setAngleClamped(Number(e.target.value))}
|
||||||
|
min={-180}
|
||||||
|
max={180}
|
||||||
|
className="w-16 px-1.5 py-0.5 rounded border border-border bg-background text-xs text-foreground text-right font-mono tabular-nums"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground">°</span>
|
||||||
|
{angle !== 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setAngle(0)}
|
||||||
|
className="p-0.5 rounded hover:bg-muted text-muted-foreground hover:text-foreground"
|
||||||
|
title="Reset angle"
|
||||||
|
>
|
||||||
|
<ResetIcon className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min={0}
|
min={-180}
|
||||||
max={360}
|
max={180}
|
||||||
|
step={1}
|
||||||
value={angle}
|
value={angle}
|
||||||
onChange={(e) => setAngle(Number(e.target.value))}
|
onChange={(e) => setAngle(Number(e.target.value))}
|
||||||
className="w-full mt-1"
|
className="w-full mt-1"
|
||||||
/>
|
/>
|
||||||
|
<div className="flex justify-between text-[10px] text-muted-foreground mt-0.5">
|
||||||
|
<span>-180°</span>
|
||||||
|
<span>0°</span>
|
||||||
|
<span>180°</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Flip buttons */}
|
{/* Flip buttons */}
|
||||||
@@ -125,6 +172,17 @@ export function RotateSettings({ onPreviewTransform }: RotateSettingsProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Reset all */}
|
||||||
|
{hasChanges && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleReset}
|
||||||
|
className="w-full text-xs text-muted-foreground hover:text-foreground py-1"
|
||||||
|
>
|
||||||
|
Reset all changes
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Error */}
|
{/* Error */}
|
||||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,154 @@
|
|||||||
import { useState } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { useFileStore } from "@/stores/file-store";
|
import { useFileStore } from "@/stores/file-store";
|
||||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||||
import { Download } from "lucide-react";
|
import { Download, ChevronDown, ChevronRight, Loader2, MapPin, AlertTriangle } from "lucide-react";
|
||||||
import { ProgressCard } from "@/components/common/progress-card";
|
import { ProgressCard } from "@/components/common/progress-card";
|
||||||
|
|
||||||
|
function getToken(): string {
|
||||||
|
return localStorage.getItem("stirling-token") || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MetadataResult {
|
||||||
|
filename: string;
|
||||||
|
fileSize: number;
|
||||||
|
exif?: Record<string, unknown> | null;
|
||||||
|
exifError?: string;
|
||||||
|
gps?: Record<string, unknown> | null;
|
||||||
|
icc?: Record<string, string> | null;
|
||||||
|
xmp?: Record<string, string> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Human-friendly labels for common EXIF keys */
|
||||||
|
const EXIF_LABELS: Record<string, string> = {
|
||||||
|
Make: "Camera Make",
|
||||||
|
Model: "Camera Model",
|
||||||
|
Software: "Software",
|
||||||
|
DateTime: "Date/Time",
|
||||||
|
DateTimeOriginal: "Date Taken",
|
||||||
|
DateTimeDigitized: "Date Digitized",
|
||||||
|
ExposureTime: "Exposure Time",
|
||||||
|
FNumber: "F-Number",
|
||||||
|
ISOSpeedRatings: "ISO",
|
||||||
|
FocalLength: "Focal Length",
|
||||||
|
FocalLengthIn35mmFilm: "Focal Length (35mm)",
|
||||||
|
ExposureBiasValue: "Exposure Bias",
|
||||||
|
MeteringMode: "Metering Mode",
|
||||||
|
Flash: "Flash",
|
||||||
|
WhiteBalance: "White Balance",
|
||||||
|
ExposureMode: "Exposure Mode",
|
||||||
|
SceneCaptureType: "Scene Type",
|
||||||
|
Contrast: "Contrast",
|
||||||
|
Saturation: "Saturation",
|
||||||
|
Sharpness: "Sharpness",
|
||||||
|
DigitalZoomRatio: "Digital Zoom",
|
||||||
|
ImageWidth: "Width",
|
||||||
|
ImageLength: "Height",
|
||||||
|
Orientation: "Orientation",
|
||||||
|
XResolution: "X Resolution",
|
||||||
|
YResolution: "Y Resolution",
|
||||||
|
ResolutionUnit: "Resolution Unit",
|
||||||
|
ColorSpace: "Color Space",
|
||||||
|
PixelXDimension: "Pixel Width",
|
||||||
|
PixelYDimension: "Pixel Height",
|
||||||
|
Artist: "Artist",
|
||||||
|
Copyright: "Copyright",
|
||||||
|
ImageDescription: "Description",
|
||||||
|
LensMake: "Lens Make",
|
||||||
|
LensModel: "Lens Model",
|
||||||
|
BodySerialNumber: "Body Serial",
|
||||||
|
CameraOwnerName: "Camera Owner",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Keys to skip in display (internal/binary/redundant) */
|
||||||
|
const SKIP_KEYS = new Set([
|
||||||
|
"ExifTag", "GPSTag", "InteroperabilityTag", "MakerNote",
|
||||||
|
"PrintImageMatching", "ComponentsConfiguration", "FlashpixVersion",
|
||||||
|
"ExifVersion", "FileSource", "SceneType", "UserComment",
|
||||||
|
"InteroperabilityIndex", "InteroperabilityVersion",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function formatExifValue(key: string, value: unknown): string {
|
||||||
|
if (value === null || value === undefined) return "N/A";
|
||||||
|
if (typeof value === "string") return value;
|
||||||
|
if (typeof value === "number") {
|
||||||
|
if (key === "ExposureTime" && value > 0 && value < 1) {
|
||||||
|
return `1/${Math.round(1 / value)}s`;
|
||||||
|
}
|
||||||
|
if (key === "FNumber") return `f/${value}`;
|
||||||
|
if (key === "FocalLength") return `${value}mm`;
|
||||||
|
if (key === "FocalLengthIn35mmFilm") return `${value}mm`;
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
if (typeof value[0] === "number" && value.length <= 4) {
|
||||||
|
return value.join(", ");
|
||||||
|
}
|
||||||
|
return `[${value.length} values]`;
|
||||||
|
}
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CollapsibleSection({
|
||||||
|
title,
|
||||||
|
badge,
|
||||||
|
warning,
|
||||||
|
defaultOpen,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
badge?: string;
|
||||||
|
warning?: boolean;
|
||||||
|
defaultOpen?: boolean;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(defaultOpen ?? false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border border-border rounded-lg overflow-hidden">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen(!open)}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2 text-xs font-medium text-foreground hover:bg-muted/50 transition-colors"
|
||||||
|
>
|
||||||
|
{open ? <ChevronDown className="h-3 w-3 shrink-0" /> : <ChevronRight className="h-3 w-3 shrink-0" />}
|
||||||
|
<span className="flex-1 text-left">{title}</span>
|
||||||
|
{warning && <AlertTriangle className="h-3 w-3 text-amber-500 shrink-0" />}
|
||||||
|
{badge && (
|
||||||
|
<span className="px-1.5 py-0.5 rounded bg-muted text-muted-foreground text-[10px]">
|
||||||
|
{badge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{open && <div className="px-3 pb-2">{children}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MetadataGrid({ data, labelMap }: { data: Record<string, unknown>; labelMap?: Record<string, string> }) {
|
||||||
|
const entries = Object.entries(data).filter(
|
||||||
|
([k, v]) => !SKIP_KEYS.has(k) && !k.startsWith("_") && v !== undefined && v !== null && String(v) !== ""
|
||||||
|
);
|
||||||
|
|
||||||
|
if (entries.length === 0) {
|
||||||
|
return <p className="text-[10px] text-muted-foreground italic">No data</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-[minmax(0,2fr)_minmax(0,3fr)] gap-x-2 gap-y-0.5">
|
||||||
|
{entries.map(([k, v]) => (
|
||||||
|
<div key={k} className="contents">
|
||||||
|
<div className="text-[10px] text-muted-foreground truncate" title={k}>
|
||||||
|
{labelMap?.[k] ?? k}
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-foreground font-mono truncate" title={formatExifValue(k, v)}>
|
||||||
|
{formatExifValue(k, v)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function StripMetadataSettings() {
|
export function StripMetadataSettings() {
|
||||||
const { files } = useFileStore();
|
const { files } = useFileStore();
|
||||||
const { processFiles, processing, error, downloadUrl, originalSize, processedSize, progress } =
|
const { processFiles, processing, error, downloadUrl, originalSize, processedSize, progress } =
|
||||||
@@ -15,6 +160,56 @@ export function StripMetadataSettings() {
|
|||||||
const [stripIcc, setStripIcc] = useState(false);
|
const [stripIcc, setStripIcc] = useState(false);
|
||||||
const [stripXmp, setStripXmp] = useState(false);
|
const [stripXmp, setStripXmp] = useState(false);
|
||||||
|
|
||||||
|
const [metadata, setMetadata] = useState<MetadataResult | null>(null);
|
||||||
|
const [inspecting, setInspecting] = useState(false);
|
||||||
|
const [inspectError, setInspectError] = useState<string | null>(null);
|
||||||
|
const lastInspectedFile = useRef<string | null>(null);
|
||||||
|
|
||||||
|
// Auto-fetch metadata when files change
|
||||||
|
useEffect(() => {
|
||||||
|
if (files.length === 0) {
|
||||||
|
setMetadata(null);
|
||||||
|
setInspectError(null);
|
||||||
|
lastInspectedFile.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = files[0];
|
||||||
|
const fileKey = `${file.name}-${file.size}-${file.lastModified}`;
|
||||||
|
if (lastInspectedFile.current === fileKey) return;
|
||||||
|
lastInspectedFile.current = fileKey;
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
(async () => {
|
||||||
|
setInspecting(true);
|
||||||
|
setInspectError(null);
|
||||||
|
setMetadata(null);
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
const res = await fetch("/api/v1/tools/strip-metadata/inspect", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Authorization: `Bearer ${getToken()}` },
|
||||||
|
body: formData,
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(body.error || `Failed: ${res.status}`);
|
||||||
|
}
|
||||||
|
const data: MetadataResult = await res.json();
|
||||||
|
setMetadata(data);
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as Error).name === "AbortError") return;
|
||||||
|
setInspectError(err instanceof Error ? err.message : "Failed to inspect metadata");
|
||||||
|
} finally {
|
||||||
|
setInspecting(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [files]);
|
||||||
|
|
||||||
const handleStripAllChange = (checked: boolean) => {
|
const handleStripAllChange = (checked: boolean) => {
|
||||||
setStripAll(checked);
|
setStripAll(checked);
|
||||||
if (checked) {
|
if (checked) {
|
||||||
@@ -36,8 +231,91 @@ export function StripMetadataSettings() {
|
|||||||
if (hasFile && !processing) handleProcess();
|
if (hasFile && !processing) handleProcess();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const hasExif = metadata?.exif && Object.keys(metadata.exif).length > 0;
|
||||||
|
const hasGps = metadata?.gps && Object.keys(metadata.gps).length > 0;
|
||||||
|
const hasIcc = metadata?.icc && Object.keys(metadata.icc).length > 0;
|
||||||
|
const hasXmp = metadata?.xmp && Object.keys(metadata.xmp).length > 0;
|
||||||
|
const hasAnyMetadata = hasExif || hasGps || hasIcc || hasXmp;
|
||||||
|
const sectionCount = [hasExif, hasGps, hasIcc, hasXmp].filter(Boolean).length;
|
||||||
|
|
||||||
|
// GPS coordinates for display
|
||||||
|
const gpsLat = metadata?.gps?.["_latitude"] as number | undefined;
|
||||||
|
const gpsLon = metadata?.gps?.["_longitude"] as number | undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
{/* Metadata Display */}
|
||||||
|
{hasFile && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs font-medium text-muted-foreground">Current Metadata</label>
|
||||||
|
|
||||||
|
{inspecting && (
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground py-2">
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
|
Reading metadata...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{inspectError && (
|
||||||
|
<p className="text-[10px] text-red-500">{inspectError}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{metadata && !hasAnyMetadata && !inspecting && (
|
||||||
|
<p className="text-xs text-muted-foreground italic py-1">
|
||||||
|
No metadata found in this image.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{metadata && hasAnyMetadata && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{/* GPS warning banner */}
|
||||||
|
{hasGps && gpsLat !== undefined && gpsLon !== undefined && (
|
||||||
|
<div className="flex items-center gap-2 px-2.5 py-1.5 rounded-md bg-amber-500/10 border border-amber-500/20">
|
||||||
|
<MapPin className="h-3 w-3 text-amber-500 shrink-0" />
|
||||||
|
<span className="text-[10px] text-amber-600 dark:text-amber-400 font-medium">
|
||||||
|
Location data: {gpsLat.toFixed(4)}, {gpsLon.toFixed(4)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasExif && (
|
||||||
|
<CollapsibleSection
|
||||||
|
title="EXIF"
|
||||||
|
badge={`${Object.keys(metadata.exif!).filter(k => !SKIP_KEYS.has(k) && !k.startsWith("_")).length} fields`}
|
||||||
|
defaultOpen
|
||||||
|
>
|
||||||
|
<MetadataGrid data={metadata.exif!} labelMap={EXIF_LABELS} />
|
||||||
|
</CollapsibleSection>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasGps && (
|
||||||
|
<CollapsibleSection title="GPS" warning badge={`${Object.keys(metadata.gps!).filter(k => !k.startsWith("_")).length} fields`}>
|
||||||
|
<MetadataGrid data={metadata.gps!} />
|
||||||
|
</CollapsibleSection>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasIcc && (
|
||||||
|
<CollapsibleSection title="ICC Profile" badge={`${Object.keys(metadata.icc!).length} fields`}>
|
||||||
|
<MetadataGrid data={metadata.icc!} />
|
||||||
|
</CollapsibleSection>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasXmp && (
|
||||||
|
<CollapsibleSection title="XMP" badge={`${Object.keys(metadata.xmp!).length} fields`}>
|
||||||
|
<MetadataGrid data={metadata.xmp!} />
|
||||||
|
</CollapsibleSection>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
{sectionCount} metadata {sectionCount === 1 ? "section" : "sections"} found
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasFile && hasAnyMetadata && <div className="border-t border-border" />}
|
||||||
|
|
||||||
{/* Strip All */}
|
{/* Strip All */}
|
||||||
<label className="flex items-center gap-2 text-sm text-foreground font-medium">
|
<label className="flex items-center gap-2 text-sm text-foreground font-medium">
|
||||||
<input
|
<input
|
||||||
@@ -64,6 +342,9 @@ export function StripMetadataSettings() {
|
|||||||
className="rounded"
|
className="rounded"
|
||||||
/>
|
/>
|
||||||
Strip EXIF (camera info, date, exposure)
|
Strip EXIF (camera info, date, exposure)
|
||||||
|
{hasExif && !stripAll && (
|
||||||
|
<span className="ml-auto text-[10px] text-muted-foreground">{Object.keys(metadata!.exif!).filter(k => !SKIP_KEYS.has(k)).length} fields</span>
|
||||||
|
)}
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className={`flex items-center gap-2 text-sm ${stripAll ? "text-muted-foreground" : "text-foreground"}`}>
|
<label className={`flex items-center gap-2 text-sm ${stripAll ? "text-muted-foreground" : "text-foreground"}`}>
|
||||||
@@ -75,6 +356,9 @@ export function StripMetadataSettings() {
|
|||||||
className="rounded"
|
className="rounded"
|
||||||
/>
|
/>
|
||||||
Strip GPS (location data)
|
Strip GPS (location data)
|
||||||
|
{hasGps && !stripAll && (
|
||||||
|
<span className="ml-auto text-[10px] text-amber-500">location found</span>
|
||||||
|
)}
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className={`flex items-center gap-2 text-sm ${stripAll ? "text-muted-foreground" : "text-foreground"}`}>
|
<label className={`flex items-center gap-2 text-sm ${stripAll ? "text-muted-foreground" : "text-foreground"}`}>
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ const COLOR_TOOL_IDS = new Set([
|
|||||||
|
|
||||||
// Tools that don't need a file dropzone (they generate content or have custom UI)
|
// Tools that don't need a file dropzone (they generate content or have custom UI)
|
||||||
const NO_DROPZONE_TOOLS = new Set(["qr-generate"]);
|
const NO_DROPZONE_TOOLS = new Set(["qr-generate"]);
|
||||||
const SIDE_BY_SIDE_TOOLS = new Set(["resize"]);
|
const SIDE_BY_SIDE_TOOLS = new Set(["resize", "crop"]);
|
||||||
const LIVE_PREVIEW_TOOLS = new Set(["rotate"]);
|
const LIVE_PREVIEW_TOOLS = new Set(["rotate"]);
|
||||||
|
|
||||||
function ToolSettingsPanel({
|
function ToolSettingsPanel({
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export default defineConfig({
|
|||||||
server: {
|
server: {
|
||||||
port: 1349,
|
port: 1349,
|
||||||
proxy: {
|
proxy: {
|
||||||
"/api": "http://localhost:1350",
|
"/api": "http://localhost:13490",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ export async function compress(image: Sharp, options: CompressOptions): Promise<
|
|||||||
if (targetSizeBytes <= 0) {
|
if (targetSizeBytes <= 0) {
|
||||||
throw new Error("Target size must be greater than 0");
|
throw new Error("Target size must be greater than 0");
|
||||||
}
|
}
|
||||||
return compressToTargetSize(image, outputFormat, targetSizeBytes);
|
const inputBuffer = await image.toBuffer();
|
||||||
|
return compressToTargetSize(inputBuffer, outputFormat, targetSizeBytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
const q = quality ?? 80;
|
const q = quality ?? 80;
|
||||||
@@ -34,16 +35,13 @@ export async function compress(image: Sharp, options: CompressOptions): Promise<
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function compressToTargetSize(
|
async function compressToTargetSize(
|
||||||
image: Sharp,
|
inputBuffer: Buffer,
|
||||||
format: keyof import("sharp").FormatEnum,
|
format: keyof import("sharp").FormatEnum,
|
||||||
targetBytes: number
|
targetBytes: number
|
||||||
): Promise<Sharp> {
|
): Promise<Sharp> {
|
||||||
// Get the raw buffer to re-create Sharp instances for each attempt
|
|
||||||
const inputBuffer = await image.toBuffer();
|
|
||||||
|
|
||||||
let low = 1;
|
let low = 1;
|
||||||
let high = 100;
|
let high = 100;
|
||||||
let bestQuality = 80;
|
let bestQuality = 1;
|
||||||
let bestBuffer: Buffer | null = null;
|
let bestBuffer: Buffer | null = null;
|
||||||
const maxIterations = 8;
|
const maxIterations = 8;
|
||||||
const tolerance = 0.05; // 5%
|
const tolerance = 0.05; // 5%
|
||||||
@@ -69,12 +67,13 @@ async function compressToTargetSize(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we never found a suitable buffer, compress at the best quality we found
|
// If we never found a suitable buffer, compress at lowest quality found
|
||||||
if (bestBuffer === null) {
|
if (bestBuffer === null) {
|
||||||
bestBuffer = await sharp(inputBuffer)
|
bestBuffer = await sharp(inputBuffer)
|
||||||
.toFormat(format, { quality: bestQuality })
|
.toFormat(format, { quality: bestQuality })
|
||||||
.toBuffer();
|
.toBuffer();
|
||||||
}
|
}
|
||||||
|
|
||||||
return sharp(bestBuffer);
|
// Preserve format + quality so the caller's .toBuffer() doesn't re-encode at defaults
|
||||||
|
return sharp(bestBuffer).toFormat(format, { quality: bestQuality });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,9 +42,10 @@ export default defineConfig({
|
|||||||
webServer: [
|
webServer: [
|
||||||
{
|
{
|
||||||
command: "pnpm --filter @stirling-image/api dev",
|
command: "pnpm --filter @stirling-image/api dev",
|
||||||
port: 1350,
|
port: 13490,
|
||||||
reuseExistingServer: !process.env.CI,
|
reuseExistingServer: !process.env.CI,
|
||||||
env: {
|
env: {
|
||||||
|
PORT: "13490",
|
||||||
AUTH_ENABLED: "true",
|
AUTH_ENABLED: "true",
|
||||||
DEFAULT_USERNAME: "admin",
|
DEFAULT_USERNAME: "admin",
|
||||||
DEFAULT_PASSWORD: "admin",
|
DEFAULT_PASSWORD: "admin",
|
||||||
|
|||||||
Generated
+8
@@ -101,6 +101,9 @@ importers:
|
|||||||
drizzle-orm:
|
drizzle-orm:
|
||||||
specifier: ^0.38.0
|
specifier: ^0.38.0
|
||||||
version: 0.38.4(@types/better-sqlite3@7.6.13)(@types/react@19.2.14)(better-sqlite3@11.10.0)(react@19.2.4)
|
version: 0.38.4(@types/better-sqlite3@7.6.13)(@types/react@19.2.14)(better-sqlite3@11.10.0)(react@19.2.4)
|
||||||
|
exif-reader:
|
||||||
|
specifier: ^2.0.3
|
||||||
|
version: 2.0.3
|
||||||
fastify:
|
fastify:
|
||||||
specifier: ^5.2.0
|
specifier: ^5.2.0
|
||||||
version: 5.8.2
|
version: 5.8.2
|
||||||
@@ -3207,6 +3210,9 @@ packages:
|
|||||||
exif-parser@0.1.12:
|
exif-parser@0.1.12:
|
||||||
resolution: {integrity: sha512-c2bQfLNbMzLPmzQuOr8fy0csy84WmwnER81W88DzTp9CYNPJ6yzOj2EZAh9pywYpqHnshVLHQJ8WzldAyfY+Iw==}
|
resolution: {integrity: sha512-c2bQfLNbMzLPmzQuOr8fy0csy84WmwnER81W88DzTp9CYNPJ6yzOj2EZAh9pywYpqHnshVLHQJ8WzldAyfY+Iw==}
|
||||||
|
|
||||||
|
exif-reader@2.0.3:
|
||||||
|
resolution: {integrity: sha512-zFbQvguwT9JkqyYhR7pjE1Yn8SagwaGLNRU0Oh14xFa1paSf5Gzxn4gxgk0XhnudI0UIqU+HgnBX93+nva592A==}
|
||||||
|
|
||||||
expand-template@2.0.3:
|
expand-template@2.0.3:
|
||||||
resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==}
|
resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -8220,6 +8226,8 @@ snapshots:
|
|||||||
|
|
||||||
exif-parser@0.1.12: {}
|
exif-parser@0.1.12: {}
|
||||||
|
|
||||||
|
exif-reader@2.0.3: {}
|
||||||
|
|
||||||
expand-template@2.0.3: {}
|
expand-template@2.0.3: {}
|
||||||
|
|
||||||
expect-type@1.3.0: {}
|
expect-type@1.3.0: {}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { test, expect } from "@playwright/test";
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import { getTestImagePath } from "./helpers";
|
import { getTestImagePath } from "./helpers";
|
||||||
|
|
||||||
const API = "http://localhost:1350";
|
const API = "http://localhost:13490";
|
||||||
|
|
||||||
async function getAuthToken(): Promise<string> {
|
async function getAuthToken(): Promise<string> {
|
||||||
const res = await fetch(`${API}/api/auth/login`, {
|
const res = await fetch(`${API}/api/auth/login`, {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { getTestImagePath } from "./helpers";
|
|||||||
// auth token handling, and unauthenticated access.
|
// auth token handling, and unauthenticated access.
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const API = "http://localhost:1350";
|
const API = "http://localhost:13490";
|
||||||
|
|
||||||
async function getAuthToken(): Promise<string> {
|
async function getAuthToken(): Promise<string> {
|
||||||
const res = await fetch(`${API}/api/auth/login`, {
|
const res = await fetch(`${API}/api/auth/login`, {
|
||||||
|
|||||||
Reference in New Issue
Block a user