web: give the grain a size of its own, and read the count off the print
MONOCHROME GRAIN was one integer knob 0..10 with one meaning, how much. It is now
a strip of three: AMOUNT — the same knob, in half steps — SIZE, a percentage of
the stock's own grain cell (50..200%, so the same number means the same texture
relative to the picture on both platforms), and an inert readout of N/INCH, the
clump count the two knobs and the stock add up to in the print's own terms (300
dpi = 300px of the 1080-wide reference the knob was tuned at).
Emulsion is not one grain size across the frame: the coating settles unevenly.
The field now prints that — the same hash read slowly (ZONE_FREQ = 1/96 cells,
turned off the axes, smoothed so a border between two patches is a slope and not
a seam) swings each patch's own cell by half of ZONE_SWING either way, ±20%.
Nothing in it moves the field's mean: a coarser patch prints bigger clumps, not a
brighter one, which is why the strip can read out one number while the frame
carries a range.
A patch may not swing a cell under the pixel the target can print, or the clumps
are sub-pixel and print as static — aliasing, not a finer emulsion. The shader
takes that floor as a `mincell` uniform beside the cell (u, mincell, seed.xy, in
declaration order): an export passes one output pixel, a preview one device pixel
(1 / PixelRatio), which is the floor the phone's preview already needed.
SIZE is stored as an integer percent so no float noise reaches the recipe JSON,
and it is read by the same two engines that read grain: the web's
grainCell(width, stock, sizePct) and the phone's grainCell(width, minCell,
sizePct). The chip above the strip carries the amount in half steps the way TEMP
carries the kelvin, and the readout moves with SIZE, not with AMOUNT.
Measured:
grain-controls-test.cjs 20/0 — the strip carries grp-grain, grain:amount,
grain:size and grain-inch; the AMOUNT ruler is 0..10 step 0.5, and 3 -> 3.5
moves the frame (sigma 18.53 -> 21.91, new hash) without moving the readout;
10 -> sigma 60.43, 0 -> sigma 0; SIZE 200% -> 130/INCH (sigma 40.06), 50% ->
522/INCH (66.56: under the preview's pixel floor what is printed is static,
not finer grain); the region claim on an 8x8 grid of the flat frame gives
tile sd 51.0..66.1, max/min 1.295, and a tile mean spread of 1.14 — a coarser
patch is not a brighter one.
grain-size-test.cjs 17/0 (was 12/0) — the SIZE rule and the readout on the
module itself: 200% doubles the cell, 50% halves it, the output pixel still
floors the smaller one. 4000px file cell 3.704 against 1.083 device px on a 3x
preview, the old one-dp floor 2.77x coarser, rho1 0.627 against 0.074. The
harness built its own 3-uniform array; it now passes [u, mincell, seed.xy]
like every other caller.
_grain-zone-ck.cjs — the zone's own contribution, at preview scale (cell 1.70,
1600px, 8x8 tiles of 200px): zone on, tile sd 23.22..24.82 (ratio 1.069);
zone off, 24.42..24.79 (ratio 1.015). Nothing else differs.
_grain-ck.cjs — the clump field is otherwise what it was: rho1 0.62
classic-neg / 0.12 velvia, residual autocorr 0.035 against 0.036 with the
swing forced to 0, peak/median 32.3 against 27.6.
_grain-spectrum.cjs (app, 1600px render) — residual autocorr 0.017..0.018,
spectral peak/median 4.6..6.1: the slow lattice adds no peak of its own.
grain-stock-test 53/0, sims-test 31/0, fx-mono-test 15/0, wb-preset-test 33/0,
temp-swatch-test 33/0, wm-font-test 38/0, grain-analog-test 7/0 (its grain
selectors moved to the strip).
tsc: web clean; the phone's scoped config reports exactly the pre-change
baseline (Viewfinder.tsx's own errors, none new).
ponytail: the amount is fractional now, so the two recipe-create forms read grain
through their own half() instead of the int() that would truncate the half the
ruler just spent — every other knob there is still whole. The SIZE knob is one
number for the whole strip: no way to dial a single patch, and no seed control.
The readout is the DESIGN count the field is built on, never a per-patch
measurement.
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<Record<string, (v: number) => 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<Record<string, GroupKey>> = { temperature: 'temp', straighten: 'rotate' };
|
||||
const PARAM_GROUP: Partial<Record<string, GroupKey>> = {
|
||||
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<Exclude<GroupKey, 'wm'>, {
|
||||
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'
|
||||
|
||||
@@ -531,7 +531,7 @@ export async function renderPhoto(input: RenderInput): Promise<RenderResult | nu
|
||||
grainPaint.setBlendMode(Skia.BlendMode.Overlay);
|
||||
grainPaint.setAlphaf(grainAmount / 20);
|
||||
const noiseEffect = own(Skia.RuntimeEffect.Make(GRAIN_SKSL));
|
||||
const cell = grainCell(width, stock);
|
||||
const cell = grainCell(width, stock, adjustments.grainSize);
|
||||
const noiseShader = noiseEffect ? own(noiseEffect.makeShader(grainUniformArray(cell, stock))) : null;
|
||||
if (noiseShader) {
|
||||
grainPaint.setShader(noiseShader);
|
||||
|
||||
@@ -47,6 +47,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;
|
||||
};
|
||||
|
||||
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'],
|
||||
|
||||
@@ -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<Record<string, GroupKey>> = {
|
||||
straighten: 'rotate',
|
||||
temperature: 'temp',
|
||||
grain: 'grain',
|
||||
grainSize: 'grain',
|
||||
};
|
||||
|
||||
const toggleParam = (key: string) => {
|
||||
@@ -580,6 +584,22 @@ export default function AdjustmentPanel({
|
||||
];
|
||||
}, [pro]);
|
||||
const groupDefs: Record<Exclude<GroupKey, 'wm'>, { 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: () => {},
|
||||
})}
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -1505,6 +1505,17 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(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<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
>
|
||||
<Shader
|
||||
source={noiseEffect}
|
||||
uniforms={{ u: grainCell(framedWin ? framedWin.w : region.w, GRAIN_MIN_CELL), seed: GRAIN_SEED }}
|
||||
uniforms={grainUniforms(framedWin ? framedWin.w : region.w)}
|
||||
/>
|
||||
</Rect>
|
||||
</Group>
|
||||
@@ -3120,7 +3131,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
{grainOpacity > 0 && noiseEffect && (
|
||||
<Group blendMode="overlay">
|
||||
<Rect x={win.x} y={win.y} width={win.w} height={win.h} opacity={grainOpacity}>
|
||||
<Shader source={noiseEffect} uniforms={{ u: grainCell(win.w, GRAIN_MIN_CELL), seed: GRAIN_SEED }} />
|
||||
<Shader source={noiseEffect} uniforms={grainUniforms(win.w)} />
|
||||
</Rect>
|
||||
</Group>
|
||||
)}
|
||||
@@ -3178,7 +3189,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
{grainOpacity > 0 && noiseEffect && (
|
||||
<Group blendMode="overlay">
|
||||
<Rect x={win.x} y={win.y} width={win.w} height={win.h} opacity={grainOpacity}>
|
||||
<Shader source={noiseEffect} uniforms={{ u: grainCell(win.w, GRAIN_MIN_CELL), seed: GRAIN_SEED }} />
|
||||
<Shader source={noiseEffect} uniforms={grainUniforms(win.w)} />
|
||||
</Rect>
|
||||
</Group>
|
||||
)}
|
||||
@@ -3228,7 +3239,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
{/* Grain covers the whole photo area — the contain rect of the
|
||||
library photo, the ratio band on the camera. */}
|
||||
<Rect x={frameRect.x} y={frameRect.y} width={frameRect.w} height={frameRect.h} opacity={grainOpacity}>
|
||||
<Shader source={noiseEffect} uniforms={{ u: grainCell(frameRect.w, GRAIN_MIN_CELL), seed: GRAIN_SEED }} />
|
||||
<Shader source={noiseEffect} uniforms={grainUniforms(frameRect.w)} />
|
||||
</Rect>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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));
|
||||
|
||||
+17
-1
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user