Stream RAW saves with live progress v0.8.3

This commit is contained in:
dmo committed 2026-07-25 08:38:39 +02:00
1 parent 2dee67021e
commit f1704c9822
6 files changed
+85 -27

No files matched your search

+10 -4
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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) => {