Add Canon CR2 and CR3 support for v0.8

This commit is contained in:
dmo committed 2026-07-25 08:32:24 +02:00
1 parent 8ed5490afc
commit 4e3c442b26
8 files changed
+99 -42

No files matched your search

+57 -20
View File
@@ -31,6 +31,7 @@ FORMAT_GROUPS: dict[str, tuple[str, ...]] = {
"tiff": (".tif", ".tiff"),
"heif": (".heic", ".heif", ".hif"),
"dng": (".dng",),
"canon": (".cr2", ".cr3"),
}
FORMAT_LABELS = {
"jpg": "JPG",
@@ -38,6 +39,7 @@ FORMAT_LABELS = {
"tiff": "TIFF",
"heif": "HEIF",
"dng": "DNG",
"canon": "Canon RAW",
}
@@ -83,7 +85,7 @@ APP_SUBTITLE = (
or f"Local folder · in-place {ENABLED_FORMAT_LABEL} metadata"
)
APP_URL = os.getenv("APP_URL", "http://localhost:8080")
APP_VERSION = "0.7.1"
APP_VERSION = "0.8.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"))
@@ -93,8 +95,10 @@ Image.MAX_IMAGE_PIXELS = PREVIEW_MAX_PIXELS
TIFF_EXTENSIONS = {".tif", ".tiff"}
HEIF_EXTENSIONS = {".heic", ".heif", ".hif"}
DNG_EXTENSIONS = {".dng"}
CANON_EXTENSIONS = {".cr2", ".cr3"}
IPTC_EXTENSIONS = {".jpg", ".jpeg", ".tif", ".tiff", ".dng", ".cr2"}
SERVER_PREVIEW_EXTENSIONS = (
TIFF_EXTENSIONS | HEIF_EXTENSIONS | DNG_EXTENSIONS
TIFF_EXTENSIONS | HEIF_EXTENSIONS | DNG_EXTENSIONS | CANON_EXTENSIONS
) & SUPPORTED_EXTENSIONS
MEDIA_TYPES = {
".jpg": "image/jpeg",
@@ -106,6 +110,8 @@ MEDIA_TYPES = {
".heif": "image/heif",
".hif": "image/heif",
".dng": "image/x-adobe-dng",
".cr2": "image/x-canon-cr2",
".cr3": "image/x-canon-cr3",
}
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper()
@@ -465,6 +471,10 @@ def _format_name(suffix: str) -> str:
return "HEIF"
if suffix in DNG_EXTENSIONS:
return "DNG"
if suffix == ".cr2":
return "CR2"
if suffix == ".cr3":
return "CR3"
return suffix.lstrip(".").upper() or "image"
@@ -492,6 +502,20 @@ def _validate_image_signature(path: Path, suffix: str) -> None:
# 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)
)
)
if not valid:
raise HTTPException(
@@ -652,7 +676,7 @@ def _ensure_heif_decoder() -> None:
def _get_rawpy() -> Any:
"""Import rawpy once, on the first DNG that needs a rendered fallback."""
"""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
@@ -661,12 +685,14 @@ def _get_rawpy() -> Any:
import rawpy
_rawpy_module = rawpy
logger.info("DNG LibRaw preview decoder loaded.")
logger.info("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."""
def _embedded_raw_preview(path: Path, format_name: str) -> Image.Image | None:
"""Extract the largest usable JPEG from the common RAW preview tags."""
best: Image.Image | None = None
best_area = 0
for tag in ("JpgFromRaw", "PreviewImage", "ThumbnailImage"):
try:
completed = subprocess.run(
@@ -676,28 +702,38 @@ def _embedded_dng_preview(path: Path) -> Image.Image | None:
check=False,
)
except subprocess.TimeoutExpired:
logger.warning("Timed out extracting DNG %s data.", tag)
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()
return source.copy()
candidate = source.copy()
area = candidate.width * candidate.height
if area > best_area:
if best is not None:
best.close()
best = candidate
best_area = area
else:
candidate.close()
except (UnidentifiedImageError, OSError, ValueError):
logger.debug("DNG %s data was not a usable image preview.", tag)
return None
logger.debug("%s %s data was not a usable image preview.", format_name, tag)
return best
def _render_dng(path: Path) -> Image.Image:
"""Render a half-size RGB fallback when a DNG has no embedded JPEG preview."""
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("DNG dimensions exceed the configured preview pixel limit.")
raise ValueError(
f"{format_name} dimensions exceed the configured preview pixel limit."
)
rgb = raw.postprocess(
use_camera_wb=True,
use_auto_wb=False,
@@ -706,13 +742,13 @@ def _render_dng(path: Path) -> Image.Image:
output_bps=8,
)
except rawpy.LibRawError as exc:
raise ValueError("LibRaw could not decode this DNG.") from exc
raise ValueError(f"LibRaw could not decode this {format_name} file.") 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, HEIF, and DNG images."""
"""Generate a browser-friendly preview for TIFF, HEIF, DNG, CR2, and CR3."""
filename = file.filename or "photo.tiff"
suffix = Path(filename).suffix.lower()
if suffix not in SUPPORTED_EXTENSIONS:
@@ -723,17 +759,18 @@ async def create_preview(file: Annotated[UploadFile, File(...)]) -> Response:
if suffix not in SERVER_PREVIEW_EXTENSIONS:
raise HTTPException(
status_code=415,
detail="Server-generated previews are only required for TIFF, HEIF, and DNG files.",
detail="Server-generated previews are only required for TIFF, HEIF, DNG, CR2, and CR3 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:
if suffix in DNG_EXTENSIONS:
preview = _embedded_dng_preview(temp_path)
if suffix in DNG_EXTENSIONS | CANON_EXTENSIONS:
format_name = _format_name(suffix)
preview = _embedded_raw_preview(temp_path, format_name)
if preview is None:
preview = _render_dng(temp_path)
preview = _render_raw(temp_path, format_name)
preview = ImageOps.exif_transpose(preview)
else:
if suffix in HEIF_EXTENSIONS:
@@ -881,7 +918,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", ".dng"}
supports_iptc = suffix in IPTC_EXTENSIONS
command = [
"exiftool",
"-overwrite_original",
+3 -1
View File
@@ -124,11 +124,13 @@ function formatType(filename) {
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';
return extension.toUpperCase();
}
function requiresServerPreview(photo) {
return photo.format === 'TIFF' || photo.format === 'HEIF' || photo.format === 'DNG';
return ['TIFF', 'HEIF', 'DNG', 'CR2', 'CR3'].includes(photo.format);
}
function isEnabledFilename(filename) {
+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, HEIF, and DNG photos."
"description": "Edit dates, descriptions, keywords, and locations in common images, DNG, and Canon RAW photos."
}
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE_NAME = 'photo-date-editor-v7-1';
const CACHE_NAME = 'photo-date-editor-v8-0';
const SHELL = ['/', '/styles.css', '/app.js', '/manifest.webmanifest', '/vendor/leaflet/leaflet.css', '/vendor/leaflet/leaflet.js'];
self.addEventListener('install', (event) => {