Optimize RAW inspection and add timings v0.8.1

This commit is contained in:
dmo committed 2026-07-25 08:35:22 +02:00
1 parent 4e3c442b26
commit d17bc6a132
6 files changed
+175 -97

No files matched your search

+2 -1
View File
@@ -19,6 +19,7 @@ LOG_LEVEL=INFO
# Optional TIFF, HEIF, and DNG preview limits. Originals are never converted or replaced by previews. # Optional TIFF, HEIF, and DNG preview limits. Originals are never converted or replaced by previews.
PREVIEW_MAX_EDGE=2400 PREVIEW_MAX_EDGE=2400
RAW_PREVIEW_MAX_EDGE=1600
PREVIEW_MAX_PIXELS=300000000 PREVIEW_MAX_PIXELS=300000000
# Optional map/geocoder overrides. Existing .env files may omit these. # Optional map/geocoder overrides. Existing .env files may omit these.
@@ -30,4 +31,4 @@ DEFAULT_MAP_LON=11.0
DEFAULT_MAP_ZOOM=5 DEFAULT_MAP_ZOOM=5
GEOCODER_URL=https://nominatim.openstreetmap.org/search GEOCODER_URL=https://nominatim.openstreetmap.org/search
# Optional custom identification sent to the geocoder. The default uses APP_NAME, version and APP_URL. # Optional custom identification sent to the geocoder. The default uses APP_NAME, version and APP_URL.
# GEOCODER_USER_AGENT=MorkPhotoDateEditor/0.8.0 (+https://edit.mork.fyi) # GEOCODER_USER_AGENT=MorkPhotoDateEditor/0.8.1 (+https://edit.mork.fyi)
+12 -1
View File
@@ -1,5 +1,16 @@
# Changelog # Changelog
## 0.8.1
- Upload server-preview formats only once when opening them, returning the preview and metadata together.
- Stop at the first usable embedded RAW preview instead of extracting every candidate.
- Add `RAW_PREVIEW_MAX_EDGE` (default `1600`) for smaller temporary RAW previews.
- Keep LibRaw as a lazy fallback only when no embedded RAW preview is usable.
- Show separate server/transfer and local-file-write durations after saving.
- Log upload, ExifTool rewrite, and response-read timings and expose them through `Server-Timing`.
- Allow up to three minutes for large DNG and Canon RAW metadata rewrites.
- Preserve all existing format and in-place overwrite behavior.
## 0.8.0 ## 0.8.0
- Add the `canon` format group with CR2 and CR3 support. - Add the `canon` format group with CR2 and CR3 support.
@@ -10,7 +21,7 @@
- Read and write CR2 EXIF, XMP, IPTC, date precision, description, keywords, GPS coordinates, and location labels. - Read and write CR2 EXIF, XMP, IPTC, date precision, description, keywords, GPS coordinates, and location labels.
- Read and write CR3 EXIF and XMP metadata without applying unsupported IPTC-IIM fields. - Read and write CR3 EXIF and XMP metadata without applying unsupported IPTC-IIM fields.
- Preserve Canon maker notes, sensor data, embedded previews, and other untouched camera metadata. - Preserve Canon maker notes, sensor data, embedded previews, and other untouched camera metadata.
- Update enabled-format configuration, UI text, manifest, README, and the RAW roadmap. - Update enabled-format configuration, UI text, manifest, README, and the RAW roadmap, including planned Nikon and Sony phases.
- Preserve all established JPG, PNG, TIFF, HEIF, and DNG behavior. - Preserve all established JPG, PNG, TIFF, HEIF, and DNG behavior.
- Bump the application and service-worker cache version to 0.8.0. - Bump the application and service-worker cache version to 0.8.0.
+10 -4
View File
@@ -1,6 +1,6 @@
# Photo Date Editor # Photo Date Editor
Version 0.8.0 Version 0.8.1
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. 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 ## 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/preview` and returned as a browser-friendly JPEG. TIFF uses Pillow, HEIF uses `pillow-heif`, and RAW formats inspect `JpgFromRaw`, `PreviewImage`, and `ThumbnailImage`, selecting the largest usable embedded image. 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. 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. 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.
@@ -60,6 +60,7 @@ LOG_LEVEL=INFO
# Optional TIFF/HEIF/RAW preview limits # Optional TIFF/HEIF/RAW preview limits
PREVIEW_MAX_EDGE=2400 PREVIEW_MAX_EDGE=2400
RAW_PREVIEW_MAX_EDGE=1600
PREVIEW_MAX_PIXELS=300000000 PREVIEW_MAX_PIXELS=300000000
# Optional map defaults and service overrides # Optional map defaults and service overrides
@@ -141,6 +142,8 @@ 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. 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.
## Metadata behavior by format ## Metadata behavior by format
- **JPG/JPEG:** EXIF, XMP, and IPTC fields are written. - **JPG/JPEG:** EXIF, XMP, and IPTC fields are written.
@@ -198,8 +201,8 @@ The default map tiles and address search are external OpenStreetMap services. Bo
- Preview rotation is visual only; it does not rotate image pixels or write orientation metadata. - Preview rotation is visual only; it does not rotate image pixels or write orientation metadata.
- Multi-page TIFF files display the first page/frame in the editor. Metadata is written to the TIFF container, not to separate pages. - Multi-page TIFF files display the first page/frame in the editor. Metadata is written to the TIFF container, not to separate pages.
- HEIF image sequences display their primary/first image in the editor. Metadata is written to the HEIF container. - HEIF image sequences display their primary/first image in the editor. Metadata is written to the HEIF container.
- DNG uses the largest common embedded JPEG preview available before falling back to a half-size LibRaw render. A small embedded thumbnail may look softer but does not alter the DNG. - DNG, CR2, and CR3 use the first usable embedded JPEG in a format-specific priority order before falling back to a half-size LibRaw render.
- CR2 and CR3 use the largest common embedded JPEG preview available before falling back to a half-size LibRaw render. - `RAW_PREVIEW_MAX_EDGE` defaults to 1600 pixels to keep temporary previews responsive. It never resizes the RAW original.
- Very large TIFF, HEIF, DNG, CR2, or CR3 files may require raising `MAX_UPLOAD_MB` or `PREVIEW_MAX_PIXELS`. - Very large TIFF, HEIF, DNG, CR2, or CR3 files may require raising `MAX_UPLOAD_MB` or `PREVIEW_MAX_PIXELS`.
- RAW rendering depends on the camera and compression variant being supported by the bundled LibRaw version. Metadata editing can still be supported by ExifTool even when a particular RAW file cannot be rendered. - RAW rendering depends on the camera and compression variant being supported by the bundled LibRaw version. Metadata editing can still be supported by ExifTool even when a particular RAW file cannot be rendered.
- Canon maker notes, sensor data, embedded previews, and camera-specific metadata are preserved rather than intentionally rewritten. Always validate with copies from the camera models in your archive. - Canon maker notes, sensor data, embedded previews, and camera-specific metadata are preserved rather than intentionally rewritten. Always validate with copies from the camera models in your archive.
@@ -214,6 +217,9 @@ Format support needs both safe metadata writing and a browser-friendly preview.
### Proposed order ### Proposed order
1. **Nikon RAW** — `NEF` and `NRW`, using per-format tag rules and embedded/server-generated previews. 1. **Nikon RAW** — `NEF` and `NRW`, using per-format tag rules and embedded/server-generated previews.
2. **Sony RAW** — modern `ARW` and `ARQ`, using TIFF-based metadata profiles and embedded/server-generated previews.
Older Sony `SR2` and `SRF` files are recorded as a compatibility decision for the Sony phase. `SR2` is writable through ExifTool, while `SRF` is read-only, so they should not be enabled automatically with modern Sony RAW support.
`RAW` is not treated as one universal format. Each camera family will be enabled and tested explicitly. `RAW` is not treated as one universal format. Each camera family will be enabled and tested explicitly.
+100 -86
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import base64
import io import io
import json import json
import logging import logging
@@ -85,10 +86,11 @@ APP_SUBTITLE = (
or f"Local folder · in-place {ENABLED_FORMAT_LABEL} metadata" or f"Local folder · in-place {ENABLED_FORMAT_LABEL} metadata"
) )
APP_URL = os.getenv("APP_URL", "http://localhost:8080") APP_URL = os.getenv("APP_URL", "http://localhost:8080")
APP_VERSION = "0.8.0" APP_VERSION = "0.8.1"
MAX_UPLOAD_MB = int(os.getenv("MAX_UPLOAD_MB", "150")) MAX_UPLOAD_MB = int(os.getenv("MAX_UPLOAD_MB", "150"))
MAX_UPLOAD_BYTES = MAX_UPLOAD_MB * 1024 * 1024 MAX_UPLOAD_BYTES = MAX_UPLOAD_MB * 1024 * 1024
PREVIEW_MAX_EDGE = int(os.getenv("PREVIEW_MAX_EDGE", "2400")) PREVIEW_MAX_EDGE = int(os.getenv("PREVIEW_MAX_EDGE", "2400"))
RAW_PREVIEW_MAX_EDGE = int(os.getenv("RAW_PREVIEW_MAX_EDGE", "1600"))
PREVIEW_MAX_PIXELS = int(os.getenv("PREVIEW_MAX_PIXELS", "300000000")) PREVIEW_MAX_PIXELS = int(os.getenv("PREVIEW_MAX_PIXELS", "300000000"))
Image.MAX_IMAGE_PIXELS = PREVIEW_MAX_PIXELS Image.MAX_IMAGE_PIXELS = PREVIEW_MAX_PIXELS
@@ -689,11 +691,17 @@ def _get_rawpy() -> Any:
return _rawpy_module return _rawpy_module
def _embedded_raw_preview(path: Path, format_name: str) -> Image.Image | None: def _raw_preview_tags(suffix: str) -> tuple[str, ...]:
"""Extract the largest usable JPEG from the common RAW preview tags.""" if suffix == ".cr2":
best: Image.Image | None = None return ("PreviewImage", "JpgFromRaw", "ThumbnailImage")
best_area = 0 if suffix == ".cr3":
for tag in ("JpgFromRaw", "PreviewImage", "ThumbnailImage"): return ("JpgFromRaw", "PreviewImage", "ThumbnailImage")
return ("JpgFromRaw", "PreviewImage", "ThumbnailImage")
def _embedded_raw_preview(path: Path, format_name: str, suffix: str) -> Image.Image | None:
"""Return the first usable embedded JPEG, ordered for the RAW container."""
for tag in _raw_preview_tags(suffix):
try: try:
completed = subprocess.run( completed = subprocess.run(
["exiftool", "-b", f"-{tag}", "--", str(path)], ["exiftool", "-b", f"-{tag}", "--", str(path)],
@@ -709,18 +717,12 @@ def _embedded_raw_preview(path: Path, format_name: str) -> Image.Image | None:
try: try:
with Image.open(io.BytesIO(completed.stdout)) as source: with Image.open(io.BytesIO(completed.stdout)) as source:
source.load() source.load()
candidate = source.copy() preview = source.copy()
area = candidate.width * candidate.height logger.info("Using %s for %s preview.", tag, format_name)
if area > best_area: return preview
if best is not None:
best.close()
best = candidate
best_area = area
else:
candidate.close()
except (UnidentifiedImageError, OSError, ValueError): except (UnidentifiedImageError, OSError, ValueError):
logger.debug("%s %s data was not a usable image preview.", format_name, tag) logger.debug("%s %s data was not a usable image preview.", format_name, tag)
return best return None
def _render_raw(path: Path, format_name: str) -> Image.Image: def _render_raw(path: Path, format_name: str) -> Image.Image:
@@ -746,29 +748,11 @@ def _render_raw(path: Path, format_name: str) -> Image.Image:
return Image.fromarray(rgb) return Image.fromarray(rgb)
@app.post("/api/preview") def _preview_bytes(temp_path: Path, suffix: str, filename: str) -> bytes:
async def create_preview(file: Annotated[UploadFile, File(...)]) -> Response:
"""Generate a browser-friendly preview for TIFF, HEIF, DNG, CR2, and CR3."""
filename = file.filename or "photo.tiff"
suffix = Path(filename).suffix.lower()
if suffix not in SUPPORTED_EXTENSIONS:
raise HTTPException(
status_code=415,
detail=f"This format is disabled. Enabled format groups: {ENABLED_FORMAT_LABEL}.",
)
if suffix not in SERVER_PREVIEW_EXTENSIONS:
raise HTTPException(
status_code=415,
detail="Server-generated previews are only required for TIFF, HEIF, DNG, CR2, and CR3 files.",
)
with tempfile.TemporaryDirectory(prefix="photo-date-editor-preview-") as temp_dir:
temp_path = Path(temp_dir) / f"source{suffix}"
await _save_upload(file, temp_path, suffix)
try: try:
if suffix in DNG_EXTENSIONS | CANON_EXTENSIONS: if suffix in DNG_EXTENSIONS | CANON_EXTENSIONS:
format_name = _format_name(suffix) format_name = _format_name(suffix)
preview = _embedded_raw_preview(temp_path, format_name) preview = _embedded_raw_preview(temp_path, format_name, suffix)
if preview is None: if preview is None:
preview = _render_raw(temp_path, format_name) preview = _render_raw(temp_path, format_name)
preview = ImageOps.exif_transpose(preview) preview = ImageOps.exif_transpose(preview)
@@ -781,8 +765,8 @@ async def create_preview(file: Annotated[UploadFile, File(...)]) -> Response:
preview.load() preview.load()
try: try:
preview.thumbnail((PREVIEW_MAX_EDGE, PREVIEW_MAX_EDGE), Image.Resampling.LANCZOS) max_edge = RAW_PREVIEW_MAX_EDGE if suffix in DNG_EXTENSIONS | CANON_EXTENSIONS else PREVIEW_MAX_EDGE
preview.thumbnail((max_edge, max_edge), Image.Resampling.LANCZOS)
if preview.mode in {"RGBA", "LA"} or (preview.mode == "P" and "transparency" in preview.info): if preview.mode in {"RGBA", "LA"} or (preview.mode == "P" and "transparency" in preview.info):
rgba = preview.convert("RGBA") rgba = preview.convert("RGBA")
background = Image.new("RGBA", rgba.size, (255, 255, 255, 255)) background = Image.new("RGBA", rgba.size, (255, 255, 255, 255))
@@ -790,9 +774,9 @@ async def create_preview(file: Annotated[UploadFile, File(...)]) -> Response:
preview = background.convert("RGB") preview = background.convert("RGB")
elif preview.mode != "RGB": elif preview.mode != "RGB":
preview = preview.convert("RGB") preview = preview.convert("RGB")
output = io.BytesIO() output = io.BytesIO()
preview.save(output, format="JPEG", quality=88, optimize=True) preview.save(output, format="JPEG", quality=86, optimize=True)
return output.getvalue()
finally: finally:
preview.close() preview.close()
except (Image.DecompressionBombError, UnidentifiedImageError, OSError, ValueError) as exc: except (Image.DecompressionBombError, UnidentifiedImageError, OSError, ValueError) as exc:
@@ -803,11 +787,62 @@ async def create_preview(file: Annotated[UploadFile, File(...)]) -> Response:
detail=f"The {format_name} image could not be rendered for preview.", detail=f"The {format_name} image could not be rendered for preview.",
) from exc ) from exc
return Response(
content=output.getvalue(), def _read_metadata_path(temp_path: Path) -> dict[str, bool | dict[str, Any]]:
media_type="image/jpeg", completed = subprocess.run(
headers={"Cache-Control": "no-store"}, [
"exiftool", "-json", "-s", "-n",
"-DateTimeOriginal", "-CreateDate", "-ModifyDate", "-DateCreated",
"-Instructions", "-ImageDescription", "-Description", "-Caption-Abstract",
"-Subject", "-Keywords", "-GPSLatitude", "-GPSLatitudeRef",
"-GPSLongitude", "-GPSLongitudeRef", "-Location",
"-LocationShownLocationName", "-City", str(temp_path),
],
capture_output=True, text=True, timeout=45, check=False,
) )
if completed.returncode != 0:
logger.error("ExifTool metadata read failed: %s", completed.stderr.strip())
raise HTTPException(status_code=500, detail="ExifTool could not read this image's metadata.")
try:
output = json.loads(completed.stdout)
metadata = output[0] if isinstance(output, list) and output else {}
except json.JSONDecodeError as exc:
raise HTTPException(status_code=500, detail="ExifTool returned invalid metadata output.") from exc
has_metadata, edited_by_app, values = _metadata_values(metadata)
return {"hasMetadata": has_metadata, "editedByApp": edited_by_app, "values": values}
@app.post("/api/preview")
async def create_preview(file: Annotated[UploadFile, File(...)]) -> Response:
"""Generate a browser-friendly preview for server-preview formats."""
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 a server preview.")
with tempfile.TemporaryDirectory(prefix="photo-date-editor-preview-") as temp_dir:
temp_path = Path(temp_dir) / f"source{suffix}"
await _save_upload(file, temp_path, suffix)
preview_bytes = _preview_bytes(temp_path, suffix, filename)
return Response(content=preview_bytes, media_type="image/jpeg", headers={"Cache-Control": "no-store"})
@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."""
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}"
await _save_upload(file, temp_path, suffix)
preview_bytes = _preview_bytes(temp_path, suffix, filename)
metadata = _read_metadata_path(temp_path)
return {
**metadata,
"previewBase64": base64.b64encode(preview_bytes).decode("ascii"),
"previewMediaType": "image/jpeg",
}
@app.post("/api/metadata") @app.post("/api/metadata")
@@ -823,47 +858,8 @@ async def read_metadata(file: Annotated[UploadFile, File(...)]) -> dict[str, boo
with tempfile.TemporaryDirectory(prefix="photo-date-editor-read-") as temp_dir: with tempfile.TemporaryDirectory(prefix="photo-date-editor-read-") as temp_dir:
temp_path = Path(temp_dir) / f"source{suffix}" temp_path = Path(temp_dir) / f"source{suffix}"
await _save_upload(file, temp_path, suffix) await _save_upload(file, temp_path, suffix)
completed = subprocess.run( result = _read_metadata_path(temp_path)
[ return result
"exiftool",
"-json",
"-s",
"-n",
"-DateTimeOriginal",
"-CreateDate",
"-ModifyDate",
"-DateCreated",
"-Instructions",
"-ImageDescription",
"-Description",
"-Caption-Abstract",
"-Subject",
"-Keywords",
"-GPSLatitude",
"-GPSLatitudeRef",
"-GPSLongitude",
"-GPSLongitudeRef",
"-Location",
"-LocationShownLocationName",
"-City",
str(temp_path),
],
capture_output=True,
text=True,
timeout=45,
check=False,
)
if completed.returncode != 0:
logger.error("ExifTool metadata read failed: %s", completed.stderr.strip())
raise HTTPException(status_code=500, detail="ExifTool could not read this image's metadata.")
try:
output = json.loads(completed.stdout)
metadata = output[0] if isinstance(output, list) and output else {}
except json.JSONDecodeError as exc:
raise HTTPException(status_code=500, detail="ExifTool returned invalid metadata output.") from exc
has_metadata, edited_by_app, values = _metadata_values(metadata)
return {"hasMetadata": has_metadata, "editedByApp": edited_by_app, "values": values}
@app.post("/api/process") @app.post("/api/process")
@@ -916,7 +912,9 @@ async def process_photo(
with tempfile.TemporaryDirectory(prefix="photo-date-editor-") as temp_dir: with tempfile.TemporaryDirectory(prefix="photo-date-editor-") as temp_dir:
temp_path = Path(temp_dir) / f"working{suffix}" temp_path = Path(temp_dir) / f"working{suffix}"
upload_started = time.perf_counter()
await _save_upload(file, temp_path, suffix) await _save_upload(file, temp_path, suffix)
upload_seconds = time.perf_counter() - upload_started
supports_iptc = suffix in IPTC_EXTENSIONS supports_iptc = suffix in IPTC_EXTENSIONS
command = [ command = [
@@ -988,18 +986,29 @@ async def process_photo(
command.append(str(temp_path)) command.append(str(temp_path))
logger.info("Processing %s with precision %s", filename, precision) logger.info("Processing %s with precision %s", filename, precision)
exiftool_started = time.perf_counter()
completed = subprocess.run( completed = subprocess.run(
command, command,
capture_output=True, capture_output=True,
text=True, text=True,
timeout=90, timeout=180 if suffix in DNG_EXTENSIONS | CANON_EXTENSIONS else 90,
check=False, check=False,
) )
exiftool_seconds = time.perf_counter() - exiftool_started
if completed.returncode != 0: if completed.returncode != 0:
logger.error("ExifTool failed: %s", completed.stderr.strip()) logger.error("ExifTool failed: %s", completed.stderr.strip())
raise HTTPException(status_code=500, detail="ExifTool could not update this image.") raise HTTPException(status_code=500, detail="ExifTool could not update this image.")
response_started = time.perf_counter()
updated_bytes = temp_path.read_bytes() updated_bytes = temp_path.read_bytes()
response_read_seconds = time.perf_counter() - response_started
logger.info(
"Processed %s: upload %.2fs, ExifTool %.2fs, response read %.2fs",
filename,
upload_seconds,
exiftool_seconds,
response_read_seconds,
)
return Response( return Response(
content=updated_bytes, content=updated_bytes,
@@ -1008,6 +1017,11 @@ async def process_photo(
"Content-Disposition": f'inline; filename="{Path(filename).name}"', "Content-Disposition": f'inline; filename="{Path(filename).name}"',
"Cache-Control": "no-store", "Cache-Control": "no-store",
"X-Date-Precision": precision_label, "X-Date-Precision": precision_label,
"Server-Timing": (
f'upload;dur={upload_seconds * 1000:.1f}, '
f'exiftool;dur={exiftool_seconds * 1000:.1f}, '
f'read;dur={response_read_seconds * 1000:.1f}'
),
}, },
) )
+50 -4
View File
@@ -378,7 +378,10 @@ async function readPhotoMetadata(photo, file) {
try { message = (await response.json()).detail || message; } catch {} try { message = (await response.json()).detail || message; } catch {}
throw new Error(message); throw new Error(message);
} }
const data = await response.json(); applyMetadataResult(photo, await response.json());
}
function applyMetadataResult(photo, data) {
photo.metadataLoaded = true; photo.metadataLoaded = true;
photo.metadataPresent = Boolean(data.hasMetadata); photo.metadataPresent = Boolean(data.hasMetadata);
photo.fileValues = data.hasMetadata ? data.values : null; photo.fileValues = data.hasMetadata ? data.values : null;
@@ -391,6 +394,31 @@ async function readPhotoMetadata(photo, file) {
} }
} }
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);
const response = await fetch('/api/inspect', { method: 'POST', body: payload });
if (!response.ok) {
let message = `Photo inspection failed (${response.status}).`;
try { message = (await response.json()).detail || message; } catch {}
throw new Error(message);
}
const data = await response.json();
const oldUrl = state.thumbUrls.get(photo.name);
if (oldUrl) URL.revokeObjectURL(oldUrl);
const previewUrl = base64PreviewUrl(data.previewBase64, data.previewMediaType);
state.thumbUrls.set(photo.name, previewUrl);
applyMetadataResult(photo, data);
return previewUrl;
}
async function selectPhoto(index, force = false) { async function selectPhoto(index, force = false) {
if (index < 0 || index >= state.photos.length || (state.processing && !force)) return; if (index < 0 || index >= state.photos.length || (state.processing && !force)) return;
captureCurrentValues(); captureCurrentValues();
@@ -414,9 +442,17 @@ async function selectPhoto(index, force = false) {
if (requiresServerPreview(photo)) { if (requiresServerPreview(photo)) {
setSaveStatus('loading', `Rendering ${photo.format} preview…`, photo.name); setSaveStatus('loading', `Rendering ${photo.format} preview…`, photo.name);
} }
const previewUrl = await getPreviewUrl(photo, file); const serverInspectionNeeded = requiresServerPreview(photo)
&& (!state.thumbUrls.has(photo.name) || !photo.metadataLoaded);
const previewUrl = serverInspectionNeeded
? await inspectServerPreviewPhoto(photo, file)
: await getPreviewUrl(photo, file);
if (selectionToken !== state.selectionToken || state.currentIndex !== index) return; if (selectionToken !== state.selectionToken || state.currentIndex !== index) return;
elements.preview.src = previewUrl; elements.preview.src = previewUrl;
if (serverInspectionNeeded) {
if (!formHasUserInput() && photo.values) restoreValues(photo.values);
updatePhotoStatus(photo);
}
renderFileList(); renderFileList();
} catch (error) { } catch (error) {
if (selectionToken === state.selectionToken && state.currentIndex === index) { if (selectionToken === state.selectionToken && state.currentIndex === index) {
@@ -574,6 +610,8 @@ async function savePhoto(moveNext) {
payload.append('location_name', values.locationName); payload.append('location_name', values.locationName);
} }
const saveStarted = performance.now();
setSaveStatus('saving', 'Uploading and rewriting metadata…', `${photo.name} · large RAW files can take a while`);
const response = await fetch('/api/process', { method: 'POST', body: payload }); const response = await fetch('/api/process', { method: 'POST', body: payload });
if (!response.ok) { if (!response.ok) {
let message = `Save failed (${response.status}).`; let message = `Save failed (${response.status}).`;
@@ -582,6 +620,9 @@ async function savePhoto(moveNext) {
} }
const updatedBlob = await response.blob(); const updatedBlob = await response.blob();
const serverAndTransferSeconds = (performance.now() - saveStarted) / 1000;
setSaveStatus('saving', 'Writing updated file locally…', `${photo.name} · ${serverAndTransferSeconds.toFixed(1)}s processing and transfer`);
const localWriteStarted = performance.now();
const writable = await photo.handle.createWritable({ keepExistingData: false }); const writable = await photo.handle.createWritable({ keepExistingData: false });
try { try {
await writable.write(updatedBlob); await writable.write(updatedBlob);
@@ -590,6 +631,7 @@ async function savePhoto(moveNext) {
await writable.abort().catch(() => {}); await writable.abort().catch(() => {});
throw error; throw error;
} }
const localWriteSeconds = (performance.now() - localWriteStarted) / 1000;
const updatedFile = await photo.handle.getFile(); const updatedFile = await photo.handle.getFile();
photo.size = updatedFile.size; photo.size = updatedFile.size;
@@ -602,8 +644,12 @@ async function savePhoto(moveNext) {
photo.metadataLoaded = true; photo.metadataLoaded = true;
photo.metadataPresent = true; photo.metadataPresent = true;
saveFolderState(); saveFolderState();
setSaveStatus('saved', 'Saved in place', formatMetadataSummary(savedValues)); setSaveStatus(
showToast(`Saved ${photo.name}`); '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`);
updateProgress(); updateProgress();
const oldThumb = state.thumbUrls.get(photo.name); const oldThumb = state.thumbUrls.get(photo.name);
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE_NAME = 'photo-date-editor-v8-0'; const CACHE_NAME = 'photo-date-editor-v8-1';
const SHELL = ['/', '/styles.css', '/app.js', '/manifest.webmanifest', '/vendor/leaflet/leaflet.css', '/vendor/leaflet/leaflet.js']; const SHELL = ['/', '/styles.css', '/app.js', '/manifest.webmanifest', '/vendor/leaflet/leaflet.css', '/vendor/leaflet/leaflet.js'];
self.addEventListener('install', (event) => { self.addEventListener('install', (event) => {