Optimize RAW inspection and add timings v0.8.1
This commit is contained in:
1 parent
4e3c442b26
commit
d17bc6a132
6 files changed
+200
-122
No files matched your search
+125
-111
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
@@ -85,10 +86,11 @@ APP_SUBTITLE = (
|
||||
or f"Local folder · in-place {ENABLED_FORMAT_LABEL} metadata"
|
||||
)
|
||||
APP_URL = os.getenv("APP_URL", "http://localhost:8080")
|
||||
APP_VERSION = "0.8.0"
|
||||
APP_VERSION = "0.8.1"
|
||||
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"))
|
||||
RAW_PREVIEW_MAX_EDGE = int(os.getenv("RAW_PREVIEW_MAX_EDGE", "1600"))
|
||||
PREVIEW_MAX_PIXELS = int(os.getenv("PREVIEW_MAX_PIXELS", "300000000"))
|
||||
Image.MAX_IMAGE_PIXELS = PREVIEW_MAX_PIXELS
|
||||
|
||||
@@ -689,11 +691,17 @@ def _get_rawpy() -> Any:
|
||||
return _rawpy_module
|
||||
|
||||
|
||||
def _embedded_raw_preview(path: Path, format_name: str) -> Image.Image | None:
|
||||
"""Extract the largest usable JPEG from the common RAW preview tags."""
|
||||
best: Image.Image | None = None
|
||||
best_area = 0
|
||||
for tag in ("JpgFromRaw", "PreviewImage", "ThumbnailImage"):
|
||||
def _raw_preview_tags(suffix: str) -> tuple[str, ...]:
|
||||
if suffix == ".cr2":
|
||||
return ("PreviewImage", "JpgFromRaw", "ThumbnailImage")
|
||||
if suffix == ".cr3":
|
||||
return ("JpgFromRaw", "PreviewImage", "ThumbnailImage")
|
||||
return ("JpgFromRaw", "PreviewImage", "ThumbnailImage")
|
||||
|
||||
|
||||
def _embedded_raw_preview(path: Path, format_name: str, suffix: str) -> Image.Image | None:
|
||||
"""Return the first usable embedded JPEG, ordered for the RAW container."""
|
||||
for tag in _raw_preview_tags(suffix):
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
["exiftool", "-b", f"-{tag}", "--", str(path)],
|
||||
@@ -709,18 +717,12 @@ def _embedded_raw_preview(path: Path, format_name: str) -> Image.Image | None:
|
||||
try:
|
||||
with Image.open(io.BytesIO(completed.stdout)) as source:
|
||||
source.load()
|
||||
candidate = source.copy()
|
||||
area = candidate.width * candidate.height
|
||||
if area > best_area:
|
||||
if best is not None:
|
||||
best.close()
|
||||
best = candidate
|
||||
best_area = area
|
||||
else:
|
||||
candidate.close()
|
||||
preview = source.copy()
|
||||
logger.info("Using %s for %s preview.", tag, format_name)
|
||||
return preview
|
||||
except (UnidentifiedImageError, OSError, ValueError):
|
||||
logger.debug("%s %s data was not a usable image preview.", format_name, tag)
|
||||
return best
|
||||
return None
|
||||
|
||||
|
||||
def _render_raw(path: Path, format_name: str) -> Image.Image:
|
||||
@@ -746,68 +748,101 @@ def _render_raw(path: Path, format_name: str) -> Image.Image:
|
||||
return Image.fromarray(rgb)
|
||||
|
||||
|
||||
def _preview_bytes(temp_path: Path, suffix: str, filename: str) -> bytes:
|
||||
try:
|
||||
if suffix in DNG_EXTENSIONS | CANON_EXTENSIONS:
|
||||
format_name = _format_name(suffix)
|
||||
preview = _embedded_raw_preview(temp_path, format_name, suffix)
|
||||
if preview is None:
|
||||
preview = _render_raw(temp_path, format_name)
|
||||
preview = ImageOps.exif_transpose(preview)
|
||||
else:
|
||||
if suffix in HEIF_EXTENSIONS:
|
||||
_ensure_heif_decoder()
|
||||
with Image.open(temp_path) as source:
|
||||
source.seek(0)
|
||||
preview = ImageOps.exif_transpose(source)
|
||||
preview.load()
|
||||
|
||||
try:
|
||||
max_edge = RAW_PREVIEW_MAX_EDGE if suffix in DNG_EXTENSIONS | CANON_EXTENSIONS else PREVIEW_MAX_EDGE
|
||||
preview.thumbnail((max_edge, 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=86, optimize=True)
|
||||
return output.getvalue()
|
||||
finally:
|
||||
preview.close()
|
||||
except (Image.DecompressionBombError, UnidentifiedImageError, OSError, ValueError) as exc:
|
||||
format_name = _format_name(suffix)
|
||||
logger.warning("%s preview generation failed for %s: %s", format_name, filename, exc)
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"The {format_name} image could not be rendered for preview.",
|
||||
) from exc
|
||||
|
||||
|
||||
def _read_metadata_path(temp_path: Path) -> dict[str, bool | dict[str, Any]]:
|
||||
completed = subprocess.run(
|
||||
[
|
||||
"exiftool", "-json", "-s", "-n",
|
||||
"-DateTimeOriginal", "-CreateDate", "-ModifyDate", "-DateCreated",
|
||||
"-Instructions", "-ImageDescription", "-Description", "-Caption-Abstract",
|
||||
"-Subject", "-Keywords", "-GPSLatitude", "-GPSLatitudeRef",
|
||||
"-GPSLongitude", "-GPSLongitudeRef", "-Location",
|
||||
"-LocationShownLocationName", "-City", str(temp_path),
|
||||
],
|
||||
capture_output=True, text=True, timeout=45, check=False,
|
||||
)
|
||||
if completed.returncode != 0:
|
||||
logger.error("ExifTool metadata read failed: %s", completed.stderr.strip())
|
||||
raise HTTPException(status_code=500, detail="ExifTool could not read this image's metadata.")
|
||||
try:
|
||||
output = json.loads(completed.stdout)
|
||||
metadata = output[0] if isinstance(output, list) and output else {}
|
||||
except json.JSONDecodeError as exc:
|
||||
raise HTTPException(status_code=500, detail="ExifTool returned invalid metadata output.") from exc
|
||||
has_metadata, edited_by_app, values = _metadata_values(metadata)
|
||||
return {"hasMetadata": has_metadata, "editedByApp": edited_by_app, "values": values}
|
||||
|
||||
|
||||
@app.post("/api/preview")
|
||||
async def create_preview(file: Annotated[UploadFile, File(...)]) -> Response:
|
||||
"""Generate a browser-friendly preview for TIFF, HEIF, DNG, CR2, and CR3."""
|
||||
"""Generate a browser-friendly preview for server-preview formats."""
|
||||
filename = file.filename or "photo.tiff"
|
||||
suffix = Path(filename).suffix.lower()
|
||||
if suffix not in SUPPORTED_EXTENSIONS:
|
||||
raise HTTPException(
|
||||
status_code=415,
|
||||
detail=f"This format is disabled. Enabled format groups: {ENABLED_FORMAT_LABEL}.",
|
||||
)
|
||||
if suffix not in SERVER_PREVIEW_EXTENSIONS:
|
||||
raise HTTPException(
|
||||
status_code=415,
|
||||
detail="Server-generated previews are only required for TIFF, HEIF, DNG, CR2, and CR3 files.",
|
||||
)
|
||||
|
||||
if suffix not in SUPPORTED_EXTENSIONS or suffix not in SERVER_PREVIEW_EXTENSIONS:
|
||||
raise HTTPException(status_code=415, detail="This format is disabled or does not require a server preview.")
|
||||
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:
|
||||
if suffix in DNG_EXTENSIONS | CANON_EXTENSIONS:
|
||||
format_name = _format_name(suffix)
|
||||
preview = _embedded_raw_preview(temp_path, format_name)
|
||||
if preview is None:
|
||||
preview = _render_raw(temp_path, format_name)
|
||||
preview = ImageOps.exif_transpose(preview)
|
||||
else:
|
||||
if suffix in HEIF_EXTENSIONS:
|
||||
_ensure_heif_decoder()
|
||||
with Image.open(temp_path) as source:
|
||||
source.seek(0)
|
||||
preview = ImageOps.exif_transpose(source)
|
||||
preview.load()
|
||||
preview_bytes = _preview_bytes(temp_path, suffix, filename)
|
||||
return Response(content=preview_bytes, media_type="image/jpeg", headers={"Cache-Control": "no-store"})
|
||||
|
||||
try:
|
||||
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)
|
||||
finally:
|
||||
preview.close()
|
||||
except (Image.DecompressionBombError, UnidentifiedImageError, OSError, ValueError) as exc:
|
||||
format_name = _format_name(suffix)
|
||||
logger.warning("%s preview generation failed for %s: %s", format_name, filename, exc)
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"The {format_name} image 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/inspect")
|
||||
async def inspect_photo(file: Annotated[UploadFile, File(...)]) -> dict[str, Any]:
|
||||
"""Upload once and return both metadata and a browser-friendly preview."""
|
||||
filename = file.filename or "photo.tiff"
|
||||
suffix = Path(filename).suffix.lower()
|
||||
if suffix not in SUPPORTED_EXTENSIONS or suffix not in SERVER_PREVIEW_EXTENSIONS:
|
||||
raise HTTPException(status_code=415, detail="This format is disabled or does not require server inspection.")
|
||||
with tempfile.TemporaryDirectory(prefix="photo-date-editor-inspect-") as temp_dir:
|
||||
temp_path = Path(temp_dir) / f"source{suffix}"
|
||||
await _save_upload(file, temp_path, suffix)
|
||||
preview_bytes = _preview_bytes(temp_path, suffix, filename)
|
||||
metadata = _read_metadata_path(temp_path)
|
||||
return {
|
||||
**metadata,
|
||||
"previewBase64": base64.b64encode(preview_bytes).decode("ascii"),
|
||||
"previewMediaType": "image/jpeg",
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/metadata")
|
||||
@@ -823,47 +858,8 @@ async def read_metadata(file: Annotated[UploadFile, File(...)]) -> dict[str, boo
|
||||
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, suffix)
|
||||
completed = subprocess.run(
|
||||
[
|
||||
"exiftool",
|
||||
"-json",
|
||||
"-s",
|
||||
"-n",
|
||||
"-DateTimeOriginal",
|
||||
"-CreateDate",
|
||||
"-ModifyDate",
|
||||
"-DateCreated",
|
||||
"-Instructions",
|
||||
"-ImageDescription",
|
||||
"-Description",
|
||||
"-Caption-Abstract",
|
||||
"-Subject",
|
||||
"-Keywords",
|
||||
"-GPSLatitude",
|
||||
"-GPSLatitudeRef",
|
||||
"-GPSLongitude",
|
||||
"-GPSLongitudeRef",
|
||||
"-Location",
|
||||
"-LocationShownLocationName",
|
||||
"-City",
|
||||
str(temp_path),
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=45,
|
||||
check=False,
|
||||
)
|
||||
if completed.returncode != 0:
|
||||
logger.error("ExifTool metadata read failed: %s", completed.stderr.strip())
|
||||
raise HTTPException(status_code=500, detail="ExifTool could not read this image's metadata.")
|
||||
try:
|
||||
output = json.loads(completed.stdout)
|
||||
metadata = output[0] if isinstance(output, list) and output else {}
|
||||
except json.JSONDecodeError as exc:
|
||||
raise HTTPException(status_code=500, detail="ExifTool returned invalid metadata output.") from exc
|
||||
|
||||
has_metadata, edited_by_app, values = _metadata_values(metadata)
|
||||
return {"hasMetadata": has_metadata, "editedByApp": edited_by_app, "values": values}
|
||||
result = _read_metadata_path(temp_path)
|
||||
return result
|
||||
|
||||
|
||||
@app.post("/api/process")
|
||||
@@ -916,7 +912,9 @@ async def process_photo(
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="photo-date-editor-") as temp_dir:
|
||||
temp_path = Path(temp_dir) / f"working{suffix}"
|
||||
upload_started = time.perf_counter()
|
||||
await _save_upload(file, temp_path, suffix)
|
||||
upload_seconds = time.perf_counter() - upload_started
|
||||
|
||||
supports_iptc = suffix in IPTC_EXTENSIONS
|
||||
command = [
|
||||
@@ -988,18 +986,29 @@ async def process_photo(
|
||||
command.append(str(temp_path))
|
||||
|
||||
logger.info("Processing %s with precision %s", filename, precision)
|
||||
exiftool_started = time.perf_counter()
|
||||
completed = subprocess.run(
|
||||
command,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=90,
|
||||
timeout=180 if suffix in DNG_EXTENSIONS | CANON_EXTENSIONS else 90,
|
||||
check=False,
|
||||
)
|
||||
exiftool_seconds = time.perf_counter() - exiftool_started
|
||||
if completed.returncode != 0:
|
||||
logger.error("ExifTool failed: %s", completed.stderr.strip())
|
||||
raise HTTPException(status_code=500, detail="ExifTool could not update this image.")
|
||||
|
||||
response_started = time.perf_counter()
|
||||
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",
|
||||
filename,
|
||||
upload_seconds,
|
||||
exiftool_seconds,
|
||||
response_read_seconds,
|
||||
)
|
||||
|
||||
return Response(
|
||||
content=updated_bytes,
|
||||
@@ -1008,6 +1017,11 @@ async def process_photo(
|
||||
"Content-Disposition": f'inline; filename="{Path(filename).name}"',
|
||||
"Cache-Control": "no-store",
|
||||
"X-Date-Precision": precision_label,
|
||||
"Server-Timing": (
|
||||
f'upload;dur={upload_seconds * 1000:.1f}, '
|
||||
f'exiftool;dur={exiftool_seconds * 1000:.1f}, '
|
||||
f'read;dur={response_read_seconds * 1000:.1f}'
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
+50
-4
@@ -378,7 +378,10 @@ async function readPhotoMetadata(photo, file) {
|
||||
try { message = (await response.json()).detail || message; } catch {}
|
||||
throw new Error(message);
|
||||
}
|
||||
const data = await response.json();
|
||||
applyMetadataResult(photo, await response.json());
|
||||
}
|
||||
|
||||
function applyMetadataResult(photo, data) {
|
||||
photo.metadataLoaded = true;
|
||||
photo.metadataPresent = Boolean(data.hasMetadata);
|
||||
photo.fileValues = data.hasMetadata ? data.values : null;
|
||||
@@ -391,6 +394,31 @@ async function readPhotoMetadata(photo, file) {
|
||||
}
|
||||
}
|
||||
|
||||
function base64PreviewUrl(base64, mediaType) {
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
|
||||
return URL.createObjectURL(new Blob([bytes], { type: mediaType || 'image/jpeg' }));
|
||||
}
|
||||
|
||||
async function inspectServerPreviewPhoto(photo, file) {
|
||||
const payload = new FormData();
|
||||
payload.append('file', file, photo.name);
|
||||
const response = await fetch('/api/inspect', { method: 'POST', body: payload });
|
||||
if (!response.ok) {
|
||||
let message = `Photo inspection failed (${response.status}).`;
|
||||
try { message = (await response.json()).detail || message; } catch {}
|
||||
throw new Error(message);
|
||||
}
|
||||
const data = await response.json();
|
||||
const oldUrl = state.thumbUrls.get(photo.name);
|
||||
if (oldUrl) URL.revokeObjectURL(oldUrl);
|
||||
const previewUrl = base64PreviewUrl(data.previewBase64, data.previewMediaType);
|
||||
state.thumbUrls.set(photo.name, previewUrl);
|
||||
applyMetadataResult(photo, data);
|
||||
return previewUrl;
|
||||
}
|
||||
|
||||
async function selectPhoto(index, force = false) {
|
||||
if (index < 0 || index >= state.photos.length || (state.processing && !force)) return;
|
||||
captureCurrentValues();
|
||||
@@ -414,9 +442,17 @@ async function selectPhoto(index, force = false) {
|
||||
if (requiresServerPreview(photo)) {
|
||||
setSaveStatus('loading', `Rendering ${photo.format} preview…`, photo.name);
|
||||
}
|
||||
const previewUrl = await getPreviewUrl(photo, file);
|
||||
const serverInspectionNeeded = requiresServerPreview(photo)
|
||||
&& (!state.thumbUrls.has(photo.name) || !photo.metadataLoaded);
|
||||
const previewUrl = serverInspectionNeeded
|
||||
? await inspectServerPreviewPhoto(photo, file)
|
||||
: await getPreviewUrl(photo, file);
|
||||
if (selectionToken !== state.selectionToken || state.currentIndex !== index) return;
|
||||
elements.preview.src = previewUrl;
|
||||
if (serverInspectionNeeded) {
|
||||
if (!formHasUserInput() && photo.values) restoreValues(photo.values);
|
||||
updatePhotoStatus(photo);
|
||||
}
|
||||
renderFileList();
|
||||
} catch (error) {
|
||||
if (selectionToken === state.selectionToken && state.currentIndex === index) {
|
||||
@@ -574,6 +610,8 @@ async function savePhoto(moveNext) {
|
||||
payload.append('location_name', values.locationName);
|
||||
}
|
||||
|
||||
const saveStarted = performance.now();
|
||||
setSaveStatus('saving', 'Uploading and rewriting metadata…', `${photo.name} · large RAW files can take a while`);
|
||||
const response = await fetch('/api/process', { method: 'POST', body: payload });
|
||||
if (!response.ok) {
|
||||
let message = `Save failed (${response.status}).`;
|
||||
@@ -582,6 +620,9 @@ async function savePhoto(moveNext) {
|
||||
}
|
||||
|
||||
const updatedBlob = await response.blob();
|
||||
const serverAndTransferSeconds = (performance.now() - saveStarted) / 1000;
|
||||
setSaveStatus('saving', 'Writing updated file locally…', `${photo.name} · ${serverAndTransferSeconds.toFixed(1)}s processing and transfer`);
|
||||
const localWriteStarted = performance.now();
|
||||
const writable = await photo.handle.createWritable({ keepExistingData: false });
|
||||
try {
|
||||
await writable.write(updatedBlob);
|
||||
@@ -590,6 +631,7 @@ async function savePhoto(moveNext) {
|
||||
await writable.abort().catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
const localWriteSeconds = (performance.now() - localWriteStarted) / 1000;
|
||||
|
||||
const updatedFile = await photo.handle.getFile();
|
||||
photo.size = updatedFile.size;
|
||||
@@ -602,8 +644,12 @@ async function savePhoto(moveNext) {
|
||||
photo.metadataLoaded = true;
|
||||
photo.metadataPresent = true;
|
||||
saveFolderState();
|
||||
setSaveStatus('saved', 'Saved in place', formatMetadataSummary(savedValues));
|
||||
showToast(`Saved ${photo.name}`);
|
||||
setSaveStatus(
|
||||
'saved',
|
||||
'Saved in place',
|
||||
`${formatMetadataSummary(savedValues)} · ${serverAndTransferSeconds.toFixed(1)}s server/transfer · ${localWriteSeconds.toFixed(1)}s local write`,
|
||||
);
|
||||
showToast(`Saved ${photo.name} in ${(serverAndTransferSeconds + localWriteSeconds).toFixed(1)}s`);
|
||||
updateProgress();
|
||||
|
||||
const oldThumb = state.thumbUrls.get(photo.name);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const CACHE_NAME = 'photo-date-editor-v8-0';
|
||||
const CACHE_NAME = 'photo-date-editor-v8-1';
|
||||
const SHELL = ['/', '/styles.css', '/app.js', '/manifest.webmanifest', '/vendor/leaflet/leaflet.css', '/vendor/leaflet/leaflet.js'];
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
|
||||
Reference in new issue
Block a user