diff --git a/.env.example b/.env.example index f95b447..5c0ebba 100644 --- a/.env.example +++ b/.env.example @@ -31,4 +31,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.9.1 (+https://edit.mork.fyi) +# GEOCODER_USER_AGENT=MorkPhotoDateEditor/0.9.2 (+https://edit.mork.fyi) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3132d1d..e335179 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## 0.9.2 + +- Apply pending preview rotations physically when saving JPG, PNG, and TIFF files. +- Show an explicit overlay stating whether rotation will be saved or is preview-only. +- Preserve pending rotation while navigating between photos during the current session. +- Use lossless `jpegtran` rotation for compatible JPEG dimensions. +- Fall back to one high-quality JPEG re-encode when a perfect lossless transform is unavailable. +- Rotate PNG pixels losslessly and reject animated PNG files rather than dropping frames. +- Rotate every page of a TIFF with lossless TIFF compression. +- Restore existing metadata after PNG/TIFF reconstruction, then normalize EXIF/XMP orientation to 1. +- Keep HEIF, DNG, and all camera RAW formats strictly preview-only for rotation. +- Reject unsupported or invalid rotation requests at the backend. +- Add `libjpeg-turbo-progs` to the container for lossless JPEG transforms. +- Preserve all existing metadata editing, format support, viewport, and streamed-save behavior. + ## 0.9.1 - Lock the three-column desktop workspace to the available browser viewport height. diff --git a/Dockerfile b/Dockerfile index 078a377..9d413fc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,7 +4,7 @@ ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 RUN apt-get update \ - && apt-get install -y --no-install-recommends ca-certificates libgomp1 libheif1 libimage-exiftool-perl \ + && apt-get install -y --no-install-recommends ca-certificates libgomp1 libheif1 libimage-exiftool-perl libjpeg-turbo-progs \ && rm -rf /var/lib/apt/lists/* WORKDIR /app diff --git a/README.md b/README.md index 8509176..24e1bf2 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Photo Date Editor -Version 0.9.1 +Version 0.9.2 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. @@ -206,7 +206,11 @@ The default map tiles and address search are external OpenStreetMap services. Bo - Browser permission may need to be granted again after closing/reopening the browser. - Saved and skipped progress is stored per browser and folder name. The app also reads its own XMP marker from edited files so saved metadata can be recognized on another browser. - Overwriting a file through the browser generally changes its filesystem modified time to the time of the save. EXIF/XMP photo dates are independent of that filesystem timestamp. -- Preview rotation is visual only; it does not rotate image pixels or write orientation metadata. +- Preview rotations are physically applied when saving JPG, PNG, and TIFF files. Orientation metadata is normalized after the pixels are rotated. +- JPEG rotation uses lossless `jpegtran` when the dimensions permit a perfect transform. Otherwise it performs one high-quality re-encode and reports that in the save notification. +- PNG rotation is lossless. Animated PNG files are deliberately rejected rather than silently dropping frames. +- TIFF rotation preserves and rotates all pages, uses lossless TIFF compression, and restores metadata before applying the edited fields. +- HEIF, DNG, CR2, CR3, NEF, NRW, ARW, and ARQ rotation remains preview-only. Their pixels and orientation metadata are never changed by the rotation controls. - 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, CR2, CR3, NEF, NRW, ARW, and ARQ use the first usable embedded JPEG in a format-specific priority order before falling back to a half-size LibRaw render. diff --git a/app/main.py b/app/main.py index e491021..9671d3a 100644 --- a/app/main.py +++ b/app/main.py @@ -6,6 +6,7 @@ import json import logging import os import re +import shutil import subprocess import tempfile import threading @@ -24,6 +25,7 @@ from pydantic import BaseModel, Field from fastapi.responses import FileResponse, Response, StreamingResponse from fastapi.staticfiles import StaticFiles from PIL import Image, ImageOps, UnidentifiedImageError +from PIL import ImageSequence, JpegImagePlugin FORMAT_GROUPS: dict[str, tuple[str, ...]] = { "jpg": (".jpg", ".jpeg"), @@ -89,7 +91,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.9.1" +APP_VERSION = "0.9.2" 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")) @@ -98,6 +100,7 @@ PREVIEW_MAX_PIXELS = int(os.getenv("PREVIEW_MAX_PIXELS", "300000000")) Image.MAX_IMAGE_PIXELS = PREVIEW_MAX_PIXELS TIFF_EXTENSIONS = {".tif", ".tiff"} +PIXEL_ROTATION_EXTENSIONS = {".jpg", ".jpeg", ".png", ".tif", ".tiff"} HEIF_EXTENSIONS = {".heic", ".heif", ".hif"} DNG_EXTENSIONS = {".dng"} CANON_EXTENSIONS = {".cr2", ".cr3"} @@ -788,6 +791,146 @@ def _render_raw(path: Path, format_name: str) -> Image.Image: return Image.fromarray(rgb) +def _transpose_for_clockwise_rotation(image: Image.Image, rotation: int) -> Image.Image: + operation = { + 90: Image.Transpose.ROTATE_270, + 180: Image.Transpose.ROTATE_180, + 270: Image.Transpose.ROTATE_90, + }[rotation] + return image.transpose(operation) + + +def _copy_metadata(source: Path, destination: Path) -> None: + completed = subprocess.run( + [ + "exiftool", + "-overwrite_original", + "-m", + "-TagsFromFile", + str(source), + "-all:all", + str(destination), + ], + capture_output=True, + text=True, + timeout=90, + check=False, + ) + if completed.returncode != 0: + logger.error("ExifTool metadata restore after rotation failed: %s", completed.stderr.strip()) + raise ValueError("Metadata could not be preserved after rotating the image.") + + +def _rotate_jpeg(source: Path, destination: Path, rotation: int) -> str: + with Image.open(source) as image: + orientation = int(image.getexif().get(274, 1) or 1) + + if orientation == 1: + completed = subprocess.run( + [ + "jpegtran", + "-copy", + "all", + "-perfect", + "-rotate", + str(rotation), + "-outfile", + str(destination), + str(source), + ], + capture_output=True, + timeout=90, + check=False, + ) + if completed.returncode == 0 and destination.exists(): + return "jpeg-lossless" + + with Image.open(source) as image: + image.load() + corrected = ImageOps.exif_transpose(image) + rotated = _transpose_for_clockwise_rotation(corrected, rotation) + try: + sampling = JpegImagePlugin.get_sampling(image) + save_options: dict[str, Any] = { + "format": "JPEG", + "quality": 95, + "optimize": True, + "progressive": bool(image.info.get("progressive") or image.info.get("progression")), + } + if sampling >= 0: + save_options["subsampling"] = sampling + if image.info.get("icc_profile"): + save_options["icc_profile"] = image.info["icc_profile"] + rotated.save(destination, **save_options) + finally: + if corrected is not image: + corrected.close() + rotated.close() + _copy_metadata(source, destination) + return "jpeg-reencoded" + + +def _rotate_png_or_tiff(source: Path, destination: Path, suffix: str, rotation: int) -> str: + with Image.open(source) as image: + image.seek(0) + if suffix == ".png" and int(getattr(image, "n_frames", 1)) > 1: + raise ValueError("Animated PNG rotation is not supported.") + frames: list[Image.Image] = [] + for frame in ImageSequence.Iterator(image): + base = frame.copy() + corrected = ImageOps.exif_transpose(base) + if corrected is not base: + base.close() + rotated = _transpose_for_clockwise_rotation(corrected, rotation) + corrected.close() + frames.append(rotated) + if not frames: + raise ValueError("The image contains no rotatable frames.") + try: + if suffix == ".png": + options: dict[str, Any] = {"format": "PNG", "optimize": True} + if image.info.get("icc_profile"): + options["icc_profile"] = image.info["icc_profile"] + if image.info.get("dpi"): + options["dpi"] = image.info["dpi"] + frames[0].save(destination, **options) + mode = "png-lossless" + else: + options = { + "format": "TIFF", + "save_all": len(frames) > 1, + "append_images": frames[1:], + "compression": image.info.get("compression", "tiff_deflate"), + } + if image.info.get("icc_profile"): + options["icc_profile"] = image.info["icc_profile"] + if image.info.get("dpi"): + options["dpi"] = image.info["dpi"] + frames[0].save(destination, **options) + mode = "tiff-lossless-reencode" + finally: + for frame in frames: + frame.close() + _copy_metadata(source, destination) + return mode + + +def _rotate_pixels(path: Path, suffix: str, rotation: int) -> str: + source = path.with_name(f"rotation-source{suffix}") + destination = path.with_name(f"rotation-output{suffix}") + shutil.copy2(path, source) + try: + if suffix in {".jpg", ".jpeg"}: + mode = _rotate_jpeg(source, destination, rotation) + else: + mode = _rotate_png_or_tiff(source, destination, suffix, rotation) + destination.replace(path) + return mode + except (Image.DecompressionBombError, UnidentifiedImageError, OSError, ValueError) as exc: + logger.warning("Pixel rotation failed for %s: %s", path.name, exc) + raise HTTPException(status_code=422, detail=f"The image could not be rotated safely: {exc}") from exc + + def _preview_bytes(temp_path: Path, suffix: str, filename: str) -> bytes: try: if suffix in RAW_EXTENSIONS: @@ -939,6 +1082,7 @@ async def process_photo( latitude: Annotated[float | None, Form()] = None, longitude: Annotated[float | None, Form()] = None, location_name: Annotated[str, Form()] = "", + rotation: Annotated[int, Form()] = 0, ) -> Response: filename = file.filename or "photo.jpg" suffix = Path(filename).suffix.lower() @@ -947,6 +1091,13 @@ async def process_photo( status_code=415, detail=f"This format is disabled. Enabled format groups: {ENABLED_FORMAT_LABEL}.", ) + if rotation not in {0, 90, 180, 270}: + raise HTTPException(status_code=422, detail="Rotation must be 0, 90, 180, or 270 degrees.") + if rotation and suffix not in PIXEL_ROTATION_EXTENSIONS: + raise HTTPException( + status_code=422, + detail=f"Pixel rotation is not supported for {_format_name(suffix)} files.", + ) exif_datetime, xmp_date, precision_label = _normalise_datetime( precision=precision, @@ -978,6 +1129,12 @@ async def process_photo( upload_started = time.perf_counter() await _save_upload(file, temp_path, suffix) upload_seconds = time.perf_counter() - upload_started + rotation_mode = "none" + rotation_seconds = 0.0 + if rotation: + rotation_started = time.perf_counter() + rotation_mode = _rotate_pixels(temp_path, suffix, rotation) + rotation_seconds = time.perf_counter() - rotation_started supports_iptc = suffix in IPTC_EXTENSIONS command = [ @@ -999,6 +1156,8 @@ async def process_photo( ] if supports_iptc: command.extend(["-IPTC:Caption-Abstract=", "-IPTC:Keywords="]) + if rotation: + command.extend(["-EXIF:Orientation=1", "-XMP-tiff:Orientation=1"]) clean_description = description.strip() if clean_description: @@ -1066,9 +1225,11 @@ async def process_photo( updated_bytes = temp_path.read_bytes() response_read_seconds = time.perf_counter() - response_started logger.info( - "Processed %s: upload %.2fs, ExifTool %.2fs, response read %.2fs", + "Processed %s: upload %.2fs, rotation %.2fs (%s), ExifTool %.2fs, response read %.2fs", filename, upload_seconds, + rotation_seconds, + rotation_mode, exiftool_seconds, response_read_seconds, ) @@ -1086,8 +1247,10 @@ async def process_photo( "Content-Length": str(len(updated_bytes)), "Cache-Control": "no-store", "X-Date-Precision": precision_label, + "X-Pixel-Rotation": rotation_mode, "Server-Timing": ( f'upload;dur={upload_seconds * 1000:.1f}, ' + f'rotation;dur={rotation_seconds * 1000:.1f}, ' f'exiftool;dur={exiftool_seconds * 1000:.1f}, ' f'read;dur={response_read_seconds * 1000:.1f}' ), diff --git a/app/static/app.js b/app/static/app.js index a4397bd..c508f39 100644 --- a/app/static/app.js +++ b/app/static/app.js @@ -40,6 +40,7 @@ const elements = { viewerEmpty: $('#viewer-empty'), viewerContent: $('#viewer-content'), preview: $('#photo-preview'), + rotationStatus: $('#rotation-status'), filename: $('#current-filename'), filesize: $('#current-filesize'), position: $('#viewer-position'), @@ -208,6 +209,7 @@ async function scanFolder() { fileValues: null, metadataLoaded: false, metadataPresent: false, + pendingRotation: 0, }); } @@ -447,7 +449,9 @@ async function selectPhoto(index, force = false) { elements.filename.textContent = photo.name; elements.filesize.textContent = `${photo.format} · ${formatBytes(file.size)} · modified ${new Date(file.lastModified).toLocaleString()}`; elements.position.textContent = `${index + 1} / ${state.photos.length}`; - resetView(); + state.zoom = 1; + state.rotation = normalizeRotation(photo.pendingRotation || 0); + applyView(); restoreValues(photo.values); updatePhotoStatus(photo); updateNavigation(); @@ -626,9 +630,11 @@ async function savePhoto(moveNext) { payload.append('longitude', values.longitude); payload.append('location_name', values.locationName); } + const rotationToSave = supportsPixelRotation(photo) ? normalizeRotation(state.rotation) : 0; + payload.append('rotation', String(rotationToSave)); const saveStarted = performance.now(); - setSaveStatus('saving', 'Uploading and rewriting metadata…', `${photo.name} · large RAW files can take a while`); + setSaveStatus('saving', 'Uploading and applying changes…', `${photo.name} · large files can take a while`); const response = await fetch('/api/process', { method: 'POST', body: payload }); if (!response.ok) { let message = `Save failed (${response.status}).`; @@ -637,6 +643,7 @@ async function savePhoto(moveNext) { } const responseReceivedAt = performance.now(); + const rotationMode = response.headers.get('x-pixel-rotation') || 'none'; const firstByteSeconds = (responseReceivedAt - saveStarted) / 1000; const writable = await photo.handle.createWritable({ keepExistingData: false }); let timing; @@ -700,6 +707,8 @@ async function savePhoto(moveNext) { photo.fileValues = { ...savedValues }; photo.metadataLoaded = true; photo.metadataPresent = true; + photo.pendingRotation = 0; + state.rotation = 0; photo.lastSaveTiming = timing; saveFolderState(); setSaveStatus( @@ -707,7 +716,10 @@ async function savePhoto(moveNext) { 'Saved in place', `${formatMetadataSummary(savedValues)} · ${formatSaveTiming(timing)}`, ); - showToast(`Saved ${photo.name}: ${formatSaveTiming(timing)}`); + const rotationNote = rotationMode === 'jpeg-reencoded' + ? ' · rotation applied; JPEG re-encoded at high quality' + : rotationMode !== 'none' ? ' · pixel rotation applied' : ''; + showToast(`Saved ${photo.name}: ${formatSaveTiming(timing)}${rotationNote}`); updateProgress(); const oldThumb = state.thumbUrls.get(photo.name); @@ -1075,8 +1087,45 @@ async function searchAddress() { function applyView() { elements.preview.style.transform = `scale(${state.zoom}) rotate(${state.rotation}deg)`; + updateRotationStatus(); +} + +function normalizeRotation(rotation) { + return ((Number(rotation) % 360) + 360) % 360; +} + +function supportsPixelRotation(photo) { + return Boolean(photo && ['JPG', 'PNG', 'TIFF'].includes(photo.format)); +} + +function updateRotationStatus() { + const rotation = normalizeRotation(state.rotation); + const photo = state.photos[state.currentIndex]; + elements.rotationStatus.classList.toggle('hidden', rotation === 0 || !photo); + if (!rotation || !photo) return; + const supported = supportsPixelRotation(photo); + elements.rotationStatus.classList.toggle('preview-only', !supported); + elements.rotationStatus.textContent = supported + ? `Rotation ${rotation}° will be applied to the image when saved` + : `Preview rotation ${rotation}° · ${photo.format} files are not altered`; +} + +function setPendingRotation(rotation) { + state.rotation = normalizeRotation(rotation); + const photo = state.photos[state.currentIndex]; + if (photo) photo.pendingRotation = state.rotation; + applyView(); +} + +function fitView() { + state.zoom = 1; + applyView(); +} + +function resetView() { + state.zoom = 1; + setPendingRotation(0); } -function resetView() { state.zoom = 1; state.rotation = 0; applyView(); } for (const button of elements.openButtons) button.addEventListener('click', openFolder); elements.rescan.addEventListener('click', scanFolder); @@ -1102,9 +1151,9 @@ for (const input of [elements.latitude, elements.longitude]) { elements.locationName.addEventListener('input', markLocationDirty); elements.zoomOut.addEventListener('click', () => { state.zoom = Math.max(.25, state.zoom - .15); applyView(); }); elements.zoomIn.addEventListener('click', () => { state.zoom = Math.min(4, state.zoom + .15); applyView(); }); -elements.fit.addEventListener('click', resetView); -elements.rotateLeft.addEventListener('click', () => { state.rotation -= 90; applyView(); }); -elements.rotateRight.addEventListener('click', () => { state.rotation += 90; applyView(); }); +elements.fit.addEventListener('click', fitView); +elements.rotateLeft.addEventListener('click', () => setPendingRotation(state.rotation - 90)); +elements.rotateRight.addEventListener('click', () => setPendingRotation(state.rotation + 90)); elements.resetView.addEventListener('click', resetView); elements.timeMode.addEventListener('change', () => elements.timeField.classList.toggle('hidden', elements.timeMode.value !== 'known')); elements.description.addEventListener('input', () => { elements.descriptionCount.textContent = `${elements.description.value.length} / 2000`; captureCurrentValues(); }); diff --git a/app/static/index.html b/app/static/index.html index 31cd53a..0f5e6aa 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -94,6 +94,7 @@