web: a catalogue of the folders on the visitor's own disk
A RAW studio that cannot see a folder is one photo at a time. /library now takes a folder through Chromium's directory picker, keeps the handle in IndexedDB so the folder is there on the next visit, and walks it into a grid: one thumbnail per frame, the frame's own date, and the recipe it was last graded with. Nothing is uploaded and nothing is read twice — the RAW itself is opened only when a tile is clicked, at which point the studio develops it and the recipe comes back on top. The studio files every change back against the frame, debounced, so reopening a RAW is not doing the grade again. Thumbnails come off LibRaw's unpack_thumb for a RAW, which is a seek and a copy where a develop is a full decode of every pixel, and off createImageBitmap for anything else. A RAW with no preview inside it gets a placeholder tile rather than a minute of decoding per file. node scripts/library-check.mjs ok both frames indexed as tiles — 2 tiles from P1010256.JPG + P1010256.RW2 ok thumbnail for P1010256.JPG — 40400 bytes, jpeg=true ok thumbnail for P1010256.RW2 — 39895 bytes, jpeg=true (LibRaw preview) ok studio developed the frame from its handle ok the address was handed back — url=/app (no ?lib= left behind) ok the look was filed back against the frame — baseFilter=none, 19 knobs ok the tile says the frame is edited
This commit is contained in:
+69
-13
@@ -18,6 +18,8 @@ import { renderPhoto, type RenderAssets } from './engine/exportEngine';
|
||||
import { download, fileToBytes, readCapturedAt, readGps, readIso, resizedJpeg, scaledJpeg } from './engine/imageOps';
|
||||
import { developRaw, isRawName } from './engine/rawDevelop';
|
||||
import { clearRaw, readRaw, storeRaw } from './engine/rawStore';
|
||||
import { getPhoto, loadEdit, readPhotoFile, saveEdit } from './engine/library';
|
||||
import { offerInstall } from './pwa/install';
|
||||
import { cameraSupported } from './engine/camera';
|
||||
import { clearSession, forgetPhoto, loadPhoto, loadState, savePhoto, saveState } from './engine/session';
|
||||
import { preloadSuperRes, upscaleJpeg } from './engine/superRes';
|
||||
@@ -572,6 +574,10 @@ export function Workspace() {
|
||||
const [myPhotos, setMyPhotos] = useState<Photo[]>([]);
|
||||
// The saved photo standing on the stage, if any — its own consent chip shows.
|
||||
const [openPhotoId, setOpenPhotoId] = useState<number | null>(null);
|
||||
// The catalogue frame on the stage, if any (`/library` hands one over as
|
||||
// `/app?lib=<id>`). It is what the recipe is filed back against, so the frame
|
||||
// reopens in the studio at the look it was left at.
|
||||
const [libPhotoId, setLibPhotoId] = useState<string | null>(null);
|
||||
// Recipes made in this session, guest ones included. A guest's copy lives
|
||||
// here and nowhere else, so reloading the page is what deletes it.
|
||||
const [created, setCreated] = useState<Recipe[]>([]);
|
||||
@@ -638,18 +644,23 @@ export function Workspace() {
|
||||
// ...and the photo half of the session, back out of IndexedDB. When that is
|
||||
// empty but a RAW is still parked in OPFS, the RAW is what "the last photo"
|
||||
// means: develop it again rather than open on nothing.
|
||||
loadPhoto()
|
||||
.then(async (photo) => {
|
||||
if (!alive) return;
|
||||
if (photo) {
|
||||
return adoptPhoto(photo.bytes, photo.name, new Blob([photo.bytes as BlobPart], { type: 'image/jpeg' }), true);
|
||||
}
|
||||
const parked = await readRaw();
|
||||
if (!alive || !parked) return;
|
||||
const jpeg = await developRaw(parked.bytes);
|
||||
await adoptPhoto(jpeg, parked.name, new Blob([jpeg as BlobPart], { type: 'image/jpeg' }), true, parked.bytes);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
// A frame handed over by the catalogue (`/app?lib=<id>`) wins over both: the
|
||||
// visitor just clicked it, and it is newer than anything the session holds.
|
||||
const wanted = new URLSearchParams(window.location.search).get('lib');
|
||||
if (wanted) openLibraryPhoto(wanted);
|
||||
else
|
||||
loadPhoto()
|
||||
.then(async (photo) => {
|
||||
if (!alive) return;
|
||||
if (photo) {
|
||||
return adoptPhoto(photo.bytes, photo.name, new Blob([photo.bytes as BlobPart], { type: 'image/jpeg' }), true);
|
||||
}
|
||||
const parked = await readRaw();
|
||||
if (!alive || !parked) return;
|
||||
const jpeg = await developRaw(parked.bytes);
|
||||
await adoptPhoto(jpeg, parked.name, new Blob([jpeg as BlobPart], { type: 'image/jpeg' }), true, parked.bytes);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
@@ -936,6 +947,7 @@ export function Workspace() {
|
||||
setError(null);
|
||||
setBusy(t('auth.busy'));
|
||||
setOpenPhotoId(null);
|
||||
setLibPhotoId(null);
|
||||
try {
|
||||
await adoptPhoto(bytes, name, blob);
|
||||
// A shot survives a reload like any other photo of the session's own.
|
||||
@@ -955,8 +967,10 @@ export function Workspace() {
|
||||
setBusy(t('auth.busy'));
|
||||
// A file from the disk is nobody's saved frame: SAVE PHOTO must add one,
|
||||
// not overwrite whichever of the folder's own was open before. Opening one
|
||||
// of those sets the id back, after this call.
|
||||
// of those sets the id back, after this call. Same for a catalogue frame —
|
||||
// the caller that hands one over names it again on the way back.
|
||||
setOpenPhotoId(null);
|
||||
setLibPhotoId(null);
|
||||
try {
|
||||
const bytes = await fileToBytes(file);
|
||||
if (isRawName(file.name)) {
|
||||
@@ -1635,6 +1649,10 @@ export function Workspace() {
|
||||
);
|
||||
const stamp = new Date().toISOString().replace(/[-:T]/g, '').slice(0, 14);
|
||||
download(bytes, `recipescam_${(recipe.name || 'photo').replace(/\W+/g, '-').toLowerCase()}_${stamp}.jpg`);
|
||||
// The moment the app has done the visitor's work is the moment it has earned
|
||||
// a place on the machine: the install offer waits for this instead of
|
||||
// opening on the way in (which nobody reads).
|
||||
offerInstall();
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError(t('err.generic', { msg: String(err) }));
|
||||
@@ -1866,6 +1884,44 @@ export function Workspace() {
|
||||
[loadFile, applyRecipe, restoreLook, defaultLook, clearPreview, t]
|
||||
);
|
||||
|
||||
// A frame from the local catalogue, handed over as `/app?lib=<id>`: the bytes
|
||||
// come off the disk through the handle the catalogue kept, then the recipe that
|
||||
// frame was last left at goes on top. A frame first opened has no recipe yet
|
||||
// and arrives on the neutral stock, like any other file.
|
||||
const openLibraryPhoto = useCallback(
|
||||
async (id: string) => {
|
||||
setBusy(t('auth.busy'));
|
||||
setError(null);
|
||||
// The address is a handover, not a state: the frame lives in the session
|
||||
// from here on, so a reload reopens it like any other photo on the stage.
|
||||
window.history.replaceState(null, '', window.location.pathname);
|
||||
clearPreview();
|
||||
setHistPick(null);
|
||||
try {
|
||||
const row = await getPhoto(id);
|
||||
if (!row) throw new Error('not in the catalogue');
|
||||
await loadFile(await readPhotoFile(row));
|
||||
const saved = await loadEdit(id);
|
||||
if (saved) applyRecipe(saved.recipe);
|
||||
setLibPhotoId(id);
|
||||
} catch {
|
||||
setError(t('err.decode'));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
},
|
||||
[loadFile, applyRecipe, clearPreview, t]
|
||||
);
|
||||
|
||||
// What the catalogue remembers about a frame is the look it was left at, filed
|
||||
// back on every change — debounced, because a slider drag fires this per frame
|
||||
// of the drag and IndexedDB is on the wrong side of that.
|
||||
useEffect(() => {
|
||||
if (!libPhotoId || !source) return;
|
||||
const timer = setTimeout(() => void saveEdit(libPhotoId, recipe), 600);
|
||||
return () => clearTimeout(timer);
|
||||
}, [libPhotoId, source, recipe]);
|
||||
|
||||
// The saved frame's own switch. The reel reads `consent`, so the tick answers
|
||||
// the click at once; only the store's refusal (a failed call) takes it back.
|
||||
const togglePhotoConsent = useCallback(async (photo: Photo | undefined) => {
|
||||
|
||||
Reference in New Issue
Block a user