Add configurable formats and lazy decoders, v0.7.1
This commit is contained in:
+4
-1
@@ -11,6 +11,9 @@ 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
|
||||
# Use ENABLED_FORMATS=jpg for a JPEG-only workflow.
|
||||
ENABLED_FORMATS=jpg,png,tiff,heif,dng
|
||||
MAX_UPLOAD_MB=150
|
||||
LOG_LEVEL=INFO
|
||||
|
||||
@@ -27,4 +30,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.7.0 (+https://edit.mork.fyi)
|
||||
# GEOCODER_USER_AGENT=MorkPhotoDateEditor/0.7.1 (+https://edit.mork.fyi)
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
# Changelog
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Photo Date Editor
|
||||
|
||||
Version 0.7.0
|
||||
Version 0.7.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.
|
||||
|
||||
@@ -54,6 +54,7 @@ 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
|
||||
MAX_UPLOAD_MB=150
|
||||
LOG_LEVEL=INFO
|
||||
|
||||
@@ -72,7 +73,35 @@ 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.6.1
|
||||
## Enabled formats
|
||||
|
||||
`ENABLED_FORMATS` accepts a comma-separated list of format groups:
|
||||
|
||||
| Group | File extensions |
|
||||
|---|---|
|
||||
| `jpg` | `.jpg`, `.jpeg` |
|
||||
| `png` | `.png` |
|
||||
| `tiff` | `.tif`, `.tiff` |
|
||||
| `heif` | `.heic`, `.heif`, `.hif` |
|
||||
| `dng` | `.dng` |
|
||||
|
||||
The default enables every group:
|
||||
|
||||
```dotenv
|
||||
ENABLED_FORMATS=jpg,png,tiff,heif,dng
|
||||
```
|
||||
|
||||
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 DNG 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, LibRaw is loaded only if no usable embedded JPEG preview is available.
|
||||
|
||||
## Upgrade from 0.1 through 0.7
|
||||
|
||||
Replace the project files with this version and rebuild:
|
||||
|
||||
@@ -80,7 +109,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
|
||||
|
||||
|
||||
+101
-13
@@ -24,33 +24,78 @@ from pydantic import BaseModel, Field
|
||||
from fastapi.responses import FileResponse, Response
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from PIL import Image, ImageOps, UnidentifiedImageError
|
||||
from pillow_heif import register_heif_opener
|
||||
import rawpy
|
||||
|
||||
register_heif_opener()
|
||||
FORMAT_GROUPS: dict[str, tuple[str, ...]] = {
|
||||
"jpg": (".jpg", ".jpeg"),
|
||||
"png": (".png",),
|
||||
"tiff": (".tif", ".tiff"),
|
||||
"heif": (".heic", ".heif", ".hif"),
|
||||
"dng": (".dng",),
|
||||
}
|
||||
FORMAT_LABELS = {
|
||||
"jpg": "JPG",
|
||||
"png": "PNG",
|
||||
"tiff": "TIFF",
|
||||
"heif": "HEIF",
|
||||
"dng": "DNG",
|
||||
}
|
||||
|
||||
|
||||
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.7.0"
|
||||
APP_VERSION = "0.7.1"
|
||||
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"))
|
||||
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", ".dng",
|
||||
}
|
||||
TIFF_EXTENSIONS = {".tif", ".tiff"}
|
||||
HEIF_EXTENSIONS = {".heic", ".heif", ".hif"}
|
||||
DNG_EXTENSIONS = {".dng"}
|
||||
SERVER_PREVIEW_EXTENSIONS = TIFF_EXTENSIONS | HEIF_EXTENSIONS | DNG_EXTENSIONS
|
||||
SERVER_PREVIEW_EXTENSIONS = (
|
||||
TIFF_EXTENSIONS | HEIF_EXTENSIONS | DNG_EXTENSIONS
|
||||
) & SUPPORTED_EXTENSIONS
|
||||
MEDIA_TYPES = {
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
@@ -87,6 +132,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),
|
||||
@@ -116,7 +164,7 @@ def health() -> dict[str, str]:
|
||||
|
||||
|
||||
@app.get("/api/config")
|
||||
def config() -> dict[str, str | int | float]:
|
||||
def config() -> dict[str, Any]:
|
||||
return {
|
||||
"appName": APP_NAME,
|
||||
"appTitle": APP_TITLE,
|
||||
@@ -124,6 +172,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,
|
||||
@@ -585,6 +636,35 @@ def _metadata_values(metadata: dict[str, Any]) -> tuple[bool, bool, dict[str, An
|
||||
return has_metadata, edited_by_app, values
|
||||
|
||||
|
||||
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
|
||||
|
||||
register_heif_opener()
|
||||
_heif_decoder_registered = True
|
||||
logger.info("HEIF preview decoder loaded.")
|
||||
|
||||
|
||||
def _get_rawpy() -> Any:
|
||||
"""Import rawpy once, on the first DNG 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("DNG LibRaw preview decoder loaded.")
|
||||
return _rawpy_module
|
||||
|
||||
|
||||
def _embedded_dng_preview(path: Path) -> Image.Image | None:
|
||||
"""Return the first usable DNG preview, preferring the largest common tags."""
|
||||
for tag in ("JpgFromRaw", "PreviewImage", "ThumbnailImage"):
|
||||
@@ -611,6 +691,7 @@ def _embedded_dng_preview(path: Path) -> Image.Image | None:
|
||||
|
||||
def _render_dng(path: Path) -> Image.Image:
|
||||
"""Render a half-size RGB fallback when a DNG has no embedded JPEG preview."""
|
||||
rawpy = _get_rawpy()
|
||||
try:
|
||||
with rawpy.imread(str(path)) as raw:
|
||||
width = int(raw.sizes.width)
|
||||
@@ -634,6 +715,11 @@ async def create_preview(file: Annotated[UploadFile, File(...)]) -> Response:
|
||||
"""Generate a temporary browser-friendly preview for TIFF, HEIF, and DNG images."""
|
||||
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,
|
||||
@@ -650,6 +736,8 @@ async def create_preview(file: Annotated[UploadFile, File(...)]) -> Response:
|
||||
preview = _render_dng(temp_path)
|
||||
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)
|
||||
@@ -692,7 +780,7 @@ 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, HIF, and DNG.",
|
||||
detail=f"This format is disabled. Enabled format groups: {ENABLED_FORMAT_LABEL}.",
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="photo-date-editor-read-") as temp_dir:
|
||||
@@ -761,7 +849,7 @@ async def process_photo(
|
||||
if suffix not in SUPPORTED_EXTENSIONS:
|
||||
raise HTTPException(
|
||||
status_code=415,
|
||||
detail="Supported formats are JPG, JPEG, PNG, TIF, TIFF, HEIC, HEIF, HIF, and DNG.",
|
||||
detail=f"This format is disabled. Enabled format groups: {ENABLED_FORMAT_LABEL}.",
|
||||
)
|
||||
|
||||
exif_datetime, xmp_date, precision_label = _normalise_datetime(
|
||||
|
||||
+39
-15
@@ -13,6 +13,9 @@ const state = {
|
||||
mapMarker: null,
|
||||
locationDirty: false,
|
||||
favoriteLocations: [],
|
||||
configPromise: null,
|
||||
enabledExtensions: new Set(),
|
||||
enabledFormatLabel: '',
|
||||
};
|
||||
|
||||
const $ = (selector) => document.querySelector(selector);
|
||||
@@ -128,6 +131,12 @@ function requiresServerPreview(photo) {
|
||||
return photo.format === 'TIFF' || photo.format === 'HEIF' || photo.format === 'DNG';
|
||||
}
|
||||
|
||||
function isEnabledFilename(filename) {
|
||||
const dot = filename.lastIndexOf('.');
|
||||
if (dot < 0) return false;
|
||||
return state.enabledExtensions.has(filename.slice(dot).toLowerCase());
|
||||
}
|
||||
|
||||
function storageKey() {
|
||||
return state.directoryHandle ? `photo-date-editor:${state.directoryHandle.name}` : null;
|
||||
}
|
||||
@@ -154,14 +163,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.');
|
||||
@@ -179,7 +187,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|dng)$/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;
|
||||
@@ -218,7 +226,7 @@ async function scanFolder() {
|
||||
} else {
|
||||
elements.viewerContent.classList.add('hidden');
|
||||
elements.viewerEmpty.classList.remove('hidden');
|
||||
showToast('No JPG, PNG, TIFF, HEIC, HEIF, HIF, or DNG files were found in that folder.', true);
|
||||
showToast(`No enabled ${state.enabledFormatLabel} files were found in that folder.`, true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1026,18 +1034,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, HEIF and DNG 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, HIF, or DNG photos.</div>
|
||||
<div class="empty-list">Choose a folder containing an enabled photo format.</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const CACHE_NAME = 'photo-date-editor-v7-0';
|
||||
const CACHE_NAME = 'photo-date-editor-v7-1';
|
||||
const SHELL = ['/', '/styles.css', '/app.js', '/manifest.webmanifest', '/vendor/leaflet/leaflet.css', '/vendor/leaflet/leaflet.js'];
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
|
||||
@@ -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}"
|
||||
MAX_UPLOAD_MB: "${MAX_UPLOAD_MB:-150}"
|
||||
LOG_LEVEL: "${LOG_LEVEL:-INFO}"
|
||||
DATA_DIR: "/data"
|
||||
|
||||
Reference in New Issue
Block a user