Add DNG support v0.7

This commit is contained in:
dmo
2026-07-23 21:33:20 +02:00
parent c2640cf75a
commit b9a0259166
10 changed files with 117 additions and 33 deletions
+2 -2
View File
@@ -14,7 +14,7 @@ APP_SUBTITLE=Family photo metadata editor
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
PREVIEW_MAX_PIXELS=300000000
@@ -27,4 +27,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.7.0 (+https://edit.mork.fyi)
+13
View File
@@ -1,5 +1,18 @@
# Changelog
## 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
View File
@@ -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 \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
+14 -12
View File
@@ -1,12 +1,12 @@
# Photo Date Editor
Version 0.6.1
Version 0.7.0
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, and DNG
- Local directory picker in supported Chromium browsers
- Three-column photo workflow
- Exact date, month/year, year-only, approximate year
@@ -17,7 +17,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, and DNG 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 +25,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, or DNG, so those formats are temporarily uploaded to `/api/preview` and returned as a browser-friendly JPEG. TIFF uses Pillow, HEIF uses `pillow-heif`, and DNG first tries its embedded `JpgFromRaw`, `PreviewImage`, or `ThumbnailImage`; 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 TIFF, HEIF-family image, or DNG 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.
## Requirements
@@ -57,7 +57,7 @@ APP_SUBTITLE=Family photo metadata editor
MAX_UPLOAD_MB=150
LOG_LEVEL=INFO
# Optional TIFF/HEIF preview limits
# Optional TIFF/HEIF/DNG preview limits
PREVIEW_MAX_EDGE=2400
PREVIEW_MAX_PIXELS=300000000
@@ -72,7 +72,7 @@ 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
## Upgrade from 0.1 through 0.6.1
Replace the project files with this version and rebuild:
@@ -102,7 +102,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 JPG, PNG, TIFF, HEIC, HEIF, HIF, or DNG.
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.
@@ -117,6 +117,7 @@ No second photo is intentionally left on the client computer or Docker host.
- **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.
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.
@@ -165,7 +166,9 @@ The default map tiles and address search are external OpenStreetMap services. Bo
- Preview rotation is visual only; it does not rotate image pixels or write orientation metadata.
- 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 uses the largest common embedded JPEG preview available before falling back to a half-size LibRaw render. A small embedded thumbnail may look softer but does not alter the DNG.
- Very large TIFF, HEIF, or DNG files may require raising `MAX_UPLOAD_MB` or `PREVIEW_MAX_PIXELS`.
- DNG 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 DNG cannot be rendered.
- 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.
@@ -176,9 +179,8 @@ Format support needs both safe metadata writing and a browser-friendly preview.
### Proposed order
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.
1. **Canon RAW**`CR2` and `CR3`, using per-format tag rules and embedded/server-generated previews.
2. **Nikon RAW**`NEF` and `NRW`, using per-format tag rules and embedded/server-generated previews.
`RAW` is not treated as one universal format. Each camera family will be enabled and tested explicitly.
+78 -11
View File
@@ -25,6 +25,7 @@ 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()
@@ -35,17 +36,21 @@ APP_SUBTITLE = (
or "Local folder · in-place JPG, PNG, TIFF and HEIF metadata"
)
APP_URL = os.getenv("APP_URL", "http://localhost:8080")
APP_VERSION = "0.6.1"
APP_VERSION = "0.7.0"
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"}
SUPPORTED_EXTENSIONS = {
".jpg", ".jpeg", ".png", ".tif", ".tiff",
".heic", ".heif", ".hif", ".dng",
}
TIFF_EXTENSIONS = {".tif", ".tiff"}
HEIF_EXTENSIONS = {".heic", ".heif", ".hif"}
SERVER_PREVIEW_EXTENSIONS = TIFF_EXTENSIONS | HEIF_EXTENSIONS
DNG_EXTENSIONS = {".dng"}
SERVER_PREVIEW_EXTENSIONS = TIFF_EXTENSIONS | HEIF_EXTENSIONS | DNG_EXTENSIONS
MEDIA_TYPES = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
@@ -55,6 +60,7 @@ MEDIA_TYPES = {
".heic": "image/heic",
".heif": "image/heif",
".hif": "image/heif",
".dng": "image/x-adobe-dng",
}
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper()
@@ -406,6 +412,8 @@ def _format_name(suffix: str) -> str:
return "TIFF"
if suffix in HEIF_EXTENSIONS:
return "HEIF"
if suffix in DNG_EXTENSIONS:
return "DNG"
return suffix.lstrip(".").upper() or "image"
@@ -429,6 +437,10 @@ 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*"))
if not valid:
raise HTTPException(
@@ -573,24 +585,77 @@ def _metadata_values(metadata: dict[str, Any]) -> tuple[bool, bool, dict[str, An
return has_metadata, edited_by_app, values
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"):
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 DNG %s data.", tag)
continue
if completed.returncode != 0 or not completed.stdout:
continue
try:
with Image.open(io.BytesIO(completed.stdout)) as source:
source.load()
return source.copy()
except (UnidentifiedImageError, OSError, ValueError):
logger.debug("DNG %s data was not a usable image preview.", tag)
return None
def _render_dng(path: Path) -> Image.Image:
"""Render a half-size RGB fallback when a DNG has no embedded JPEG preview."""
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("DNG 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("LibRaw could not decode this DNG.") from exc
return Image.fromarray(rgb)
@app.post("/api/preview")
async def create_preview(file: Annotated[UploadFile, File(...)]) -> Response:
"""Generate a temporary browser-friendly preview for TIFF and HEIF images."""
"""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 SERVER_PREVIEW_EXTENSIONS:
raise HTTPException(
status_code=415,
detail="Server-generated previews are only required for TIFF and HEIF files.",
detail="Server-generated previews are only required for TIFF, HEIF, and DNG files.",
)
with tempfile.TemporaryDirectory(prefix="photo-date-editor-preview-") as temp_dir:
temp_path = Path(temp_dir) / f"source{suffix}"
await _save_upload(file, temp_path, suffix)
try:
with Image.open(temp_path) as source:
source.seek(0)
preview = ImageOps.exif_transpose(source)
if suffix in DNG_EXTENSIONS:
preview = _embedded_dng_preview(temp_path)
if preview is None:
preview = _render_dng(temp_path)
preview = ImageOps.exif_transpose(preview)
else:
with Image.open(temp_path) as source:
source.seek(0)
preview = ImageOps.exif_transpose(source)
preview.load()
try:
preview.thumbnail((PREVIEW_MAX_EDGE, PREVIEW_MAX_EDGE), Image.Resampling.LANCZOS)
if preview.mode in {"RGBA", "LA"} or (preview.mode == "P" and "transparency" in preview.info):
@@ -603,6 +668,8 @@ async def create_preview(file: Annotated[UploadFile, File(...)]) -> Response:
output = io.BytesIO()
preview.save(output, format="JPEG", quality=88, optimize=True)
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)
@@ -625,7 +692,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, and HIF.",
detail="Supported formats are JPG, JPEG, PNG, TIF, TIFF, HEIC, HEIF, HIF, and DNG.",
)
with tempfile.TemporaryDirectory(prefix="photo-date-editor-read-") as temp_dir:
@@ -694,7 +761,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, and HIF.",
detail="Supported formats are JPG, JPEG, PNG, TIF, TIFF, HEIC, HEIF, HIF, and DNG.",
)
exif_datetime, xmp_date, precision_label = _normalise_datetime(
@@ -726,7 +793,7 @@ async def process_photo(
temp_path = Path(temp_dir) / f"working{suffix}"
await _save_upload(file, temp_path, suffix)
supports_iptc = suffix in {".jpg", ".jpeg", ".tif", ".tiff"}
supports_iptc = suffix in {".jpg", ".jpeg", ".tif", ".tiff", ".dng"}
command = [
"exiftool",
"-overwrite_original",
+4 -3
View File
@@ -120,11 +120,12 @@ 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';
return extension.toUpperCase();
}
function requiresServerPreview(photo) {
return photo.format === 'TIFF' || photo.format === 'HEIF';
return photo.format === 'TIFF' || photo.format === 'HEIF' || photo.format === 'DNG';
}
function storageKey() {
@@ -178,7 +179,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' || !/\.(jpe?g|png|tiff?|heic|heif|hif|dng)$/i.test(name)) continue;
const file = await handle.getFile();
const previous = stored.photos?.[name] || {};
const unchanged = !previous.lastModified || previous.lastModified === file.lastModified;
@@ -217,7 +218,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 JPG, PNG, TIFF, HEIC, HEIF, HIF, or DNG files were found in that folder.', true);
}
}
+2 -2
View File
@@ -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">Local folder · in-place JPG, PNG, TIFF, HEIF and DNG metadata</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 JPG, PNG, TIFF, HEIC, HEIF, HIF, or DNG photos.</div>
</div>
</section>
+1 -1
View File
@@ -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 JPG, PNG, TIFF, HEIF, and DNG photos."
}
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE_NAME = 'photo-date-editor-v6-1';
const CACHE_NAME = 'photo-date-editor-v7-0';
const SHELL = ['/', '/styles.css', '/app.js', '/manifest.webmanifest', '/vendor/leaflet/leaflet.css', '/vendor/leaflet/leaflet.js'];
self.addEventListener('install', (event) => {
+1
View File
@@ -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