Add DNG support v0.7
This commit is contained in:
1 parent
c2640cf75a
commit
b9a0259166
10 files changed
+117
-33
No files matched your search
+78
-11
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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,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) => {
|
||||
|
||||
Reference in new issue
Block a user