Add Canon CR2 and CR3 support for v0.8
This commit is contained in:
1 parent
8ed5490afc
commit
4e3c442b26
8 files changed
+99
-42
No files matched your search
+57
-20
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user