diff --git a/.env.example b/.env.example index f200512d..ac6a1231 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,6 @@ -# In dev: API runs on this port, UI on 1349 (Vite proxies /api here) -# In Docker/production: Fastify serves everything on 1349 (set PORT=1349) -PORT=1350 +# Server port (used in production / Docker) +# In dev, the API auto-starts on an internal port; you always access localhost:1349 +PORT=1349 AUTH_ENABLED=true DEFAULT_USERNAME=admin DEFAULT_PASSWORD=admin diff --git a/apps/api/package.json b/apps/api/package.json index 3ba183c4..5105a39c 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "scripts": { - "dev": "tsx watch src/index.ts", + "dev": "PORT=13490 tsx watch src/index.ts", "build": "tsc", "start": "tsx src/index.ts", "typecheck": "tsc --noEmit", @@ -24,6 +24,7 @@ "better-sqlite3": "^11.7.0", "dotenv": "^16.4.0", "drizzle-orm": "^0.38.0", + "exif-reader": "^2.0.3", "fastify": "^5.2.0", "jsqr": "^1.4.0", "p-queue": "^9.1.0", diff --git a/apps/api/src/lib/env.ts b/apps/api/src/lib/env.ts index 4d6c9540..ff091150 100644 --- a/apps/api/src/lib/env.ts +++ b/apps/api/src/lib/env.ts @@ -1,7 +1,7 @@ import { z } from "zod"; const envSchema = z.object({ - PORT: z.coerce.number().default(1350), + PORT: z.coerce.number().default(1349), AUTH_ENABLED: z .enum(["true", "false"]) .default("true") diff --git a/apps/api/src/routes/tool-factory.ts b/apps/api/src/routes/tool-factory.ts index 036eca57..501bf3bd 100644 --- a/apps/api/src/routes/tool-factory.ts +++ b/apps/api/src/routes/tool-factory.ts @@ -3,6 +3,7 @@ import { writeFile } from "node:fs/promises"; import { join } from "node:path"; import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify"; import { z } from "zod"; +import sharp from "sharp"; import { createWorkspace } from "../lib/workspace.js"; import { validateImageBuffer } from "../lib/file-validation.js"; import { sanitizeFilename } from "../lib/filename.js"; @@ -125,9 +126,24 @@ export function createToolRoute( 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 try { - const result = await config.process(fileBuffer, settings, filename); + const result = await config.process(processBuffer, settings, filename); // Create workspace and save output const jobId = randomUUID(); diff --git a/apps/api/src/routes/tools/strip-metadata.ts b/apps/api/src/routes/tools/strip-metadata.ts index 9b83ca93..083d7947 100644 --- a/apps/api/src/routes/tools/strip-metadata.ts +++ b/apps/api/src/routes/tools/strip-metadata.ts @@ -2,7 +2,9 @@ import { z } from "zod"; import { createToolRoute } from "../tool-factory.js"; import { stripMetadata } from "@stirling-image/image-engine"; 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({ stripExif: z.boolean().default(false), @@ -12,7 +14,264 @@ const settingsSchema = z.object({ 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 ``; + return Array.from(v); + } + if (Array.isArray(v)) return v.map(sanitizeValue); + if (v !== null && typeof v === "object") { + const out: Record = {}; + 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): { + 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 { + const xml = xmpBuffer.toString("utf-8"); + const result: Record = {}; + + 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 { + const info: Record = {}; + + 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 = { + 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) { + // 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 = { + filename, + fileSize: fileBuffer.length, + }; + + // Parse EXIF + if (metadata.exif) { + try { + const parsed = exifReader(metadata.exif); + const exifData: Record = {}; + const gpsData: Record = {}; + + 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); + 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, { toolId: "strip-metadata", settingsSchema, diff --git a/apps/docs/guide/configuration.md b/apps/docs/guide/configuration.md index 9462e942..712bcb68 100644 --- a/apps/docs/guide/configuration.md +++ b/apps/docs/guide/configuration.md @@ -8,7 +8,7 @@ All configuration is done through environment variables. Every variable has a se | 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. | ### Authentication diff --git a/apps/web/src/components/common/before-after-slider.tsx b/apps/web/src/components/common/before-after-slider.tsx index 1f163cdb..dfa9c37b 100644 --- a/apps/web/src/components/common/before-after-slider.tsx +++ b/apps/web/src/components/common/before-after-slider.tsx @@ -102,17 +102,11 @@ export function BeforeAfterSlider({ draggable={false} /> - {/* After image (clipped, top layer) — checkerboard background shows transparency */} + {/* After image (clipped, top layer) */}
{/* Side-by-side images */} @@ -45,14 +36,11 @@ export function SideBySideComparison({ Original -
+
Original { const img = e.currentTarget; @@ -70,19 +58,16 @@ export function SideBySideComparison({
- {/* Resized */} + {/* Processed */}
- Resized + Processed -
+
Resized { const img = e.currentTarget; diff --git a/apps/web/src/components/tools/resize-settings.tsx b/apps/web/src/components/tools/resize-settings.tsx index a70aa4ae..a9319048 100644 --- a/apps/web/src/components/tools/resize-settings.tsx +++ b/apps/web/src/components/tools/resize-settings.tsx @@ -22,7 +22,7 @@ export function ResizeSettings() { const { processFiles, processing, error, downloadUrl, progress } = useToolProcessor("resize"); - const [tab, setTab] = useState("presets"); + const [tab, setTab] = useState("custom"); const [selectedPreset, setSelectedPreset] = useState(null); const [width, setWidth] = useState(""); const [height, setHeight] = useState(""); @@ -80,15 +80,15 @@ export function ResizeSettings() { {/* Tab selector */}
- +
diff --git a/apps/web/src/components/tools/rotate-settings.tsx b/apps/web/src/components/tools/rotate-settings.tsx index 9a23b734..ba809a03 100644 --- a/apps/web/src/components/tools/rotate-settings.tsx +++ b/apps/web/src/components/tools/rotate-settings.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useCallback } from "react"; import { useFileStore } from "@/stores/file-store"; import { useToolProcessor } from "@/hooks/use-tool-processor"; import { @@ -7,6 +7,7 @@ import { RotateCw, FlipHorizontal, FlipVertical, + RotateCcw as ResetIcon, } from "lucide-react"; import { ProgressCard } from "@/components/common/progress-card"; @@ -34,12 +35,26 @@ export function RotateSettings({ onPreviewTransform }: RotateSettingsProps) { onPreviewTransform?.({ rotate: angle, flipH, flipV }); }, [angle, flipH, flipV, onPreviewTransform]); - const rotateLeft = () => setAngle((a) => (a - 90 + 360) % 360); - const rotateRight = () => setAngle((a) => (a + 90) % 360); + const rotateLeft = () => setAngle((a) => { + 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 = () => { + // Convert -180..180 to 0..360 for the backend + const backendAngle = angle < 0 ? angle + 360 : angle; processFiles(files, { - angle, + angle: backendAngle, horizontal: flipH, vertical: flipV, }); @@ -53,6 +68,12 @@ export function RotateSettings({ onPreviewTransform }: RotateSettingsProps) { if (hasFile && hasChanges && !processing) handleProcess(); }; + const handleReset = () => { + setAngle(0); + setFlipH(false); + setFlipV(false); + }; + return (
{/* 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" > - 90 Left + 90° Left
- {/* Angle slider */} + {/* Angle control */}
- - {angle} deg + +
+ 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" + /> + ° + {angle !== 0 && ( + + )} +
setAngle(Number(e.target.value))} className="w-full mt-1" /> +
+ -180° + + 180° +
{/* Flip buttons */} @@ -125,6 +172,17 @@ export function RotateSettings({ onPreviewTransform }: RotateSettingsProps) {
+ {/* Reset all */} + {hasChanges && ( + + )} + {/* Error */} {error &&

{error}

} diff --git a/apps/web/src/components/tools/strip-metadata-settings.tsx b/apps/web/src/components/tools/strip-metadata-settings.tsx index 125c414e..6d0ac67b 100644 --- a/apps/web/src/components/tools/strip-metadata-settings.tsx +++ b/apps/web/src/components/tools/strip-metadata-settings.tsx @@ -1,9 +1,154 @@ -import { useState } from "react"; +import { useState, useEffect, useRef } from "react"; import { useFileStore } from "@/stores/file-store"; 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"; +function getToken(): string { + return localStorage.getItem("stirling-token") || ""; +} + +interface MetadataResult { + filename: string; + fileSize: number; + exif?: Record | null; + exifError?: string; + gps?: Record | null; + icc?: Record | null; + xmp?: Record | null; +} + +/** Human-friendly labels for common EXIF keys */ +const EXIF_LABELS: Record = { + 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 ( +
+ + {open &&
{children}
} +
+ ); +} + +function MetadataGrid({ data, labelMap }: { data: Record; labelMap?: Record }) { + 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

No data

; + } + + return ( +
+ {entries.map(([k, v]) => ( +
+
+ {labelMap?.[k] ?? k} +
+
+ {formatExifValue(k, v)} +
+
+ ))} +
+ ); +} + export function StripMetadataSettings() { const { files } = useFileStore(); const { processFiles, processing, error, downloadUrl, originalSize, processedSize, progress } = @@ -15,6 +160,56 @@ export function StripMetadataSettings() { const [stripIcc, setStripIcc] = useState(false); const [stripXmp, setStripXmp] = useState(false); + const [metadata, setMetadata] = useState(null); + const [inspecting, setInspecting] = useState(false); + const [inspectError, setInspectError] = useState(null); + const lastInspectedFile = useRef(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) => { setStripAll(checked); if (checked) { @@ -36,8 +231,91 @@ export function StripMetadataSettings() { 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 ( + {/* Metadata Display */} + {hasFile && ( +
+ + + {inspecting && ( +
+ + Reading metadata... +
+ )} + + {inspectError && ( +

{inspectError}

+ )} + + {metadata && !hasAnyMetadata && !inspecting && ( +

+ No metadata found in this image. +

+ )} + + {metadata && hasAnyMetadata && ( +
+ {/* GPS warning banner */} + {hasGps && gpsLat !== undefined && gpsLon !== undefined && ( +
+ + + Location data: {gpsLat.toFixed(4)}, {gpsLon.toFixed(4)} + +
+ )} + + {hasExif && ( + !SKIP_KEYS.has(k) && !k.startsWith("_")).length} fields`} + defaultOpen + > + + + )} + + {hasGps && ( + !k.startsWith("_")).length} fields`}> + + + )} + + {hasIcc && ( + + + + )} + + {hasXmp && ( + + + + )} + +

+ {sectionCount} metadata {sectionCount === 1 ? "section" : "sections"} found +

+
+ )} +
+ )} + + {hasFile && hasAnyMetadata &&
} + {/* Strip All */}