Stream RAW saves with live progress v0.8.3
This commit is contained in:
+1
-1
@@ -31,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.2 (+https://edit.mork.fyi)
|
||||
# GEOCODER_USER_AGENT=MorkPhotoDateEditor/0.8.3 (+https://edit.mork.fyi)
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
# Changelog
|
||||
|
||||
## 0.8.3
|
||||
|
||||
- Stream processed files from the backend in 256 KiB chunks with an explicit content length.
|
||||
- Write response chunks directly into the browser's protected writable file instead of creating a complete in-memory Blob.
|
||||
- Show live transferred bytes and percentage while saving.
|
||||
- Commit the local replacement only after the complete response stream closes successfully.
|
||||
- Cancel the response reader and abort the browser writable file if streaming fails.
|
||||
- Retain the buffered Blob workflow as a compatibility fallback for browsers without response streaming.
|
||||
- Report time to first byte and combined stream/write time for streamed saves.
|
||||
- Preserve all existing metadata, format, progress, and folder-state behavior.
|
||||
|
||||
## 0.8.2
|
||||
|
||||
- Return inspection metadata and the JPEG preview in one compact binary response instead of Base64 JSON.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Photo Date Editor
|
||||
|
||||
Version 0.8.2
|
||||
Version 0.8.3
|
||||
|
||||
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.
|
||||
|
||||
@@ -144,7 +144,9 @@ 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. The optimized opening path removes duplicate uploads, but the full save round trip is inherent while the folder belongs to the browser rather than the Docker host.
|
||||
|
||||
After a save, the status panel and popup show total, server/transfer, and local-write durations. Container logs also show end-to-end request duration and break inspection work into temporary-copy, preview, and metadata stages.
|
||||
During a save, the backend streams the processed file in 256 KiB chunks and the browser writes each chunk directly into its protected temporary writable file. The UI shows transferred bytes and percentage. The original is replaced only after the complete stream closes successfully; failures abort the temporary writable file. Browsers without response streaming use the previous buffered Blob workflow.
|
||||
|
||||
After a streamed save, the status panel and popup show total time, time to first byte, and combined stream/write time. Container logs also show end-to-end request duration and break inspection work into temporary-copy, preview, and metadata stages.
|
||||
|
||||
## Metadata behavior by format
|
||||
|
||||
|
||||
+10
-4
@@ -21,7 +21,7 @@ from typing import Annotated, Any
|
||||
|
||||
from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile
|
||||
from pydantic import BaseModel, Field
|
||||
from fastapi.responses import FileResponse, Response
|
||||
from fastapi.responses import FileResponse, Response, StreamingResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from PIL import Image, ImageOps, UnidentifiedImageError
|
||||
|
||||
@@ -85,7 +85,7 @@ APP_SUBTITLE = (
|
||||
or f"Local folder · in-place {ENABLED_FORMAT_LABEL} metadata"
|
||||
)
|
||||
APP_URL = os.getenv("APP_URL", "http://localhost:8080")
|
||||
APP_VERSION = "0.8.2"
|
||||
APP_VERSION = "0.8.3"
|
||||
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"))
|
||||
@@ -1043,11 +1043,17 @@ async def process_photo(
|
||||
response_read_seconds,
|
||||
)
|
||||
|
||||
return Response(
|
||||
content=updated_bytes,
|
||||
def response_chunks() -> Any:
|
||||
chunk_size = 256 * 1024
|
||||
for offset in range(0, len(updated_bytes), chunk_size):
|
||||
yield updated_bytes[offset:offset + chunk_size]
|
||||
|
||||
return StreamingResponse(
|
||||
response_chunks(),
|
||||
media_type=MEDIA_TYPES[suffix],
|
||||
headers={
|
||||
"Content-Disposition": f'inline; filename="{Path(filename).name}"',
|
||||
"Content-Length": str(len(updated_bytes)),
|
||||
"Cache-Control": "no-store",
|
||||
"X-Date-Precision": precision_label,
|
||||
"Server-Timing": (
|
||||
|
||||
+58
-19
@@ -357,7 +357,7 @@ function updatePhotoStatus(photo) {
|
||||
const values = photo.fileValues || photo.values;
|
||||
if (photo.status === 'saved') {
|
||||
const timing = photo.lastSaveTiming
|
||||
? ` · ${photo.lastSaveTiming.total.toFixed(1)}s total · ${photo.lastSaveTiming.serverTransfer.toFixed(1)}s server/transfer · ${photo.lastSaveTiming.localWrite.toFixed(1)}s local write`
|
||||
? ` · ${formatSaveTiming(photo.lastSaveTiming)}`
|
||||
: '';
|
||||
setSaveStatus('saved', 'Previously saved', `${formatMetadataSummary(values)}${timing}`);
|
||||
} else if (photo.status === 'skipped') {
|
||||
@@ -371,6 +371,13 @@ function updatePhotoStatus(photo) {
|
||||
}
|
||||
}
|
||||
|
||||
function formatSaveTiming(timing) {
|
||||
if (timing.streamed) {
|
||||
return `${timing.total.toFixed(1)}s total · ${timing.firstByte.toFixed(1)}s to first byte · ${timing.streamWrite.toFixed(1)}s stream/write`;
|
||||
}
|
||||
return `${timing.total.toFixed(1)}s total · ${timing.serverTransfer.toFixed(1)}s server/transfer · ${timing.localWrite.toFixed(1)}s local write`;
|
||||
}
|
||||
|
||||
async function readPhotoMetadata(photo, file) {
|
||||
if (photo.metadataLoaded) return;
|
||||
const payload = new FormData();
|
||||
@@ -625,20 +632,59 @@ async function savePhoto(moveNext) {
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
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 responseReceivedAt = performance.now();
|
||||
const firstByteSeconds = (responseReceivedAt - saveStarted) / 1000;
|
||||
const writable = await photo.handle.createWritable({ keepExistingData: false });
|
||||
let timing;
|
||||
let responseReader = null;
|
||||
try {
|
||||
await writable.write(updatedBlob);
|
||||
await writable.close();
|
||||
if (response.body && typeof response.body.getReader === 'function') {
|
||||
responseReader = response.body.getReader();
|
||||
const expectedBytes = Number(response.headers.get('content-length')) || 0;
|
||||
let receivedBytes = 0;
|
||||
let lastProgressUpdate = 0;
|
||||
while (true) {
|
||||
const { done, value } = await responseReader.read();
|
||||
if (done) break;
|
||||
await writable.write(value);
|
||||
receivedBytes += value.byteLength;
|
||||
const now = performance.now();
|
||||
if (now - lastProgressUpdate >= 200) {
|
||||
const progress = expectedBytes
|
||||
? `${Math.min(100, (receivedBytes / expectedBytes) * 100).toFixed(0)}% · ${formatBytes(receivedBytes)} / ${formatBytes(expectedBytes)}`
|
||||
: `${formatBytes(receivedBytes)} received`;
|
||||
setSaveStatus('saving', 'Streaming updated file…', `${photo.name} · ${progress}`);
|
||||
lastProgressUpdate = now;
|
||||
}
|
||||
}
|
||||
await writable.close();
|
||||
const completedAt = performance.now();
|
||||
timing = {
|
||||
streamed: true,
|
||||
total: (completedAt - saveStarted) / 1000,
|
||||
firstByte: firstByteSeconds,
|
||||
streamWrite: (completedAt - responseReceivedAt) / 1000,
|
||||
};
|
||||
} else {
|
||||
const updatedBlob = await response.blob();
|
||||
const serverAndTransferSeconds = (performance.now() - saveStarted) / 1000;
|
||||
setSaveStatus('saving', 'Writing updated file locally…', `${photo.name} · buffered compatibility mode`);
|
||||
const localWriteStarted = performance.now();
|
||||
await writable.write(updatedBlob);
|
||||
await writable.close();
|
||||
const localWriteSeconds = (performance.now() - localWriteStarted) / 1000;
|
||||
timing = {
|
||||
streamed: false,
|
||||
total: serverAndTransferSeconds + localWriteSeconds,
|
||||
serverTransfer: serverAndTransferSeconds,
|
||||
localWrite: localWriteSeconds,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
if (responseReader) await responseReader.cancel().catch(() => {});
|
||||
await writable.abort().catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
const localWriteSeconds = (performance.now() - localWriteStarted) / 1000;
|
||||
const totalSaveSeconds = serverAndTransferSeconds + localWriteSeconds;
|
||||
|
||||
const updatedFile = await photo.handle.getFile();
|
||||
photo.size = updatedFile.size;
|
||||
@@ -650,21 +696,14 @@ async function savePhoto(moveNext) {
|
||||
photo.fileValues = { ...savedValues };
|
||||
photo.metadataLoaded = true;
|
||||
photo.metadataPresent = true;
|
||||
photo.lastSaveTiming = {
|
||||
total: totalSaveSeconds,
|
||||
serverTransfer: serverAndTransferSeconds,
|
||||
localWrite: localWriteSeconds,
|
||||
};
|
||||
photo.lastSaveTiming = timing;
|
||||
saveFolderState();
|
||||
setSaveStatus(
|
||||
'saved',
|
||||
'Saved in place',
|
||||
`${formatMetadataSummary(savedValues)} · ${serverAndTransferSeconds.toFixed(1)}s server/transfer · ${localWriteSeconds.toFixed(1)}s local write`,
|
||||
);
|
||||
showToast(
|
||||
`Saved ${photo.name}: ${totalSaveSeconds.toFixed(1)}s total · `
|
||||
+ `${serverAndTransferSeconds.toFixed(1)}s server/transfer · ${localWriteSeconds.toFixed(1)}s local write`,
|
||||
`${formatMetadataSummary(savedValues)} · ${formatSaveTiming(timing)}`,
|
||||
);
|
||||
showToast(`Saved ${photo.name}: ${formatSaveTiming(timing)}`);
|
||||
updateProgress();
|
||||
|
||||
const oldThumb = state.thumbUrls.get(photo.name);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const CACHE_NAME = 'photo-date-editor-v8-2';
|
||||
const CACHE_NAME = 'photo-date-editor-v8-3';
|
||||
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