diff --git a/docker/frontend/shared/utils/grainShader.ts b/docker/frontend/shared/utils/grainShader.ts new file mode 100644 index 0000000..9fef264 --- /dev/null +++ b/docker/frontend/shared/utils/grainShader.ts @@ -0,0 +1,275 @@ +// 35mm & 120 FILM GRAIN — the structures the marketing card promises, per +// stock: "authentic grain structures plus halation bloom, tuned per stock +// rather than one global overlay." +// +// The phone's grainShader.ts rolls the film and sizes the cell; this is the +// same film pulled through the lab's stock table. One grain field is still +// printed for the whole picture, but WHICH field — how coarse, how clumpy, and +// how much the highlights bleed back — is the emulsion's and its format's, not +// a constant. +// +// FORMAT. A 35mm frame is 24x36mm; a 120 frame is 6x6 or bigger — two to four +// times the linear area for the same print. So at one picture width the 35mm +// negative's grain prints COARSER and a 120 negative's FINER and smoother: the +// same emulsion is two different textures. `cell` is the format's share of the +// 1080-wide reference the GRAIN knob was tuned at — 1.0 is the 35mm baseline +// that ships today, so an existing 35mm recipe keeps the grain it has. +// +// STOCK. Within a format the emulsion sets the rest: how hard the grain clumps +// (`spread` and the octave `mix`), and how far the highlights bleed back as +// halation. Halation is RED because red is the light that passes the emulsion, +// bounces off the anti-halation backing and returns: the colour negative, whose +// red-sensitive layer sits deepest, halates hardest. Slides halate less. B&W has +// no colour layer to bleed, so it gets a weak, barely-warm grey. A sensor has no +// backing at all — LEICA's two looks are digital, so they carry none. +// +// The GRAIN knob stays the only control, exactly as it is today: its amount +// scales BOTH the grain's alpha and the halation's, so GRAIN OFF is still a +// clean frame and the OFF/WEAK/STRONG chips still mean 0/3/6. Nothing here +// moves a stock's look until grain is dialled in. + +import type { BaseFilter } from '../types'; + +export type FilmFormat = '35mm' | '120'; + +export interface GrainStock { + format: FilmFormat; + /** The emulsion this entry stands for — the record behind the numbers. */ + film: string; + /** Grain cell, as a share of the 1080-wide reference (see GRAIN_REF). */ + cell: number; + /** Contrast of the noise, i.e. how hard the grain clumps. */ + spread: number; + /** Octave weights of the grain field, fine → coarse. */ + mix: [number, number, number]; + /** Halation strength at GRAIN 10, 0..1. */ + halation: number; + /** Halo radius in 1080-reference units (picture-relative, see grainCell). */ + halo: number; + /** What the halo is made of: the highlight, tinted. */ + haloRGB: [number, number, number]; +} + +// The baseline field the GRAIN knob was calibrated against, and the one the +// phone still prints: a fine octave carrying most of the structure, then its +// clumps at 2x and 4x. Coarser and clumpier (35mm), or the base octave opened +// up and the clumps pulled back (120's finer, smoother texture). +const CLUMPY: [number, number, number] = [0.55, 0.3, 0.15]; +const SMOOTH: [number, number, number] = [0.62, 0.26, 0.12]; + +// Digital: a sensor has no emulsion and no backing — the grain the knob prints +// is the user's own choice, so it keeps the neutral baseline field and no +// halation. Both LEICA looks land here. +const SENSOR: GrainStock = { + format: '35mm', + film: 'digital sensor', + cell: 1, + spread: 2.95, + mix: CLUMPY, + halation: 0, + halo: 0, + haloRGB: [1, 1, 1], +}; + +export const GRAIN_STOCKS: Record = { + // --- 35mm: the consumer, press and cinema stocks --- + 'classic-neg': { + format: '35mm', + film: 'colour negative, 35mm', + cell: 1.15, + spread: 3.1, + mix: CLUMPY, + halation: 0.45, + halo: 7, + haloRGB: [1, 0.18, 0.06], + }, + 'classic-chrome': { + format: '35mm', + film: 'colour slide, 35mm', + cell: 1, + spread: 2.95, + mix: CLUMPY, + halation: 0.25, + halo: 6, + haloRGB: [1, 0.22, 0.08], + }, + 'classic-vivid': { + format: '35mm', + film: 'colour slide, vivid, 35mm', + cell: 1, + spread: 2.95, + mix: CLUMPY, + halation: 0.3, + halo: 6, + haloRGB: [1, 0.2, 0.07], + }, + eterna: { + format: '35mm', + film: 'motion-picture negative, 35mm', + cell: 1.1, + spread: 3.2, + mix: CLUMPY, + halation: 0.38, + halo: 6.5, + haloRGB: [1, 0.2, 0.07], + }, + 'mono-high-contrast': { + format: '35mm', + film: 'B&W, high contrast, 35mm', + cell: 1.25, + spread: 3.4, + mix: CLUMPY, + halation: 0.15, + halo: 4.5, + haloRGB: [1, 0.72, 0.55], + }, + // --- 120: the pro emulsions whose reputation is the big negative --- + provia: { + format: '120', + film: 'Fuji Provia 100F, 120', + cell: 0.62, + spread: 2.5, + mix: SMOOTH, + halation: 0.18, + halo: 5.5, + haloRGB: [1, 0.24, 0.09], + }, + velvia: { + format: '120', + film: 'Fuji Velvia 50, 120', + cell: 0.55, + spread: 2.35, + mix: SMOOTH, + halation: 0.3, + halo: 6, + haloRGB: [1, 0.2, 0.07], + }, + astia: { + format: '120', + film: 'Fuji Astia 100F, 120', + cell: 0.62, + spread: 2.5, + mix: SMOOTH, + halation: 0.16, + halo: 5.5, + haloRGB: [1, 0.26, 0.1], + }, + monochrome: { + format: '120', + film: 'Fuji Acros 100, 120', + cell: 0.72, + spread: 2.7, + mix: SMOOTH, + halation: 0.12, + halo: 4, + haloRGB: [1, 0.78, 0.62], + }, + // --- digital --- + leica: SENSOR, + 'leica-vivid': SENSOR, + none: SENSOR, +}; + +// The stock a base filter prints with. Every id is in the table, so the fallback +// is only there for a recipe that names a filter this build does not know. +export function grainStockFor(base: BaseFilter | string | null | undefined): GrainStock { + return (base && GRAIN_STOCKS[base as BaseFilter]) || SENSOR; +} + +// The roll of film: one seed per page load, hashed into the noise's domain, so +// two visitors never print the same clumps while every render inside one +// session — the preview, the compare copy and the file — prints the same one. +export const GRAIN_SEED: readonly [number, number] = [Math.random() * 61.7, Math.random() * 43.9]; + +// Grain is sized against the PICTURE, never against the screen: one noise cell +// per 1/1080 of the picture's width, the size the GRAIN knob was tuned at. A +// stock scales that cell by its format and its emulsion (see GRAIN_STOCKS). +export const GRAIN_REF = 1080; + +// The cell GRAIN_SKSL is handed for a picture of `pictureWidth` px: the stock's +// share of the reference, floored at one pixel because a cell smaller than the +// target's own pixel cannot be resolved — it prints as static instead of grain, +// which is aliasing, not a finer emulsion. +export function grainCell(pictureWidth: number, stock: GrainStock, minCell = 1): number { + return Math.max((pictureWidth / GRAIN_REF) * stock.cell, minCell); +} + +// The halo radius in px for the same picture: measured in reference units too, +// so a 120 stock's tighter grain cannot leave it a 35mm-sized halo. +export function halationSigma(pictureWidth: number, stock: GrainStock): number { + return Math.max((pictureWidth / GRAIN_REF) * stock.halo, 0.6); +} + +// `u` is the cell, `seed` this session's roll, `mix`/`spread` the stock's own +// structure. The three octaves go COARSER only: a finer one lands under the +// pixel, the clumping is lost (rho(1) 0.275 -> 0, measured) and the field is +// static again. `spread` holds the AMOUNT knob on the spread it was tuned with. +export const GRAIN_SKSL = ` + uniform float u; + uniform vec2 seed; + uniform vec3 mixw; + uniform float spread; + float grainHash(vec2 q) { + return fract(sin(dot(q, vec2(12.9898, 78.233))) * 43758.5453); + } + // Three uncorrelated hashes averaged: a bell, the way an emulsion's density + // swings, in place of the flat spread of one hash. + float grainDensity(vec2 q) { + return (grainHash(q) + grainHash(q + vec2(19.19, 7.77)) + grainHash(q + vec2(3.33, 41.71))) * 0.3333; + } + // Value noise: the density of the cell's four corners, smoothed. The bilinear + // over smoothed corners is what makes a clump instead of one pixel of static. + float grainNoise(vec2 p) { + vec2 i = floor(p); + vec2 f = p - i; + f = f * f * (3.0 - 2.0 * f); + return mix(mix(grainDensity(i), grainDensity(i + vec2(1.0, 0.0)), f.x), + mix(grainDensity(i + vec2(0.0, 1.0)), grainDensity(i + vec2(1.0, 1.0)), f.x), f.y); + } + vec4 main(vec2 pos) { + // 20 degrees off the axes: no lattice shows through the picture. + vec2 p = mat2(0.9397, -0.3420, 0.3420, 0.9397) * (pos.xy / max(u, 0.0001)) + seed; + // The stock's own field: the base octave, then its clumps at 2x and 4x. + float n = grainNoise(p) * mixw.x + + grainNoise(p * 0.5 + vec2(13.7, 7.3)) * mixw.y + + grainNoise(p * 0.25 + vec2(4.1, 27.9)) * mixw.z; + return vec4(vec3(clamp((n - 0.5) * spread + 0.5, 0.0, 1.0)), 1.0); + } +`; + +// Flat uniform buffer for makeShader — declaration order above: u, seed, mix, +// spread. +export function grainUniformArray(cell: number, stock: GrainStock): number[] { + return [cell, GRAIN_SEED[0], GRAIN_SEED[1], stock.mix[0], stock.mix[1], stock.mix[2], stock.spread]; +} + +// HALATION: the highlight bleed the backing returns. Threshold first — a +// mid-tone never reaches the emulsion's scatter ceiling, so only what is going +// white bleeds — then the highlight is TINTED (the stock's halo colour, red for +// every colour emulsion) before the caller blurs it wide and screens it back. +export const HALATION_SKSL = ` + uniform shader src; + uniform vec3 tint; + uniform float t0; + uniform float t1; + vec4 main(vec2 xy) { + vec4 c = src.eval(xy); + float luma = dot(clamp(c.rgb, 0.0, 1.0), vec3(0.2126, 0.7152, 0.0722)); + return vec4(clamp(c.rgb * tint, 0.0, 1.0) * smoothstep(t0, t1, luma), c.a); + } +`; + +// The print has to be near white before the backing sees anything: a touch +// higher than the HDF bloom's threshold, so a bright sky does not smear the +// frame the way a specular highlight does. +export const HALATION_T0 = 0.62; +export const HALATION_T1 = 0.92; + +// What the halo is worth at GRAIN 10, over the stock's own strength: a screen +// blend of a blurred highlight is a strong move, so a halating stock lands +// under half alpha at full grain. +export const HALATION_MAX = 0.6; + +export function halationUniformArray(stock: GrainStock): number[] { + return [...stock.haloRGB, HALATION_T0, HALATION_T1]; +} diff --git a/docker/frontend/src/engine/exportEngine.ts b/docker/frontend/src/engine/exportEngine.ts index c740ca8..154a7c7 100644 --- a/docker/frontend/src/engine/exportEngine.ts +++ b/docker/frontend/src/engine/exportEngine.ts @@ -33,6 +33,16 @@ import { glowUniformArray, } from '../../shared/utils/toneShader'; import { CINEMA_SKSL, getCinemaUniforms, cinemaIsActive } from '../../shared/utils/cinemaShader'; +import { + GRAIN_SKSL, + HALATION_SKSL, + HALATION_MAX, + grainCell, + grainStockFor, + grainUniformArray, + halationSigma, + halationUniformArray, +} from '../../shared/utils/grainShader'; import { drawFrameOnCanvas, polaroidLayout, @@ -111,11 +121,6 @@ function release(owned: SkDisposable[], item: SkDisposable | null): void { disposeAll([item]); } -// The grain's roll of film: one seed per page load, hashed into the noise's -// domain so two runs never print the same clumps, while every render inside one -// session — the preview, the compare copy and the file — prints the same one. -const GRAIN_SEED = [Math.random() * 61.7, Math.random() * 43.9] as const; - function createSurface(width: number, height: number) { return Skia.Surface.MakeOffscreen(width, height) ?? Skia.Surface.Make(width, height); } @@ -132,13 +137,15 @@ let sharpenEffect: any = null; let toneEffect: any = null; let cinemaEffect: any = null; let glowEffect: any = null; +let halationEffect: any = null; function effects() { if (!sharpenEffect) sharpenEffect = Skia.RuntimeEffect.Make(CLARITY_SKSL); if (!toneEffect) toneEffect = Skia.RuntimeEffect.Make(TONE_SKSL); if (!cinemaEffect) cinemaEffect = Skia.RuntimeEffect.Make(CINEMA_SKSL); if (!glowEffect) glowEffect = Skia.RuntimeEffect.Make(GLOW_SKSL); - return { sharpenEffect, toneEffect, cinemaEffect, glowEffect }; + if (!halationEffect) halationEffect = Skia.RuntimeEffect.Make(HALATION_SKSL); + return { sharpenEffect, toneEffect, cinemaEffect, glowEffect, halationEffect }; } // CLARITY_SKSL uniforms are (a, px.x, px.y); px = one source pixel = 1 unit on @@ -509,59 +516,50 @@ export async function renderPhoto(input: RenderInput): Promise 0) { const grainPaint = own(Skia.Paint()); grainPaint.setBlendMode(Skia.BlendMode.Overlay); grainPaint.setAlphaf(grainAmount / 20); - const noiseEffect = own(Skia.RuntimeEffect.Make(` - uniform float u; - uniform vec2 seed; - float grainHash(vec2 q) { - return fract(sin(dot(q, vec2(12.9898, 78.233))) * 43758.5453); - } - // Three uncorrelated hashes averaged: a bell, the way an emulsion's - // density swings, in place of the flat spread of one hash. - float grainDensity(vec2 q) { - return (grainHash(q) + grainHash(q + vec2(19.19, 7.77)) + grainHash(q + vec2(3.33, 41.71))) * 0.3333; - } - float grainNoise(vec2 p) { - vec2 i = floor(p); - vec2 f = p - i; - f = f * f * (3.0 - 2.0 * f); - return mix(mix(grainDensity(i), grainDensity(i + vec2(1.0, 0.0)), f.x), - mix(grainDensity(i + vec2(0.0, 1.0)), grainDensity(i + vec2(1.0, 1.0)), f.x), f.y); - } - vec4 main(vec2 pos) { - // 20 degrees off the axes: no lattice shows through the picture. - vec2 p = mat2(0.9397, -0.3420, 0.3420, 0.9397) * (pos.xy / max(u, 1.0)) + seed; - // The grain, then the clumps of grain above it (2x and 4x the cell — - // sub-cells would alias instead of clumping). One cell alone reads as - // static; three scales read as an emulsion. - float n = grainNoise(p) * 0.55 - + grainNoise(p * 0.5 + vec2(13.7, 7.3)) * 0.30 - + grainNoise(p * 0.25 + vec2(4.1, 27.9)) * 0.15; - // Scaled so the AMOUNT knob keeps the spread it was tuned with. - return vec4(vec3(clamp((n - 0.5) * 2.95 + 0.5, 0.0, 1.0)), 1.0); - } - `)); - const noiseShader = noiseEffect ? own(noiseEffect.makeShader([width / 1080, GRAIN_SEED[0], GRAIN_SEED[1]])) : null; + const noiseEffect = own(Skia.RuntimeEffect.Make(GRAIN_SKSL)); + const cell = grainCell(width, stock); + const noiseShader = noiseEffect ? own(noiseEffect.makeShader(grainUniformArray(cell, stock))) : null; if (noiseShader) { grainPaint.setShader(noiseShader); canvas.drawRect(Skia.XYWHRect(0, 0, width, height), grainPaint); } } - // 6b. Vignette. + // 6b. Halation — the stock's own highlight bleed (see HALATION_SKSL): the + // highlights above the threshold, tinted the emulsion's halo colour and + // blurred wide, screened back over the frame. It rides the GRAIN knob like + // the grain does, so GRAIN 0 is still a clean frame and the OFF/WEAK/STRONG + // chips still mean 0/3/6; a sensor stock carries none at any amount. + if (grainAmount > 0 && stock.halation > 0 && stock.halo > 0) { + const { halationEffect: effect } = effects(); + const srcShader = paintShader ?? imageShaderOf(); + const haloShader = effect ? own(effect.makeShaderWithChildren(halationUniformArray(stock), [srcShader])) : null; + if (haloShader) { + const haloPaint = own(Skia.Paint()); + haloPaint.setBlendMode(Skia.BlendMode.Screen); + haloPaint.setAlphaf(stock.halation * (grainAmount / 10) * HALATION_MAX); + haloPaint.setShader(haloShader); + const sigma = halationSigma(width, stock); + haloPaint.setImageFilter(own(Skia.ImageFilter.MakeBlur(sigma, sigma, Skia.TileMode.Clamp, null))); + canvas.drawRect(Skia.XYWHRect(0, 0, width, height), haloPaint); + } + } + + // 6c. Vignette. if ((adjustments.vignette ?? 0) > 0) { const v = Math.min(10, adjustments.vignette ?? 0) / 10; const vignetteShader = own(Skia.Shader.MakeRadialGradient(