Add saved pixel rotation for JPG, PNG and TIFF v0.9.2
This commit is contained in:
1 parent
34602e4ebd
commit
ad52a95d4f
9 files changed
+251
-17
No files matched your search
+165
-2
@@ -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
@@ -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(); });
|
||||
|
||||
@@ -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,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) => {
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in new issue
Block a user