Add configurable formats and lazy decoders, v0.7.1

This commit is contained in:
dmo committed 2026-07-23 21:51:37 +02:00
1 parent b9a0259166
commit 8ed5490afc
8 files changed
+193 -35

No files matched your search

+4 -1
View File
@@ -11,6 +11,9 @@ APP_NAME=Photo Date Editor
APP_TITLE=Mork Photo Date Editor APP_TITLE=Mork Photo Date Editor
# Optional text shown directly below the page heading. # Optional text shown directly below the page heading.
APP_SUBTITLE=Family photo metadata editor 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 MAX_UPLOAD_MB=150
LOG_LEVEL=INFO LOG_LEVEL=INFO
@@ -27,4 +30,4 @@ DEFAULT_MAP_LON=11.0
DEFAULT_MAP_ZOOM=5 DEFAULT_MAP_ZOOM=5
GEOCODER_URL=https://nominatim.openstreetmap.org/search GEOCODER_URL=https://nominatim.openstreetmap.org/search
# Optional custom identification sent to the geocoder. The default uses APP_NAME, version and APP_URL. # Optional custom identification sent to the geocoder. The default uses APP_NAME, version and APP_URL.
# GEOCODER_USER_AGENT=MorkPhotoDateEditor/0.7.0 (+https://edit.mork.fyi) # GEOCODER_USER_AGENT=MorkPhotoDateEditor/0.7.1 (+https://edit.mork.fyi)
+13
View File
@@ -1,5 +1,18 @@
# Changelog # 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 ## 0.7.0
- Add DNG folder scanning, `DNG` format badges, and format-sort compatibility. - Add DNG folder scanning, `DNG` format badges, and format-sort compatibility.
+32 -3
View File
@@ -1,6 +1,6 @@
# Photo Date Editor # 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. 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_NAME=Photo Date Editor
APP_TITLE=Mork Photo Date Editor APP_TITLE=Mork Photo Date Editor
APP_SUBTITLE=Family photo metadata editor APP_SUBTITLE=Family photo metadata editor
ENABLED_FORMATS=jpg,png,tiff,heif,dng
MAX_UPLOAD_MB=150 MAX_UPLOAD_MB=150
LOG_LEVEL=INFO 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. 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: 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 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 ## Caddy example
+101 -13
View File
@@ -24,33 +24,78 @@ from pydantic import BaseModel, Field
from fastapi.responses import FileResponse, Response from fastapi.responses import FileResponse, Response
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from PIL import Image, ImageOps, UnidentifiedImageError 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_NAME = os.getenv("APP_NAME", "Photo Date Editor")
APP_TITLE = os.getenv("APP_TITLE") or APP_NAME APP_TITLE = os.getenv("APP_TITLE") or APP_NAME
APP_SUBTITLE = ( APP_SUBTITLE = (
os.getenv("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_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_MB = int(os.getenv("MAX_UPLOAD_MB", "150"))
MAX_UPLOAD_BYTES = MAX_UPLOAD_MB * 1024 * 1024 MAX_UPLOAD_BYTES = MAX_UPLOAD_MB * 1024 * 1024
PREVIEW_MAX_EDGE = int(os.getenv("PREVIEW_MAX_EDGE", "2400")) PREVIEW_MAX_EDGE = int(os.getenv("PREVIEW_MAX_EDGE", "2400"))
PREVIEW_MAX_PIXELS = int(os.getenv("PREVIEW_MAX_PIXELS", "300000000")) PREVIEW_MAX_PIXELS = int(os.getenv("PREVIEW_MAX_PIXELS", "300000000"))
Image.MAX_IMAGE_PIXELS = PREVIEW_MAX_PIXELS Image.MAX_IMAGE_PIXELS = PREVIEW_MAX_PIXELS
SUPPORTED_EXTENSIONS = {
".jpg", ".jpeg", ".png", ".tif", ".tiff",
".heic", ".heif", ".hif", ".dng",
}
TIFF_EXTENSIONS = {".tif", ".tiff"} TIFF_EXTENSIONS = {".tif", ".tiff"}
HEIF_EXTENSIONS = {".heic", ".heif", ".hif"} HEIF_EXTENSIONS = {".heic", ".heif", ".hif"}
DNG_EXTENSIONS = {".dng"} 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 = { MEDIA_TYPES = {
".jpg": "image/jpeg", ".jpg": "image/jpeg",
".jpeg": "image/jpeg", ".jpeg": "image/jpeg",
@@ -87,6 +132,9 @@ _last_geocode_request = 0.0
_tile_cache: OrderedDict[str, tuple[bytes, str]] = OrderedDict() _tile_cache: OrderedDict[str, tuple[bytes, str]] = OrderedDict()
_tile_cache_limit = 512 _tile_cache_limit = 512
_tile_lock = asyncio.Lock() _tile_lock = asyncio.Lock()
_decoder_lock = threading.Lock()
_heif_decoder_registered = False
_rawpy_module: Any | None = None
logging.basicConfig( logging.basicConfig(
level=getattr(logging, LOG_LEVEL, logging.INFO), level=getattr(logging, LOG_LEVEL, logging.INFO),
@@ -116,7 +164,7 @@ def health() -> dict[str, str]:
@app.get("/api/config") @app.get("/api/config")
def config() -> dict[str, str | int | float]: def config() -> dict[str, Any]:
return { return {
"appName": APP_NAME, "appName": APP_NAME,
"appTitle": APP_TITLE, "appTitle": APP_TITLE,
@@ -124,6 +172,9 @@ def config() -> dict[str, str | int | float]:
"appUrl": APP_URL, "appUrl": APP_URL,
"version": APP_VERSION, "version": APP_VERSION,
"maxUploadMb": MAX_UPLOAD_MB, "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", "mapTileUrl": "/api/map/tiles/{z}/{x}/{y}.png",
"mapAttribution": MAP_ATTRIBUTION, "mapAttribution": MAP_ATTRIBUTION,
"defaultMapLat": DEFAULT_MAP_LAT, "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 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: def _embedded_dng_preview(path: Path) -> Image.Image | None:
"""Return the first usable DNG preview, preferring the largest common tags.""" """Return the first usable DNG preview, preferring the largest common tags."""
for tag in ("JpgFromRaw", "PreviewImage", "ThumbnailImage"): 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: def _render_dng(path: Path) -> Image.Image:
"""Render a half-size RGB fallback when a DNG has no embedded JPEG preview.""" """Render a half-size RGB fallback when a DNG has no embedded JPEG preview."""
rawpy = _get_rawpy()
try: try:
with rawpy.imread(str(path)) as raw: with rawpy.imread(str(path)) as raw:
width = int(raw.sizes.width) 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.""" """Generate a temporary browser-friendly preview for TIFF, HEIF, and DNG images."""
filename = file.filename or "photo.tiff" filename = file.filename or "photo.tiff"
suffix = Path(filename).suffix.lower() 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: if suffix not in SERVER_PREVIEW_EXTENSIONS:
raise HTTPException( raise HTTPException(
status_code=415, status_code=415,
@@ -650,6 +736,8 @@ async def create_preview(file: Annotated[UploadFile, File(...)]) -> Response:
preview = _render_dng(temp_path) preview = _render_dng(temp_path)
preview = ImageOps.exif_transpose(preview) preview = ImageOps.exif_transpose(preview)
else: else:
if suffix in HEIF_EXTENSIONS:
_ensure_heif_decoder()
with Image.open(temp_path) as source: with Image.open(temp_path) as source:
source.seek(0) source.seek(0)
preview = ImageOps.exif_transpose(source) 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: if suffix not in SUPPORTED_EXTENSIONS:
raise HTTPException( raise HTTPException(
status_code=415, 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: 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: if suffix not in SUPPORTED_EXTENSIONS:
raise HTTPException( raise HTTPException(
status_code=415, 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( exif_datetime, xmp_date, precision_label = _normalise_datetime(
+39 -15
View File
@@ -13,6 +13,9 @@ const state = {
mapMarker: null, mapMarker: null,
locationDirty: false, locationDirty: false,
favoriteLocations: [], favoriteLocations: [],
configPromise: null,
enabledExtensions: new Set(),
enabledFormatLabel: '',
}; };
const $ = (selector) => document.querySelector(selector); const $ = (selector) => document.querySelector(selector);
@@ -128,6 +131,12 @@ function requiresServerPreview(photo) {
return photo.format === 'TIFF' || photo.format === 'HEIF' || photo.format === 'DNG'; 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() { function storageKey() {
return state.directoryHandle ? `photo-date-editor:${state.directoryHandle.name}` : null; return state.directoryHandle ? `photo-date-editor:${state.directoryHandle.name}` : null;
} }
@@ -154,14 +163,13 @@ function saveFolderState() {
} }
async function openFolder() { async function openFolder() {
await loadFavoriteLocations();
if (!isSupported()) {
showToast('Use a current Chromium browser over HTTPS. Folder access is unavailable here.', true);
return;
}
try { 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 handle = await window.showDirectoryPicker({ mode: 'readwrite', id: 'photo-date-editor' });
const permission = await handle.requestPermission({ mode: 'readwrite' }); const permission = await handle.requestPermission({ mode: 'readwrite' });
if (permission !== 'granted') throw new Error('Read/write permission was not granted.'); if (permission !== 'granted') throw new Error('Read/write permission was not granted.');
@@ -179,7 +187,7 @@ async function scanFolder() {
const photos = []; const photos = [];
for await (const [name, handle] of state.directoryHandle.entries()) { 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 file = await handle.getFile();
const previous = stored.photos?.[name] || {}; const previous = stored.photos?.[name] || {};
const unchanged = !previous.lastModified || previous.lastModified === file.lastModified; const unchanged = !previous.lastModified || previous.lastModified === file.lastModified;
@@ -218,7 +226,7 @@ async function scanFolder() {
} else { } else {
elements.viewerContent.classList.add('hidden'); elements.viewerContent.classList.add('hidden');
elements.viewerEmpty.classList.remove('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(); else if (!typing && event.key.toLowerCase() === 'c') copyPreviousValues();
}); });
async function initialise() { function loadAppConfig() {
try { if (!state.configPromise) {
const response = await fetch('/api/config', { cache: 'no-store' }); state.configPromise = (async () => {
if (response.ok) { 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 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; const pageTitle = config.appTitle || config.appName;
elements.appName.textContent = pageTitle; elements.appName.textContent = pageTitle;
elements.appSubtitle.textContent = config.appSubtitle; elements.appSubtitle.textContent = config.appSubtitle;
document.title = pageTitle; document.title = pageTitle;
state.mapConfig = config; state.mapConfig = config;
} state.enabledExtensions = new Set(extensions.map((value) => String(value).toLowerCase()));
} catch {} 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, // 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. // independently of whether the user opens a photo folder in this browser session.
+2 -2
View File
@@ -16,7 +16,7 @@
<div class="brand-mark" aria-hidden="true">◫</div> <div class="brand-mark" aria-hidden="true">◫</div>
<div> <div>
<h1 id="app-name">Photo Date Editor</h1> <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> </div>
<div class="top-actions"> <div class="top-actions">
@@ -74,7 +74,7 @@
</select> </select>
</div> </div>
<div id="file-list" class="file-list" aria-live="polite"> <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> </div>
</section> </section>
+1 -1
View File
@@ -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']; const SHELL = ['/', '/styles.css', '/app.js', '/manifest.webmanifest', '/vendor/leaflet/leaflet.css', '/vendor/leaflet/leaflet.js'];
self.addEventListener('install', (event) => { self.addEventListener('install', (event) => {
+1
View File
@@ -12,6 +12,7 @@ services:
APP_NAME: "${APP_NAME:-Photo Date Editor}" APP_NAME: "${APP_NAME:-Photo Date Editor}"
APP_TITLE: "${APP_TITLE:-}" APP_TITLE: "${APP_TITLE:-}"
APP_SUBTITLE: "${APP_SUBTITLE:-}" APP_SUBTITLE: "${APP_SUBTITLE:-}"
ENABLED_FORMATS: "${ENABLED_FORMATS:-jpg,png,tiff,heif,dng}"
MAX_UPLOAD_MB: "${MAX_UPLOAD_MB:-150}" MAX_UPLOAD_MB: "${MAX_UPLOAD_MB:-150}"
LOG_LEVEL: "${LOG_LEVEL:-INFO}" LOG_LEVEL: "${LOG_LEVEL:-INFO}"
DATA_DIR: "/data" DATA_DIR: "/data"