Add PNG and TIFF support v0.5
This commit is contained in:
1 parent
0541790dba
commit
3acf6bb7ec
10 files changed
+230
-66
No files matched your search
+99
-17
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -22,12 +23,26 @@ from fastapi import FastAPI, File, Form, HTTPException, UploadFile
|
||||
from pydantic import BaseModel, Field
|
||||
from fastapi.responses import FileResponse, Response
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from PIL import Image, ImageOps, UnidentifiedImageError
|
||||
|
||||
APP_NAME = os.getenv("APP_NAME", "Photo Date Editor")
|
||||
APP_URL = os.getenv("APP_URL", "http://localhost:8080")
|
||||
APP_VERSION = "0.4.3"
|
||||
APP_VERSION = "0.5.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"))
|
||||
PREVIEW_MAX_PIXELS = int(os.getenv("PREVIEW_MAX_PIXELS", "300000000"))
|
||||
Image.MAX_IMAGE_PIXELS = PREVIEW_MAX_PIXELS
|
||||
|
||||
SUPPORTED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".tif", ".tiff"}
|
||||
TIFF_EXTENSIONS = {".tif", ".tiff"}
|
||||
MEDIA_TYPES = {
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".tif": "image/tiff",
|
||||
".tiff": "image/tiff",
|
||||
}
|
||||
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper()
|
||||
|
||||
MAP_TILE_SOURCE_URL = os.getenv(
|
||||
@@ -367,7 +382,36 @@ def _normalise_datetime(
|
||||
return exif_datetime, xmp_date, precision_labels[precision]
|
||||
|
||||
|
||||
async def _save_upload(upload: UploadFile, destination: Path) -> None:
|
||||
def _format_name(suffix: str) -> str:
|
||||
if suffix in {".jpg", ".jpeg"}:
|
||||
return "JPEG"
|
||||
if suffix == ".png":
|
||||
return "PNG"
|
||||
if suffix in TIFF_EXTENSIONS:
|
||||
return "TIFF"
|
||||
return suffix.lstrip(".").upper() or "image"
|
||||
|
||||
|
||||
def _validate_image_signature(path: Path, suffix: str) -> None:
|
||||
with path.open("rb") as source:
|
||||
signature = source.read(16)
|
||||
|
||||
valid = False
|
||||
if suffix in {".jpg", ".jpeg"}:
|
||||
valid = signature.startswith(b"\xff\xd8\xff")
|
||||
elif suffix == ".png":
|
||||
valid = signature.startswith(b"\x89PNG\r\n\x1a\n")
|
||||
elif suffix in TIFF_EXTENSIONS:
|
||||
valid = signature.startswith((b"II*\x00", b"MM\x00*", b"II+\x00", b"MM\x00+"))
|
||||
|
||||
if not valid:
|
||||
raise HTTPException(
|
||||
status_code=415,
|
||||
detail=f"The uploaded file is not a valid {_format_name(suffix)} image.",
|
||||
)
|
||||
|
||||
|
||||
async def _save_upload(upload: UploadFile, destination: Path, suffix: str) -> None:
|
||||
total = 0
|
||||
with destination.open("wb") as output:
|
||||
while chunk := await upload.read(1024 * 1024):
|
||||
@@ -379,10 +423,7 @@ async def _save_upload(upload: UploadFile, destination: Path) -> None:
|
||||
)
|
||||
output.write(chunk)
|
||||
|
||||
with destination.open("rb") as source:
|
||||
signature = source.read(3)
|
||||
if signature[:2] != b"\xff\xd8":
|
||||
raise HTTPException(status_code=415, detail="The uploaded file is not a valid JPEG image.")
|
||||
_validate_image_signature(destination, suffix)
|
||||
|
||||
|
||||
def _first_value(metadata: dict[str, Any], *keys: str) -> Any:
|
||||
@@ -506,16 +547,54 @@ def _metadata_values(metadata: dict[str, Any]) -> tuple[bool, bool, dict[str, An
|
||||
return has_metadata, edited_by_app, values
|
||||
|
||||
|
||||
@app.post("/api/preview")
|
||||
async def create_preview(file: Annotated[UploadFile, File(...)]) -> Response:
|
||||
"""Generate a temporary browser-friendly preview for TIFF images."""
|
||||
filename = file.filename or "photo.tiff"
|
||||
suffix = Path(filename).suffix.lower()
|
||||
if suffix not in TIFF_EXTENSIONS:
|
||||
raise HTTPException(status_code=415, detail="Server-generated previews are only required for TIFF 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:
|
||||
with Image.open(temp_path) as source:
|
||||
source.seek(0)
|
||||
preview = ImageOps.exif_transpose(source)
|
||||
preview.thumbnail((PREVIEW_MAX_EDGE, PREVIEW_MAX_EDGE), Image.Resampling.LANCZOS)
|
||||
|
||||
if preview.mode in {"RGBA", "LA"} or (preview.mode == "P" and "transparency" in preview.info):
|
||||
rgba = preview.convert("RGBA")
|
||||
background = Image.new("RGBA", rgba.size, (255, 255, 255, 255))
|
||||
background.alpha_composite(rgba)
|
||||
preview = background.convert("RGB")
|
||||
elif preview.mode != "RGB":
|
||||
preview = preview.convert("RGB")
|
||||
|
||||
output = io.BytesIO()
|
||||
preview.save(output, format="JPEG", quality=88, optimize=True)
|
||||
except (Image.DecompressionBombError, UnidentifiedImageError, OSError, ValueError) as exc:
|
||||
logger.warning("TIFF preview generation failed for %s: %s", filename, exc)
|
||||
raise HTTPException(status_code=422, detail="The TIFF could not be rendered for preview.") from exc
|
||||
|
||||
return Response(
|
||||
content=output.getvalue(),
|
||||
media_type="image/jpeg",
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
|
||||
@app.post("/api/metadata")
|
||||
async def read_metadata(file: Annotated[UploadFile, File(...)]) -> dict[str, bool | dict[str, Any]]:
|
||||
filename = file.filename or "photo.jpg"
|
||||
suffix = Path(filename).suffix.lower()
|
||||
if suffix not in {".jpg", ".jpeg"}:
|
||||
raise HTTPException(status_code=415, detail="This version supports JPG and JPEG files only.")
|
||||
if suffix not in SUPPORTED_EXTENSIONS:
|
||||
raise HTTPException(status_code=415, detail="Supported formats are JPG, JPEG, PNG, TIF, and TIFF.")
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="photo-date-editor-read-") as temp_dir:
|
||||
temp_path = Path(temp_dir) / f"source{suffix}"
|
||||
await _save_upload(file, temp_path)
|
||||
await _save_upload(file, temp_path, suffix)
|
||||
completed = subprocess.run(
|
||||
[
|
||||
"exiftool",
|
||||
@@ -576,8 +655,8 @@ async def process_photo(
|
||||
) -> Response:
|
||||
filename = file.filename or "photo.jpg"
|
||||
suffix = Path(filename).suffix.lower()
|
||||
if suffix not in {".jpg", ".jpeg"}:
|
||||
raise HTTPException(status_code=415, detail="This version supports JPG and JPEG files only.")
|
||||
if suffix not in SUPPORTED_EXTENSIONS:
|
||||
raise HTTPException(status_code=415, detail="Supported formats are JPG, JPEG, PNG, TIF, and TIFF.")
|
||||
|
||||
exif_datetime, xmp_date, precision_label = _normalise_datetime(
|
||||
precision=precision,
|
||||
@@ -606,8 +685,9 @@ async def process_photo(
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="photo-date-editor-") as temp_dir:
|
||||
temp_path = Path(temp_dir) / f"working{suffix}"
|
||||
await _save_upload(file, temp_path)
|
||||
await _save_upload(file, temp_path, suffix)
|
||||
|
||||
supports_iptc = suffix in {".jpg", ".jpeg", ".tif", ".tiff"}
|
||||
command = [
|
||||
"exiftool",
|
||||
"-overwrite_original",
|
||||
@@ -623,10 +703,10 @@ async def process_photo(
|
||||
f"-XMP-photoshop:Instructions=Photo Date Editor precision: {precision_label}; time: {time_label}",
|
||||
"-EXIF:ImageDescription=",
|
||||
"-XMP-dc:Description=",
|
||||
"-IPTC:Caption-Abstract=",
|
||||
"-XMP-dc:Subject=",
|
||||
"-IPTC:Keywords=",
|
||||
]
|
||||
if supports_iptc:
|
||||
command.extend(["-IPTC:Caption-Abstract=", "-IPTC:Keywords="])
|
||||
|
||||
clean_description = description.strip()
|
||||
if clean_description:
|
||||
@@ -634,13 +714,15 @@ async def process_photo(
|
||||
[
|
||||
f"-EXIF:ImageDescription={clean_description}",
|
||||
f"-XMP-dc:Description={clean_description}",
|
||||
f"-IPTC:Caption-Abstract={clean_description}",
|
||||
]
|
||||
)
|
||||
if supports_iptc:
|
||||
command.append(f"-IPTC:Caption-Abstract={clean_description}")
|
||||
|
||||
for keyword in keywords:
|
||||
command.append(f"-XMP-dc:Subject+={keyword}")
|
||||
command.append(f"-IPTC:Keywords+={keyword}")
|
||||
if supports_iptc:
|
||||
command.append(f"-IPTC:Keywords+={keyword}")
|
||||
|
||||
if gps_action == "clear":
|
||||
command.extend(
|
||||
@@ -690,7 +772,7 @@ async def process_photo(
|
||||
|
||||
return Response(
|
||||
content=updated_bytes,
|
||||
media_type="image/jpeg",
|
||||
media_type=MEDIA_TYPES[suffix],
|
||||
headers={
|
||||
"Content-Disposition": f'inline; filename="{Path(filename).name}"',
|
||||
"Cache-Control": "no-store",
|
||||
|
||||
+61
-12
@@ -113,6 +113,18 @@ function formatBytes(bytes) {
|
||||
return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function formatType(filename) {
|
||||
const extension = filename.split('.').pop()?.toLowerCase() || '';
|
||||
if (extension === 'jpg' || extension === 'jpeg') return 'JPG';
|
||||
if (extension === 'png') return 'PNG';
|
||||
if (extension === 'tif' || extension === 'tiff') return 'TIFF';
|
||||
return extension.toUpperCase();
|
||||
}
|
||||
|
||||
function requiresServerPreview(photo) {
|
||||
return photo.format === 'TIFF';
|
||||
}
|
||||
|
||||
function storageKey() {
|
||||
return state.directoryHandle ? `photo-date-editor:${state.directoryHandle.name}` : null;
|
||||
}
|
||||
@@ -164,13 +176,14 @@ async function scanFolder() {
|
||||
const photos = [];
|
||||
|
||||
for await (const [name, handle] of state.directoryHandle.entries()) {
|
||||
if (handle.kind !== 'file' || !/\.(jpe?g)$/i.test(name)) continue;
|
||||
if (handle.kind !== 'file' || !/\.(jpe?g|png|tiff?)$/i.test(name)) continue;
|
||||
const file = await handle.getFile();
|
||||
const previous = stored.photos?.[name] || {};
|
||||
const unchanged = !previous.lastModified || previous.lastModified === file.lastModified;
|
||||
photos.push({
|
||||
name,
|
||||
handle,
|
||||
format: formatType(name),
|
||||
size: file.size,
|
||||
lastModified: file.lastModified,
|
||||
status: unchanged ? (previous.status || 'pending') : 'pending',
|
||||
@@ -202,7 +215,7 @@ async function scanFolder() {
|
||||
} else {
|
||||
elements.viewerContent.classList.add('hidden');
|
||||
elements.viewerEmpty.classList.remove('hidden');
|
||||
showToast('No JPG or JPEG files were found in that folder.', true);
|
||||
showToast('No JPG, PNG, or TIFF files were found in that folder.', true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,14 +226,34 @@ function clearObjectUrls() {
|
||||
state.thumbUrls.clear();
|
||||
}
|
||||
|
||||
async function getThumbUrl(photo) {
|
||||
async function getPreviewUrl(photo, suppliedFile = null) {
|
||||
if (state.thumbUrls.has(photo.name)) return state.thumbUrls.get(photo.name);
|
||||
const file = await photo.handle.getFile();
|
||||
const url = URL.createObjectURL(file);
|
||||
const file = suppliedFile || await photo.handle.getFile();
|
||||
let previewBlob = file;
|
||||
|
||||
if (requiresServerPreview(photo)) {
|
||||
const payload = new FormData();
|
||||
payload.append('file', file, photo.name);
|
||||
const response = await fetch('/api/preview', { method: 'POST', body: payload });
|
||||
if (!response.ok) {
|
||||
let message = `Preview failed (${response.status}).`;
|
||||
try { message = (await response.json()).detail || message; } catch {}
|
||||
throw new Error(message);
|
||||
}
|
||||
previewBlob = await response.blob();
|
||||
}
|
||||
|
||||
const url = URL.createObjectURL(previewBlob);
|
||||
state.thumbUrls.set(photo.name, url);
|
||||
return url;
|
||||
}
|
||||
|
||||
async function getThumbUrl(photo) {
|
||||
if (state.thumbUrls.has(photo.name)) return state.thumbUrls.get(photo.name);
|
||||
if (requiresServerPreview(photo)) return null;
|
||||
return getPreviewUrl(photo);
|
||||
}
|
||||
|
||||
function statusSymbol(status) {
|
||||
if (status === 'saved') return '✓';
|
||||
if (status === 'skipped') return '→';
|
||||
@@ -243,6 +276,8 @@ function visiblePhotoEntries() {
|
||||
const statusDifference = statusSortOrder[a.photo.status] - statusSortOrder[b.photo.status];
|
||||
return statusDifference || naturalCompare(a.photo.name, b.photo.name);
|
||||
});
|
||||
} else if (elements.sortSelect.value === 'format') {
|
||||
entries.sort((a, b) => naturalCompare(a.photo.format, b.photo.format) || naturalCompare(a.photo.name, b.photo.name));
|
||||
} else {
|
||||
entries.sort((a, b) => naturalCompare(a.photo.name, b.photo.name));
|
||||
}
|
||||
@@ -264,12 +299,16 @@ function renderFileList() {
|
||||
row.className = `file-row${index === state.currentIndex ? ' active' : ''}`;
|
||||
row.dataset.index = String(index);
|
||||
row.innerHTML = `
|
||||
<div class="file-thumb" aria-hidden="true"></div>
|
||||
<div class="file-meta"><strong>${escapeHtml(photo.name)}</strong><span>${index + 1} / ${state.photos.length} · ${formatBytes(photo.size)}</span></div>
|
||||
<div class="file-thumb format-placeholder" aria-hidden="true">${escapeHtml(photo.format)}</div>
|
||||
<div class="file-meta">
|
||||
<strong>${escapeHtml(photo.name)}</strong>
|
||||
<span class="file-details"><span>${index + 1} / ${state.photos.length}</span><span>${formatBytes(photo.size)}</span><span class="format-badge">${escapeHtml(photo.format)}</span></span>
|
||||
</div>
|
||||
<span class="file-state ${photo.status}" title="${photo.status}">${statusSymbol(photo.status)}</span>`;
|
||||
row.addEventListener('click', () => selectPhoto(index));
|
||||
elements.fileList.appendChild(row);
|
||||
getThumbUrl(photo).then((url) => {
|
||||
if (!url || !row.isConnected) return;
|
||||
const placeholder = row.querySelector('.file-thumb');
|
||||
const image = document.createElement('img');
|
||||
image.className = 'file-thumb';
|
||||
@@ -346,11 +385,9 @@ async function selectPhoto(index, force = false) {
|
||||
const selectionToken = ++state.selectionToken;
|
||||
const photo = state.photos[index];
|
||||
const file = await photo.handle.getFile();
|
||||
if (state.objectUrl) URL.revokeObjectURL(state.objectUrl);
|
||||
state.objectUrl = URL.createObjectURL(file);
|
||||
elements.preview.src = state.objectUrl;
|
||||
elements.preview.removeAttribute('src');
|
||||
elements.filename.textContent = photo.name;
|
||||
elements.filesize.textContent = `${formatBytes(file.size)} · modified ${new Date(file.lastModified).toLocaleString()}`;
|
||||
elements.filesize.textContent = `${photo.format} · ${formatBytes(file.size)} · modified ${new Date(file.lastModified).toLocaleString()}`;
|
||||
elements.position.textContent = `${index + 1} / ${state.photos.length}`;
|
||||
resetView();
|
||||
restoreValues(photo.values);
|
||||
@@ -360,8 +397,20 @@ async function selectPhoto(index, force = false) {
|
||||
const active = elements.fileList.querySelector('.file-row.active');
|
||||
active?.scrollIntoView({ block: 'nearest' });
|
||||
|
||||
try {
|
||||
if (requiresServerPreview(photo)) setSaveStatus('loading', 'Rendering TIFF preview…', photo.name);
|
||||
const previewUrl = await getPreviewUrl(photo, file);
|
||||
if (selectionToken !== state.selectionToken || state.currentIndex !== index) return;
|
||||
elements.preview.src = previewUrl;
|
||||
renderFileList();
|
||||
} catch (error) {
|
||||
if (selectionToken === state.selectionToken && state.currentIndex === index) {
|
||||
setSaveStatus('failed', 'Could not render preview', error.message || photo.name);
|
||||
}
|
||||
}
|
||||
|
||||
if (!photo.metadataLoaded) {
|
||||
if (!photo.values) setSaveStatus('loading', 'Reading EXIF metadata…', photo.name);
|
||||
if (!photo.values) setSaveStatus('loading', 'Reading image metadata…', photo.name);
|
||||
try {
|
||||
await readPhotoMetadata(photo, file);
|
||||
if (selectionToken !== state.selectionToken || state.currentIndex !== index) return;
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<div class="brand-mark" aria-hidden="true">◫</div>
|
||||
<div>
|
||||
<h1 id="app-name">Photo Date Editor</h1>
|
||||
<p>Local folder · in-place JPEG metadata</p>
|
||||
<p>Local folder · in-place JPG, PNG and TIFF metadata</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="top-actions">
|
||||
@@ -70,10 +70,11 @@
|
||||
<select id="sort-select" class="input filter" aria-label="Sort photos" disabled>
|
||||
<option value="filename">Sort: Filename</option>
|
||||
<option value="status">Sort: Status</option>
|
||||
<option value="format">Sort: Format</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="file-list" class="file-list" aria-live="polite">
|
||||
<div class="empty-list">Choose a folder containing JPG or JPEG photos.</div>
|
||||
<div class="empty-list">Choose a folder containing JPG, PNG, or TIFF photos.</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
"display": "standalone",
|
||||
"background_color": "#0d1218",
|
||||
"theme_color": "#121820",
|
||||
"description": "Edit dates, descriptions, and keywords in scanned JPEG photos."
|
||||
"description": "Edit dates, descriptions, keywords, and locations in scanned JPG, PNG, and TIFF photos."
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
const CACHE_NAME = 'photo-date-editor-v4-3';
|
||||
const CACHE_NAME = 'photo-date-editor-v5-0';
|
||||
const SHELL = ['/', '/styles.css', '/app.js', '/manifest.webmanifest', '/vendor/leaflet/leaflet.css', '/vendor/leaflet/leaflet.js'];
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
|
||||
@@ -58,9 +58,12 @@ button { color: inherit; }
|
||||
.file-row:hover { background: rgba(255,255,255,.045); }
|
||||
.file-row.active { background: var(--primary-soft); outline: 1px solid rgba(79,135,255,.4); }
|
||||
.file-thumb { width: 50px; height: 44px; object-fit: cover; background: #0b1015; border-radius: 5px; }
|
||||
.file-thumb.format-placeholder { display: grid; place-items: center; border: 1px solid var(--border); color: var(--muted); font-size: 10px; font-weight: 800; letter-spacing: .06em; }
|
||||
.file-meta { min-width: 0; }
|
||||
.file-meta strong, .file-meta span { display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.file-meta strong { font-size: 13px; }.file-meta span { margin-top: 4px; color: var(--muted); font-size: 11px; }
|
||||
.file-meta strong { display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: 13px; }
|
||||
.file-details { margin-top: 4px; display: flex; align-items: center; gap: 6px; min-width: 0; color: var(--muted); font-size: 11px; white-space: nowrap; }
|
||||
.file-details > span:not(.format-badge) { overflow: hidden; text-overflow: ellipsis; }
|
||||
.format-badge { flex: 0 0 auto; padding: 1px 5px; border: 1px solid var(--border); border-radius: 999px; color: var(--text); font-size: 9px; font-weight: 800; letter-spacing: .05em; }
|
||||
.file-state { width: 19px; height: 19px; display: grid; place-items: center; border-radius: 50%; font-size: 11px; border: 1px solid #536071; color: #8390a0; }
|
||||
.file-state.saved { color: #092b1b; background: var(--success); border-color: var(--success); font-weight: 800; }
|
||||
.file-state.skipped { color: #2e2111; background: #c49a5a; border-color: #c49a5a; font-weight: 800; }
|
||||
|
||||
Reference in new issue
Block a user