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.
304 lines
8.4 KiB
TypeScript
304 lines
8.4 KiB
TypeScript
import { Recipe, ColorAdjustments } from '../types';
|
|
|
|
// Fallback values for every adjustable knob. Old recipes/sessions persisted in
|
|
// AsyncStorage lack the newer fields — merge this under stored JSON to keep
|
|
// every adjustment well-defined (see storageUtils.getAllRecipes / App session).
|
|
export const DEFAULT_ADJUSTMENTS: ColorAdjustments = {
|
|
exposure: 0,
|
|
contrast: 0,
|
|
saturation: 0,
|
|
temperature: 5500,
|
|
tint: 0,
|
|
wbRed: 0,
|
|
wbBlue: 0,
|
|
highlight: 0,
|
|
shadow: 0,
|
|
denoise: 0,
|
|
clarity: 0,
|
|
grain: 0,
|
|
colorChrome: 'none',
|
|
colorChromeBlue: 'none',
|
|
dynamicRange: 'auto',
|
|
sharpening: 0,
|
|
exposureCompensation: 0,
|
|
};
|
|
|
|
// Standard Fuji film simulations. These are the fixed "look" presets shown at
|
|
// the front of the PRESETS chip row: tapping one applies the simulation — its
|
|
// baseFilter colour matrix plus whatever the stock itself carries outside the
|
|
// matrix (its FILM_TONE curve, and a grain amount when the grain IS the look,
|
|
// as on Acros). Every other knob still resets to its neutral default (0 /
|
|
// auto), so a sim is a clean starting point, never a baked recipe; any further
|
|
// look (DR, CC, contrast, more grain) is added by the user afterwards.
|
|
export interface FilmSim {
|
|
id: string; // Recipe-like id used only while the sim is the current selection
|
|
name: string;
|
|
baseFilter: Recipe['baseFilter'];
|
|
adjustments: ColorAdjustments;
|
|
}
|
|
|
|
export const FILM_SIMS: FilmSim[] = [
|
|
{ id: 'sim-provia', name: 'PROVIPES', baseFilter: 'provia', adjustments: { ...DEFAULT_ADJUSTMENTS } },
|
|
{ id: 'sim-velvia', name: 'VELVIPES', baseFilter: 'velvia', adjustments: { ...DEFAULT_ADJUSTMENTS } },
|
|
{ id: 'sim-classic-chrome', name: 'CLASSIC CHRIPES', baseFilter: 'classic-chrome', adjustments: { ...DEFAULT_ADJUSTMENTS } },
|
|
{ id: 'sim-classic-neg', name: 'CLASSIC NEGIPES', baseFilter: 'classic-neg', adjustments: { ...DEFAULT_ADJUSTMENTS } },
|
|
{ id: 'sim-astia', name: 'ASTIPES', baseFilter: 'astia', adjustments: { ...DEFAULT_ADJUSTMENTS } },
|
|
{ id: 'sim-eterna', name: 'ETERNIPES', baseFilter: 'eterna', adjustments: { ...DEFAULT_ADJUSTMENTS } },
|
|
{ id: 'sim-acros', name: 'ACRIPES', baseFilter: 'monochrome', adjustments: { ...DEFAULT_ADJUSTMENTS, grain: 3 } },
|
|
// The rangefinder digital look (M/Q/SL) is not a film stock, so it ships as its own
|
|
// pair instead of one look that has to compromise:
|
|
// CLASSIC — the stock character: deep solid darks, natural shadows, rich but
|
|
// quiet colour. Its micro-contrast is CLARITY (a local unsharp mask in both
|
|
// renderers), never a global saturation boost, and a whisker of grain
|
|
// keeps it feeling like film rather than digital sharpness.
|
|
// VIVID — the same channel separation opened up for the digital era: lifted
|
|
// exposure, lifted shadows, a little colour. It reads bright and flattering
|
|
// straight off the shutter, no editing pass needed.
|
|
// Both share the 'leica'/'leica-vivid' matrices; the tonal half is the sim's
|
|
// own adjustments, which every apply path now carries into the panel.
|
|
{ id: 'sim-leica', name: 'LC STREETLIFE CLASSIC', baseFilter: 'leica', adjustments: { ...DEFAULT_ADJUSTMENTS, clarity: 2, grain: 1 } },
|
|
{ id: 'sim-leica-vivid', name: 'LC STREETLIFE VIVID', baseFilter: 'leica-vivid', adjustments: { ...DEFAULT_ADJUSTMENTS, exposure: 2, saturation: 2, clarity: 2, shadow: 1, highlight: -1 } },
|
|
];
|
|
|
|
// Film sim → full Recipe (never persisted; sims are applied on the fly).
|
|
export function filmSimToRecipe(sim: FilmSim): Recipe {
|
|
return {
|
|
id: sim.id,
|
|
name: sim.name,
|
|
baseFilter: sim.baseFilter,
|
|
adjustments: { ...sim.adjustments },
|
|
frameId: 'none',
|
|
useGeotag: true,
|
|
};
|
|
}
|
|
|
|
export const DEFAULT_RECIPES: Recipe[] = [
|
|
{
|
|
id: 'classic-neg-default',
|
|
name: 'CLASSIC NEGIPES.',
|
|
baseFilter: 'classic-neg',
|
|
adjustments: {
|
|
exposure: 2,
|
|
contrast: 1,
|
|
saturation: -1,
|
|
temperature: 6300,
|
|
tint: 2,
|
|
highlight: -2,
|
|
shadow: 1,
|
|
denoise: 1,
|
|
clarity: 2,
|
|
grain: 4,
|
|
colorChrome: 'weak',
|
|
colorChromeBlue: 'none',
|
|
dynamicRange: 'auto',
|
|
sharpening: 0,
|
|
exposureCompensation: 0,
|
|
},
|
|
frameId: 'none',
|
|
useGeotag: true,
|
|
},
|
|
{
|
|
id: 'velvia-default',
|
|
name: 'VELVIPES VIVID',
|
|
baseFilter: 'velvia',
|
|
adjustments: {
|
|
exposure: 0,
|
|
contrast: 2,
|
|
saturation: 3,
|
|
temperature: 5500,
|
|
tint: 0,
|
|
highlight: 1,
|
|
shadow: -1,
|
|
denoise: 2,
|
|
clarity: 4,
|
|
grain: 1,
|
|
colorChrome: 'strong',
|
|
colorChromeBlue: 'none',
|
|
dynamicRange: 'auto',
|
|
sharpening: 0,
|
|
exposureCompensation: 0,
|
|
},
|
|
frameId: 'none',
|
|
useGeotag: true,
|
|
},
|
|
{
|
|
id: 'provia-default',
|
|
name: 'PROVIPES STD',
|
|
baseFilter: 'provia',
|
|
adjustments: {
|
|
exposure: 1,
|
|
contrast: 0,
|
|
saturation: 0,
|
|
temperature: 5000,
|
|
tint: 0,
|
|
highlight: 0,
|
|
shadow: 0,
|
|
denoise: 2,
|
|
clarity: 1,
|
|
grain: 2,
|
|
colorChrome: 'none',
|
|
colorChromeBlue: 'none',
|
|
dynamicRange: 'auto',
|
|
sharpening: 0,
|
|
exposureCompensation: 0,
|
|
},
|
|
frameId: 'none',
|
|
useGeotag: true,
|
|
},
|
|
{
|
|
id: 'acros-default',
|
|
name: 'ACRIPES MONO',
|
|
baseFilter: 'monochrome',
|
|
adjustments: {
|
|
exposure: 0,
|
|
// Low: the deep black comes from FILM_TONE's toe and the micro-contrast
|
|
// from CLARITY, both of which add separation WITHOUT flattening the grey
|
|
// ramp. The old 4 / highlight 3 / shadow -2 stack was three global
|
|
// contrast moves on top of a matrix that was already clipping — it
|
|
// bought its deep black by throwing away every step below 9% and every
|
|
// one above 91%.
|
|
contrast: 2,
|
|
saturation: 0,
|
|
temperature: 5000,
|
|
tint: 0,
|
|
highlight: 0,
|
|
shadow: 0,
|
|
denoise: 0,
|
|
clarity: 3,
|
|
grain: 5,
|
|
colorChrome: 'none',
|
|
colorChromeBlue: 'none',
|
|
dynamicRange: 'auto',
|
|
sharpening: 0,
|
|
exposureCompensation: 0,
|
|
},
|
|
frameId: 'none',
|
|
useGeotag: true,
|
|
},
|
|
{
|
|
id: 'retro-amber-default',
|
|
name: 'RETRO AMBER 🏮',
|
|
baseFilter: 'classic-neg',
|
|
adjustments: {
|
|
exposure: 3,
|
|
contrast: 2,
|
|
saturation: 1,
|
|
temperature: 7500,
|
|
tint: -2,
|
|
highlight: -3,
|
|
shadow: 2,
|
|
denoise: 3,
|
|
clarity: -2,
|
|
grain: 5,
|
|
colorChrome: 'strong',
|
|
colorChromeBlue: 'none',
|
|
dynamicRange: 'auto',
|
|
sharpening: 0,
|
|
exposureCompensation: 0,
|
|
},
|
|
frameId: 'classic-white',
|
|
useGeotag: true,
|
|
},
|
|
|
|
{
|
|
id: 'cinema-spring-default',
|
|
name: 'CINEMA SPRING',
|
|
baseFilter: 'none',
|
|
adjustments: {
|
|
exposure: 0,
|
|
contrast: 1,
|
|
saturation: 1,
|
|
temperature: 5800,
|
|
tint: 0,
|
|
highlight: -1,
|
|
shadow: 1,
|
|
denoise: 2,
|
|
clarity: 1,
|
|
grain: 3,
|
|
colorChrome: 'none',
|
|
colorChromeBlue: 'none',
|
|
dynamicRange: 'auto',
|
|
sharpening: 0,
|
|
exposureCompensation: 0,
|
|
},
|
|
frameId: 'none',
|
|
cinema: 'spring',
|
|
useGeotag: true,
|
|
},
|
|
{
|
|
id: 'cinema-summer-default',
|
|
name: 'CINEMA SUMMER',
|
|
baseFilter: 'none',
|
|
adjustments: {
|
|
exposure: 0,
|
|
contrast: 2,
|
|
saturation: 2,
|
|
temperature: 6000,
|
|
tint: -1,
|
|
highlight: 1,
|
|
shadow: -1,
|
|
denoise: 1,
|
|
clarity: 2,
|
|
grain: 2,
|
|
colorChrome: 'weak',
|
|
colorChromeBlue: 'none',
|
|
dynamicRange: 'auto',
|
|
sharpening: 0,
|
|
exposureCompensation: 0,
|
|
},
|
|
frameId: 'none',
|
|
cinema: 'summer',
|
|
useGeotag: true,
|
|
},
|
|
{
|
|
id: 'cinema-autumn-default',
|
|
name: 'CINEMA AUTUMN',
|
|
baseFilter: 'none',
|
|
adjustments: {
|
|
exposure: 0,
|
|
contrast: 1,
|
|
saturation: 2,
|
|
temperature: 7200,
|
|
tint: -2,
|
|
highlight: -2,
|
|
shadow: 1,
|
|
denoise: 2,
|
|
clarity: 0,
|
|
grain: 4,
|
|
colorChrome: 'weak',
|
|
colorChromeBlue: 'none',
|
|
dynamicRange: 'auto',
|
|
sharpening: 0,
|
|
exposureCompensation: 0,
|
|
},
|
|
frameId: 'none',
|
|
cinema: 'autumn',
|
|
useGeotag: true,
|
|
},
|
|
{
|
|
id: 'cinema-winter-default',
|
|
name: 'CINEMA WINTER',
|
|
baseFilter: 'none',
|
|
adjustments: {
|
|
exposure: 1,
|
|
contrast: -1,
|
|
saturation: -2,
|
|
temperature: 4500,
|
|
tint: 2,
|
|
highlight: -2,
|
|
shadow: 2,
|
|
denoise: 3,
|
|
clarity: -2,
|
|
grain: 3,
|
|
colorChrome: 'none',
|
|
colorChromeBlue: 'weak',
|
|
dynamicRange: 'auto',
|
|
sharpening: 0,
|
|
exposureCompensation: 0,
|
|
},
|
|
frameId: 'none',
|
|
cinema: 'winter',
|
|
useGeotag: true,
|
|
},
|
|
];
|