Add PNG and TIFF support v0.5

This commit is contained in:
dmo
2026-07-23 11:23:49 +02:00
parent 0541790dba
commit 3acf6bb7ec
10 changed files with 230 additions and 66 deletions
+5 -1
View File
@@ -10,6 +10,10 @@ APP_NAME=Photo Date Editor
MAX_UPLOAD_MB=150
LOG_LEVEL=INFO
# Optional TIFF preview limits. The original TIFF is never converted or replaced by the preview.
PREVIEW_MAX_EDGE=2400
PREVIEW_MAX_PIXELS=300000000
# Optional map/geocoder overrides. Existing .env files may omit these.
# Map tiles are fetched by the backend and served same-origin to the browser.
MAP_TILE_SOURCE_URL=https://tile.openstreetmap.org/{z}/{x}/{y}.png
@@ -19,4 +23,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.4.1 (+https://edit.mork.fyi)
# GEOCODER_USER_AGENT=MorkPhotoDateEditor/0.5.0 (+https://edit.mork.fyi)
+13
View File
@@ -1,5 +1,18 @@
# Changelog
## 0.5.0
- Add in-place PNG metadata reading and writing.
- Add in-place TIFF/TIF metadata reading and writing.
- Generate temporary browser-friendly JPEG previews for TIFF files while preserving and overwriting the original TIFF on save.
- Validate JPEG, PNG, and TIFF file signatures before metadata processing.
- Add normalized JPG, PNG, and TIFF format badges to the left photo list.
- Show the current file format alongside size and modified time in the viewer.
- Add **Sort: Format**, grouping by normalized format and then filename.
- Keep TIFF list thumbnails lazy: a format tile is shown until that TIFF has been opened and previewed.
- Add configurable TIFF preview edge and pixel limits.
- Bump the application and service-worker cache version to 0.5.0.
## 0.4.3
- Fix favorite locations appearing empty after a browser refresh.
+41 -30
View File
@@ -1,12 +1,12 @@
# Photo Date Editor
Version 0.4.3
Version 0.5.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 JPEG at a time to the Docker backend for ExifTool processing, and overwrites the same local file after processing.
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 and JPEG
- JPG/JPEG, PNG, and TIFF/TIF
- Local directory picker in supported Chromium browsers
- Three-column photo workflow
- Exact date, month/year, year-only, approximate year
@@ -16,12 +16,19 @@ A self-hosted browser UI for manually dating scanned photographs. The browser re
- Shared favorite locations persisted in a Docker volume for all family members and browsers
- 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 sorting
- Filename search, status filtering, and filename/status/format sorting
- Normalized JPG, PNG, and TIFF 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 the JPEG when a photo is selected
- 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
- Keyboard navigation, including `Shift+Tab` between right-side tabs
## Preview behavior
JPG and PNG are displayed directly by the browser. Chromium browsers do not reliably display TIFF, so TIFF files are temporarily uploaded to `/api/preview` and rendered as a JPEG preview by Pillow. This preview exists only in memory and is never written over the source file.
When a TIFF is saved, ExifTool updates the original TIFF and the browser overwrites the same local `.tif` or `.tiff` file. The file is not converted to JPEG and no additional copy is intentionally left behind.
## Requirements
- Docker with Docker Compose
@@ -29,7 +36,7 @@ A self-hosted browser UI for manually dating scanned photographs. The browser re
- HTTPS when accessed from another device
- A backup of irreplaceable scans before testing any metadata editor
The browser File System Access API is required. Firefox and Safari are not supported by this first version.
The browser File System Access API is required. Firefox and Safari are not supported by this version.
## Install
@@ -48,6 +55,10 @@ APP_NAME=Photo Date Editor
MAX_UPLOAD_MB=150
LOG_LEVEL=INFO
# Optional TIFF preview limits
PREVIEW_MAX_EDGE=2400
PREVIEW_MAX_PIXELS=300000000
# Optional map defaults and service overrides
MAP_TILE_SOURCE_URL=https://tile.openstreetmap.org/{z}/{x}/{y}.png
MAP_ATTRIBUTION=© OpenStreetMap contributors
@@ -59,7 +70,7 @@ GEOCODER_URL=https://nominatim.openstreetmap.org/search
Open the configured HTTPS URL through Caddy, click **Open photo folder**, and grant read/write access.
## Upgrade from 0.1 through 0.4.2
## Upgrade from 0.1 through 0.4.3
Replace the project files with this version and rebuild:
@@ -67,7 +78,7 @@ Replace the project files with this version and rebuild:
docker compose up -d --build
```
The existing `.env` can be kept. Browser progress from 0.1 remains compatible. A normal refresh should load the new service-worker cache; use a hard refresh if an old UI remains visible.
The existing `.env` can be kept. The preview variables are optional. Browser progress from earlier releases remains compatible. A normal refresh should load the new service-worker cache; use a hard refresh if an old UI remains visible.
## Caddy example
@@ -89,15 +100,23 @@ 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 JPEG.
1. The browser reads the selected local JPG, PNG, or TIFF.
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 JPEG.
4. The backend returns the modified file using its original media type.
5. The browser writes the returned bytes over the selected original file with `createWritable()`.
6. The backend temporary directory is deleted automatically.
No second photo is intentionally left on the client computer or Docker host.
## Metadata behavior by format
- **JPG/JPEG:** EXIF, XMP, and IPTC fields are written.
- **TIFF/TIF:** EXIF, XMP, and IPTC fields are written.
- **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.
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.
## Date behavior
EXIF date fields require a complete timestamp:
@@ -120,7 +139,6 @@ The chosen precision is also written into XMP Photoshop Instructions.
- `C`: Copy previous values when not typing
- `Shift+Tab`: Open the previous right-side metadata tab and place focus in its first field; normal `Tab` still advances through fields
## Location behavior
Favorite locations are loaded automatically when the page opens and are shared by everyone using the same Docker stack. Choose a saved place and click **Use**, or place a pin/set coordinates and click **Add current location** in the favorite-locations card. The favorite list is stored in the named Docker volume `photo-date-editor-data`, so it survives image rebuilds and container recreation. Deleting a favorite only removes it from the reusable list; it does not alter photos that were already geotagged.
@@ -128,15 +146,12 @@ Favorite locations are loaded automatically when the page opens and are shared b
The **Location** tab supports five ways to set GPS metadata:
1. Choose a shared favorite location.
2. Search for an address or place and choose a result.
3. Click directly on the map.
4. Drag the existing marker.
5. Enter decimal latitude and longitude manually.
On save, coordinates are written to EXIF and XMP GPS fields. The optional location label is written to XMP IPTC Core Location. Use **Clear location** and save to remove GPS coordinates and the app-managed location label.
The default map tiles and address search are external OpenStreetMap services. Both are requested by the Docker backend rather than directly by the browser. This avoids Brave third-party blocking and keeps all browser requests same-origin through Caddy. Tiles are cached in container memory. Search is user-triggered rather than autocomplete, rate-limited to one uncached request per second, and cached in memory. The tile source and geocoder URLs remain configurable in `.env` for later self-hosting or another provider.
The default map tiles and address search are external OpenStreetMap services. Both are requested by the Docker backend rather than directly by the browser. Tiles are cached in container memory. Search is user-triggered, rate-limited, and cached in memory. The tile source and geocoder URLs remain configurable in `.env`.
## Important limitations
@@ -144,22 +159,22 @@ 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 in this MVP; it does not rotate image pixels or write orientation metadata.
- The Docker host needs outbound HTTPS access for the default map tiles and address search. Browser requests remain same-origin through the app.
- Test with copies first, then use your own normal backup routine for the originals.
- Preview rotation is visual only; it does not rotate image pixels or write orientation metadata.
- Multi-page TIFF files display the first page/frame in the editor. Metadata is written to the TIFF container, not to separate pages.
- Very large TIFF files may require raising `MAX_UPLOAD_MB` or `PREVIEW_MAX_PIXELS`.
- 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.
## Planned format expansion
Format support needs two separate capabilities: safe metadata writing and a preview the browser can display. ExifTool gives the backend a strong metadata foundation, while formats that browsers do not reliably preview will use a temporary server-generated preview without converting or replacing the original file.
Format support needs both safe metadata writing and a browser-friendly preview. Formats that browsers do not reliably display will use a temporary server-generated preview without converting or replacing the original file.
### Proposed order
1. **PNG** — direct browser preview and in-place EXIF/XMP/IPTC metadata writing.
2. **TIFF/TIF** — in-place metadata writing, with a server-generated preview where the browser cannot display the file directly.
3. **HEIC/HEIF/HIF** — in-place EXIF/XMP writing and a server-generated preview. This phase should use a recent ExifTool release and test normal, HDR, and motion-photo samples.
4. **DNG** — in-place metadata writing and preview extraction/rendering.
5. **Canon RAW**`CR2` and `CR3`, using per-format tag rules and embedded/server-generated previews.
6. **Nikon RAW**`NEF` and `NRW`, using per-format tag rules and embedded/server-generated previews.
1. **HEIC/HEIF/HIF** — in-place EXIF/XMP writing and a server-generated preview.
2. **DNG** — in-place metadata writing and preview extraction/rendering.
3. **Canon RAW**`CR2` and `CR3`, using per-format tag rules and embedded/server-generated previews.
4. **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.
@@ -169,10 +184,6 @@ Format support needs two separate capabilities: safe metadata writing and a prev
- **BMP/DIB** — ExifTool can read it but cannot write the metadata this app needs.
- **SVG, PDF, EPS, WebP, AVIF, EXR, XCF** — intentionally outside the scope of this photo workflow.
RAW support will remain **in-place** by default to match the JPEG workflow. Before enabling it, the app needs format-specific round-trip tests and clear warnings because proprietary camera originals deserve a stricter safety bar than scans.
## Future video support
MP4 is feasible after the planned photo formats, but it will be a separate media phase rather than just another image extension. Browsers can preview MP4 directly, while ExifTool can write selected QuickTime/MP4 metadata such as creation dates, descriptive fields, and static GPS coordinates. Videos do not use the same EXIF model as JPEGs, so the app will map the existing fields to compatible QuickTime and XMP tags.
The current browser-to-Docker-to-browser workflow transfers the complete file for every save. That is acceptable for photos but inefficient for large videos. Before enabling MP4, the app should add streamed/chunked transfer, larger configurable upload limits, clear progress reporting, and round-trip tests against common players and photo libraries. MP4 support is planned after PNG, TIFF, HEIC/HEIF, DNG, Canon RAW, and Nikon RAW.
MP4 remains feasible after the planned photo formats, but it will be a separate media phase rather than just another image extension. The existing fields can be mapped to compatible QuickTime and XMP metadata, but large videos need streamed/chunked transfer, progress reporting, larger limits, cancellation, and compatibility testing before in-place support is enabled.
+99 -17
View File
@@ -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
View File
@@ -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;
+3 -2
View File
@@ -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>
+1 -1
View File
@@ -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 -1
View File
@@ -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) => {
+5 -2
View File
@@ -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; }
+1
View File
@@ -1,3 +1,4 @@
fastapi>=0.116,<1
uvicorn[standard]>=0.35,<1
python-multipart>=0.0.20,<1
Pillow>=11.3,<13