Files
RecipesCam/docker/frontend/shared/utils/frameUtils.ts
T
3dtours 8c6e7930db Add self-contained docker/ stack for the web UI
`docker/` now holds the whole web build — frontend (Vite + React + CanvasKit),
backend (Fastify + SQLite) and the compose file — so the folder can be moved to
another machine and run without the React Native project:

    cd docker && cp .env.example .env && docker compose up -d --build

Only `${WEB_PORT:-8090}` is published; nginx serves the SPA and proxies /api to
the `api` container over Docker's DNS. Photos never reach the server.

The shared render code is vendored into `docker/frontend/shared/` and aliased to
a CanvasKit shim, so the app's own frameUtils/toneShader/jpegDpi run unchanged.

Fix the all-black render on GPU surfaces: `MakeWebGLCanvasSurface` creates a
separate WebGL context per call, and a texture from one context cannot be
sampled by a surface on another — so any pass that drew a snapshot onto a second
surface (output sharpen, screen sharpen, polaroid/wallframe cards) came out
solid black, while the raster fallback was correct. Use one shared
GrDirectContext + MakeRenderTarget instead.

Verified in headless Chromium against the running stack: 12MP JPEG in, preview
mean=120.5 sd=60.5, export 2048x1536 mean=107.2 sd=62.1, JFIF density 300/300,
EXIF present, no console errors; health/signup/login/me/recipes all 2xx through
the nginx proxy.
2026-09-17 17:43:03 +07:00

205 lines
7.9 KiB
TypeScript

import { SkCanvas, SkPaint, Skia, BlendMode } from '@shopify/react-native-skia';
import { FrameId } from '../types';
export interface FrameDefinition {
id: FrameId;
name: string;
}
export const FRAMES: FrameDefinition[] = [
{ id: 'none', name: 'NO FRAME' },
{ id: 'classic-white', name: 'CLASSIC BORDER' },
{ id: 'polaroid', name: 'RETRO INSTANT' },
{ id: 'wallframe', name: 'WALL FRAME' },
];
// Real Polaroid print proportions (88 x 107 mm card, 79 x 79 mm image print).
// All fractions are relative to the CARD WIDTH so the layout scales to any
// pixel size while keeping the physical look.
export const POLAROID_CARD = {
// Horizontal + top margin: (88 - 79)/2 / 88 mm each side.
sideMargin: (88 - 79) / 2 / 88, // ≈ 0.05114
// Bottom deck: remaining height of the 107 mm card below the 79 mm print,
// minus the top margin: (107 - 79 - 4.5) / 88 mm.
bottomDeck: (107 - 79 - (88 - 79) / 2) / 88, // ≈ 0.26705
} as const;
// Image window width as a fraction of card width (79 / 88 mm).
export const POLAROID_WIN_W = 1 - 2 * POLAROID_CARD.sideMargin; // ≈ 0.89773
export interface PolaroidLayout {
card: { x: number; y: number; w: number; h: number };
window: { x: number; y: number; w: number; h: number };
}
// Wall frame: the PNG artwork lives at the project root (wallframe.png,
// 4000x3117 RGBA, transparent window cols 675..3322 x rows 675..2441). Only the
// geometry lives here; the bitmap itself is drawn by the caller. The phone is
// portrait, so the app shows the artwork ROTATED 90° CW (original top edge →
// right edge): a portrait frame whose transparent window is a fixed 2:3
// vertical opening. The photo is cover-cropped (center) into that opening — the
// mat of a real frame hides the print edges. WALLFRAME_W/H and WALLFRAME_WIN
// are the rotated display orientation; the affine in wallframeLayout maps the
// source PNG pixels onto the frame rect so the drawn opening and the photo
// window always line up.
export const WALLFRAME_PNG_W = 4000; // source PNG width
export const WALLFRAME_PNG_H = 3117; // source PNG height
export const WALLFRAME_W = 3117; // rotated display width (= PNG height)
export const WALLFRAME_H = 4000; // rotated display height (= PNG width)
// Transparent window as fractions of the rotated image, derived from the same
// affine used to draw the bitmap: X = w - (y/3117)w, Y = (x/4000)h.
export const WALLFRAME_WIN = {
left: 676 / 3117, // ≈ 0.21688
top: 675 / 4000, // 0.16875
w: 1766 / 3117, // ≈ 0.56657
h: 2647 / 4000, // 0.66175
} as const;
// Landscape hang: the PNG's OWN orientation, so nothing is rotated — the
// canvas is the artwork (4000x3117) and the opening is the source window as
// fractions of it. Same 3:2 mat opening, hung the other way round.
export const WALLFRAME_LAND_W = WALLFRAME_PNG_W; // 4000
export const WALLFRAME_LAND_H = WALLFRAME_PNG_H; // 3117
export const WALLFRAME_LAND_WIN = {
left: 675 / WALLFRAME_PNG_W, // 0.16875
top: 675 / WALLFRAME_PNG_H, // ≈ 0.21688
w: 2647 / WALLFRAME_PNG_W, // 0.66175
h: 1766 / WALLFRAME_PNG_H, // ≈ 0.56657
} as const;
export interface WallframeLayout {
frame: { x: number; y: number; w: number; h: number };
window: { x: number; y: number; w: number; h: number };
// Row-major Skia matrix that rotates the source PNG (WALLFRAME_PNG_W/H) 90° CW
// into the frame rect. Feed it to <Group matrix> (viewfinder) or concat it on
// the canvas (export) before drawing the bitmap.
drawMatrix: number[];
}
// Geometry of the wall-frame inside an arbitrary container. The frame is
// contain-fitted ('contain', centered, letterboxed) for the live viewfinder,
// or fills the container exactly ('fill', export canvas already at frame size).
// `landscape` hangs the artwork the other way round: same PNG, no rotation.
export function wallframeLayout(
contW: number,
contH: number,
mode: 'contain' | 'fill' = 'contain',
landscape = false
): WallframeLayout {
const FW = landscape ? WALLFRAME_LAND_W : WALLFRAME_W;
const FH = landscape ? WALLFRAME_LAND_H : WALLFRAME_H;
const WIN = landscape ? WALLFRAME_LAND_WIN : WALLFRAME_WIN;
let frameW: number;
let frameH: number;
if (mode === 'fill') {
frameW = contW;
frameH = contH;
} else {
const s = Math.min(contW / FW, contH / FH);
frameW = FW * s;
frameH = FH * s;
}
const fx = (contW - frameW) / 2;
const fy = (contH - frameH) / 2;
const scale = frameW / FW; // == frameH / FH
return {
frame: { x: fx, y: fy, w: frameW, h: frameH },
window: {
x: fx + WIN.left * frameW,
y: fy + WIN.top * frameH,
w: WIN.w * frameW,
h: WIN.h * frameH,
},
// Portrait: the source PNG must be turned 90° CW onto the canvas.
// Landscape: it already sits the right way up — scale and centre it.
drawMatrix: landscape
? [scale, 0, fx, 0, scale, fy, 0, 0, 1]
: [0, -scale, fx + frameW, scale, 0, fy, 0, 0, 1],
};
}
// Geometry of a retro-polaroid card inside an arbitrary container. The photo is
// NEVER cropped: the image window keeps the photo aspect ratio (photoW/photoH)
// and the card grows/shrinks around it, so the whole picture always fits the
// window. 'contain' fits the full card inside the container (centered, with
// letterbox bars); 'fill' sizes the container exactly to the natural card
// proportions (used at export where the canvas IS the card).
export function polaroidLayout(
contW: number,
contH: number,
photoAspect: number, // photo width / photo height (>1 = landscape)
mode: 'contain' | 'fill' = 'contain'
): PolaroidLayout {
const S = POLAROID_CARD;
// Height of the card once the photo window (width WIN_W of the card width,
// height = that / photoAspect) is stacked below the top margin and above the
// bottom deck: cardH = cardW*(sideMargin + bottomDeck) + cardW*WIN_W/photoAspect
const naturalAspect = S.sideMargin + S.bottomDeck + POLAROID_WIN_W / photoAspect; // cardH/cardW
let cardW: number;
let cardH: number;
if (mode === 'fill') {
cardW = contW;
cardH = contH;
} else {
cardW = Math.min(contW, contH / naturalAspect);
cardH = cardW * naturalAspect;
}
const cx = (contW - cardW) / 2;
const cy = (contH - cardH) / 2;
const top = S.sideMargin * cardW;
const side = S.sideMargin * cardW;
let winW = POLAROID_WIN_W * cardW;
let winH = winW / photoAspect;
// Container aspect may leave less vertical room than the natural card (e.g.
// phone camera feed) — shrink the window to the available space instead of
// overflowing the card.
const maxWinH = cardH - top - S.bottomDeck * cardW;
if (winH > maxWinH) {
winH = maxWinH;
winW = winH * photoAspect;
}
return {
card: { x: cx, y: cy, w: cardW, h: cardH },
window: { x: cx + (cardW - winW) / 2, y: cy + top, w: winW, h: winH },
};
}
export function drawFrameOnCanvas(
canvas: SkCanvas,
width: number,
height: number,
frameId: FrameId
) {
if (frameId === 'none') return;
const paint = Skia.Paint();
if (frameId === 'classic-white') {
// A clean white border around the picture
paint.setColor(Skia.Color('#ffffff'));
paint.setStyle(0); // Fill
const borderWidth = Math.min(width, height) * 0.05; // 5% border
// Top border
canvas.drawRect(Skia.XYWHRect(0, 0, width, borderWidth), paint);
// Bottom border
canvas.drawRect(Skia.XYWHRect(0, height - borderWidth, width, borderWidth), paint);
// Left border
canvas.drawRect(Skia.XYWHRect(0, 0, borderWidth, height), paint);
// Right border
canvas.drawRect(Skia.XYWHRect(width - borderWidth, 0, borderWidth, height), paint);
} else if (frameId === 'cinematic') {
// Cinematic black bars on top and bottom
paint.setColor(Skia.Color('#000000'));
paint.setStyle(0); // Fill
const barHeight = height * 0.12; // 12% cinematic letterbox height
// Top letterbox
canvas.drawRect(Skia.XYWHRect(0, 0, width, barHeight), paint);
// Bottom letterbox
canvas.drawRect(Skia.XYWHRect(0, height - barHeight, width, barHeight), paint);
}
}