Add binary RAW inspection and diagnostics v0.8.2
This commit is contained in:
1 parent
d17bc6a132
commit
2dee67021e
6 files changed
+87
-26
No files matched your search
+43
-10
@@ -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
@@ -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,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) => {
|
||||
|
||||
Reference in new issue
Block a user