Add saved pixel rotation for JPG, PNG and TIFF v0.9.2

This commit is contained in:
dmo committed 2026-07-25 10:42:25 +02:00
1 parent 34602e4ebd
commit ad52a95d4f
9 files changed
+251 -17

No files matched your search

+165 -2
View File
@@ -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}'
),
+56 -7
View File
@@ -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(); });
+3 -2
View File
@@ -94,6 +94,7 @@
<div id="viewer-content" class="viewer-content hidden">
<div class="image-stage">
<img id="photo-preview" alt="Current scanned photograph">
<div id="rotation-status" class="rotation-status hidden" role="status"></div>
</div>
<div class="viewer-tools">
<div class="tool-group">
@@ -106,8 +107,8 @@
<span id="current-filesize">—</span>
</div>
<div class="tool-group">
<button id="rotate-left" class="icon-button" title="Rotate preview left">↶</button>
<button id="rotate-right" class="icon-button" title="Rotate preview right">↷</button>
<button id="rotate-left" class="icon-button" title="Rotate left">↶</button>
<button id="rotate-right" class="icon-button" title="Rotate right">↷</button>
<button id="reset-view" class="icon-button" title="Reset preview">⟳</button>
</div>
</div>
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE_NAME = 'photo-date-editor-v9-1';
const CACHE_NAME = 'photo-date-editor-v9-2';
const SHELL = ['/', '/styles.css', '/app.js', '/manifest.webmanifest', '/vendor/leaflet/leaflet.css', '/vendor/leaflet/leaflet.js'];
self.addEventListener('install', (event) => {
+3 -1
View File
@@ -74,8 +74,10 @@ button { color: inherit; }
.viewer-empty { margin: auto; text-align: center; max-width: 470px; padding: 35px; }
.empty-icon { font-size: 48px; color: #6685ae; }.viewer-empty h2 { margin: 15px 0 7px; }.viewer-empty p { color: var(--muted); line-height: 1.6; margin: 0 0 22px; }
.viewer-content { width: 100%; height: 100%; min-height: 0; overflow: hidden; display: grid; grid-template-rows: minmax(220px, 1fr) auto 76px; }
.image-stage { min-height: 0; display: grid; place-items: center; overflow: auto; padding: 18px; background-image: radial-gradient(circle at 50% 40%, #18212a 0, #0a0f14 68%); }
.image-stage { position: relative; min-height: 0; display: grid; place-items: center; overflow: auto; padding: 18px; background-image: radial-gradient(circle at 50% 40%, #18212a 0, #0a0f14 68%); }
.image-stage img { display: block; max-width: 100%; max-height: 100%; object-fit: contain; box-shadow: var(--shadow); transition: transform .15s ease; transform-origin: center; }
.rotation-status { position: absolute; left: 18px; bottom: 18px; max-width: calc(100% - 36px); padding: 7px 10px; border: 1px solid rgba(84,201,138,.55); border-radius: 7px; background: rgba(12,20,27,.9); color: #bcebd0; font-size: 11px; box-shadow: var(--shadow); }
.rotation-status.preview-only { border-color: rgba(228,179,92,.55); color: #f0d59f; }
.viewer-tools { border-top: 1px solid var(--line-soft); min-height: 58px; display: grid; grid-template-columns: 1fr minmax(170px, auto) 1fr; align-items: center; padding: 10px 18px; gap: 18px; background: #10171f; }
.viewer-tools > :last-child { justify-self: end; }
.tool-group { display: flex; align-items: center; gap: 7px; }