feat: the camera's own colour, a reading that keeps its place, and cloud links in Settings

- RAW opens at the colour the camera wrote into it: the develop carries the
  difference against the file's own preview (previewResidual) instead of the
  per-channel cubic fit, dE00 6.04 -> 0.54; previewMatch.ts keeps only the grid.
- Immich reading: keyset position kept across visits, one reading per node,
  a page in flight retried, timeline buckets for the years on offer.
- The wall draws a server frame at the address the proxy serves it (no blob
  minted for it), and the 80-thumbnail pre-cache on open is gone.
- Settings lists the cloud links, flags two that answer at the same address,
  and forgets one.
- Custom frames imported from a file (engine/customFrame.ts, IndexedDB).
- Upload an edited frame from Studio to Immich Cloud.
This commit is contained in:
2026-10-12 07:59:24 +07:00
parent 807c271b6b
commit 97fd181cfa
25 changed files with 2402 additions and 616 deletions
+76 -2
View File
@@ -100,6 +100,13 @@ export interface RenderOptions {
// quarter turn and the fine angle (they ride on top of both).
photoFlipH?: boolean;
photoFlipV?: boolean;
customFrame?: {
bytes: Uint8Array;
hole: { x: number; y: number; w: number; h: number };
scale?: number;
panX?: number;
panY?: number;
} | null;
// The strip the FILE carries: the amber `#TAG` over the photo and a caption
// band appended below it, mirroring the landing page's `.lp-shot` +
// `.lp-frame-meta`. Left off the live preview, which must not show it.
@@ -770,8 +777,8 @@ function loadWallframe(assets: RenderAssets) {
// is a transparent window a couple of percent of paper wide around it.
const isOldFilm = (id: FrameId) => id === 'old-film' || id === 'old-film-portrait';
// A frame that carries its own opening: the aspect chip has nothing to say to
// it (both walls, both films).
const hasOwnAspect = (id: FrameId) => isWallframe(id) || isOldFilm(id);
// it (both walls, both films, custom frames).
const hasOwnAspect = (id: FrameId) => isWallframe(id) || isOldFilm(id) || id === 'custom';
function loadOldFilm(assets: RenderAssets) {
if (oldFilmImage) return oldFilmImage;
@@ -1521,6 +1528,73 @@ export async function renderPhoto(input: RenderInput): Promise<RenderResult | nu
stampCanvas = cardCanvas;
stampW = outW;
stampH = outH;
} else if (frameId === 'custom' && options?.customFrame) {
flush(surface);
const photoImg = own(surface.makeImageSnapshot());
cardSource = photoImg;
const pw = photoImg.width();
const ph = photoImg.height();
const customImg = own(Skia.Image.MakeImageFromEncoded(options.customFrame.bytes));
if (!customImg) {
console.error('Failed to decode custom frame');
return null;
}
const fw = customImg.width();
const fh = customImg.height();
const cardSurface = createSurface(fw, fh);
if (!cardSurface) return null;
own(cardSurface);
const cardCanvas = cardSurface.getCanvas();
const plainPaint = own(Skia.Paint());
const hole = options.customFrame.hole;
const holeX = hole.x * fw;
const holeY = hole.y * fh;
const holeW = hole.w * fw;
const holeH = hole.h * fh;
const holeCenterX = holeX + holeW / 2;
const holeCenterY = holeY + holeH / 2;
// Base cover scale to fill the transparent hole
const baseScale = Math.max(holeW / pw, holeH / ph);
const zoom = options.customFrame.scale ?? 1.0;
const finalScale = baseScale * zoom;
const drawW = pw * finalScale;
const drawH = ph * finalScale;
const panX = options.customFrame.panX ?? 0;
const panY = options.customFrame.panY ?? 0;
const drawX = holeCenterX + panX - drawW / 2;
const drawY = holeCenterY + panY - drawH / 2;
// 1. Draw photo underneath
cardCanvas.drawImageRectOptions(
photoImg,
Skia.XYWHRect(0, 0, pw, ph),
Skia.XYWHRect(drawX, drawY, drawW, drawH),
Skia.FilterMode.Linear,
Skia.MipmapMode.None,
plainPaint
);
// 2. Draw custom frame on top so transparent area displays the photo
cardCanvas.drawImageRectOptions(
customImg,
Skia.XYWHRect(0, 0, fw, fh),
Skia.XYWHRect(0, 0, fw, fh),
Skia.FilterMode.Linear,
Skia.MipmapMode.None,
plainPaint
);
outputSurface = cardSurface;
stampCanvas = cardCanvas;
stampW = fw;
stampH = fh;
} else {
drawFrameOnCanvas(canvas, width, height, frameId);
}