Files
RecipesCam/docker/frontend/src/engine/customFrame.ts
T
3dtours 97fd181cfa 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.
2026-10-12 07:59:24 +07:00

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,
};
}