97fd181cfa
- 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.
254 lines
7.0 KiB
TypeScript
254 lines
7.0 KiB
TypeScript
// Custom imported transparent photo frame engine and storage
|
|
|
|
export interface CustomFrameHole {
|
|
x: number; // 0..1 fraction of frame width
|
|
y: number; // 0..1 fraction of frame height
|
|
w: number; // 0..1 fraction of frame width
|
|
h: number; // 0..1 fraction of frame height
|
|
}
|
|
|
|
export interface CustomFrameData {
|
|
id: string;
|
|
name: string;
|
|
bytes: Uint8Array;
|
|
dataUrl: string;
|
|
width: number;
|
|
height: number;
|
|
hole: CustomFrameHole;
|
|
scale: number; // default 1.0 (cover/fill hole)
|
|
panX: number; // offset in frame pixels
|
|
panY: number; // offset in frame pixels
|
|
}
|
|
|
|
const DB_NAME = 'recipescam-studio';
|
|
const STORE = 'custom_frames';
|
|
const FRAME_KEY = 'active_custom_frame';
|
|
|
|
function openDb(): Promise<IDBDatabase> {
|
|
return new Promise((resolve, reject) => {
|
|
const req = indexedDB.open(DB_NAME, 2);
|
|
req.onupgradeneeded = () => {
|
|
const db = req.result;
|
|
if (!db.objectStoreNames.contains('session')) {
|
|
db.createObjectStore('session');
|
|
}
|
|
if (!db.objectStoreNames.contains(STORE)) {
|
|
db.createObjectStore(STORE);
|
|
}
|
|
};
|
|
req.onsuccess = () => resolve(req.result);
|
|
req.onerror = () => reject(req.error);
|
|
});
|
|
}
|
|
|
|
export async function saveCustomFrame(frame: CustomFrameData): Promise<void> {
|
|
try {
|
|
const db = await openDb();
|
|
await new Promise<void>((resolve, reject) => {
|
|
const tx = db.transaction(STORE, 'readwrite');
|
|
tx.objectStore(STORE).put(frame, FRAME_KEY);
|
|
tx.oncomplete = () => resolve();
|
|
tx.onerror = () => reject(tx.error);
|
|
});
|
|
db.close();
|
|
} catch (err) {
|
|
console.warn('Failed to save custom frame to IndexedDB', err);
|
|
}
|
|
}
|
|
|
|
export async function loadCustomFrame(): Promise<CustomFrameData | null> {
|
|
try {
|
|
const db = await openDb();
|
|
const row = await new Promise<CustomFrameData | undefined>((resolve, reject) => {
|
|
const tx = db.transaction(STORE, 'readonly');
|
|
const req = tx.objectStore(STORE).get(FRAME_KEY);
|
|
req.onsuccess = () => resolve(req.result);
|
|
req.onerror = () => reject(req.error);
|
|
});
|
|
db.close();
|
|
if (row && row.bytes instanceof Uint8Array && row.hole) {
|
|
return row;
|
|
}
|
|
return null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export async function clearCustomFrame(): Promise<void> {
|
|
try {
|
|
const db = await openDb();
|
|
await new Promise<void>((resolve, reject) => {
|
|
const tx = db.transaction(STORE, 'readwrite');
|
|
tx.objectStore(STORE).delete(FRAME_KEY);
|
|
tx.oncomplete = () => resolve();
|
|
tx.onerror = () => reject(tx.error);
|
|
});
|
|
db.close();
|
|
} catch {}
|
|
}
|
|
|
|
/**
|
|
* Analyzes an imported frame file (PNG, WebP, SVG, etc.),
|
|
* decodes it, and detects the transparent opening/hole via alpha channel analysis.
|
|
*/
|
|
export async function analyzeCustomFrame(file: File): Promise<CustomFrameData> {
|
|
const bytes = new Uint8Array(await file.arrayBuffer());
|
|
const blob = new Blob([bytes], { type: file.type || 'image/png' });
|
|
const dataUrl = await new Promise<string>((resolve, reject) => {
|
|
const reader = new FileReader();
|
|
reader.onload = () => resolve(reader.result as string);
|
|
reader.onerror = reject;
|
|
reader.readAsDataURL(blob);
|
|
});
|
|
|
|
const img = new Image();
|
|
await new Promise<void>((resolve, reject) => {
|
|
img.onload = () => resolve();
|
|
img.onerror = () => reject(new Error('Cannot decode frame image'));
|
|
img.src = dataUrl;
|
|
});
|
|
|
|
const w = img.naturalWidth || img.width;
|
|
const h = img.naturalHeight || img.height;
|
|
if (!(w > 0 && h > 0)) {
|
|
throw new Error('Invalid image dimensions');
|
|
}
|
|
|
|
// Downscale slightly for fast and accurate pixel analysis (max 600px)
|
|
const maxDim = 600;
|
|
const sampleScale = Math.min(1, maxDim / Math.max(w, h));
|
|
const sw = Math.max(10, Math.round(w * sampleScale));
|
|
const sh = Math.max(10, Math.round(h * sampleScale));
|
|
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = sw;
|
|
canvas.height = sh;
|
|
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
|
if (!ctx) {
|
|
throw new Error('Canvas 2D context not available');
|
|
}
|
|
|
|
ctx.drawImage(img, 0, 0, sw, sh);
|
|
const imgData = ctx.getImageData(0, 0, sw, sh);
|
|
const data = imgData.data;
|
|
|
|
// Transparent threshold: alpha < 70 (out of 255)
|
|
const isAlphaLow = (x: number, y: number) => {
|
|
return data[(y * sw + x) * 4 + 3] < 70;
|
|
};
|
|
|
|
// 1. Flood-fill from outer edges to distinguish outer transparent padding from the inner transparent hole
|
|
const visited = new Uint8Array(sw * sh);
|
|
const queue: number[] = [];
|
|
|
|
for (let x = 0; x < sw; x++) {
|
|
if (isAlphaLow(x, 0)) {
|
|
visited[0 * sw + x] = 1;
|
|
queue.push(x, 0);
|
|
}
|
|
if (isAlphaLow(x, sh - 1)) {
|
|
visited[(sh - 1) * sw + x] = 1;
|
|
queue.push(x, sh - 1);
|
|
}
|
|
}
|
|
for (let y = 0; y < sh; y++) {
|
|
if (isAlphaLow(0, y) && !visited[y * sw + 0]) {
|
|
visited[y * sw + 0] = 1;
|
|
queue.push(0, y);
|
|
}
|
|
if (isAlphaLow(sw - 1, y) && !visited[y * sw + (sw - 1)]) {
|
|
visited[y * sw + (sw - 1)] = 1;
|
|
queue.push(sw - 1, y);
|
|
}
|
|
}
|
|
|
|
let head = 0;
|
|
while (head < queue.length) {
|
|
const qx = queue[head++];
|
|
const qy = queue[head++];
|
|
const neighbors = [
|
|
[qx + 1, qy],
|
|
[qx - 1, qy],
|
|
[qx, qy + 1],
|
|
[qx, qy - 1],
|
|
];
|
|
for (const [nx, ny] of neighbors) {
|
|
if (nx >= 0 && nx < sw && ny >= 0 && ny < sh) {
|
|
const idx = ny * sw + nx;
|
|
if (!visited[idx] && isAlphaLow(nx, ny)) {
|
|
visited[idx] = 1;
|
|
queue.push(nx, ny);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 2. Find bounding box of inner transparent pixels (not reached by flood-fill from border)
|
|
let minX = sw;
|
|
let minY = sh;
|
|
let maxX = -1;
|
|
let maxY = -1;
|
|
let innerTransparentCount = 0;
|
|
|
|
for (let y = 0; y < sh; y++) {
|
|
for (let x = 0; x < sw; x++) {
|
|
const idx = y * sw + x;
|
|
if (isAlphaLow(x, y) && !visited[idx]) {
|
|
innerTransparentCount++;
|
|
if (x < minX) minX = x;
|
|
if (x > maxX) maxX = x;
|
|
if (y < minY) minY = y;
|
|
if (y > maxY) maxY = y;
|
|
}
|
|
}
|
|
}
|
|
|
|
// 3. Fallback: if no inner transparent pixel was isolated, search for any transparent pixels
|
|
if (innerTransparentCount < 20 || minX > maxX) {
|
|
minX = sw;
|
|
minY = sh;
|
|
maxX = -1;
|
|
maxY = -1;
|
|
let anyCount = 0;
|
|
for (let y = 0; y < sh; y++) {
|
|
for (let x = 0; x < sw; x++) {
|
|
if (isAlphaLow(x, y)) {
|
|
anyCount++;
|
|
if (x < minX) minX = x;
|
|
if (x > maxX) maxX = x;
|
|
if (y < minY) minY = y;
|
|
if (y > maxY) maxY = y;
|
|
}
|
|
}
|
|
}
|
|
innerTransparentCount = anyCount;
|
|
}
|
|
|
|
// 4. Compute normalized hole geometry
|
|
let hole: CustomFrameHole = { x: 0.1, y: 0.1, w: 0.8, h: 0.8 };
|
|
if (innerTransparentCount > 20 && minX <= maxX && minY <= maxY) {
|
|
const holeW = Math.max(0.05, (maxX - minX + 1) / sw);
|
|
const holeH = Math.max(0.05, (maxY - minY + 1) / sh);
|
|
hole = {
|
|
x: minX / sw,
|
|
y: minY / sh,
|
|
w: holeW,
|
|
h: holeH,
|
|
};
|
|
}
|
|
|
|
return {
|
|
id: `custom-${Date.now()}`,
|
|
name: file.name,
|
|
bytes,
|
|
dataUrl,
|
|
width: w,
|
|
height: h,
|
|
hole,
|
|
scale: 1.0,
|
|
panX: 0,
|
|
panY: 0,
|
|
};
|
|
}
|