Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f11aa28b4 | ||
|
|
6e8da87271 | ||
|
|
ad52a95d4f | ||
|
|
34602e4ebd | ||
|
|
6e8c30c1d4 | ||
|
|
f1704c9822 | ||
|
|
2dee67021e | ||
|
|
d17bc6a132 | ||
|
|
4e3c442b26 | ||
|
|
8ed5490afc | ||
|
|
b9a0259166 |
+6
-2
@@ -11,11 +11,15 @@ APP_NAME=Photo Date Editor
|
||||
APP_TITLE=Mork Photo Date Editor
|
||||
# Optional text shown directly below the page heading.
|
||||
APP_SUBTITLE=Family photo metadata editor
|
||||
# Enabled groups: jpg, png, tiff, heif, dng, canon, nikon, sony
|
||||
# Use ENABLED_FORMATS=jpg for a JPEG-only workflow.
|
||||
ENABLED_FORMATS=jpg,png,tiff,heif,dng,canon,nikon,sony
|
||||
MAX_UPLOAD_MB=150
|
||||
LOG_LEVEL=INFO
|
||||
|
||||
# Optional TIFF preview limits. The original TIFF is never converted or replaced by the preview.
|
||||
# Optional TIFF, HEIF, and DNG preview limits. Originals are never converted or replaced by previews.
|
||||
PREVIEW_MAX_EDGE=2400
|
||||
RAW_PREVIEW_MAX_EDGE=1600
|
||||
PREVIEW_MAX_PIXELS=300000000
|
||||
|
||||
# Optional map/geocoder overrides. Existing .env files may omit these.
|
||||
@@ -27,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.6.1 (+https://edit.mork.fyi)
|
||||
# GEOCODER_USER_AGENT=MorkPhotoDateEditor/0.9.2 (+https://edit.mork.fyi)
|
||||
|
||||
+112
@@ -1,5 +1,117 @@
|
||||
# Changelog
|
||||
|
||||
## 0.9.2
|
||||
|
||||
- Apply pending preview rotations physically when saving JPG, PNG, and TIFF files.
|
||||
- Show an explicit overlay stating whether rotation will be saved or is preview-only.
|
||||
- Preserve pending rotation while navigating between photos during the current session.
|
||||
- Use lossless `jpegtran` rotation for compatible JPEG dimensions.
|
||||
- Fall back to one high-quality JPEG re-encode when a perfect lossless transform is unavailable.
|
||||
- Rotate PNG pixels losslessly and reject animated PNG files rather than dropping frames.
|
||||
- Rotate every page of a TIFF with lossless TIFF compression.
|
||||
- Restore existing metadata after PNG/TIFF reconstruction, then normalize EXIF/XMP orientation to 1.
|
||||
- Keep HEIF, DNG, and all camera RAW formats strictly preview-only for rotation.
|
||||
- Reject unsupported or invalid rotation requests at the backend.
|
||||
- Add `libjpeg-turbo-progs` to the container for lossless JPEG transforms.
|
||||
- Preserve all existing metadata editing, format support, viewport, and streamed-save behavior.
|
||||
|
||||
## 0.9.1
|
||||
|
||||
- Lock the three-column desktop workspace to the available browser viewport height.
|
||||
- Keep the bottom photo-navigation bar visible without whole-page scrolling.
|
||||
- Let the photo list and metadata form scroll independently inside their panels.
|
||||
- Allow the image stage to shrink with shorter laptop displays while preserving aspect ratio.
|
||||
- Add a compact desktop-height layout for browser viewports 800 pixels tall or shorter.
|
||||
- Preserve normal document scrolling for tablet and mobile layouts.
|
||||
- Preserve all v0.9 format, metadata, preview, and streamed-save behavior.
|
||||
|
||||
## 0.9.0
|
||||
|
||||
- Add the `nikon` format group with NEF and NRW support.
|
||||
- Add the `sony` format group with ARW and ARQ support.
|
||||
- Add exact NEF, NRW, ARW, and ARQ badges and format-sort compatibility.
|
||||
- Validate the TIFF headers used by Nikon and Sony RAW containers before processing.
|
||||
- Prefer embedded JPEG previews before loading the lazy LibRaw fallback.
|
||||
- Read and write EXIF, XMP, IPTC, dates, descriptions, keywords, GPS coordinates, and location labels.
|
||||
- Preserve Nikon and Sony maker notes, sensor data, embedded previews, and untouched camera metadata.
|
||||
- Use the established streamed save path, live transfer progress, and timing diagnostics.
|
||||
- Document every `ENABLED_FORMATS` group, its extensions, and its behavior in the README.
|
||||
- Keep older Sony SR2 as a future compatibility decision and exclude read-only SRF.
|
||||
- Preserve all existing JPG, PNG, TIFF, HEIF, DNG, and Canon RAW behavior.
|
||||
|
||||
## 0.8.3
|
||||
|
||||
- Stream processed files from the backend in 256 KiB chunks with an explicit content length.
|
||||
- Write response chunks directly into the browser's protected writable file instead of creating a complete in-memory Blob.
|
||||
- Show live transferred bytes and percentage while saving.
|
||||
- Commit the local replacement only after the complete response stream closes successfully.
|
||||
- Cancel the response reader and abort the browser writable file if streaming fails.
|
||||
- Retain the buffered Blob workflow as a compatibility fallback for browsers without response streaming.
|
||||
- Report time to first byte and combined stream/write time for streamed saves.
|
||||
- Preserve all existing metadata, format, progress, and folder-state behavior.
|
||||
|
||||
## 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.
|
||||
- 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
|
||||
|
||||
- Add the `canon` format group with CR2 and CR3 support.
|
||||
- Add exact `CR2` and `CR3` badges and format-sort compatibility.
|
||||
- Validate TIFF-based CR2 headers separately from ISO-BMFF-based CR3 headers.
|
||||
- Extract `JpgFromRaw`, `PreviewImage`, and `ThumbnailImage`, using the largest valid embedded preview.
|
||||
- Use the lazy LibRaw decoder only when a Canon RAW file has no usable embedded preview.
|
||||
- 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.
|
||||
- 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, including planned Nikon and Sony phases.
|
||||
- Preserve all established JPG, PNG, TIFF, HEIF, and DNG behavior.
|
||||
- Bump the application and service-worker cache version to 0.8.0.
|
||||
|
||||
## 0.7.1
|
||||
|
||||
- Add `ENABLED_FORMATS` with the available groups `jpg`, `png`, `tiff`, `heif`, and `dng`.
|
||||
- Expand each group to its associated extensions and expose the enabled groups/extensions through `/api/config`.
|
||||
- Omit disabled formats from browser folder scans and reject them at backend metadata, preview, and processing endpoints.
|
||||
- Generate the default page subtitle and empty-folder guidance from the enabled format groups.
|
||||
- Validate configuration strictly so unknown or empty format lists fail clearly at startup.
|
||||
- Load `pillow-heif` only on the first HEIF preview.
|
||||
- Prefer an embedded DNG preview without loading LibRaw, and import `rawpy` only when a rendered DNG fallback is actually required.
|
||||
- Keep a decoder loaded after first use until container restart, avoiding repeated load/unload overhead.
|
||||
- Preserve all format behavior when `ENABLED_FORMATS` is omitted.
|
||||
- Bump the application and service-worker cache version to 0.7.1.
|
||||
|
||||
## 0.7.0
|
||||
|
||||
- Add DNG folder scanning, `DNG` format badges, and format-sort compatibility.
|
||||
- Add browser-friendly DNG previews by preferring embedded `JpgFromRaw`, `PreviewImage`, and `ThumbnailImage` data.
|
||||
- Add a half-size LibRaw rendering fallback for DNG files without a usable embedded preview.
|
||||
- Add `rawpy` and the container OpenMP runtime required by its LibRaw decoder.
|
||||
- Validate DNG uploads as TIFF-based files before processing.
|
||||
- Read and write DNG EXIF, XMP, IPTC, date precision, description, keywords, GPS coordinates, and location label metadata.
|
||||
- Preserve the original DNG container, sensor data, and embedded previews while overwriting the same local file.
|
||||
- Update the interface, manifest, environment example, README, and format roadmap.
|
||||
- Preserve all established JPG, PNG, TIFF, and HEIF processing behavior.
|
||||
- Bump the application and service-worker cache version to 0.7.0.
|
||||
|
||||
## 0.6.1
|
||||
|
||||
- Add optional `APP_TITLE` and `APP_SUBTITLE` environment variables for the visible page heading and subtitle.
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates libheif1 libimage-exiftool-perl \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates libgomp1 libheif1 libimage-exiftool-perl libjpeg-turbo-progs \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
# Photo Date Editor
|
||||
# mExifEditor
|
||||
|
||||
Version 0.6.1
|
||||
> [!Important]
|
||||
This project was written 100% by AI for my needs. Feel free to use it if it fits your needs.\
|
||||
The only things I've done as a human is to tell it what to make, how to modify to fit my needs and which direction to go.\
|
||||
Project is shared as-is since it covers my needs.
|
||||
|
||||
Version 0.9.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.
|
||||
|
||||
## Current scope
|
||||
|
||||
- JPG/JPEG, PNG, TIFF/TIF, and HEIC/HEIF/HIF
|
||||
- JPG/JPEG, PNG, TIFF/TIF, HEIC/HEIF/HIF, DNG, Canon CR2/CR3, Nikon NEF/NRW, and Sony ARW/ARQ
|
||||
- Local directory picker in supported Chromium browsers
|
||||
- Three-column photo workflow
|
||||
- Exact date, month/year, year-only, approximate year
|
||||
@@ -17,7 +22,7 @@ A self-hosted browser UI for manually dating scanned photographs. The browser re
|
||||
- In-place overwrite: no browser download and no `_original` file
|
||||
- Saved/skipped/pending/failed state stored in browser local storage
|
||||
- Filename search, status filtering, and filename/status/format sorting
|
||||
- Normalized JPG, PNG, TIFF, and HEIF format badges in the photo list
|
||||
- Normalized JPG, PNG, TIFF, HEIF, DNG, CR2, CR3, NEF, NRW, ARW, and ARQ format badges in the photo list
|
||||
- Segmented progress bar: saved is green, skipped is yellow, failed is red, and pending remains grey
|
||||
- Existing metadata is read from supported files when selected
|
||||
- Files previously edited by this app are recognized from their XMP marker, even in a different browser
|
||||
@@ -25,9 +30,9 @@ 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 or HEIF-family images, so TIFF, HEIC, HEIF, and HIF files are temporarily uploaded to `/api/preview` and rendered as a JPEG preview by Pillow with `pillow-heif`. This 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, CR3, NEF, NRW, ARW, or ARQ, 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 or HEIF-family image 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 server-preview format is saved, ExifTool updates the original container and the browser overwrites the same local file through the streamed save path. The original is not converted to JPEG and no additional copy is intentionally left behind.
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -54,11 +59,13 @@ APP_PORT=8080
|
||||
APP_NAME=Photo Date Editor
|
||||
APP_TITLE=Mork Photo Date Editor
|
||||
APP_SUBTITLE=Family photo metadata editor
|
||||
ENABLED_FORMATS=jpg,png,tiff,heif,dng,canon,nikon,sony
|
||||
MAX_UPLOAD_MB=150
|
||||
LOG_LEVEL=INFO
|
||||
|
||||
# Optional TIFF/HEIF preview limits
|
||||
# Optional TIFF/HEIF/RAW preview limits
|
||||
PREVIEW_MAX_EDGE=2400
|
||||
RAW_PREVIEW_MAX_EDGE=1600
|
||||
PREVIEW_MAX_PIXELS=300000000
|
||||
|
||||
# Optional map defaults and service overrides
|
||||
@@ -72,7 +79,38 @@ GEOCODER_URL=https://nominatim.openstreetmap.org/search
|
||||
|
||||
Open the configured HTTPS URL through Caddy, click **Open photo folder**, and grant read/write access.
|
||||
|
||||
## Upgrade from 0.1 through 0.5
|
||||
## Enabled formats
|
||||
|
||||
`ENABLED_FORMATS` accepts a comma-separated list of format groups:
|
||||
|
||||
| `.env` group | Formats enabled | Preview and metadata behavior |
|
||||
|---|---|---|
|
||||
| `jpg` | JPG/JPEG: `.jpg`, `.jpeg` | Browser-native preview; EXIF, XMP, and IPTC |
|
||||
| `png` | PNG: `.png` | Browser-native preview; EXIF and XMP |
|
||||
| `tiff` | TIFF: `.tif`, `.tiff` | Server JPEG preview; EXIF, XMP, and IPTC |
|
||||
| `heif` | HEIF family: `.heic`, `.heif`, `.hif` | Lazy HEIF decoder; EXIF and XMP |
|
||||
| `dng` | Adobe DNG: `.dng` | Embedded preview or lazy LibRaw; EXIF, XMP, and IPTC |
|
||||
| `canon` | Canon RAW: `.cr2`, `.cr3` | Embedded preview or lazy LibRaw; CR2 uses EXIF/XMP/IPTC, CR3 uses EXIF/XMP |
|
||||
| `nikon` | Nikon RAW: `.nef`, `.nrw` | Embedded preview or lazy LibRaw; EXIF, XMP, and IPTC |
|
||||
| `sony` | Sony RAW: `.arw`, `.arq` | Embedded preview or lazy LibRaw; EXIF, XMP, and IPTC |
|
||||
|
||||
The default enables every group:
|
||||
|
||||
```dotenv
|
||||
ENABLED_FORMATS=jpg,png,tiff,heif,dng,canon,nikon,sony
|
||||
```
|
||||
|
||||
For a JPEG-only installation:
|
||||
|
||||
```dotenv
|
||||
ENABLED_FORMATS=jpg
|
||||
```
|
||||
|
||||
Disabled formats are omitted from folder scans and rejected by the backend. The page subtitle and empty-folder message reflect the enabled groups. Unknown group names stop application startup with a clear configuration error instead of silently enabling or disabling the wrong format.
|
||||
|
||||
HEIF and RAW decoders load lazily. A JPEG-only process does not import them. After the first applicable preview, the decoder remains loaded until the container restarts; repeatedly unloading it would add avoidable delay and memory churn. For DNG, Canon, Nikon, and Sony RAW, LibRaw is loaded only if no usable embedded JPEG preview is available.
|
||||
|
||||
## Upgrade from earlier releases
|
||||
|
||||
Replace the project files with this version and rebuild:
|
||||
|
||||
@@ -80,7 +118,7 @@ Replace the project files with this version and rebuild:
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
The existing `.env` can be kept. `APP_TITLE` and `APP_SUBTITLE` are optional. When `APP_TITLE` is omitted, the visible page title continues to use `APP_NAME`; the subtitle uses the built-in format description when omitted. Browser progress from earlier releases remains compatible. A normal refresh should load the new service-worker cache; use a hard refresh if an old UI remains visible.
|
||||
The existing `.env` can be kept. When `ENABLED_FORMATS` is omitted, all current format groups remain enabled. `APP_TITLE` and `APP_SUBTITLE` are optional. When `APP_TITLE` is omitted, the visible page title continues to use `APP_NAME`; when `APP_SUBTITLE` is omitted, it is generated from the enabled formats. Browser progress from earlier releases remains compatible. A normal refresh should load the new service-worker cache; use a hard refresh if an old UI remains visible.
|
||||
|
||||
## Caddy example
|
||||
|
||||
@@ -102,7 +140,7 @@ The browser directory picker requires a secure context. Use a certificate truste
|
||||
|
||||
## What happens when Save is clicked
|
||||
|
||||
1. The browser reads the selected local JPG, PNG, TIFF, HEIC, HEIF, or HIF.
|
||||
1. The browser reads the selected local file in any enabled format.
|
||||
2. It sends the file and entered fields to `/api/process`.
|
||||
3. ExifTool modifies a temporary file inside the container using `-overwrite_original`.
|
||||
4. The backend returns the modified file using its original media type.
|
||||
@@ -111,12 +149,23 @@ 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. 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.
|
||||
|
||||
During a save, the backend streams the processed file in 256 KiB chunks and the browser writes each chunk directly into its protected temporary writable file. The UI shows transferred bytes and percentage. The original is replaced only after the complete stream closes successfully; failures abort the temporary writable file. Browsers without response streaming use the previous buffered Blob workflow.
|
||||
|
||||
After a streamed save, the status panel and popup show total time, time to first byte, and combined stream/write time. Container logs also show end-to-end request duration and break inspection work into temporary-copy, preview, and metadata stages.
|
||||
|
||||
## Metadata behavior by format
|
||||
|
||||
- **JPG/JPEG:** EXIF, XMP, and IPTC fields are written.
|
||||
- **TIFF/TIF:** EXIF, XMP, and IPTC fields are written.
|
||||
- **PNG:** EXIF and XMP fields are written. PNG metadata support varies more between third-party viewers than JPEG/TIFF support, so test the applications that will consume the files.
|
||||
- **HEIC/HEIF/HIF:** EXIF and XMP fields are written. IPTC-IIM is not used for these ISO Base Media File Format containers. Date precision, description, keywords, GPS coordinates, and the location label remain represented through EXIF/XMP fields.
|
||||
- **DNG:** EXIF, XMP, and IPTC fields are written into the original TIFF-based DNG container. Raw sensor data and embedded previews are not regenerated or replaced.
|
||||
- **CR2:** EXIF, XMP, and IPTC fields are written into the original TIFF-based Canon RAW container.
|
||||
- **CR3:** EXIF and XMP fields are written into the original ISO Base Media File Format container. IPTC-IIM is not used for CR3.
|
||||
- **NEF/NRW:** EXIF, XMP, and IPTC fields are written into the original TIFF-based Nikon RAW container.
|
||||
- **ARW/ARQ:** EXIF, XMP, and IPTC fields are written into the original TIFF-based Sony RAW container.
|
||||
|
||||
The app writes its precision marker to XMP for all supported formats. GPS coordinates are written to EXIF and XMP. The optional location label is written to XMP IPTC Core Location.
|
||||
|
||||
@@ -162,10 +211,19 @@ The default map tiles and address search are external OpenStreetMap services. Bo
|
||||
- Browser permission may need to be granted again after closing/reopening the browser.
|
||||
- Saved and skipped progress is stored per browser and folder name. The app also reads its own XMP marker from edited files so saved metadata can be recognized on another browser.
|
||||
- Overwriting a file through the browser generally changes its filesystem modified time to the time of the save. EXIF/XMP photo dates are independent of that filesystem timestamp.
|
||||
- Preview rotation is visual only; it does not rotate image pixels or write orientation metadata.
|
||||
- Preview rotations are physically applied when saving JPG, PNG, and TIFF files. Orientation metadata is normalized after the pixels are rotated.
|
||||
- JPEG rotation uses lossless `jpegtran` when the dimensions permit a perfect transform. Otherwise it performs one high-quality re-encode and reports that in the save notification.
|
||||
- PNG rotation is lossless. Animated PNG files are deliberately rejected rather than silently dropping frames.
|
||||
- TIFF rotation preserves and rotates all pages, uses lossless TIFF compression, and restores metadata before applying the edited fields.
|
||||
- HEIF, DNG, CR2, CR3, NEF, NRW, ARW, and ARQ rotation remains preview-only. Their pixels and orientation metadata are never changed by the rotation controls.
|
||||
- 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.
|
||||
- Very large TIFF or HEIF files may require raising `MAX_UPLOAD_MB` or `PREVIEW_MAX_PIXELS`.
|
||||
- DNG, CR2, CR3, NEF, NRW, ARW, and ARQ use the first usable embedded JPEG in a format-specific priority order 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, or RAW 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.
|
||||
- Canon, Nikon, and Sony 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.
|
||||
- [ExifTool documents](https://exiftool.org/#supported) that some Sony Imaging Edge versions may reject ARW/ARQ files after ExifTool edits even though Adobe, Apple, Capture One, Affinity Photo, and LibRaw-based software can open them. Test Sony copies in the exact software you intend to use.
|
||||
- HEIF metadata compatibility varies between operating-system galleries and photo-management applications. Verify the fields in the software that will consume your library.
|
||||
- The Docker host needs outbound HTTPS access for the default map tiles and address search.
|
||||
- Test with copies first, then use your normal backup routine for the originals.
|
||||
@@ -174,11 +232,9 @@ The default map tiles and address search are external OpenStreetMap services. Bo
|
||||
|
||||
Format support needs both safe metadata writing and a browser-friendly preview. Formats that browsers do not reliably display will use a temporary server-generated preview without converting or replacing the original file.
|
||||
|
||||
### Proposed order
|
||||
### Remaining considerations
|
||||
|
||||
1. **DNG** — in-place metadata writing and preview extraction/rendering.
|
||||
2. **Canon RAW** — `CR2` and `CR3`, using per-format tag rules and embedded/server-generated previews.
|
||||
3. **Nikon RAW** — `NEF` and `NRW`, using per-format tag rules and embedded/server-generated previews.
|
||||
Older Sony `SR2` remains a possible compatibility addition. `SRF` is read-only in ExifTool and remains excluded.
|
||||
|
||||
`RAW` is not treated as one universal format. Each camera family will be enabled and tested explicitly.
|
||||
|
||||
|
||||
+527
-89
@@ -6,6 +6,7 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
@@ -19,33 +20,100 @@ 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.responses import FileResponse, Response, StreamingResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from PIL import Image, ImageOps, UnidentifiedImageError
|
||||
from pillow_heif import register_heif_opener
|
||||
from PIL import ImageSequence, JpegImagePlugin
|
||||
|
||||
register_heif_opener()
|
||||
FORMAT_GROUPS: dict[str, tuple[str, ...]] = {
|
||||
"jpg": (".jpg", ".jpeg"),
|
||||
"png": (".png",),
|
||||
"tiff": (".tif", ".tiff"),
|
||||
"heif": (".heic", ".heif", ".hif"),
|
||||
"dng": (".dng",),
|
||||
"canon": (".cr2", ".cr3"),
|
||||
"nikon": (".nef", ".nrw"),
|
||||
"sony": (".arw", ".arq"),
|
||||
}
|
||||
FORMAT_LABELS = {
|
||||
"jpg": "JPG",
|
||||
"png": "PNG",
|
||||
"tiff": "TIFF",
|
||||
"heif": "HEIF",
|
||||
"dng": "DNG",
|
||||
"canon": "Canon RAW",
|
||||
"nikon": "Nikon RAW",
|
||||
"sony": "Sony RAW",
|
||||
}
|
||||
|
||||
|
||||
def _enabled_format_groups() -> tuple[str, ...]:
|
||||
configured = os.getenv("ENABLED_FORMATS", ",".join(FORMAT_GROUPS))
|
||||
requested = tuple(dict.fromkeys(
|
||||
value.strip().casefold()
|
||||
for value in configured.split(",")
|
||||
if value.strip()
|
||||
))
|
||||
unknown = sorted(set(requested) - set(FORMAT_GROUPS))
|
||||
if unknown:
|
||||
raise RuntimeError(
|
||||
"Unknown ENABLED_FORMATS value(s): "
|
||||
f"{', '.join(unknown)}. Available groups: {', '.join(FORMAT_GROUPS)}."
|
||||
)
|
||||
if not requested:
|
||||
raise RuntimeError("ENABLED_FORMATS must contain at least one format group.")
|
||||
return tuple(group for group in FORMAT_GROUPS if group in requested)
|
||||
|
||||
|
||||
def _format_list_text(groups: tuple[str, ...]) -> str:
|
||||
labels = [FORMAT_LABELS[group] for group in groups]
|
||||
if len(labels) == 1:
|
||||
return labels[0]
|
||||
if len(labels) == 2:
|
||||
return " and ".join(labels)
|
||||
return f"{', '.join(labels[:-1])} and {labels[-1]}"
|
||||
|
||||
|
||||
ENABLED_FORMAT_GROUPS = _enabled_format_groups()
|
||||
SUPPORTED_EXTENSIONS = {
|
||||
extension
|
||||
for group in ENABLED_FORMAT_GROUPS
|
||||
for extension in FORMAT_GROUPS[group]
|
||||
}
|
||||
ENABLED_FORMAT_LABEL = _format_list_text(ENABLED_FORMAT_GROUPS)
|
||||
|
||||
APP_NAME = os.getenv("APP_NAME", "Photo Date Editor")
|
||||
APP_TITLE = os.getenv("APP_TITLE") or APP_NAME
|
||||
APP_SUBTITLE = (
|
||||
os.getenv("APP_SUBTITLE")
|
||||
or "Local folder · in-place JPG, PNG, TIFF and HEIF metadata"
|
||||
or f"Local folder · in-place {ENABLED_FORMAT_LABEL} metadata"
|
||||
)
|
||||
APP_URL = os.getenv("APP_URL", "http://localhost:8080")
|
||||
APP_VERSION = "0.6.1"
|
||||
APP_VERSION = "0.9.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"))
|
||||
RAW_PREVIEW_MAX_EDGE = int(os.getenv("RAW_PREVIEW_MAX_EDGE", "1600"))
|
||||
PREVIEW_MAX_PIXELS = int(os.getenv("PREVIEW_MAX_PIXELS", "300000000"))
|
||||
Image.MAX_IMAGE_PIXELS = PREVIEW_MAX_PIXELS
|
||||
|
||||
SUPPORTED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".tif", ".tiff", ".heic", ".heif", ".hif"}
|
||||
TIFF_EXTENSIONS = {".tif", ".tiff"}
|
||||
PIXEL_ROTATION_EXTENSIONS = {".jpg", ".jpeg", ".png", ".tif", ".tiff"}
|
||||
HEIF_EXTENSIONS = {".heic", ".heif", ".hif"}
|
||||
SERVER_PREVIEW_EXTENSIONS = TIFF_EXTENSIONS | HEIF_EXTENSIONS
|
||||
DNG_EXTENSIONS = {".dng"}
|
||||
CANON_EXTENSIONS = {".cr2", ".cr3"}
|
||||
NIKON_EXTENSIONS = {".nef", ".nrw"}
|
||||
SONY_EXTENSIONS = {".arw", ".arq"}
|
||||
RAW_EXTENSIONS = DNG_EXTENSIONS | CANON_EXTENSIONS | NIKON_EXTENSIONS | SONY_EXTENSIONS
|
||||
IPTC_EXTENSIONS = {
|
||||
".jpg", ".jpeg", ".tif", ".tiff", ".dng", ".cr2",
|
||||
".nef", ".nrw", ".arw", ".arq",
|
||||
}
|
||||
SERVER_PREVIEW_EXTENSIONS = (
|
||||
TIFF_EXTENSIONS | HEIF_EXTENSIONS | RAW_EXTENSIONS
|
||||
) & SUPPORTED_EXTENSIONS
|
||||
MEDIA_TYPES = {
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
@@ -55,6 +123,13 @@ MEDIA_TYPES = {
|
||||
".heic": "image/heic",
|
||||
".heif": "image/heif",
|
||||
".hif": "image/heif",
|
||||
".dng": "image/x-adobe-dng",
|
||||
".cr2": "image/x-canon-cr2",
|
||||
".cr3": "image/x-canon-cr3",
|
||||
".nef": "image/x-nikon-nef",
|
||||
".nrw": "image/x-nikon-nrw",
|
||||
".arw": "image/x-sony-arw",
|
||||
".arq": "image/x-sony-arq",
|
||||
}
|
||||
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper()
|
||||
|
||||
@@ -81,6 +156,9 @@ _last_geocode_request = 0.0
|
||||
_tile_cache: OrderedDict[str, tuple[bytes, str]] = OrderedDict()
|
||||
_tile_cache_limit = 512
|
||||
_tile_lock = asyncio.Lock()
|
||||
_decoder_lock = threading.Lock()
|
||||
_heif_decoder_registered = False
|
||||
_rawpy_module: Any | None = None
|
||||
|
||||
logging.basicConfig(
|
||||
level=getattr(logging, LOG_LEVEL, logging.INFO),
|
||||
@@ -104,13 +182,24 @@ 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}
|
||||
|
||||
|
||||
@app.get("/api/config")
|
||||
def config() -> dict[str, str | int | float]:
|
||||
def config() -> dict[str, Any]:
|
||||
return {
|
||||
"appName": APP_NAME,
|
||||
"appTitle": APP_TITLE,
|
||||
@@ -118,6 +207,9 @@ def config() -> dict[str, str | int | float]:
|
||||
"appUrl": APP_URL,
|
||||
"version": APP_VERSION,
|
||||
"maxUploadMb": MAX_UPLOAD_MB,
|
||||
"enabledFormats": list(ENABLED_FORMAT_GROUPS),
|
||||
"enabledExtensions": sorted(SUPPORTED_EXTENSIONS),
|
||||
"enabledFormatLabel": ENABLED_FORMAT_LABEL,
|
||||
"mapTileUrl": "/api/map/tiles/{z}/{x}/{y}.png",
|
||||
"mapAttribution": MAP_ATTRIBUTION,
|
||||
"defaultMapLat": DEFAULT_MAP_LAT,
|
||||
@@ -406,6 +498,20 @@ def _format_name(suffix: str) -> str:
|
||||
return "TIFF"
|
||||
if suffix in HEIF_EXTENSIONS:
|
||||
return "HEIF"
|
||||
if suffix in DNG_EXTENSIONS:
|
||||
return "DNG"
|
||||
if suffix == ".cr2":
|
||||
return "CR2"
|
||||
if suffix == ".cr3":
|
||||
return "CR3"
|
||||
if suffix == ".nef":
|
||||
return "NEF"
|
||||
if suffix == ".nrw":
|
||||
return "NRW"
|
||||
if suffix == ".arw":
|
||||
return "ARW"
|
||||
if suffix == ".arq":
|
||||
return "ARQ"
|
||||
return suffix.lstrip(".").upper() or "image"
|
||||
|
||||
|
||||
@@ -429,6 +535,28 @@ def _validate_image_signature(path: Path, suffix: str) -> None:
|
||||
signature[offset:offset + 4] in hevc_brands
|
||||
for offset in range(8, len(signature) - 3, 4)
|
||||
)
|
||||
elif suffix in DNG_EXTENSIONS:
|
||||
# DNG is TIFF-based. ExifTool performs the deeper DNG structure
|
||||
# validation when reading, extracting a preview, or writing metadata.
|
||||
valid = signature.startswith((b"II*\x00", b"MM\x00*"))
|
||||
elif suffix == ".cr2":
|
||||
valid = (
|
||||
signature.startswith((b"II*\x00", b"MM\x00*"))
|
||||
and signature[8:12] == b"CR\x02\x00"
|
||||
)
|
||||
elif suffix == ".cr3":
|
||||
valid = (
|
||||
len(signature) >= 16
|
||||
and signature[4:8] == b"ftyp"
|
||||
and any(
|
||||
signature[offset:offset + 4] == b"crx "
|
||||
for offset in range(8, len(signature) - 3, 4)
|
||||
)
|
||||
)
|
||||
elif suffix in NIKON_EXTENSIONS | SONY_EXTENSIONS:
|
||||
# NEF, NRW, ARW, and ARQ are TIFF-based. ExifTool performs deeper
|
||||
# camera-format validation when the file is inspected or written.
|
||||
valid = signature.startswith((b"II*\x00", b"MM\x00*"))
|
||||
|
||||
if not valid:
|
||||
raise HTTPException(
|
||||
@@ -573,48 +701,353 @@ def _metadata_values(metadata: dict[str, Any]) -> tuple[bool, bool, dict[str, An
|
||||
return has_metadata, edited_by_app, values
|
||||
|
||||
|
||||
@app.post("/api/preview")
|
||||
async def create_preview(file: Annotated[UploadFile, File(...)]) -> Response:
|
||||
"""Generate a temporary browser-friendly preview for TIFF and HEIF images."""
|
||||
filename = file.filename or "photo.tiff"
|
||||
suffix = Path(filename).suffix.lower()
|
||||
if suffix not in SERVER_PREVIEW_EXTENSIONS:
|
||||
raise HTTPException(
|
||||
status_code=415,
|
||||
detail="Server-generated previews are only required for TIFF and HEIF files.",
|
||||
)
|
||||
def _ensure_heif_decoder() -> None:
|
||||
"""Register pillow-heif once, on the first HEIF preview request."""
|
||||
global _heif_decoder_registered
|
||||
if _heif_decoder_registered:
|
||||
return
|
||||
with _decoder_lock:
|
||||
if _heif_decoder_registered:
|
||||
return
|
||||
from pillow_heif import register_heif_opener
|
||||
|
||||
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)
|
||||
register_heif_opener()
|
||||
_heif_decoder_registered = True
|
||||
logger.info("HEIF preview decoder loaded.")
|
||||
|
||||
|
||||
def _get_rawpy() -> Any:
|
||||
"""Import rawpy once, on the first RAW file that needs a rendered fallback."""
|
||||
global _rawpy_module
|
||||
if _rawpy_module is not None:
|
||||
return _rawpy_module
|
||||
with _decoder_lock:
|
||||
if _rawpy_module is None:
|
||||
import rawpy
|
||||
|
||||
_rawpy_module = rawpy
|
||||
logger.info("LibRaw preview decoder loaded.")
|
||||
return _rawpy_module
|
||||
|
||||
|
||||
def _raw_preview_tags(suffix: str) -> tuple[str, ...]:
|
||||
if suffix == ".cr2":
|
||||
return ("PreviewImage", "JpgFromRaw", "ThumbnailImage")
|
||||
if suffix == ".cr3":
|
||||
return ("JpgFromRaw", "PreviewImage", "ThumbnailImage")
|
||||
if suffix in NIKON_EXTENSIONS:
|
||||
return ("JpgFromRaw", "PreviewImage", "OtherImage", "ThumbnailImage")
|
||||
if suffix in SONY_EXTENSIONS:
|
||||
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:
|
||||
completed = subprocess.run(
|
||||
["exiftool", "-b", f"-{tag}", "--", str(path)],
|
||||
capture_output=True,
|
||||
timeout=45,
|
||||
check=False,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning("Timed out extracting %s %s data.", format_name, tag)
|
||||
continue
|
||||
if completed.returncode != 0 or not completed.stdout:
|
||||
continue
|
||||
try:
|
||||
with Image.open(io.BytesIO(completed.stdout)) as source:
|
||||
source.load()
|
||||
preview = source.copy()
|
||||
logger.info("Using %s for %s preview.", tag, format_name)
|
||||
return preview
|
||||
except (UnidentifiedImageError, OSError, ValueError):
|
||||
logger.debug("%s %s data was not a usable image preview.", format_name, tag)
|
||||
return None
|
||||
|
||||
|
||||
def _render_raw(path: Path, format_name: str) -> Image.Image:
|
||||
"""Render a half-size RGB fallback when RAW has no embedded JPEG preview."""
|
||||
rawpy = _get_rawpy()
|
||||
try:
|
||||
with rawpy.imread(str(path)) as raw:
|
||||
width = int(raw.sizes.width)
|
||||
height = int(raw.sizes.height)
|
||||
if width <= 0 or height <= 0 or width * height > PREVIEW_MAX_PIXELS:
|
||||
raise ValueError(
|
||||
f"{format_name} dimensions exceed the configured preview pixel limit."
|
||||
)
|
||||
rgb = raw.postprocess(
|
||||
use_camera_wb=True,
|
||||
use_auto_wb=False,
|
||||
no_auto_bright=False,
|
||||
half_size=True,
|
||||
output_bps=8,
|
||||
)
|
||||
except rawpy.LibRawError as exc:
|
||||
raise ValueError(f"LibRaw could not decode this {format_name} file.") from exc
|
||||
return Image.fromarray(rgb)
|
||||
|
||||
|
||||
def _transpose_for_clockwise_rotation(image: Image.Image, rotation: int) -> Image.Image:
|
||||
operation = {
|
||||
90: Image.Transpose.ROTATE_270,
|
||||
180: Image.Transpose.ROTATE_180,
|
||||
270: Image.Transpose.ROTATE_90,
|
||||
}[rotation]
|
||||
return image.transpose(operation)
|
||||
|
||||
|
||||
def _copy_metadata(source: Path, destination: Path) -> None:
|
||||
completed = subprocess.run(
|
||||
[
|
||||
"exiftool",
|
||||
"-overwrite_original",
|
||||
"-m",
|
||||
"-TagsFromFile",
|
||||
str(source),
|
||||
"-all:all",
|
||||
str(destination),
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=90,
|
||||
check=False,
|
||||
)
|
||||
if completed.returncode != 0:
|
||||
logger.error("ExifTool metadata restore after rotation failed: %s", completed.stderr.strip())
|
||||
raise ValueError("Metadata could not be preserved after rotating the image.")
|
||||
|
||||
|
||||
def _rotate_jpeg(source: Path, destination: Path, rotation: int) -> str:
|
||||
with Image.open(source) as image:
|
||||
orientation = int(image.getexif().get(274, 1) or 1)
|
||||
|
||||
if orientation == 1:
|
||||
completed = subprocess.run(
|
||||
[
|
||||
"jpegtran",
|
||||
"-copy",
|
||||
"all",
|
||||
"-perfect",
|
||||
"-rotate",
|
||||
str(rotation),
|
||||
"-outfile",
|
||||
str(destination),
|
||||
str(source),
|
||||
],
|
||||
capture_output=True,
|
||||
timeout=90,
|
||||
check=False,
|
||||
)
|
||||
if completed.returncode == 0 and destination.exists():
|
||||
return "jpeg-lossless"
|
||||
|
||||
with Image.open(source) as image:
|
||||
image.load()
|
||||
corrected = ImageOps.exif_transpose(image)
|
||||
rotated = _transpose_for_clockwise_rotation(corrected, rotation)
|
||||
try:
|
||||
sampling = JpegImagePlugin.get_sampling(image)
|
||||
save_options: dict[str, Any] = {
|
||||
"format": "JPEG",
|
||||
"quality": 95,
|
||||
"optimize": True,
|
||||
"progressive": bool(image.info.get("progressive") or image.info.get("progression")),
|
||||
}
|
||||
if sampling >= 0:
|
||||
save_options["subsampling"] = sampling
|
||||
if image.info.get("icc_profile"):
|
||||
save_options["icc_profile"] = image.info["icc_profile"]
|
||||
rotated.save(destination, **save_options)
|
||||
finally:
|
||||
if corrected is not image:
|
||||
corrected.close()
|
||||
rotated.close()
|
||||
_copy_metadata(source, destination)
|
||||
return "jpeg-reencoded"
|
||||
|
||||
|
||||
def _rotate_png_or_tiff(source: Path, destination: Path, suffix: str, rotation: int) -> str:
|
||||
with Image.open(source) as image:
|
||||
image.seek(0)
|
||||
if suffix == ".png" and int(getattr(image, "n_frames", 1)) > 1:
|
||||
raise ValueError("Animated PNG rotation is not supported.")
|
||||
frames: list[Image.Image] = []
|
||||
for frame in ImageSequence.Iterator(image):
|
||||
base = frame.copy()
|
||||
corrected = ImageOps.exif_transpose(base)
|
||||
if corrected is not base:
|
||||
base.close()
|
||||
rotated = _transpose_for_clockwise_rotation(corrected, rotation)
|
||||
corrected.close()
|
||||
frames.append(rotated)
|
||||
if not frames:
|
||||
raise ValueError("The image contains no rotatable frames.")
|
||||
try:
|
||||
if suffix == ".png":
|
||||
options: dict[str, Any] = {"format": "PNG", "optimize": True}
|
||||
if image.info.get("icc_profile"):
|
||||
options["icc_profile"] = image.info["icc_profile"]
|
||||
if image.info.get("dpi"):
|
||||
options["dpi"] = image.info["dpi"]
|
||||
frames[0].save(destination, **options)
|
||||
mode = "png-lossless"
|
||||
else:
|
||||
options = {
|
||||
"format": "TIFF",
|
||||
"save_all": len(frames) > 1,
|
||||
"append_images": frames[1:],
|
||||
"compression": image.info.get("compression", "tiff_deflate"),
|
||||
}
|
||||
if image.info.get("icc_profile"):
|
||||
options["icc_profile"] = image.info["icc_profile"]
|
||||
if image.info.get("dpi"):
|
||||
options["dpi"] = image.info["dpi"]
|
||||
frames[0].save(destination, **options)
|
||||
mode = "tiff-lossless-reencode"
|
||||
finally:
|
||||
for frame in frames:
|
||||
frame.close()
|
||||
_copy_metadata(source, destination)
|
||||
return mode
|
||||
|
||||
|
||||
def _rotate_pixels(path: Path, suffix: str, rotation: int) -> str:
|
||||
source = path.with_name(f"rotation-source{suffix}")
|
||||
destination = path.with_name(f"rotation-output{suffix}")
|
||||
shutil.copy2(path, source)
|
||||
try:
|
||||
if suffix in {".jpg", ".jpeg"}:
|
||||
mode = _rotate_jpeg(source, destination, rotation)
|
||||
else:
|
||||
mode = _rotate_png_or_tiff(source, destination, suffix, rotation)
|
||||
destination.replace(path)
|
||||
return mode
|
||||
except (Image.DecompressionBombError, UnidentifiedImageError, OSError, ValueError) as exc:
|
||||
logger.warning("Pixel rotation failed for %s: %s", path.name, exc)
|
||||
raise HTTPException(status_code=422, detail=f"The image could not be rotated safely: {exc}") from exc
|
||||
|
||||
|
||||
def _preview_bytes(temp_path: Path, suffix: str, filename: str) -> bytes:
|
||||
try:
|
||||
if suffix in RAW_EXTENSIONS:
|
||||
format_name = _format_name(suffix)
|
||||
preview = _embedded_raw_preview(temp_path, format_name, suffix)
|
||||
if preview is None:
|
||||
preview = _render_raw(temp_path, format_name)
|
||||
preview = ImageOps.exif_transpose(preview)
|
||||
else:
|
||||
if suffix in HEIF_EXTENSIONS:
|
||||
_ensure_heif_decoder()
|
||||
with Image.open(temp_path) as source:
|
||||
source.seek(0)
|
||||
preview = ImageOps.exif_transpose(source)
|
||||
preview.thumbnail((PREVIEW_MAX_EDGE, PREVIEW_MAX_EDGE), Image.Resampling.LANCZOS)
|
||||
preview.load()
|
||||
|
||||
if preview.mode in {"RGBA", "LA"} or (preview.mode == "P" and "transparency" in preview.info):
|
||||
rgba = preview.convert("RGBA")
|
||||
background = Image.new("RGBA", rgba.size, (255, 255, 255, 255))
|
||||
background.alpha_composite(rgba)
|
||||
preview = background.convert("RGB")
|
||||
elif preview.mode != "RGB":
|
||||
preview = preview.convert("RGB")
|
||||
try:
|
||||
max_edge = RAW_PREVIEW_MAX_EDGE if suffix in RAW_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):
|
||||
rgba = preview.convert("RGBA")
|
||||
background = Image.new("RGBA", rgba.size, (255, 255, 255, 255))
|
||||
background.alpha_composite(rgba)
|
||||
preview = background.convert("RGB")
|
||||
elif preview.mode != "RGB":
|
||||
preview = preview.convert("RGB")
|
||||
output = io.BytesIO()
|
||||
preview.save(output, format="JPEG", quality=86, optimize=True)
|
||||
return output.getvalue()
|
||||
finally:
|
||||
preview.close()
|
||||
except (Image.DecompressionBombError, UnidentifiedImageError, OSError, ValueError) as exc:
|
||||
format_name = _format_name(suffix)
|
||||
logger.warning("%s preview generation failed for %s: %s", format_name, filename, exc)
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"The {format_name} image could not be rendered for preview.",
|
||||
) from exc
|
||||
|
||||
output = io.BytesIO()
|
||||
preview.save(output, format="JPEG", quality=88, optimize=True)
|
||||
except (Image.DecompressionBombError, UnidentifiedImageError, OSError, ValueError) as exc:
|
||||
format_name = _format_name(suffix)
|
||||
logger.warning("%s preview generation failed for %s: %s", format_name, filename, exc)
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"The {format_name} image could not be rendered for preview.",
|
||||
) from exc
|
||||
|
||||
def _read_metadata_path(temp_path: Path) -> dict[str, bool | dict[str, Any]]:
|
||||
completed = subprocess.run(
|
||||
[
|
||||
"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(...)]) -> 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)
|
||||
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=output.getvalue(),
|
||||
media_type="image/jpeg",
|
||||
headers={"Cache-Control": "no-store"},
|
||||
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}'
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -625,53 +1058,14 @@ async def read_metadata(file: Annotated[UploadFile, File(...)]) -> dict[str, boo
|
||||
if suffix not in SUPPORTED_EXTENSIONS:
|
||||
raise HTTPException(
|
||||
status_code=415,
|
||||
detail="Supported formats are JPG, JPEG, PNG, TIF, TIFF, HEIC, HEIF, and HIF.",
|
||||
detail=f"This format is disabled. Enabled format groups: {ENABLED_FORMAT_LABEL}.",
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="photo-date-editor-read-") as temp_dir:
|
||||
temp_path = Path(temp_dir) / f"source{suffix}"
|
||||
await _save_upload(file, temp_path, suffix)
|
||||
completed = subprocess.run(
|
||||
[
|
||||
"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}
|
||||
result = _read_metadata_path(temp_path)
|
||||
return result
|
||||
|
||||
|
||||
@app.post("/api/process")
|
||||
@@ -688,13 +1082,21 @@ async def process_photo(
|
||||
latitude: Annotated[float | None, Form()] = None,
|
||||
longitude: Annotated[float | None, Form()] = None,
|
||||
location_name: Annotated[str, Form()] = "",
|
||||
rotation: Annotated[int, Form()] = 0,
|
||||
) -> Response:
|
||||
filename = file.filename or "photo.jpg"
|
||||
suffix = Path(filename).suffix.lower()
|
||||
if suffix not in SUPPORTED_EXTENSIONS:
|
||||
raise HTTPException(
|
||||
status_code=415,
|
||||
detail="Supported formats are JPG, JPEG, PNG, TIF, TIFF, HEIC, HEIF, and HIF.",
|
||||
detail=f"This format is disabled. Enabled format groups: {ENABLED_FORMAT_LABEL}.",
|
||||
)
|
||||
if rotation not in {0, 90, 180, 270}:
|
||||
raise HTTPException(status_code=422, detail="Rotation must be 0, 90, 180, or 270 degrees.")
|
||||
if rotation and suffix not in PIXEL_ROTATION_EXTENSIONS:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"Pixel rotation is not supported for {_format_name(suffix)} files.",
|
||||
)
|
||||
|
||||
exif_datetime, xmp_date, precision_label = _normalise_datetime(
|
||||
@@ -724,9 +1126,17 @@ async def process_photo(
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="photo-date-editor-") as temp_dir:
|
||||
temp_path = Path(temp_dir) / f"working{suffix}"
|
||||
upload_started = time.perf_counter()
|
||||
await _save_upload(file, temp_path, suffix)
|
||||
upload_seconds = time.perf_counter() - upload_started
|
||||
rotation_mode = "none"
|
||||
rotation_seconds = 0.0
|
||||
if rotation:
|
||||
rotation_started = time.perf_counter()
|
||||
rotation_mode = _rotate_pixels(temp_path, suffix, rotation)
|
||||
rotation_seconds = time.perf_counter() - rotation_started
|
||||
|
||||
supports_iptc = suffix in {".jpg", ".jpeg", ".tif", ".tiff"}
|
||||
supports_iptc = suffix in IPTC_EXTENSIONS
|
||||
command = [
|
||||
"exiftool",
|
||||
"-overwrite_original",
|
||||
@@ -746,6 +1156,8 @@ async def process_photo(
|
||||
]
|
||||
if supports_iptc:
|
||||
command.extend(["-IPTC:Caption-Abstract=", "-IPTC:Keywords="])
|
||||
if rotation:
|
||||
command.extend(["-EXIF:Orientation=1", "-XMP-tiff:Orientation=1"])
|
||||
|
||||
clean_description = description.strip()
|
||||
if clean_description:
|
||||
@@ -796,26 +1208,52 @@ async def process_photo(
|
||||
command.append(str(temp_path))
|
||||
|
||||
logger.info("Processing %s with precision %s", filename, precision)
|
||||
exiftool_started = time.perf_counter()
|
||||
completed = subprocess.run(
|
||||
command,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=90,
|
||||
timeout=180 if suffix in RAW_EXTENSIONS else 90,
|
||||
check=False,
|
||||
)
|
||||
exiftool_seconds = time.perf_counter() - exiftool_started
|
||||
if completed.returncode != 0:
|
||||
logger.error("ExifTool failed: %s", completed.stderr.strip())
|
||||
raise HTTPException(status_code=500, detail="ExifTool could not update this image.")
|
||||
|
||||
response_started = time.perf_counter()
|
||||
updated_bytes = temp_path.read_bytes()
|
||||
response_read_seconds = time.perf_counter() - response_started
|
||||
logger.info(
|
||||
"Processed %s: upload %.2fs, rotation %.2fs (%s), ExifTool %.2fs, response read %.2fs",
|
||||
filename,
|
||||
upload_seconds,
|
||||
rotation_seconds,
|
||||
rotation_mode,
|
||||
exiftool_seconds,
|
||||
response_read_seconds,
|
||||
)
|
||||
|
||||
return Response(
|
||||
content=updated_bytes,
|
||||
def response_chunks() -> Any:
|
||||
chunk_size = 256 * 1024
|
||||
for offset in range(0, len(updated_bytes), chunk_size):
|
||||
yield updated_bytes[offset:offset + chunk_size]
|
||||
|
||||
return StreamingResponse(
|
||||
response_chunks(),
|
||||
media_type=MEDIA_TYPES[suffix],
|
||||
headers={
|
||||
"Content-Disposition": f'inline; filename="{Path(filename).name}"',
|
||||
"Content-Length": str(len(updated_bytes)),
|
||||
"Cache-Control": "no-store",
|
||||
"X-Date-Precision": precision_label,
|
||||
"X-Pixel-Rotation": rotation_mode,
|
||||
"Server-Timing": (
|
||||
f'upload;dur={upload_seconds * 1000:.1f}, '
|
||||
f'rotation;dur={rotation_seconds * 1000:.1f}, '
|
||||
f'exiftool;dur={exiftool_seconds * 1000:.1f}, '
|
||||
f'read;dur={response_read_seconds * 1000:.1f}'
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
+209
-29
@@ -13,6 +13,9 @@ const state = {
|
||||
mapMarker: null,
|
||||
locationDirty: false,
|
||||
favoriteLocations: [],
|
||||
configPromise: null,
|
||||
enabledExtensions: new Set(),
|
||||
enabledFormatLabel: '',
|
||||
};
|
||||
|
||||
const $ = (selector) => document.querySelector(selector);
|
||||
@@ -37,6 +40,7 @@ const elements = {
|
||||
viewerEmpty: $('#viewer-empty'),
|
||||
viewerContent: $('#viewer-content'),
|
||||
preview: $('#photo-preview'),
|
||||
rotationStatus: $('#rotation-status'),
|
||||
filename: $('#current-filename'),
|
||||
filesize: $('#current-filesize'),
|
||||
position: $('#viewer-position'),
|
||||
@@ -120,11 +124,24 @@ function formatType(filename) {
|
||||
if (extension === 'png') return 'PNG';
|
||||
if (extension === 'tif' || extension === 'tiff') return 'TIFF';
|
||||
if (extension === 'heic' || extension === 'heif' || extension === 'hif') return 'HEIF';
|
||||
if (extension === 'dng') return 'DNG';
|
||||
if (extension === 'cr2') return 'CR2';
|
||||
if (extension === 'cr3') return 'CR3';
|
||||
if (extension === 'nef') return 'NEF';
|
||||
if (extension === 'nrw') return 'NRW';
|
||||
if (extension === 'arw') return 'ARW';
|
||||
if (extension === 'arq') return 'ARQ';
|
||||
return extension.toUpperCase();
|
||||
}
|
||||
|
||||
function requiresServerPreview(photo) {
|
||||
return photo.format === 'TIFF' || photo.format === 'HEIF';
|
||||
return ['TIFF', 'HEIF', 'DNG', 'CR2', 'CR3', 'NEF', 'NRW', 'ARW', 'ARQ'].includes(photo.format);
|
||||
}
|
||||
|
||||
function isEnabledFilename(filename) {
|
||||
const dot = filename.lastIndexOf('.');
|
||||
if (dot < 0) return false;
|
||||
return state.enabledExtensions.has(filename.slice(dot).toLowerCase());
|
||||
}
|
||||
|
||||
function storageKey() {
|
||||
@@ -153,14 +170,13 @@ function saveFolderState() {
|
||||
}
|
||||
|
||||
async function openFolder() {
|
||||
await loadFavoriteLocations();
|
||||
|
||||
if (!isSupported()) {
|
||||
showToast('Use a current Chromium browser over HTTPS. Folder access is unavailable here.', true);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await loadAppConfig();
|
||||
await loadFavoriteLocations();
|
||||
if (!isSupported()) {
|
||||
showToast('Use a current Chromium browser over HTTPS. Folder access is unavailable here.', true);
|
||||
return;
|
||||
}
|
||||
const handle = await window.showDirectoryPicker({ mode: 'readwrite', id: 'photo-date-editor' });
|
||||
const permission = await handle.requestPermission({ mode: 'readwrite' });
|
||||
if (permission !== 'granted') throw new Error('Read/write permission was not granted.');
|
||||
@@ -178,7 +194,7 @@ async function scanFolder() {
|
||||
const photos = [];
|
||||
|
||||
for await (const [name, handle] of state.directoryHandle.entries()) {
|
||||
if (handle.kind !== 'file' || !/\.(jpe?g|png|tiff?|heic|heif|hif)$/i.test(name)) continue;
|
||||
if (handle.kind !== 'file' || !isEnabledFilename(name)) continue;
|
||||
const file = await handle.getFile();
|
||||
const previous = stored.photos?.[name] || {};
|
||||
const unchanged = !previous.lastModified || previous.lastModified === file.lastModified;
|
||||
@@ -193,6 +209,7 @@ async function scanFolder() {
|
||||
fileValues: null,
|
||||
metadataLoaded: false,
|
||||
metadataPresent: false,
|
||||
pendingRotation: 0,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -217,7 +234,7 @@ async function scanFolder() {
|
||||
} else {
|
||||
elements.viewerContent.classList.add('hidden');
|
||||
elements.viewerEmpty.classList.remove('hidden');
|
||||
showToast('No JPG, PNG, TIFF, HEIC, HEIF, or HIF files were found in that folder.', true);
|
||||
showToast(`No enabled ${state.enabledFormatLabel} files were found in that folder.`, true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -345,7 +362,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
|
||||
? ` · ${formatSaveTiming(photo.lastSaveTiming)}`
|
||||
: '';
|
||||
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') {
|
||||
@@ -357,6 +377,13 @@ function updatePhotoStatus(photo) {
|
||||
}
|
||||
}
|
||||
|
||||
function formatSaveTiming(timing) {
|
||||
if (timing.streamed) {
|
||||
return `${timing.total.toFixed(1)}s total · ${timing.firstByte.toFixed(1)}s to first byte · ${timing.streamWrite.toFixed(1)}s stream/write`;
|
||||
}
|
||||
return `${timing.total.toFixed(1)}s total · ${timing.serverTransfer.toFixed(1)}s server/transfer · ${timing.localWrite.toFixed(1)}s local write`;
|
||||
}
|
||||
|
||||
async function readPhotoMetadata(photo, file) {
|
||||
if (photo.metadataLoaded) return;
|
||||
const payload = new FormData();
|
||||
@@ -367,7 +394,10 @@ async function readPhotoMetadata(photo, file) {
|
||||
try { message = (await response.json()).detail || message; } catch {}
|
||||
throw new Error(message);
|
||||
}
|
||||
const data = await response.json();
|
||||
applyMetadataResult(photo, await response.json());
|
||||
}
|
||||
|
||||
function applyMetadataResult(photo, data) {
|
||||
photo.metadataLoaded = true;
|
||||
photo.metadataPresent = Boolean(data.hasMetadata);
|
||||
photo.fileValues = data.hasMetadata ? data.values : null;
|
||||
@@ -380,6 +410,34 @@ async function readPhotoMetadata(photo, file) {
|
||||
}
|
||||
}
|
||||
|
||||
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 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 = URL.createObjectURL(new Blob([previewBytes], { type: 'image/jpeg' }));
|
||||
state.thumbUrls.set(photo.name, previewUrl);
|
||||
applyMetadataResult(photo, data);
|
||||
return previewUrl;
|
||||
}
|
||||
|
||||
async function selectPhoto(index, force = false) {
|
||||
if (index < 0 || index >= state.photos.length || (state.processing && !force)) return;
|
||||
captureCurrentValues();
|
||||
@@ -391,7 +449,9 @@ async function selectPhoto(index, force = false) {
|
||||
elements.filename.textContent = photo.name;
|
||||
elements.filesize.textContent = `${photo.format} · ${formatBytes(file.size)} · modified ${new Date(file.lastModified).toLocaleString()}`;
|
||||
elements.position.textContent = `${index + 1} / ${state.photos.length}`;
|
||||
resetView();
|
||||
state.zoom = 1;
|
||||
state.rotation = normalizeRotation(photo.pendingRotation || 0);
|
||||
applyView();
|
||||
restoreValues(photo.values);
|
||||
updatePhotoStatus(photo);
|
||||
updateNavigation();
|
||||
@@ -403,9 +463,17 @@ async function selectPhoto(index, force = false) {
|
||||
if (requiresServerPreview(photo)) {
|
||||
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;
|
||||
elements.preview.src = previewUrl;
|
||||
if (serverInspectionNeeded) {
|
||||
if (!formHasUserInput() && photo.values) restoreValues(photo.values);
|
||||
updatePhotoStatus(photo);
|
||||
}
|
||||
renderFileList();
|
||||
} catch (error) {
|
||||
if (selectionToken === state.selectionToken && state.currentIndex === index) {
|
||||
@@ -562,7 +630,11 @@ async function savePhoto(moveNext) {
|
||||
payload.append('longitude', values.longitude);
|
||||
payload.append('location_name', values.locationName);
|
||||
}
|
||||
const rotationToSave = supportsPixelRotation(photo) ? normalizeRotation(state.rotation) : 0;
|
||||
payload.append('rotation', String(rotationToSave));
|
||||
|
||||
const saveStarted = performance.now();
|
||||
setSaveStatus('saving', 'Uploading and applying changes…', `${photo.name} · large files can take a while`);
|
||||
const response = await fetch('/api/process', { method: 'POST', body: payload });
|
||||
if (!response.ok) {
|
||||
let message = `Save failed (${response.status}).`;
|
||||
@@ -570,12 +642,57 @@ async function savePhoto(moveNext) {
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
const updatedBlob = await response.blob();
|
||||
const responseReceivedAt = performance.now();
|
||||
const rotationMode = response.headers.get('x-pixel-rotation') || 'none';
|
||||
const firstByteSeconds = (responseReceivedAt - saveStarted) / 1000;
|
||||
const writable = await photo.handle.createWritable({ keepExistingData: false });
|
||||
let timing;
|
||||
let responseReader = null;
|
||||
try {
|
||||
await writable.write(updatedBlob);
|
||||
await writable.close();
|
||||
if (response.body && typeof response.body.getReader === 'function') {
|
||||
responseReader = response.body.getReader();
|
||||
const expectedBytes = Number(response.headers.get('content-length')) || 0;
|
||||
let receivedBytes = 0;
|
||||
let lastProgressUpdate = 0;
|
||||
while (true) {
|
||||
const { done, value } = await responseReader.read();
|
||||
if (done) break;
|
||||
await writable.write(value);
|
||||
receivedBytes += value.byteLength;
|
||||
const now = performance.now();
|
||||
if (now - lastProgressUpdate >= 200) {
|
||||
const progress = expectedBytes
|
||||
? `${Math.min(100, (receivedBytes / expectedBytes) * 100).toFixed(0)}% · ${formatBytes(receivedBytes)} / ${formatBytes(expectedBytes)}`
|
||||
: `${formatBytes(receivedBytes)} received`;
|
||||
setSaveStatus('saving', 'Streaming updated file…', `${photo.name} · ${progress}`);
|
||||
lastProgressUpdate = now;
|
||||
}
|
||||
}
|
||||
await writable.close();
|
||||
const completedAt = performance.now();
|
||||
timing = {
|
||||
streamed: true,
|
||||
total: (completedAt - saveStarted) / 1000,
|
||||
firstByte: firstByteSeconds,
|
||||
streamWrite: (completedAt - responseReceivedAt) / 1000,
|
||||
};
|
||||
} else {
|
||||
const updatedBlob = await response.blob();
|
||||
const serverAndTransferSeconds = (performance.now() - saveStarted) / 1000;
|
||||
setSaveStatus('saving', 'Writing updated file locally…', `${photo.name} · buffered compatibility mode`);
|
||||
const localWriteStarted = performance.now();
|
||||
await writable.write(updatedBlob);
|
||||
await writable.close();
|
||||
const localWriteSeconds = (performance.now() - localWriteStarted) / 1000;
|
||||
timing = {
|
||||
streamed: false,
|
||||
total: serverAndTransferSeconds + localWriteSeconds,
|
||||
serverTransfer: serverAndTransferSeconds,
|
||||
localWrite: localWriteSeconds,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
if (responseReader) await responseReader.cancel().catch(() => {});
|
||||
await writable.abort().catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
@@ -590,9 +707,19 @@ async function savePhoto(moveNext) {
|
||||
photo.fileValues = { ...savedValues };
|
||||
photo.metadataLoaded = true;
|
||||
photo.metadataPresent = true;
|
||||
photo.pendingRotation = 0;
|
||||
state.rotation = 0;
|
||||
photo.lastSaveTiming = timing;
|
||||
saveFolderState();
|
||||
setSaveStatus('saved', 'Saved in place', formatMetadataSummary(savedValues));
|
||||
showToast(`Saved ${photo.name}`);
|
||||
setSaveStatus(
|
||||
'saved',
|
||||
'Saved in place',
|
||||
`${formatMetadataSummary(savedValues)} · ${formatSaveTiming(timing)}`,
|
||||
);
|
||||
const rotationNote = rotationMode === 'jpeg-reencoded'
|
||||
? ' · rotation applied; JPEG re-encoded at high quality'
|
||||
: rotationMode !== 'none' ? ' · pixel rotation applied' : '';
|
||||
showToast(`Saved ${photo.name}: ${formatSaveTiming(timing)}${rotationNote}`);
|
||||
updateProgress();
|
||||
|
||||
const oldThumb = state.thumbUrls.get(photo.name);
|
||||
@@ -960,8 +1087,45 @@ async function searchAddress() {
|
||||
|
||||
function applyView() {
|
||||
elements.preview.style.transform = `scale(${state.zoom}) rotate(${state.rotation}deg)`;
|
||||
updateRotationStatus();
|
||||
}
|
||||
|
||||
function normalizeRotation(rotation) {
|
||||
return ((Number(rotation) % 360) + 360) % 360;
|
||||
}
|
||||
|
||||
function supportsPixelRotation(photo) {
|
||||
return Boolean(photo && ['JPG', 'PNG', 'TIFF'].includes(photo.format));
|
||||
}
|
||||
|
||||
function updateRotationStatus() {
|
||||
const rotation = normalizeRotation(state.rotation);
|
||||
const photo = state.photos[state.currentIndex];
|
||||
elements.rotationStatus.classList.toggle('hidden', rotation === 0 || !photo);
|
||||
if (!rotation || !photo) return;
|
||||
const supported = supportsPixelRotation(photo);
|
||||
elements.rotationStatus.classList.toggle('preview-only', !supported);
|
||||
elements.rotationStatus.textContent = supported
|
||||
? `Rotation ${rotation}° will be applied to the image when saved`
|
||||
: `Preview rotation ${rotation}° · ${photo.format} files are not altered`;
|
||||
}
|
||||
|
||||
function setPendingRotation(rotation) {
|
||||
state.rotation = normalizeRotation(rotation);
|
||||
const photo = state.photos[state.currentIndex];
|
||||
if (photo) photo.pendingRotation = state.rotation;
|
||||
applyView();
|
||||
}
|
||||
|
||||
function fitView() {
|
||||
state.zoom = 1;
|
||||
applyView();
|
||||
}
|
||||
|
||||
function resetView() {
|
||||
state.zoom = 1;
|
||||
setPendingRotation(0);
|
||||
}
|
||||
function resetView() { state.zoom = 1; state.rotation = 0; applyView(); }
|
||||
|
||||
for (const button of elements.openButtons) button.addEventListener('click', openFolder);
|
||||
elements.rescan.addEventListener('click', scanFolder);
|
||||
@@ -987,9 +1151,9 @@ for (const input of [elements.latitude, elements.longitude]) {
|
||||
elements.locationName.addEventListener('input', markLocationDirty);
|
||||
elements.zoomOut.addEventListener('click', () => { state.zoom = Math.max(.25, state.zoom - .15); applyView(); });
|
||||
elements.zoomIn.addEventListener('click', () => { state.zoom = Math.min(4, state.zoom + .15); applyView(); });
|
||||
elements.fit.addEventListener('click', resetView);
|
||||
elements.rotateLeft.addEventListener('click', () => { state.rotation -= 90; applyView(); });
|
||||
elements.rotateRight.addEventListener('click', () => { state.rotation += 90; applyView(); });
|
||||
elements.fit.addEventListener('click', fitView);
|
||||
elements.rotateLeft.addEventListener('click', () => setPendingRotation(state.rotation - 90));
|
||||
elements.rotateRight.addEventListener('click', () => setPendingRotation(state.rotation + 90));
|
||||
elements.resetView.addEventListener('click', resetView);
|
||||
elements.timeMode.addEventListener('change', () => elements.timeField.classList.toggle('hidden', elements.timeMode.value !== 'known'));
|
||||
elements.description.addEventListener('input', () => { elements.descriptionCount.textContent = `${elements.description.value.length} / 2000`; captureCurrentValues(); });
|
||||
@@ -1025,18 +1189,34 @@ window.addEventListener('keydown', (event) => {
|
||||
else if (!typing && event.key.toLowerCase() === 'c') copyPreviousValues();
|
||||
});
|
||||
|
||||
async function initialise() {
|
||||
try {
|
||||
const response = await fetch('/api/config', { cache: 'no-store' });
|
||||
if (response.ok) {
|
||||
function loadAppConfig() {
|
||||
if (!state.configPromise) {
|
||||
state.configPromise = (async () => {
|
||||
const response = await fetch('/api/config', { cache: 'no-store' });
|
||||
if (!response.ok) throw new Error(`Configuration request failed (${response.status}).`);
|
||||
const config = await response.json();
|
||||
const extensions = Array.isArray(config.enabledExtensions) ? config.enabledExtensions : [];
|
||||
if (!extensions.length) throw new Error('The server has no enabled photo formats.');
|
||||
const pageTitle = config.appTitle || config.appName;
|
||||
elements.appName.textContent = pageTitle;
|
||||
elements.appSubtitle.textContent = config.appSubtitle;
|
||||
document.title = pageTitle;
|
||||
state.mapConfig = config;
|
||||
}
|
||||
} catch {}
|
||||
state.enabledExtensions = new Set(extensions.map((value) => String(value).toLowerCase()));
|
||||
state.enabledFormatLabel = config.enabledFormatLabel || 'photo';
|
||||
return config;
|
||||
})();
|
||||
}
|
||||
return state.configPromise;
|
||||
}
|
||||
|
||||
async function initialise() {
|
||||
try {
|
||||
await loadAppConfig();
|
||||
} catch (error) {
|
||||
setSaveStatus('failed', 'Configuration unavailable', error.message || 'Could not load enabled formats.');
|
||||
for (const button of elements.openButtons) button.disabled = true;
|
||||
}
|
||||
|
||||
// Favorite locations are server-side shared data and must be loaded on every page start,
|
||||
// independently of whether the user opens a photo folder in this browser session.
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<div class="brand-mark" aria-hidden="true">◫</div>
|
||||
<div>
|
||||
<h1 id="app-name">Photo Date Editor</h1>
|
||||
<p id="app-subtitle">Local folder · in-place JPG, PNG, TIFF and HEIF metadata</p>
|
||||
<p id="app-subtitle">Loading enabled photo formats…</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="top-actions">
|
||||
@@ -74,7 +74,7 @@
|
||||
</select>
|
||||
</div>
|
||||
<div id="file-list" class="file-list" aria-live="polite">
|
||||
<div class="empty-list">Choose a folder containing JPG, PNG, TIFF, HEIC, HEIF, or HIF photos.</div>
|
||||
<div class="empty-list">Choose a folder containing an enabled photo format.</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -94,6 +94,7 @@
|
||||
<div id="viewer-content" class="viewer-content hidden">
|
||||
<div class="image-stage">
|
||||
<img id="photo-preview" alt="Current scanned photograph">
|
||||
<div id="rotation-status" class="rotation-status hidden" role="status"></div>
|
||||
</div>
|
||||
<div class="viewer-tools">
|
||||
<div class="tool-group">
|
||||
@@ -106,8 +107,8 @@
|
||||
<span id="current-filesize">—</span>
|
||||
</div>
|
||||
<div class="tool-group">
|
||||
<button id="rotate-left" class="icon-button" title="Rotate preview left">↶</button>
|
||||
<button id="rotate-right" class="icon-button" title="Rotate preview right">↷</button>
|
||||
<button id="rotate-left" class="icon-button" title="Rotate left">↶</button>
|
||||
<button id="rotate-right" class="icon-button" title="Rotate right">↷</button>
|
||||
<button id="reset-view" class="icon-button" title="Reset preview">⟳</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
"display": "standalone",
|
||||
"background_color": "#0d1218",
|
||||
"theme_color": "#121820",
|
||||
"description": "Edit dates, descriptions, keywords, and locations in JPG, PNG, TIFF, and HEIF photos."
|
||||
"description": "Edit dates, descriptions, keywords, and locations in common images, DNG, and Canon RAW photos."
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const CACHE_NAME = 'photo-date-editor-v6-1';
|
||||
const CACHE_NAME = 'photo-date-editor-v9-2';
|
||||
const SHELL = ['/', '/styles.css', '/app.js', '/manifest.webmanifest', '/vendor/leaflet/leaflet.css', '/vendor/leaflet/leaflet.js'];
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
|
||||
+25
-9
@@ -18,20 +18,20 @@
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; min-height: 100%; background: var(--bg); color: var(--text); font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
|
||||
html, body { margin: 0; width: 100%; height: 100%; overflow: hidden; background: var(--bg); color: var(--text); font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
|
||||
button, input, select, textarea { font: inherit; }
|
||||
button { color: inherit; }
|
||||
|
||||
.app-shell { min-height: 100vh; display: flex; flex-direction: column; }
|
||||
.topbar { height: 70px; display: flex; align-items: center; justify-content: space-between; padding: 0 20px; border-bottom: 1px solid var(--line); background: linear-gradient(180deg, #151c24, #111820); }
|
||||
.app-shell { width: 100%; height: 100vh; height: 100dvh; min-height: 0; overflow: hidden; display: flex; flex-direction: column; }
|
||||
.topbar { flex: 0 0 70px; height: 70px; display: flex; align-items: center; justify-content: space-between; padding: 0 20px; border-bottom: 1px solid var(--line); background: linear-gradient(180deg, #151c24, #111820); }
|
||||
.brand { display: flex; align-items: center; gap: 12px; }
|
||||
.brand-mark { width: 34px; height: 34px; border: 1px solid #5d8df6; color: #8aafff; border-radius: 7px; display: grid; place-items: center; font-weight: 700; }
|
||||
.brand h1 { margin: 0; font-size: 20px; }
|
||||
.brand p { margin: 3px 0 0; color: var(--muted); font-size: 12px; }
|
||||
.top-actions { display: flex; gap: 10px; }
|
||||
|
||||
.workspace { flex: 1; min-height: calc(100vh - 70px); display: grid; grid-template-columns: minmax(260px, 320px) minmax(480px, 1fr) minmax(330px, 420px); overflow: hidden; }
|
||||
.sidebar { background: var(--panel); min-height: 0; display: flex; flex-direction: column; }
|
||||
.workspace { flex: 1 1 auto; min-height: 0; display: grid; grid-template-columns: minmax(260px, 320px) minmax(480px, 1fr) minmax(330px, 420px); overflow: hidden; }
|
||||
.sidebar { background: var(--panel); min-height: 0; overflow: hidden; display: flex; flex-direction: column; }
|
||||
.left-panel { border-right: 1px solid var(--line); }
|
||||
.right-panel { border-left: 1px solid var(--line); }
|
||||
.panel-section { padding: 18px; border-bottom: 1px solid var(--line-soft); }
|
||||
@@ -70,12 +70,14 @@ button { color: inherit; }
|
||||
.file-state.failed { color: white; background: var(--danger); border-color: var(--danger); }
|
||||
.sidebar-footer, .right-footer { padding: 16px 18px; border-top: 1px solid var(--line-soft); margin-top: auto; }
|
||||
|
||||
.viewer-panel { min-width: 0; min-height: 0; display: flex; background: #0a0f14; }
|
||||
.viewer-panel { min-width: 0; min-height: 0; overflow: hidden; display: flex; background: #0a0f14; }
|
||||
.viewer-empty { margin: auto; text-align: center; max-width: 470px; padding: 35px; }
|
||||
.empty-icon { font-size: 48px; color: #6685ae; }.viewer-empty h2 { margin: 15px 0 7px; }.viewer-empty p { color: var(--muted); line-height: 1.6; margin: 0 0 22px; }
|
||||
.viewer-content { width: 100%; min-height: 0; display: grid; grid-template-rows: minmax(300px, 1fr) auto 76px; }
|
||||
.image-stage { min-height: 0; display: grid; place-items: center; overflow: auto; padding: 18px; background-image: radial-gradient(circle at 50% 40%, #18212a 0, #0a0f14 68%); }
|
||||
.viewer-content { width: 100%; height: 100%; min-height: 0; overflow: hidden; display: grid; grid-template-rows: minmax(220px, 1fr) auto 76px; }
|
||||
.image-stage { position: relative; min-height: 0; display: grid; place-items: center; overflow: auto; padding: 18px; background-image: radial-gradient(circle at 50% 40%, #18212a 0, #0a0f14 68%); }
|
||||
.image-stage img { display: block; max-width: 100%; max-height: 100%; object-fit: contain; box-shadow: var(--shadow); transition: transform .15s ease; transform-origin: center; }
|
||||
.rotation-status { position: absolute; left: 18px; bottom: 18px; max-width: calc(100% - 36px); padding: 7px 10px; border: 1px solid rgba(84,201,138,.55); border-radius: 7px; background: rgba(12,20,27,.9); color: #bcebd0; font-size: 11px; box-shadow: var(--shadow); }
|
||||
.rotation-status.preview-only { border-color: rgba(228,179,92,.55); color: #f0d59f; }
|
||||
.viewer-tools { border-top: 1px solid var(--line-soft); min-height: 58px; display: grid; grid-template-columns: 1fr minmax(170px, auto) 1fr; align-items: center; padding: 10px 18px; gap: 18px; background: #10171f; }
|
||||
.viewer-tools > :last-child { justify-self: end; }
|
||||
.tool-group { display: flex; align-items: center; gap: 7px; }
|
||||
@@ -111,8 +113,22 @@ fieldset { border: 0; padding: 0; margin: 0 0 22px; } legend { font-weight: 650;
|
||||
.workspace { grid-template-columns: 250px minmax(380px, 1fr) 340px; }
|
||||
}
|
||||
@media (max-width: 850px) {
|
||||
html, body { height: auto; min-height: 100%; overflow: auto; }
|
||||
.app-shell { height: auto; min-height: 100vh; min-height: 100dvh; overflow: visible; }
|
||||
.topbar { height: auto; min-height: 70px; flex-wrap: wrap; gap: 10px; padding: 12px; }.brand p { display: none; }
|
||||
.workspace { display: block; overflow: visible; }.left-panel, .right-panel { border: 0; }.left-panel { max-height: 480px; }.viewer-panel { min-height: 620px; }.right-panel { min-height: 600px; }
|
||||
.workspace { flex: none; min-height: 0; display: block; overflow: visible; }.left-panel, .right-panel { border: 0; overflow: visible; }.left-panel { max-height: 480px; }.viewer-panel { min-height: 620px; overflow: visible; }.viewer-content { min-height: 620px; overflow: visible; }.right-panel { min-height: 600px; }
|
||||
}
|
||||
|
||||
@media (min-width: 851px) and (max-height: 800px) {
|
||||
.topbar { flex-basis: 60px; height: 60px; }
|
||||
.panel-section { padding: 13px 16px; }
|
||||
.image-stage { padding: 10px; }
|
||||
.viewer-content { grid-template-rows: minmax(180px, 1fr) auto 66px; }
|
||||
.viewer-tools { min-height: 52px; padding: 7px 14px; }
|
||||
.navigation-bar { padding: 10px 14px; }
|
||||
.tab { padding-block: 13px 10px; }
|
||||
#metadata-form { padding: 15px 18px; }
|
||||
.sidebar-footer, .right-footer { padding: 12px 16px; }
|
||||
}
|
||||
|
||||
/* v0.4 location editor */
|
||||
|
||||
@@ -12,6 +12,7 @@ services:
|
||||
APP_NAME: "${APP_NAME:-Photo Date Editor}"
|
||||
APP_TITLE: "${APP_TITLE:-}"
|
||||
APP_SUBTITLE: "${APP_SUBTITLE:-}"
|
||||
ENABLED_FORMATS: "${ENABLED_FORMATS:-jpg,png,tiff,heif,dng,canon}"
|
||||
MAX_UPLOAD_MB: "${MAX_UPLOAD_MB:-150}"
|
||||
LOG_LEVEL: "${LOG_LEVEL:-INFO}"
|
||||
DATA_DIR: "/data"
|
||||
|
||||
@@ -3,3 +3,4 @@ uvicorn[standard]>=0.35,<1
|
||||
python-multipart>=0.0.20,<1
|
||||
Pillow>=11.3,<13
|
||||
pillow-heif>=1.1,<2
|
||||
rawpy>=0.25,<1
|
||||
|
||||
Reference in New Issue
Block a user