Add binary RAW inspection and diagnostics v0.8.2

This commit is contained in:
dmo committed 2026-07-25 08:37:29 +02:00
1 parent d17bc6a132
commit 2dee67021e
6 files changed
+87 -26

No files matched your search

+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) => {