feat(library): RESET SETTING puts a frame back to the tile it came in with

This commit is contained in:
2026-10-11 12:00:22 +07:00
parent 791e7d093c
commit de5d42e272
4 changed files with 84 additions and 0 deletions
+46
View File
@@ -36,6 +36,7 @@ import {
remoteUrl,
removeFolder,
renameFolder,
resetEdit,
scanAsked,
scanBusy,
scanQueue,
@@ -1277,6 +1278,23 @@ function Catalogue({ admin }: { admin: boolean }) {
go(`/app?lib=${encodeURIComponent(photo.id)}`);
}, [t]);
// Put a frame back: the recipe goes, the graded tile goes with it, and the frame
// is drawn again from the negative. Everything this screen holds a copy of goes
// too — the URL it is painting and the mark that says the frame was ever touched
// — which is what makes the wall draw the tile it is about to be handed.
const resetSetting = useCallback(async (photo: LibraryPhoto) => {
const blob = await resetEdit(photo).catch(() => null);
dropThumbUrl(photo.id);
if (blob) cacheObjectUrl(photo.id, blob);
setEdited((prev) => {
if (!prev.has(photo.id)) return prev;
const next = new Set(prev);
next.delete(photo.id);
return next;
});
setTilesAt((n) => n + 1);
}, []);
const rootFolder = useMemo(() => {
if (!node) return folders[0] ?? null;
const normNode = normPath(node);
@@ -3163,6 +3181,24 @@ function Catalogue({ admin }: { admin: boolean }) {
</div>
) : null}
{/* The frame's own look, taken back: what the studio filed for it and the
tile it drew from it, so the shelf shows the picture the frame came in
with — the tile as it was first drawn, and the preview the file's own
develop gives. */}
<button
type="button"
className="lib-menu-item"
data-key="lib-photo-reset"
onClick={() => {
const photo = photoMenu.photo;
setPhotoMenu(null);
void resetSetting(photo);
}}
>
<span className="lib-menu-icon" aria-hidden="true">↺</span>
{t('lib.reset')}
</button>
<button
type="button"
className="lib-menu-item"
@@ -3378,6 +3414,16 @@ function cacheObjectUrl(id: string, blob: Blob): string {
return url;
}
// A tile the shelf is to draw again from the negative — the studio's grading taken
// back — is dropped here, the URL with it: the row is keyed on the tile the store
// holds, and a copy kept would answer every read after with the graded one.
function dropThumbUrl(id: string): void {
const held = globalThumbUrls.get(id);
if (!held) return;
URL.revokeObjectURL(held.url);
globalThumbUrls.delete(id);
}
function useThumbUrls(photos: LibraryPhoto[], also?: LibraryPhoto | null, stamp?: number): Record<string, string> {
const list = useMemo(() => (also && !photos.some((p) => p.id === also.id) ? [...photos, also] : photos), [photos, also]);
+36
View File
@@ -2249,6 +2249,14 @@ export function makeThumb(photo: LibraryPhoto): Promise<Blob | null> {
async function makeOneThumb(photo: LibraryPhoto): Promise<Blob | null> {
const kept = await getPhotoThumb(photo.id);
if (kept) return kept;
return tileFrom(photo);
}
// The tile the frame's own source gives, with neither keeper of a graded tile
// asked. The shelf is not the caller: a frame put back wears the picture it was
// first drawn with, and both the store and the backup still hold the look that
// was just taken back.
async function tileFrom(photo: LibraryPhoto): Promise<Blob | null> {
// A server's frame has no file to open: its tile is the one Immich drew, asked
// for here and kept, so the second look comes out of the store and the sync's
// own download is only paid once.
@@ -2516,6 +2524,34 @@ export async function saveEdit(id: string, state: StudioState): Promise<void> {
}
}
// Put a frame back to the picture it was first drawn with: the recipe the studio
// filed goes, and the tile it graded from the negative goes with it — the store's
// copy and the backup's, which is the same look. The tile is made here rather than
// left to the shelf to make, because the shelf's way in asks the backup first and
// the backup is holding the look just taken back. Written without `tell`: the
// reader's own click is what redraws this frame, and the shelf has no business
// being told about a rewrite it asked for.
export async function resetEdit(photo: LibraryPhoto): Promise<Blob | null> {
try {
const db = await openDb();
const hasThumbs = db.objectStoreNames.contains(THUMBS);
await new Promise<void>((resolve, reject) => {
const tx = db.transaction([EDITS, ...(hasThumbs ? [THUMBS] : [])], 'readwrite');
tx.objectStore(EDITS).delete(photo.id);
if (hasThumbs) tx.objectStore(THUMBS).delete(photo.id);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
} catch {
// Private mode, or nothing filed for this frame: there is no look to take
// back, and the tile below is made all the same.
}
thumbCache.delete(photo.id);
const made = await tileFrom(photo);
if (made) await writeThumb(photo.id, made, false);
return made;
}
export async function loadEdit(id: string): Promise<LibraryEdit | null> {
try {
// A row filed before the whole look was kept carries the recipe alone: lift
+1
View File
@@ -276,6 +276,7 @@ export const en: Dict = {
'lib.mergeHdr': 'HDR Merge (Anti-ghosting)…',
'lib.copiedPath': 'Copied location to clipboard: {path}',
'lib.edited': 'edited',
'lib.reset': 'Reset to default',
'lib.size': '{mb} MB',
'lib.count': '{n} photos',
// What the thumbnail view is read through. The score a frame is given is the
+1
View File
@@ -288,6 +288,7 @@ export const vi = {
'lib.mergeHdr': 'Ghép HDR (Chống nhòe)…',
'lib.copiedPath': 'Đã sao chép đường dẫn: {path}',
'lib.edited': 'đã chỉnh',
'lib.reset': 'Đặt lại ảnh về mặc định',
'lib.size': '{mb} MB',
'lib.count': '{n} ảnh',
// Cách đọc bày ảnh: điểm sao là của người xem, nên chữ nói đúng thế — "từ n