diff --git a/docker/frontend/shared/types/index.ts b/docker/frontend/shared/types/index.ts index 2668e67..eb3b912 100644 --- a/docker/frontend/shared/types/index.ts +++ b/docker/frontend/shared/types/index.ts @@ -99,6 +99,7 @@ export interface ColorAdjustments { denoise: number; // -10 to +10 (+ = blur sigma; - = add film grain back) clarity: number; // -10 to +10 (mapped to matrix convolution / bloom) grain: number; // 0 to 10 (mapped to noise turbulence opacity/scale) + grainSize?: number; // 50 to 200 (percent of the stock's own grain cell; 100 = the stock's own) colorChrome: 'none' | 'weak' | 'strong'; // Chrome effect colorChromeBlue: 'none' | 'weak' | 'strong'; // Chrome effect (Blue) dynamicRange: 'auto' | 100 | 200 | 400; // Fuji-style DR tone curve (auto/100 = no extra curve) diff --git a/docker/frontend/shared/utils/grainShader.ts b/docker/frontend/shared/utils/grainShader.ts index f5e7330..708cda8 100644 --- a/docker/frontend/shared/utils/grainShader.ts +++ b/docker/frontend/shared/utils/grainShader.ts @@ -187,11 +187,25 @@ export const GRAIN_SEED: readonly [number, number] = [Math.random() * 61.7, Math 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); +// share of the reference, scaled by the SIZE knob (a percentage of the stock's +// own cell), and 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. The floor is handed to the shader too, so the +// field's own patch-to-patch swing cannot cross it. +export function grainCell(pictureWidth: number, stock: GrainStock, sizePct = 100, minCell = 1): number { + return Math.max((pictureWidth / GRAIN_REF) * stock.cell * (sizePct / 100), minCell); +} + +// The clump count the panel READS OUT: how many clumps the design puts across an +// inch of a 300 dpi print, i.e. 300px of the GRAIN_REF frame. A statement about +// the stock and the SIZE knob, never about one patch of the frame — the field +// swings ±ZONE_SWING/2 patch by patch (see GRAIN_SKSL) — and never about the +// screen, so the same stock reads the same number in the preview and in the +// file. +export const GRAIN_DPI = 300; + +export function grainPerInch(stock: GrainStock, sizePct = 100): number { + return Math.round(GRAIN_DPI / (stock.cell * (sizePct / 100))); } // The halo radius in px for the same picture: measured in reference units too, @@ -213,8 +227,21 @@ export function halationSigma(pictureWidth: number, stock: GrainStock): number { // autocorrelation falls to 0.03 from 0.44 (measured) — nothing left to tile. The // hash behind the jitter is sin-free for the same reason: a float sinus whose // argument grows with the picture folds back on itself, a lattice of its own. +// +// Emulsion is not ONE grain size across the frame: the coating settles unevenly, +// so the clumps run coarser in patches and tighter in others. The same hash read +// as a SLOW value noise is what draws those patches — one every 1/ZONE_FREQ +// cells, turned off the axes and smoothed, because a step at a patch border +// would print as a seam — and each patch swings its own cell size by half of +// ZONE_SWING either way. Nothing here moves the field's mean: a coarser patch +// prints bigger clumps, not a brighter one, which is why the panel can read out +// one number while the frame carries a range. +export const ZONE_FREQ = 1 / 96; +export const ZONE_SWING = 0.4; + export const GRAIN_SKSL = ` uniform float u; + uniform float mincell; uniform vec2 seed; uniform vec3 mixw; uniform float spread; @@ -239,8 +266,22 @@ export const GRAIN_SKSL = ` } return 1.0 - min(sqrt(near), 1.0); } + // The patches of grain size: the same hash read slowly, smoothed so a border + // between two patches is a slope and not a step. + float grainZone(vec2 p) { + vec2 i = floor(p); + vec2 f = p - i; + f = f * f * (3.0 - 2.0 * f); + return mix(mix(grainHash(i), grainHash(i + vec2(1.0, 0.0)), f.x), + mix(grainHash(i + vec2(0.0, 1.0)), grainHash(i + vec2(1.0, 1.0)), f.x), f.y); + } vec4 main(vec2 pos) { - vec2 q = pos.xy / max(u, 0.0001) + seed; + vec2 p = pos.xy / max(u, 0.0001) + seed; + // This patch's own cell: the design cell swung by its zone, never under the + // floor — under it the clumps are sub-pixel and print as static, which is + // aliasing, not a finer emulsion. + float cell = max(u * (1.0 + (grainZone(mat2(0.9397, -0.3420, 0.3420, 0.9397) * p * ${ZONE_FREQ.toFixed(6)}) - 0.5) * ${ZONE_SWING}), mincell); + vec2 q = pos.xy / cell + seed; // The stock's own field: three octaves, no two of them on the same grid. // Each is turned to its own angle — 20, 47, 73 degrees — and sits on its own // rung of the ladder, 1 / 0.53 / 0.29, off the dyadic 1 / 0.5 / 0.25 where @@ -257,10 +298,11 @@ export const GRAIN_SKSL = ` } `; -// 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]; +// Flat uniform buffer for makeShader — declaration order above: u, mincell, +// seed, mix, spread. `minCell` is the floor grainCell() applied, so the field's +// own patch swing cannot take a cell under it. +export function grainUniformArray(cell: number, stock: GrainStock, minCell = 1): number[] { + return [cell, minCell, 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 diff --git a/docker/frontend/shared/utils/paramDefs.ts b/docker/frontend/shared/utils/paramDefs.ts index 4c4c67d..8256dc7 100644 --- a/docker/frontend/shared/utils/paramDefs.ts +++ b/docker/frontend/shared/utils/paramDefs.ts @@ -195,12 +195,28 @@ export const PARAM_DEFS: { label: 'MONOCHROME GRAIN', min: 0, max: 10, - step: 1, + // Half a step: the grain is a look, not a switch, and the AMOUNT chip in + // the GRAIN strip is what spends the half. + step: 0.5, defaultValue: 0, display: String, get: (a) => a.grain, set: (v) => ({ grain: v }), }, + { + // The SIZE knob: percent of the stock's own cell (grainShader.ts), so the + // same number means the same texture RELATIVE to the picture on both + // platforms and the readout beside it is the stock's, scaled. + key: 'grainSize', + label: 'GRAIN SIZE', + min: 50, + max: 200, + step: 5, + defaultValue: 100, + display: (v) => `${v}%`, + get: (a) => a.grainSize ?? 100, + set: (v) => ({ grainSize: v }), + }, { key: 'hdf', label: 'HDF EFFECT', diff --git a/docker/frontend/src/App.tsx b/docker/frontend/src/App.tsx index 4016519..a45b93b 100644 --- a/docker/frontend/src/App.tsx +++ b/docker/frontend/src/App.tsx @@ -34,6 +34,7 @@ import { type Recipe, } from '../shared/types'; import { HSL_BANDS, isMonochromeBase, kelvinToRGB, nearestHslBand, rgbToHsl } from '../shared/utils/colorUtils'; +import { grainPerInch, grainStockFor } from '../shared/utils/grainShader'; import type { MsgKey } from './i18n/vi'; // Mirrors the API's MAX_PHOTOS_PER_USER: shown on SAVE PHOTO, enforced there. @@ -157,12 +158,17 @@ const SLIDER_SWATCH: Partial string>> = { // The option strips, one open at a time (phone's GroupKey set minus the groups // the web engine has no knob for). -type GroupKey = 'cx' | 'cxb' | 'dr' | 'crop' | 'rotate' | 'style' | 'temp' | 'wm' +type GroupKey = 'cx' | 'cxb' | 'dr' | 'crop' | 'rotate' | 'style' | 'temp' | 'grain' | 'wm' | 'wmColor' | 'wmSize' | 'wmFont' | 'gpsColor' | 'gpsName' | 'gpsTime' | 'gpsFont'; const WM_SUBGROUPS: GroupKey[] = ['wmColor', 'wmSize', 'wmFont', 'gpsColor', 'gpsName', 'gpsTime', 'gpsFont']; const isWmPanel = (g: GroupKey | null) => g === 'wm' || (g !== null && WM_SUBGROUPS.includes(g)); // A slider row opened from a strip returns to it with "<" (COLOR TEMP -> TEMP). -const PARAM_GROUP: Partial> = { temperature: 'temp', straighten: 'rotate' }; +const PARAM_GROUP: Partial> = { + temperature: 'temp', + straighten: 'rotate', + grain: 'grain', + grainSize: 'grain', +}; const CHROME_OPTIONS = [ { v: 'none', d: 'OFF' }, @@ -1201,7 +1207,10 @@ export function Workspace() { const strip = useMemo(() => { const name = (recipe.name || 'RECIPE').trim(); const slug = name.replace(/[^\p{L}\p{N}]+/gu, '_').replace(/^_+|_+$/g, '').toUpperCase(); - const grain = Math.max(0, Math.round(recipe.adjustments.grain ?? 0)); + // The amount is on half steps now, so the tag prints the half it was set to + // instead of rounding a 3.5 to a 4 the frame does not carry. + const grainValue = Math.max(0, recipe.adjustments.grain ?? 0); + const grain = Number.isInteger(grainValue) ? String(grainValue) : grainValue.toFixed(1); const warmth = Math.round((recipe.adjustments.temperature - 5500) / 250); const tail = isMonochromeBase(recipe.baseFilter) ? 'MONO' : `WARMTH ${warmth >= 0 ? '+' : ''}${warmth}`; return { tag: `#${slug || 'RECIPE'}`, title: name, meta: `ISO ${iso ?? 'AUTO'} · GRAIN ${grain} · ${tail}` }; @@ -1794,6 +1803,13 @@ export function Workspace() { const v = wbValue(); return WB_PRESETS.find((w) => w.key === v)?.label ?? v; }; + // GRAIN's readout: the clump count the stock and the SIZE knob add up to, in + // the print's own terms (grainPerInch, grainShader.ts). A READOUT, not a pick — + // the strip prints it beside the two knobs that move it. The field swings a + // fifth of it patch by patch, which is what the knob is for, not an error in + // the number. + const grainInch = (): string => + `${grainPerInch(grainStockFor(recipe.baseFilter), recipe.adjustments.grainSize ?? 100)}/INCH`; const groupDefs: Record, { label: string; off: string; @@ -1886,6 +1902,23 @@ export function Workspace() { setAdjustment({ temperature: p.kelvin, tint: p.tint }); }, }, + // MONOCHROME GRAIN: the amount and the size of the clumps, then the count + // they add up to. The two knobs and their readout share one strip because + // they are one subject; the chip above them carries the amount, the way TEMP + // carries the kelvin. + grain: { + label: 'MONOCHROME GRAIN', off: '0', + // While a ruler is open its own entry is what the chip names, the way + // ROTATE's STRAIGHTEN does on the phone; otherwise it carries the amount, + // which is the one the strip is about. + value: + openParam === 'grain' ? 'amount' : openParam === 'grainSize' ? 'size' : String(recipe.adjustments.grain), + options: [ + { v: 'amount', d: 'AMOUNT' }, + { v: 'size', d: 'SIZE' }, + ], + onPick: (v) => toggleParam(v === 'size' ? 'grainSize' : 'grain'), + }, wmColor: { label: 'TEXT COLOR', off: '#ffffff', value: markStyle.color, options: WM_COLORS, @@ -2197,7 +2230,12 @@ export function Workspace() { case 'fx': return [ { key: 'mono', label: 'MONOCHROME', active: monoOn, onClick: toggleMono }, - ...paramChips(PARAM_DEFS.filters), + // GRAIN is a strip of its own — amount, size, and the count they add up + // to — so it is one chip here and its two knobs live inside it; the + // chip keeps the place the knob had in this row. + ...PARAM_DEFS.filters.flatMap((p): ChipDef[] => + p.key === 'grain' ? [groupChip('grain')] : p.key === 'grainSize' ? [] : paramChips([p]) + ), ]; case 'hsl': { // The mixer is PRO: the picker, the eight bands and the three @@ -2271,6 +2309,10 @@ export function Workspace() { { key: 'crop-cancel', label: t('crop.cancel'), onClick: cancelCrop }, ] : []), + // GRAIN's third entry is a READOUT: the clump count the two knobs add up to, + // in the print's own terms. Nothing to pick, so it is inert — it moves when a + // knob moves. + ...(g === 'grain' ? [{ key: 'grain-inch', label: grainInch(), disabled: true, onClick: () => {} }] : []), // STRAIGHTEN is the ROTATE strip's one entry that opens a ruler instead of // picking a value: the quarter turns are choices, the angle is a knob. ...(g === 'rotate' diff --git a/docker/frontend/src/engine/exportEngine.ts b/docker/frontend/src/engine/exportEngine.ts index 154a7c7..aa2a789 100644 --- a/docker/frontend/src/engine/exportEngine.ts +++ b/docker/frontend/src/engine/exportEngine.ts @@ -531,7 +531,7 @@ export async function renderPhoto(input: RenderInput): Promise { return Number.isFinite(n) ? clamp(n, lo, hi) : fallback; }; +// GRAIN's own row: the ruler spends HALF steps (paramDefs.ts), so the form must +// not drop the half the strip was showing — the chips are its coarse picks, and +// the live value rides along on a chip of its own. Only grain is read this way; +// every other knob here is a whole number. +const half = (s: string, fallback: number, lo: number, hi: number) => { + const n = Number(s); + return Number.isFinite(n) ? clamp(Math.round(n * 2) / 2, lo, hi) : fallback; +}; + const wbKeyOf = (s: ColorAdjustments) => WB_KEYS.find((k) => { const p = WB_PRESETS.find((w) => w.key === k)!; @@ -175,7 +184,7 @@ export function RecipeCreatePanel({ denoise: int(fNr, simAdj.denoise, -10, 10), sharpening: int(fSharp, simAdj.sharpening, -10, 10), clarity: int(fClarity, simAdj.clarity, -10, 10), - grain: int(grain, seedAdj.grain, 0, 10), + grain: half(grain, seedAdj.grain, 0, 10), colorChrome: cc as ColorAdjustments['colorChrome'], colorChromeBlue: ccb as ColorAdjustments['colorChromeBlue'], dynamicRange: (dr === 'auto' ? 'auto' : parseInt(dr, 10)) as ColorAdjustments['dynamicRange'], diff --git a/src/components/AdjustmentPanel.tsx b/src/components/AdjustmentPanel.tsx index a1d9622..44b374e 100644 --- a/src/components/AdjustmentPanel.tsx +++ b/src/components/AdjustmentPanel.tsx @@ -8,6 +8,7 @@ import { Recipe, ColorAdjustments, CustomWatermark, GpsWatermark, FrameId, BaseF import { FRAMES } from '../utils/frameUtils'; import { PRO_FRAMES } from '../utils/entitlement'; import { PARAM_DEFS, ContinuousParamDef } from '../utils/paramDefs'; +import { grainPerInch } from '../utils/grainShader'; import { FILM_SIMS, filmSimToRecipe } from '../utils/defaultRecipes'; import RecipeCreateModal from './RecipeCreateModal'; @@ -23,6 +24,7 @@ type TabId = 'recipes' | 'favorites' | 'iq' | 'wb' | 'filters' | 'frame'; type GroupKey = | 'style' | 'temp' + | 'grain' | 'cx' | 'cxb' | 'dr' @@ -318,6 +320,8 @@ export default function AdjustmentPanel({ const PARAM_GROUP: Partial> = { straighten: 'rotate', temperature: 'temp', + grain: 'grain', + grainSize: 'grain', }; const toggleParam = (key: string) => { @@ -580,6 +584,22 @@ export default function AdjustmentPanel({ ]; }, [pro]); const groupDefs: Record, { label: string; off: string; value: string; options: { v: string; d: string }[]; onPick: (v: string) => void }> = { + // MONOCHROME GRAIN: the amount and the size of the clumps, then the count + // they add up to. The two knobs and their readout share one strip because + // they are one subject; the chip above them carries the amount, the way TEMP + // carries the kelvin. + grain: { + label: 'MONOCHROME GRAIN', off: '0', + // While a ruler is open its own entry is what the chip names, the way + // ROTATE's STRAIGHTEN does; otherwise the chip carries the amount, which is + // the one the strip is about. + value: openParam === 'grain' ? 'amount' : openParam === 'grainSize' ? 'size' : String(a.grain), + options: [ + { v: 'amount', d: 'AMOUNT' }, + { v: 'size', d: 'SIZE' }, + ], + onPick: (v) => toggleParam(v === 'size' ? 'grainSize' : 'grain'), + }, cx: { label: 'COLOR CHROME', off: 'none', value: a.colorChrome || 'none', options: chromeOptions, onPick: (v) => onUpdateAdjustments({ colorChrome: v as ColorAdjustments['colorChrome'] }), @@ -716,6 +736,14 @@ export default function AdjustmentPanel({ label: wbTempLabel(), }); + // GRAIN's chip names the amount the way TEMP names the kelvin: MONOCHROME + // GRAIN alone would not say whether any is dialled in, and the amount is what + // the strip is about. The SIZE and the count live inside the strip. + const grainChip = (): ChipDef => ({ + ...groupChip('grain'), + label: `MONOCHROME GRAIN ${a.grain % 1 ? a.grain.toFixed(1) : a.grain}`, + }); + // ROTATE's chip names every part of the angle it applies: the turn and the // fine angle AUTO (or the slider) left on it. const rotateChip = (): ChipDef => { @@ -886,7 +914,13 @@ export default function AdjustmentPanel({ ]); case 'filters': - return chipRow(paramChips(paramDefs.filters)); + // GRAIN is a strip of its own — amount, size, and the count they add up + // to — so it is one chip here and its two knobs live inside it; the chip + // keeps the place the knob had in this row. + return chipRow([ + ...paramChips(paramDefs.filters.filter((p) => p.key !== 'grain' && p.key !== 'grainSize')), + grainChip(), + ]); case 'frame': // FRAME -> WATERMARK: the sub-panel row (breadcrumb back to the frames). @@ -1019,6 +1053,17 @@ export default function AdjustmentPanel({ {choiceChips(openGroup, groupDefs[openGroup].options, groupDefs[openGroup].value, groupDefs[openGroup].onPick).map( renderChip )} + {/* GRAIN's third entry is a READOUT: the clump count the two knobs add + up to, in the print's own terms. Nothing to pick, so it is inert — + it moves when a knob moves. */} + {openGroup === 'grain' && + renderChip({ + key: 'grain-inch', + label: `${grainPerInch(a.grainSize ?? 100)}/INCH`, + disabled: true, + active: false, + onPress: () => {}, + })} )} diff --git a/src/components/RecipeCreateModal.tsx b/src/components/RecipeCreateModal.tsx index 230ea71..4e57950 100644 --- a/src/components/RecipeCreateModal.tsx +++ b/src/components/RecipeCreateModal.tsx @@ -82,6 +82,15 @@ const int = (s: string, fallback: number, lo: number, hi: number) => { return Number.isFinite(n) ? clamp(n, lo, hi) : fallback; }; +// GRAIN's own row: the ruler spends HALF steps (paramDefs.ts), so the form must +// not drop the half the strip was showing — the chips are its coarse picks, and +// the live value rides along on a chip of its own. Only grain is read this way; +// every other knob here is a whole number. +const half = (s: string, fallback: number, lo: number, hi: number) => { + const n = Number(s); + return Number.isFinite(n) ? clamp(Math.round(n * 2) / 2, lo, hi) : fallback; +}; + interface ChipProps { label: string; active: boolean; @@ -291,7 +300,7 @@ export default function RecipeCreateModal({ denoise: int(fNr, simAdj.denoise, -10, 10), sharpening: int(fSharp, simAdj.sharpening, -10, 10), clarity: int(fClarity, simAdj.clarity, -10, 10), - grain: int(grain, seedAdj.grain, 0, 10), + grain: half(grain, seedAdj.grain, 0, 10), colorChrome: cc as ColorAdjustments['colorChrome'], colorChromeBlue: ccb as ColorAdjustments['colorChromeBlue'], dynamicRange: (dr === 'auto' ? 'auto' : parseInt(dr, 10)) as ColorAdjustments['dynamicRange'], diff --git a/src/components/Viewfinder.tsx b/src/components/Viewfinder.tsx index a1157d4..447266a 100644 --- a/src/components/Viewfinder.tsx +++ b/src/components/Viewfinder.tsx @@ -1505,6 +1505,17 @@ const Viewfinder = forwardRef(function Viewfi // actually be drawn, past which the noise is sub-pixel and reads as static. const GRAIN_MIN_CELL = 1 / PixelRatio.get(); + // SIZE knob: percent of the stock's own cell (paramDefs.ts). Read once, so the + // previews and the file all print the cell the knob names, and the shader gets + // the floor beside the cell — the field's own patch-to-patch swing cannot take + // a cell under it, which is what would print as static. + const grainSizePct = adjustments.grainSize ?? 100; + const grainUniforms = (pictureWidth: number) => ({ + u: grainCell(pictureWidth, GRAIN_MIN_CELL, grainSizePct), + mincell: GRAIN_MIN_CELL, + seed: GRAIN_SEED, + }); + // Library-still viewer: pinch-zoom (2 fingers) + double-tap zoom at the tap // point / double-tap again to reset to 100%. Transform is a Skia matrix // P' = scale*P + (tx,ty); gesture math keeps the focal point stationary. @@ -2790,7 +2801,7 @@ const Viewfinder = forwardRef(function Viewfi > @@ -3120,7 +3131,7 @@ const Viewfinder = forwardRef(function Viewfi {grainOpacity > 0 && noiseEffect && ( - + )} @@ -3178,7 +3189,7 @@ const Viewfinder = forwardRef(function Viewfi {grainOpacity > 0 && noiseEffect && ( - + )} @@ -3228,7 +3239,7 @@ const Viewfinder = forwardRef(function Viewfi {/* Grain covers the whole photo area — the contain rect of the library photo, the ratio band on the camera. */} - + )} diff --git a/src/types/index.ts b/src/types/index.ts index 95cec35..97e133e 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -81,6 +81,7 @@ export interface ColorAdjustments { denoise: number; // -10 to +10 (+ = blur sigma; - = add film grain back) clarity: number; // -10 to +10 (mapped to matrix convolution / bloom) grain: number; // 0 to 10 (mapped to noise turbulence opacity/scale) + grainSize?: number; // 50 to 200 (percent of the stock's own grain cell; 100 = the stock's own) colorChrome: 'none' | 'weak' | 'strong'; // Chrome effect colorChromeBlue: 'none' | 'weak' | 'strong'; // Chrome effect (Blue) dynamicRange: 'auto' | 100 | 200 | 400; // Fuji-style DR tone curve (auto/100 = no extra curve) diff --git a/src/utils/exportEngine.ts b/src/utils/exportEngine.ts index e7bcb10..49649c4 100644 --- a/src/utils/exportEngine.ts +++ b/src/utils/exportEngine.ts @@ -565,7 +565,14 @@ export async function processAndExportPhoto( // finer than the grain the user had just tuned at 1080px. const noiseEffect = own(Skia.RuntimeEffect.Make(GRAIN_SKSL)); const noiseShader = noiseEffect - ? own(noiseEffect.makeShader([grainCell(width), GRAIN_SEED[0], GRAIN_SEED[1]])) + ? own( + noiseEffect.makeShader([ + grainCell(width, 1, adjustments.grainSize ?? 100), + 1, + GRAIN_SEED[0], + GRAIN_SEED[1], + ]) + ) : null; if (noiseShader) { diff --git a/src/utils/grainShader.ts b/src/utils/grainShader.ts index 9c916b6..d0fdbec 100644 --- a/src/utils/grainShader.ts +++ b/src/utils/grainShader.ts @@ -25,8 +25,21 @@ export const GRAIN_REF = 1080; // The web port (docker/frontend/shared/utils/grainShader.ts) carries the same // field with the stock's weights, cell and gain as uniforms; the 0.55/0.30/0.15 // and 2.95 below are its classic-chrome row, so the two print the same texture. +// +// Emulsion is not ONE grain size across the frame: the coating settles unevenly, +// so the clumps run coarser in patches and tighter in others. The same hash read +// as a SLOW value noise is what draws those patches — one every 1/ZONE_FREQ +// cells, turned off the axes and smoothed, because a step at a patch border +// would print as a seam — and each patch swings its own cell size by half of +// ZONE_SWING either way. Nothing here moves the field's mean: a coarser patch +// prints bigger clumps, not a brighter one, which is why the panel can read out +// one number while the frame carries a range. +export const ZONE_FREQ = 1 / 96; +export const ZONE_SWING = 0.4; + export const GRAIN_SKSL = ` uniform float u; + uniform float mincell; uniform vec2 seed; float grainHash(vec2 q) { vec3 p3 = fract(vec3(q.x, q.y, q.x) * 0.1031); @@ -49,8 +62,22 @@ export const GRAIN_SKSL = ` } return 1.0 - min(sqrt(near), 1.0); } + // The patches of grain size: the same hash read slowly, smoothed so a border + // between two patches is a slope and not a step. + float grainZone(vec2 p) { + vec2 i = floor(p); + vec2 f = p - i; + f = f * f * (3.0 - 2.0 * f); + return mix(mix(grainHash(i), grainHash(i + vec2(1.0, 0.0)), f.x), + mix(grainHash(i + vec2(0.0, 1.0)), grainHash(i + vec2(1.0, 1.0)), f.x), f.y); + } vec4 main(vec2 pos) { - vec2 q = pos.xy / max(u, 0.0001) + seed; + vec2 p = pos.xy / max(u, 0.0001) + seed; + // This patch's own cell: the design cell swung by its zone, never under the + // floor — under it the clumps are sub-pixel and print as static, which is + // aliasing, not a finer emulsion. + float cell = max(u * (1.0 + (grainZone(mat2(0.9397, -0.3420, 0.3420, 0.9397) * p * ${ZONE_FREQ.toFixed(6)}) - 0.5) * ${ZONE_SWING}), mincell); + vec2 q = pos.xy / cell + seed; // The grain, then the clumps of grain above it at 0.53x and 0.29x the cell. // The 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 @@ -81,5 +108,19 @@ export const GRAIN_SKSL = ` // canvas units are dp: on a 3x phone the old floor of a whole dp pinned a // 390dp-wide preview to a 3 device-pixel cell, 2.8x coarser than the file's // grain, which is the preview-vs-file mismatch all over again, only that way. -export const grainCell = (pictureWidth: number, minCell = 1) => - Math.max(pictureWidth / GRAIN_REF, minCell); +// +// `sizePct` is the SIZE knob: a percentage of the cell above, which is the +// phone's own classic-chrome cell (see GRAIN_SKSL) — the web port scales its +// stock's own cell by the same number. It is handed to the shader as a floor +// too, so the field's patch-to-patch swing cannot cross it. +export const grainCell = (pictureWidth: number, minCell = 1, sizePct = 100) => + Math.max((pictureWidth / GRAIN_REF) * (sizePct / 100), minCell); + +// The clump count the panel READS OUT: how many clumps the design puts across an +// inch of a 300 dpi print, i.e. 300px of the GRAIN_REF frame. A statement about +// the cell and the SIZE knob, never about one patch of the frame — the field +// swings ±ZONE_SWING/2 patch by patch — and never about the screen, so the same +// cell reads the same number in the preview and in the file. +export const GRAIN_DPI = 300; + +export const grainPerInch = (sizePct = 100) => Math.round(GRAIN_DPI / (sizePct / 100)); diff --git a/src/utils/paramDefs.ts b/src/utils/paramDefs.ts index 464fa79..bc66e77 100644 --- a/src/utils/paramDefs.ts +++ b/src/utils/paramDefs.ts @@ -169,12 +169,28 @@ export const PARAM_DEFS: { label: 'MONOCHROME GRAIN', min: 0, max: 10, - step: 1, + // Half a step: the grain is a look, not a switch, and the AMOUNT chip in + // the GRAIN strip is what spends the half. + step: 0.5, defaultValue: 0, display: String, get: (a) => a.grain, set: (v) => ({ grain: v }), }, + { + // The SIZE knob: percent of the stock's own cell (grainShader.ts), so the + // same number means the same texture RELATIVE to the picture on both + // platforms and the readout beside it is the stock's, scaled. + key: 'grainSize', + label: 'GRAIN SIZE', + min: 50, + max: 200, + step: 5, + defaultValue: 100, + display: (v) => `${v}%`, + get: (a) => a.grainSize ?? 100, + set: (v) => ({ grainSize: v }), + }, { key: 'hdf', label: 'HDF EFFECT',