From 4e3c442b26aa53db9663820c44410cf6b65d9fce Mon Sep 17 00:00:00 2001 From: danielmorkcode Date: Sat, 25 Jul 2026 08:32:24 +0200 Subject: [PATCH] Add Canon CR2 and CR3 support for v0.8 --- .env.example | 6 +-- CHANGELOG.md | 14 ++++++ README.md | 34 ++++++++------- app/main.py | 77 ++++++++++++++++++++++++--------- app/static/app.js | 4 +- app/static/manifest.webmanifest | 2 +- app/static/service-worker.js | 2 +- docker-compose.yml | 2 +- 8 files changed, 99 insertions(+), 42 deletions(-) diff --git a/.env.example b/.env.example index 949e49d..7226d03 100644 --- a/.env.example +++ b/.env.example @@ -11,9 +11,9 @@ APP_NAME=Photo Date Editor APP_TITLE=Mork Photo Date Editor # Optional text shown directly below the page heading. APP_SUBTITLE=Family photo metadata editor -# Enabled groups: jpg, png, tiff, heif, dng +# Enabled groups: jpg, png, tiff, heif, dng, canon # Use ENABLED_FORMATS=jpg for a JPEG-only workflow. -ENABLED_FORMATS=jpg,png,tiff,heif,dng +ENABLED_FORMATS=jpg,png,tiff,heif,dng,canon MAX_UPLOAD_MB=150 LOG_LEVEL=INFO @@ -30,4 +30,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.7.1 (+https://edit.mork.fyi) +# GEOCODER_USER_AGENT=MorkPhotoDateEditor/0.8.0 (+https://edit.mork.fyi) diff --git a/CHANGELOG.md b/CHANGELOG.md index 583cc36..bbd227c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## 0.8.0 + +- Add the `canon` format group with CR2 and CR3 support. +- Add exact `CR2` and `CR3` badges and format-sort compatibility. +- Validate TIFF-based CR2 headers separately from ISO-BMFF-based CR3 headers. +- Extract `JpgFromRaw`, `PreviewImage`, and `ThumbnailImage`, using the largest valid embedded preview. +- Use the lazy LibRaw decoder only when a Canon RAW file has no usable embedded preview. +- Read and write CR2 EXIF, XMP, IPTC, date precision, description, keywords, GPS coordinates, and location labels. +- Read and write CR3 EXIF and XMP metadata without applying unsupported IPTC-IIM fields. +- Preserve Canon maker notes, sensor data, embedded previews, and other untouched camera metadata. +- Update enabled-format configuration, UI text, manifest, README, and the RAW roadmap. +- Preserve all established JPG, PNG, TIFF, HEIF, and DNG behavior. +- Bump the application and service-worker cache version to 0.8.0. + ## 0.7.1 - Add `ENABLED_FORMATS` with the available groups `jpg`, `png`, `tiff`, `heif`, and `dng`. diff --git a/README.md b/README.md index a7fc9b0..0767e7b 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ # Photo Date Editor -Version 0.7.1 +Version 0.8.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, HEIC/HEIF/HIF, and DNG +- JPG/JPEG, PNG, TIFF/TIF, HEIC/HEIF/HIF, DNG, and Canon CR2/CR3 - 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, HEIF, and DNG format badges in the photo list +- Normalized JPG, PNG, TIFF, HEIF, DNG, CR2, and CR3 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, 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. +JPG and PNG are displayed directly by the browser. Chromium browsers do not reliably display TIFF, HEIF-family images, DNG, CR2, or CR3, so those formats are temporarily uploaded to `/api/preview` and returned as a browser-friendly JPEG. TIFF uses Pillow, HEIF uses `pillow-heif`, and RAW formats inspect `JpgFromRaw`, `PreviewImage`, and `ThumbnailImage`, selecting the largest usable embedded image. 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, 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. +When a TIFF, HEIF-family image, DNG, CR2, or CR3 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 @@ -54,11 +54,11 @@ APP_PORT=8080 APP_NAME=Photo Date Editor APP_TITLE=Mork Photo Date Editor APP_SUBTITLE=Family photo metadata editor -ENABLED_FORMATS=jpg,png,tiff,heif,dng +ENABLED_FORMATS=jpg,png,tiff,heif,dng,canon MAX_UPLOAD_MB=150 LOG_LEVEL=INFO -# Optional TIFF/HEIF/DNG preview limits +# Optional TIFF/HEIF/RAW preview limits PREVIEW_MAX_EDGE=2400 PREVIEW_MAX_PIXELS=300000000 @@ -84,11 +84,12 @@ Open the configured HTTPS URL through Caddy, click **Open photo folder**, and gr | `tiff` | `.tif`, `.tiff` | | `heif` | `.heic`, `.heif`, `.hif` | | `dng` | `.dng` | +| `canon` | `.cr2`, `.cr3` | The default enables every group: ```dotenv -ENABLED_FORMATS=jpg,png,tiff,heif,dng +ENABLED_FORMATS=jpg,png,tiff,heif,dng,canon ``` For a JPEG-only installation: @@ -99,9 +100,9 @@ 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. +HEIF and RAW 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 and Canon RAW, LibRaw is loaded only if no usable embedded JPEG preview is available. -## Upgrade from 0.1 through 0.7 +## Upgrade from 0.1 through 0.7.1 Replace the project files with this version and rebuild: @@ -131,7 +132,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, HIF, or DNG. +1. The browser reads the selected local JPG, PNG, TIFF, HEIC, HEIF, HIF, DNG, CR2, or CR3. 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. @@ -147,6 +148,8 @@ No second photo is intentionally left on the client computer or Docker host. - **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. +- **CR2:** EXIF, XMP, and IPTC fields are written into the original TIFF-based Canon RAW container. +- **CR3:** EXIF and XMP fields are written into the original ISO Base Media File Format container. IPTC-IIM is not used for CR3. 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. @@ -196,8 +199,10 @@ The default map tiles and address search are external OpenStreetMap services. Bo - 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. - 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. +- CR2 and CR3 use the largest common embedded JPEG preview available before falling back to a half-size LibRaw render. +- Very large TIFF, HEIF, DNG, CR2, or CR3 files may require raising `MAX_UPLOAD_MB` or `PREVIEW_MAX_PIXELS`. +- RAW 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 RAW file cannot be rendered. +- Canon maker notes, sensor data, embedded previews, and camera-specific metadata are preserved rather than intentionally rewritten. Always validate with copies from the camera models in your archive. - 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. @@ -208,8 +213,7 @@ Format support needs both safe metadata writing and a browser-friendly preview. ### Proposed order -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. +1. **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. diff --git a/app/main.py b/app/main.py index 1a6a3da..2ba14e0 100644 --- a/app/main.py +++ b/app/main.py @@ -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", diff --git a/app/static/app.js b/app/static/app.js index 65752cf..b3bed7e 100644 --- a/app/static/app.js +++ b/app/static/app.js @@ -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) { diff --git a/app/static/manifest.webmanifest b/app/static/manifest.webmanifest index c93071d..df22a46 100644 --- a/app/static/manifest.webmanifest +++ b/app/static/manifest.webmanifest @@ -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." } diff --git a/app/static/service-worker.js b/app/static/service-worker.js index fa370e2..c2cd1a0 100644 --- a/app/static/service-worker.js +++ b/app/static/service-worker.js @@ -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) => { diff --git a/docker-compose.yml b/docker-compose.yml index 95200bc..6c96077 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,7 +12,7 @@ services: APP_NAME: "${APP_NAME:-Photo Date Editor}" APP_TITLE: "${APP_TITLE:-}" APP_SUBTITLE: "${APP_SUBTITLE:-}" - ENABLED_FORMATS: "${ENABLED_FORMATS:-jpg,png,tiff,heif,dng}" + ENABLED_FORMATS: "${ENABLED_FORMATS:-jpg,png,tiff,heif,dng,canon}" MAX_UPLOAD_MB: "${MAX_UPLOAD_MB:-150}" LOG_LEVEL: "${LOG_LEVEL:-INFO}" DATA_DIR: "/data"