Optimize RAW inspection and add timings v0.8.1

This commit is contained in:
dmo
2026-07-25 08:35:22 +02:00
parent 4e3c442b26
commit d17bc6a132
6 changed files with 200 additions and 122 deletions
+2 -1
View File
@@ -19,6 +19,7 @@ LOG_LEVEL=INFO
# Optional TIFF, HEIF, and DNG preview limits. Originals are never converted or replaced by previews.
PREVIEW_MAX_EDGE=2400
RAW_PREVIEW_MAX_EDGE=1600
PREVIEW_MAX_PIXELS=300000000
# Optional map/geocoder overrides. Existing .env files may omit these.
@@ -30,4 +31,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.8.0 (+https://edit.mork.fyi)
# GEOCODER_USER_AGENT=MorkPhotoDateEditor/0.8.1 (+https://edit.mork.fyi)
+12 -1
View File
@@ -1,5 +1,16 @@
# Changelog
## 0.8.1
- Upload server-preview formats only once when opening them, returning the preview and metadata together.
- Stop at the first usable embedded RAW preview instead of extracting every candidate.
- Add `RAW_PREVIEW_MAX_EDGE` (default `1600`) for smaller temporary RAW previews.
- Keep LibRaw as a lazy fallback only when no embedded RAW preview is usable.
- Show separate server/transfer and local-file-write durations after saving.
- Log upload, ExifTool rewrite, and response-read timings and expose them through `Server-Timing`.
- Allow up to three minutes for large DNG and Canon RAW metadata rewrites.
- Preserve all existing format and in-place overwrite behavior.
## 0.8.0
- Add the `canon` format group with CR2 and CR3 support.
@@ -10,7 +21,7 @@
- Read and write CR2 EXIF, XMP, IPTC, date precision, description, keywords, GPS coordinates, and location labels.
- Read and write CR3 EXIF and XMP metadata without applying unsupported IPTC-IIM fields.
- Preserve Canon maker notes, sensor data, embedded previews, and other untouched camera metadata.
- Update enabled-format configuration, UI text, manifest, README, and the RAW roadmap.
- Update enabled-format configuration, UI text, manifest, README, and the RAW roadmap, including planned Nikon and Sony phases.
- Preserve all established JPG, PNG, TIFF, HEIF, and DNG behavior.
- Bump the application and service-worker cache version to 0.8.0.
+10 -4
View File
@@ -1,6 +1,6 @@
# Photo Date Editor
Version 0.8.0
Version 0.8.1
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.
@@ -25,7 +25,7 @@ A self-hosted browser UI for manually dating scanned photographs. The browser re
## Preview behavior
JPG and PNG are displayed directly by the browser. Chromium browsers do not reliably display TIFF, HEIF-family images, DNG, CR2, or CR3, so those formats are temporarily uploaded to `/api/preview` and returned as a browser-friendly JPEG. TIFF uses Pillow, HEIF uses `pillow-heif`, and RAW formats inspect `JpgFromRaw`, `PreviewImage`, and `ThumbnailImage`, selecting the largest usable embedded image. If none is usable, LibRaw renders a half-size preview through `rawpy`. The preview exists only in memory and is never written over the source file.
JPG and PNG are displayed directly by the browser. Chromium browsers do not reliably display TIFF, HEIF-family images, DNG, CR2, or CR3, so those formats are temporarily uploaded to `/api/inspect`, which returns both metadata and a browser-friendly JPEG preview from one upload. TIFF uses Pillow, HEIF uses `pillow-heif`, and RAW formats stop at the first usable embedded preview in a container-specific priority order. If none is usable, LibRaw renders a half-size preview through `rawpy`. The preview exists only in memory and is never written over the source file.
When a TIFF, HEIF-family image, DNG, CR2, or CR3 is saved, ExifTool updates the original format and the browser overwrites the same local file. The original is not converted to JPEG and no additional copy is intentionally left behind.
@@ -60,6 +60,7 @@ LOG_LEVEL=INFO
# Optional TIFF/HEIF/RAW preview limits
PREVIEW_MAX_EDGE=2400
RAW_PREVIEW_MAX_EDGE=1600
PREVIEW_MAX_PIXELS=300000000
# Optional map defaults and service overrides
@@ -141,6 +142,8 @@ The browser directory picker requires a secure context. Use a certificate truste
No second photo is intentionally left on the client computer or Docker host.
RAW saves still require the complete original to travel to Docker and the complete rewritten file to return to the browser. ExifTool must also rewrite the metadata-bearing RAW container. v0.8.1 removes duplicate opening uploads and displays separate server/transfer and local-write timings, but the full save round trip is inherent while the folder belongs to the browser rather than the Docker host.
## Metadata behavior by format
- **JPG/JPEG:** EXIF, XMP, and IPTC fields are written.
@@ -198,8 +201,8 @@ The default map tiles and address search are external OpenStreetMap services. Bo
- 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.
- HEIF image sequences display their primary/first image in the editor. Metadata is written to the HEIF container.
- DNG uses the largest common embedded JPEG preview available before falling back to a half-size LibRaw render. A small embedded thumbnail may look softer but does not alter the DNG.
- CR2 and CR3 use the largest common embedded JPEG preview available before falling back to a half-size LibRaw render.
- DNG, CR2, and CR3 use the first usable embedded JPEG in a format-specific priority order before falling back to a half-size LibRaw render.
- `RAW_PREVIEW_MAX_EDGE` defaults to 1600 pixels to keep temporary previews responsive. It never resizes the RAW original.
- Very large TIFF, HEIF, DNG, CR2, or CR3 files may require raising `MAX_UPLOAD_MB` or `PREVIEW_MAX_PIXELS`.
- RAW rendering depends on the camera and compression variant being supported by the bundled LibRaw version. Metadata editing can still be supported by ExifTool even when a particular RAW file cannot be rendered.
- Canon maker notes, sensor data, embedded previews, and camera-specific metadata are preserved rather than intentionally rewritten. Always validate with copies from the camera models in your archive.
@@ -214,6 +217,9 @@ Format support needs both safe metadata writing and a browser-friendly preview.
### Proposed order
1. **Nikon RAW**`NEF` and `NRW`, using per-format tag rules and embedded/server-generated previews.
2. **Sony RAW** — modern `ARW` and `ARQ`, using TIFF-based metadata profiles and embedded/server-generated previews.
Older Sony `SR2` and `SRF` files are recorded as a compatibility decision for the Sony phase. `SR2` is writable through ExifTool, while `SRF` is read-only, so they should not be enabled automatically with modern Sony RAW support.
`RAW` is not treated as one universal format. Each camera family will be enabled and tested explicitly.
+125 -111
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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) => {