Stream RAW saves with live progress v0.8.3
This commit is contained in:
1 parent
2dee67021e
commit
f1704c9822
6 files changed
+85
-27
No files matched your search
+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