8c6e7930db
`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.
205 lines
7.9 KiB
TypeScript
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);
|
|
}
|
|
}
|