Add binary RAW inspection and diagnostics v0.8.2

This commit is contained in:
dmo
2026-07-25 08:37:29 +02:00
parent d17bc6a132
commit 2dee67021e
6 changed files with 87 additions and 26 deletions
+1 -1
View File
@@ -31,4 +31,4 @@ DEFAULT_MAP_LON=11.0
DEFAULT_MAP_ZOOM=5
GEOCODER_URL=https://nominatim.openstreetmap.org/search
# Optional custom identification sent to the geocoder. The default uses APP_NAME, version and APP_URL.
# GEOCODER_USER_AGENT=MorkPhotoDateEditor/0.8.1 (+https://edit.mork.fyi)
# GEOCODER_USER_AGENT=MorkPhotoDateEditor/0.8.2 (+https://edit.mork.fyi)
+11
View File
@@ -1,5 +1,16 @@
# Changelog
## 0.8.2
- Return inspection metadata and the JPEG preview in one compact binary response instead of Base64 JSON.
- Remove Base64 response expansion and the corresponding browser decode loop.
- Preserve the detailed save timing after the current photo reloads.
- Include total, server/transfer, and local-write durations in the save notification.
- Log end-to-end server duration for `/api/inspect` and `/api/process`.
- Log temporary-copy, preview-extraction, and metadata-read timings for inspections.
- Add request-duration and `Server-Timing` response headers for browser diagnostics.
- Preserve all v0.8.1 RAW opening optimizations and existing format behavior.
## 0.8.1
- Upload server-preview formats only once when opening them, returning the preview and metadata together.
+5 -3
View File
@@ -1,6 +1,6 @@
# Photo Date Editor
Version 0.8.1
Version 0.8.2
A self-hosted browser UI for manually dating scanned photographs. The browser receives temporary read/write access to a local computer or Chromebook folder, sends one image at a time to the Docker backend for ExifTool processing, and overwrites the same local file after processing.
@@ -25,7 +25,7 @@ A self-hosted browser UI for manually dating scanned photographs. The browser re
## Preview behavior
JPG and PNG are displayed directly by the browser. Chromium browsers do not reliably display TIFF, HEIF-family images, DNG, CR2, or CR3, so those formats are temporarily uploaded to `/api/inspect`, which returns both metadata and a browser-friendly JPEG preview from one upload. TIFF uses Pillow, HEIF uses `pillow-heif`, and RAW formats stop at the first usable embedded preview in a container-specific priority order. If none is usable, LibRaw renders a half-size preview through `rawpy`. The preview exists only in memory and is never written over the source file.
JPG and PNG are displayed directly by the browser. Chromium browsers do not reliably display TIFF, HEIF-family images, DNG, CR2, or CR3, so those formats are temporarily uploaded to `/api/inspect`, which returns both metadata and a browser-friendly JPEG preview from one upload. The response uses a compact length-prefixed binary envelope rather than Base64 JSON. TIFF uses Pillow, HEIF uses `pillow-heif`, and RAW formats stop at the first usable embedded preview in a container-specific priority order. If none is usable, LibRaw renders a half-size preview through `rawpy`. The preview exists only in memory and is never written over the source file.
When a TIFF, HEIF-family image, DNG, CR2, or CR3 is saved, ExifTool updates the original format and the browser overwrites the same local file. The original is not converted to JPEG and no additional copy is intentionally left behind.
@@ -142,7 +142,9 @@ The browser directory picker requires a secure context. Use a certificate truste
No second photo is intentionally left on the client computer or Docker host.
RAW saves still require the complete original to travel to Docker and the complete rewritten file to return to the browser. ExifTool must also rewrite the metadata-bearing RAW container. v0.8.1 removes duplicate opening uploads and displays separate server/transfer and local-write timings, but the full save round trip is inherent while the folder belongs to the browser rather than the Docker host.
RAW saves still require the complete original to travel to Docker and the complete rewritten file to return to the browser. ExifTool must also rewrite the metadata-bearing RAW container. The optimized opening path removes duplicate uploads, but the full save round trip is inherent while the folder belongs to the browser rather than the Docker host.
After a save, the status panel and popup show total, server/transfer, and local-write durations. Container logs also show end-to-end request duration and break inspection work into temporary-copy, preview, and metadata stages.
## Metadata behavior by format
+43 -10
View File
@@ -1,7 +1,6 @@
from __future__ import annotations
import asyncio
import base64
import io
import json
import logging
@@ -20,7 +19,7 @@ from datetime import datetime
from pathlib import Path
from typing import Annotated, Any
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile
from pydantic import BaseModel, Field
from fastapi.responses import FileResponse, Response
from fastapi.staticfiles import StaticFiles
@@ -86,7 +85,7 @@ APP_SUBTITLE = (
or f"Local folder · in-place {ENABLED_FORMAT_LABEL} metadata"
)
APP_URL = os.getenv("APP_URL", "http://localhost:8080")
APP_VERSION = "0.8.1"
APP_VERSION = "0.8.2"
MAX_UPLOAD_MB = int(os.getenv("MAX_UPLOAD_MB", "150"))
MAX_UPLOAD_BYTES = MAX_UPLOAD_MB * 1024 * 1024
PREVIEW_MAX_EDGE = int(os.getenv("PREVIEW_MAX_EDGE", "2400"))
@@ -166,6 +165,17 @@ class FavoriteLocationInput(BaseModel):
app = FastAPI(title=APP_TITLE, docs_url=None, redoc_url=None)
@app.middleware("http")
async def request_timing(request: Request, call_next: Any) -> Response:
started = time.perf_counter()
response = await call_next(request)
elapsed_seconds = time.perf_counter() - started
if request.url.path in {"/api/inspect", "/api/process"}:
response.headers["X-Request-Duration-Ms"] = f"{elapsed_seconds * 1000:.1f}"
logger.info("%s completed in %.2fs end-to-end on the server", request.url.path, elapsed_seconds)
return response
@app.get("/api/health")
def health() -> dict[str, str]:
return {"status": "ok", "version": APP_VERSION}
@@ -827,22 +837,45 @@ async def create_preview(file: Annotated[UploadFile, File(...)]) -> Response:
@app.post("/api/inspect")
async def inspect_photo(file: Annotated[UploadFile, File(...)]) -> dict[str, Any]:
"""Upload once and return both metadata and a browser-friendly preview."""
async def inspect_photo(file: Annotated[UploadFile, File(...)]) -> Response:
"""Return length-prefixed JSON metadata followed by binary JPEG preview data."""
filename = file.filename or "photo.tiff"
suffix = Path(filename).suffix.lower()
if suffix not in SUPPORTED_EXTENSIONS or suffix not in SERVER_PREVIEW_EXTENSIONS:
raise HTTPException(status_code=415, detail="This format is disabled or does not require server inspection.")
with tempfile.TemporaryDirectory(prefix="photo-date-editor-inspect-") as temp_dir:
temp_path = Path(temp_dir) / f"source{suffix}"
copy_started = time.perf_counter()
await _save_upload(file, temp_path, suffix)
copy_seconds = time.perf_counter() - copy_started
preview_started = time.perf_counter()
preview_bytes = _preview_bytes(temp_path, suffix, filename)
preview_seconds = time.perf_counter() - preview_started
metadata_started = time.perf_counter()
metadata = _read_metadata_path(temp_path)
return {
**metadata,
"previewBase64": base64.b64encode(preview_bytes).decode("ascii"),
"previewMediaType": "image/jpeg",
}
metadata_seconds = time.perf_counter() - metadata_started
metadata_bytes = json.dumps(metadata, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
body = len(metadata_bytes).to_bytes(4, "big") + metadata_bytes + preview_bytes
logger.info(
"Inspected %s: temporary copy %.2fs, preview %.2fs, metadata %.2fs",
filename,
copy_seconds,
preview_seconds,
metadata_seconds,
)
return Response(
content=body,
media_type="application/vnd.photo-date-editor.inspect",
headers={
"Cache-Control": "no-store",
"Server-Timing": (
f'copy;dur={copy_seconds * 1000:.1f}, '
f'preview;dur={preview_seconds * 1000:.1f}, '
f'metadata;dur={metadata_seconds * 1000:.1f}'
),
},
)
@app.post("/api/metadata")
+26 -11
View File
@@ -356,7 +356,10 @@ function formatMetadataSummary(values) {
function updatePhotoStatus(photo) {
const values = photo.fileValues || photo.values;
if (photo.status === 'saved') {
setSaveStatus('saved', 'Previously saved', formatMetadataSummary(values));
const timing = photo.lastSaveTiming
? ` · ${photo.lastSaveTiming.total.toFixed(1)}s total · ${photo.lastSaveTiming.serverTransfer.toFixed(1)}s server/transfer · ${photo.lastSaveTiming.localWrite.toFixed(1)}s local write`
: '';
setSaveStatus('saved', 'Previously saved', `${formatMetadataSummary(values)}${timing}`);
} else if (photo.status === 'skipped') {
setSaveStatus('skipped', 'Previously skipped', 'No metadata was written to this file.');
} else if (photo.status === 'failed') {
@@ -394,13 +397,6 @@ function applyMetadataResult(photo, data) {
}
}
function base64PreviewUrl(base64, mediaType) {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
return URL.createObjectURL(new Blob([bytes], { type: mediaType || 'image/jpeg' }));
}
async function inspectServerPreviewPhoto(photo, file) {
const payload = new FormData();
payload.append('file', file, photo.name);
@@ -410,10 +406,20 @@ async function inspectServerPreviewPhoto(photo, file) {
try { message = (await response.json()).detail || message; } catch {}
throw new Error(message);
}
const data = await response.json();
const buffer = await response.arrayBuffer();
if (buffer.byteLength < 5) throw new Error('The server returned an incomplete inspection response.');
const view = new DataView(buffer);
const metadataLength = view.getUint32(0, false);
if (metadataLength < 2 || metadataLength > buffer.byteLength - 4) {
throw new Error('The server returned an invalid inspection response.');
}
const metadataBytes = new Uint8Array(buffer, 4, metadataLength);
const data = JSON.parse(new TextDecoder().decode(metadataBytes));
const previewBytes = new Uint8Array(buffer, 4 + metadataLength);
if (!previewBytes.length) throw new Error('The server returned no preview image.');
const oldUrl = state.thumbUrls.get(photo.name);
if (oldUrl) URL.revokeObjectURL(oldUrl);
const previewUrl = base64PreviewUrl(data.previewBase64, data.previewMediaType);
const previewUrl = URL.createObjectURL(new Blob([previewBytes], { type: 'image/jpeg' }));
state.thumbUrls.set(photo.name, previewUrl);
applyMetadataResult(photo, data);
return previewUrl;
@@ -632,6 +638,7 @@ async function savePhoto(moveNext) {
throw error;
}
const localWriteSeconds = (performance.now() - localWriteStarted) / 1000;
const totalSaveSeconds = serverAndTransferSeconds + localWriteSeconds;
const updatedFile = await photo.handle.getFile();
photo.size = updatedFile.size;
@@ -643,13 +650,21 @@ async function savePhoto(moveNext) {
photo.fileValues = { ...savedValues };
photo.metadataLoaded = true;
photo.metadataPresent = true;
photo.lastSaveTiming = {
total: totalSaveSeconds,
serverTransfer: serverAndTransferSeconds,
localWrite: localWriteSeconds,
};
saveFolderState();
setSaveStatus(
'saved',
'Saved in place',
`${formatMetadataSummary(savedValues)} · ${serverAndTransferSeconds.toFixed(1)}s server/transfer · ${localWriteSeconds.toFixed(1)}s local write`,
);
showToast(`Saved ${photo.name} in ${(serverAndTransferSeconds + localWriteSeconds).toFixed(1)}s`);
showToast(
`Saved ${photo.name}: ${totalSaveSeconds.toFixed(1)}s total · `
+ `${serverAndTransferSeconds.toFixed(1)}s server/transfer · ${localWriteSeconds.toFixed(1)}s local write`,
);
updateProgress();
const oldThumb = state.thumbUrls.get(photo.name);
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE_NAME = 'photo-date-editor-v8-1';
const CACHE_NAME = 'photo-date-editor-v8-2';
const SHELL = ['/', '/styles.css', '/app.js', '/manifest.webmanifest', '/vendor/leaflet/leaflet.css', '/vendor/leaflet/leaflet.js'];
self.addEventListener('install', (event) => {