diff --git a/src/components/Viewfinder.tsx b/src/components/Viewfinder.tsx index 6048686..a1157d4 100644 --- a/src/components/Viewfinder.tsx +++ b/src/components/Viewfinder.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useEffect, useMemo, useRef, useState, forwardRef, useImperativeHandle, type ComponentProps } from 'react'; -import { View, Text, StyleSheet, TouchableOpacity, useWindowDimensions, AppState } from 'react-native'; +import { View, Text, StyleSheet, TouchableOpacity, useWindowDimensions, AppState, PixelRatio } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { activateKeepAwake, deactivateKeepAwake } from 'expo-keep-awake'; import { useCameraDevice, type CameraPhotoOutput } from 'react-native-vision-camera'; @@ -33,6 +33,7 @@ import { Recipe, GPSInfo, FrameId, AspectRatio, ColorAdjustments, ASPECT_RATIO_W import { getSkiaColorMatrix, applyExposureGain } from '../utils/colorUtils'; import { TONE_SKSL, getToneUniforms, toneUniformArray, GLOW_SKSL, glowUniformArray, GLOW_UNIFORMS, CLARITY_SKSL, clarityUniforms } from '../utils/toneShader'; import { CINEMA_SKSL, getCinemaUniforms, cinemaOffFlat } from '../utils/cinemaShader'; +import { GRAIN_SKSL, GRAIN_SEED, grainCell } from '../utils/grainShader'; import { polaroidLayout, wallframeLayout, WALLFRAME_PNG_W, WALLFRAME_PNG_H } from '../utils/frameUtils'; import { applyPhotoRotation } from '../utils/skiaImage'; import { detectTilt } from '../utils/horizon'; @@ -194,14 +195,6 @@ export interface ViewfinderHandle { detectHorizon(): number | null; } -// Grain: value noise on a CELL of `u` canvas px rather than on the raw pixel -// grid. A per-pixel hash gave the preview (a ~1080px rect) and the export (a -// 4000px image) two different grains — the file's came out ~4x finer than -// what was tuned on screen. Callers pass the rect's width / GRAIN_REF so both -// hash at the same size relative to the picture; u = 1 (the preview) is exactly -// the old per-pixel hash, since the smoothed cell fraction is 0 there. The -// bilinear over the four cell corners is what makes a clump instead of a pixel -// of static — at cell 1 it collapses back to the plain hash. // Every Skia object the per-frame worklet builds (paint, filter, shader) is a // native allocation; the UI runtime's GC does not reach them promptly, so a // preview left open piles up ~24 MB/minute that the device pays back as swap. @@ -219,23 +212,20 @@ const disposeTrash = (trash: SkTrash[]) => { } } }; -const noiseEffect = Skia.RuntimeEffect.Make(` - uniform float u; - float grainHash(vec2 q) { - return fract(sin(dot(q, vec2(12.9898, 78.233))) * 43758.5453); +// Monochrome grain — the shader and the roll of film live in +// utils/grainShader.ts, shared with the export and the web build. All this file +// decides is the CELL: canvas units here are dp, so a preview floors the cell at +// one DEVICE pixel (GRAIN_MIN_CELL) rather than one dp — the old one-dp floor +// pinned a 390dp-wide preview to a 3 device-pixel cell, i.e. grain 2.8x coarser +// than the file's, which is the preview-vs-file mismatch in the other direction. +const noiseEffect = (() => { + try { + return Skia.RuntimeEffect.Make(GRAIN_SKSL); + } catch (e) { + console.error('GRAIN_SKSL compile failed: ' + e); + return null; } - vec4 main(vec2 pos) { - vec2 p = pos.xy / max(u, 1.0); - vec2 i = floor(p); - vec2 f = p - i; - f = f * f * (3.0 - 2.0 * f); - float g00 = grainHash(i); - float g10 = grainHash(i + vec2(1.0, 0.0)); - float g01 = grainHash(i + vec2(0.0, 1.0)); - float g11 = grainHash(i + vec2(1.0, 1.0)); - return vec4(vec3(mix(mix(g00, g10, f.x), mix(g01, g11, f.x), f.y)), 1.0); - } -`); +})(); // Tone runtime effect (DR/Highlight/Shadow) for the live camera preview worklet. // Hoisted at module scope so the worklet runtime can capture it — same pattern @@ -1507,12 +1497,13 @@ const Viewfinder = forwardRef(function Viewfi const grainOpacity = Math.min(10, Math.max(0, adjustments.grain) + Math.max(0, -(adjustments.denoise ?? 0))) / 20; - // Grain cell size: the noise hashes one cell per `rect.w / GRAIN_REF` canvas - // px, so a ~1080px preview rect hashes per pixel and a 4000px export per ~4px - // cell — the same grain at the same size relative to the picture (see - // noiseEffect). Without this the exported grain was ~4x finer than the grain - // the user had just tuned on screen. - const GRAIN_REF = 1080; + // Grain cell: one cell per 1/1080 of the PICTURE's width (grainShader.ts), so + // the camera region, the library window and the file all show the same grain + // at the same size relative to the picture — the export used to come out ~4x + // finer, and the old one-dp floor made these previews ~3x coarser than the + // file on a 3x screen. The floor is one DEVICE pixel: the finest a cell can + // actually be drawn, past which the noise is sub-pixel and reads as static. + const GRAIN_MIN_CELL = 1 / PixelRatio.get(); // 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 @@ -2799,7 +2790,7 @@ const Viewfinder = forwardRef(function Viewfi > @@ -3129,7 +3120,7 @@ const Viewfinder = forwardRef(function Viewfi {grainOpacity > 0 && noiseEffect && ( - + )} @@ -3187,7 +3178,7 @@ const Viewfinder = forwardRef(function Viewfi {grainOpacity > 0 && noiseEffect && ( - + )} @@ -3237,7 +3228,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/utils/exportEngine.ts b/src/utils/exportEngine.ts index 617c3c4..e7bcb10 100644 --- a/src/utils/exportEngine.ts +++ b/src/utils/exportEngine.ts @@ -7,6 +7,7 @@ import { Recipe, GPSInfo, FrameId, AspectRatio, ASPECT_RATIO_W_H, CropRect } fro import { getSkiaColorMatrix, applyExposureGain } from './colorUtils'; import { TONE_SKSL, getToneUniforms, toneIsActive, toneUniformArray, GLOW_SKSL, glowUniformArray } from './toneShader'; import { CINEMA_SKSL, getCinemaUniforms, cinemaIsActive } from './cinemaShader'; +import { GRAIN_SKSL, GRAIN_SEED, grainCell } from './grainShader'; import { drawFrameOnCanvas, polaroidLayout, POLAROID_CARD, POLAROID_WIN_W, wallframeLayout, WALLFRAME_W, WALLFRAME_H, WALLFRAME_LAND_W, WALLFRAME_LAND_H } from './frameUtils'; import { patchJpegDpi } from './jpegDpi'; import { writeJpegExif } from './exifWrite'; @@ -557,29 +558,15 @@ export async function processAndExportPhoto( grainPaint.setBlendMode(BlendMode.Overlay); grainPaint.setAlphaf(grainOpacity); - // Procedural noise shader — byte-for-byte the preview's (see - // Viewfinder's noiseEffect). The cell is `width / 1080` so the grain is - // the same size RELATIVE to the picture as in the preview that tuned it; - // hashing the raw pixel grid made a 4000px file's grain ~4x finer than - // the grain the user had just tuned at 1080px. - const noiseEffect = own(Skia.RuntimeEffect.Make(` - uniform float u; - float grainHash(vec2 q) { - return fract(sin(dot(q, vec2(12.9898, 78.233))) * 43758.5453); - } - vec4 main(vec2 pos) { - vec2 p = pos.xy / max(u, 1.0); - vec2 i = floor(p); - vec2 f = p - i; - f = f * f * (3.0 - 2.0 * f); - float g00 = grainHash(i); - float g10 = grainHash(i + vec2(1.0, 0.0)); - float g01 = grainHash(i + vec2(0.0, 1.0)); - float g11 = grainHash(i + vec2(1.0, 1.0)); - return vec4(vec3(mix(mix(g00, g10, f.x), mix(g01, g11, f.x), f.y)), 1.0); - } - `)); - const noiseShader = noiseEffect ? own(noiseEffect.makeShader([width / 1080])) : null; + // Procedural noise shader — literally the preview's, and the web build's + // (see grainShader.ts). The cell is the picture's own width / GRAIN_REF, so + // the grain is the same size RELATIVE to the picture as the preview that + // tuned it; hashing the raw pixel grid made a 4000px file's grain ~4x + // 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]])) + : null; if (noiseShader) { grainPaint.setShader(noiseShader); diff --git a/src/utils/grainShader.ts b/src/utils/grainShader.ts new file mode 100644 index 0000000..1414f32 --- /dev/null +++ b/src/utils/grainShader.ts @@ -0,0 +1,63 @@ +// MONOCHROME GRAIN — one roll of film, shared by the live camera preview, the +// library preview, the web port and the file the phone writes. +// +// The seed is rolled ONCE per app launch and hashed into the noise's domain, so +// every render inside one session — the preview, the compare copy, the export — +// prints the same clumps, while the next launch (or the next visitor, on the +// web) prints a different roll. Film, not a fixed pattern. +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 in the +// first place. GRAIN_SKSL's 2.95 gain is what holds the AMOUNT slider on the +// spread it was calibrated against — move one and the other has to move too. +export const GRAIN_REF = 1080; + +export const GRAIN_SKSL = ` + 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; + } + // 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, + // which is all a cell smaller than the target's own pixel can be. + 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 grain, then the clumps of grain above it at 2x and 4x 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 again. + 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); + } +`; + +// The cell GRAIN_SKSL must be handed for a render of a picture occupying +// `pictureWidth` canvas px: the camera's composition region, the library +// photo's window on screen, or the full width of the file being exported. +// +// `minCell` is the floor under which the target cannot resolve a cell at all. +// An export passes one output pixel (the default): a 900px file's 0.83px cell +// prints as one pixel of static, so the cell stops at the pixel it can still +// print. A PREVIEW must pass one DEVICE pixel — 1 / PixelRatio.get() — because +// 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);