fix(sim): rebuild the six film-sim color matrices to match the stock looks

Provia/Velvia/Astia/Classic Chrome/Classic Neg/Acros each get their own
primaries+cross-talk matrix measured against the pipeline, plus a split-tone
stage (shadow/highlight tint uniforms) for Classic Chrome's teal and Classic
Neg's green-cyan shadows vs warm highlights.
This commit is contained in:
2026-09-10 20:57:19 +07:00
parent 93f48537f8
commit 4bb2bda5c8
5 changed files with 139 additions and 53 deletions
+31 -16
View File
@@ -29,7 +29,7 @@ import {
import { Lock } from 'lucide-react-native';
import { Recipe, GPSInfo, FrameId, AspectRatio, ColorAdjustments, ASPECT_RATIO_W_H, CropRatio, CropRect, CROP_W_H, DEFAULT_CROP_RECT, MIN_CROP_FRAC } from '../types';
import { getSkiaColorMatrix, applyExposureGain } from '../utils/colorUtils';
import { TONE_SKSL, getToneUniforms } from '../utils/toneShader';
import { TONE_SKSL, getToneUniforms, toneUniformArray } from '../utils/toneShader';
import { CINEMA_SKSL, getCinemaUniforms, cinemaOffFlat } from '../utils/cinemaShader';
import { polaroidLayout, wallframeLayout, WALLFRAME_PNG_W, WALLFRAME_PNG_H } from '../utils/frameUtils';
import { rotateSkImage90 } from '../utils/skiaImage';
@@ -392,13 +392,12 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
// Tone uniforms [dr, hl, sh] for the preview worklet; all-zero = no tone pass.
const toneParams = (() => {
const u = getToneUniforms(adjustments);
return [u.dr, u.hl, u.sh, u.vib];
})();
// Tone uniforms [dr, hl, sh, vib, shadow split RGB, highlight split RGB] for
// the preview worklet; all-zero = no tone pass. A stock may keep the pass on
// with every user knob at 0 (Classic Neg split tone, Classic Chrome crush).
const toneParams = toneUniformArray(getToneUniforms(adjustments, recipe.baseFilter));
const toneSync = useMemo(
() => createSynchronizable<number[]>([0, 0, 0]),
() => createSynchronizable<number[]>(new Array(10).fill(0)),
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
@@ -820,7 +819,11 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
}
const tone = toneSync.getDirty();
const cinema = cinemaSync.getDirty();
const hasTone = toneEffect != null && (tone[0] !== 0 || tone[1] !== 0 || tone[2] !== 0 || tone[3] !== 0);
const hasTone =
toneEffect != null &&
(tone[0] !== 0 || tone[1] !== 0 || tone[2] !== 0 || tone[3] !== 0 ||
tone[4] !== 0 || tone[5] !== 0 || tone[6] !== 0 ||
tone[7] !== 0 || tone[8] !== 0 || tone[9] !== 0);
const hasCinema = cinemaEffect != null && cinema[0] > 0;
// Draws the full frame with the tone/cinema shader when active (drawRect,
// not drawImage: paint shaders only apply to geometry fills — drawImage
@@ -2135,8 +2138,20 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
}
// Library mode — fullscreen, full-photo (contain), pinch/double-tap zoom.
const toneOn =
toneParams[0] !== 0 || toneParams[1] !== 0 || toneParams[2] !== 0 || toneParams[3] !== 0;
const toneOn = toneParams.some((v) => v !== 0);
// One object for every <Shader> below; keys must match TONE_SKSL's uniforms.
const toneUniforms = {
dr: toneParams[0],
hl: toneParams[1],
sh: toneParams[2],
vib: toneParams[3],
shTr: toneParams[4],
shTg: toneParams[5],
shTb: toneParams[6],
hlTr: toneParams[7],
hlTg: toneParams[8],
hlTb: toneParams[9],
};
const cinemaOn = cinemaEffect != null && !!cinemaParams;
const useShaderPass = !!imageFitRect && ((toneEffect != null && toneOn) || cinemaOn);
@@ -2164,7 +2179,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
{toneEffect && toneOn ? (
<Shader
source={toneEffect}
uniforms={{ dr: toneParams[0], hl: toneParams[1], sh: toneParams[2], vib: toneParams[3] }}
uniforms={toneUniforms}
>
<ImageShader image={skiaImage} fit={polaroidFit} tx="clamp" ty="clamp" rect={winRect} />
</Shader>
@@ -2175,7 +2190,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
) : toneEffect && toneOn ? (
<Shader
source={toneEffect}
uniforms={{ dr: toneParams[0], hl: toneParams[1], sh: toneParams[2], vib: toneParams[3] }}
uniforms={toneUniforms}
>
<ImageShader image={skiaImage} fit={polaroidFit} tx="clamp" ty="clamp" rect={winRect} />
</Shader>
@@ -2244,7 +2259,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
{toneEffect && toneOn ? (
<Shader
source={toneEffect}
uniforms={{ dr: toneParams[0], hl: toneParams[1], sh: toneParams[2], vib: toneParams[3] }}
uniforms={toneUniforms}
>
<ImageShader image={skiaImage} fit="cover" tx="clamp" ty="clamp" rect={winRect} />
</Shader>
@@ -2255,7 +2270,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
) : toneEffect && toneOn ? (
<Shader
source={toneEffect}
uniforms={{ dr: toneParams[0], hl: toneParams[1], sh: toneParams[2], vib: toneParams[3] }}
uniforms={toneUniforms}
>
<ImageShader image={skiaImage} fit="cover" tx="clamp" ty="clamp" rect={winRect} />
</Shader>
@@ -2321,7 +2336,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
{toneEffect && toneOn ? (
<Shader
source={toneEffect}
uniforms={{ dr: toneParams[0], hl: toneParams[1], sh: toneParams[2], vib: toneParams[3] }}
uniforms={toneUniforms}
>
<ImageShader
image={skiaImage}
@@ -2344,7 +2359,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
) : toneEffect && toneOn ? (
<Shader
source={toneEffect}
uniforms={{ dr: toneParams[0], hl: toneParams[1], sh: toneParams[2], vib: toneParams[3] }}
uniforms={toneUniforms}
>
<ImageShader
image={skiaImage}
+44 -28
View File
@@ -50,44 +50,57 @@ export function getSkiaColorMatrix(baseFilter: BaseFilter, adj: ColorAdjustments
// 2. Base Filters
if (baseFilter === 'classic-neg') {
// Classic Neg: high contrast, desaturated reds/greens, warm/amber cast
// Classic Neg (Superia-like): restrained colour, higher contrast around
// mid-grey, reds a touch deeper. Its signature split tone — green/cyan
// darks drifting to warm brights — cannot live in a linear matrix (one cast
// would hit both ends), so it ships in FILM_TONE (toneShader.ts) instead.
matrix = [
1.15, -0.05, -0.05, 0, 0.05,
-0.05, 1.05, -0.05, 0, 0.02,
-0.08, -0.08, 0.95, 0, -0.02,
0.9961, 0.0773, 0.0078, 0, -0.0406,
0.0226, 1.0298, 0.0076, 0, -0.0300,
0.0224, 0.0750, 0.9520, 0, -0.0247,
0, 0, 0, 1, 0,
];
} else if (baseFilter === 'provia') {
// Provia: standard natural colors, balanced tones
// Provia (Standard): the reference stock — balanced, faithful, easy on the
// eye. Mild saturation and a shallow contrast lift around mid-grey, with no
// channel cast, so neutrals stay neutral and only the chroma deepens.
matrix = [
1.02, 0, 0, 0, 0,
0, 1.02, 0, 0, 0,
0, 0, 1.02, 0, 0,
1.1492, -0.0901, -0.0091, 0, -0.0250,
-0.0268, 1.0859, -0.0091, 0, -0.0250,
-0.0268, -0.0901, 1.1669, 0, -0.0250,
0, 0, 0, 1, 0,
];
} else if (baseFilter === 'velvia') {
// Velvia: vivid colors, high saturation, deep blacks
// Velvia (Vivid): the loud one — saturation pushed very hard onto green,
// blue and red alike, on top of strong contrast with deep, dense darks.
// Saturated reds, skies and greens are meant to ride the clip; that hard
// edge is the look.
matrix = [
1.25, -0.05, -0.05, 0, -0.02,
-0.05, 1.25, -0.05, 0, -0.02,
-0.05, -0.05, 1.25, 0, -0.02,
1.3895, -0.2540, -0.0256, 0, -0.0550,
-0.0772, 1.2355, -0.0261, 0, -0.0661,
-0.0775, -0.2603, 1.4756, 0, -0.0689,
0, 0, 0, 1, 0,
];
} else if (baseFilter === 'classic-chrome') {
// Classic Chrome: muted/deep color with strong tone — desaturated reds and
// greens, slight warm amber in highlights, shadows stay soft (approx).
// Classic Chrome: magazine-reportage look. Saturation is pulled well down
// while the G/B rows are crossed so blue skies lean teal/cyan (green comes
// up towards blue, with blue kept high). The heavy shadow crush that goes
// with it is the `sh` entry in FILM_TONE — a matrix alone cannot deepen one
// end of the curve without dragging the rest.
matrix = [
1.12, -0.03, -0.06, 0, 0.04,
-0.04, 1.0, -0.03, 0, 0.03,
-0.05, -0.06, 0.86, 0, -0.02,
0.7792, 0.2188, 0.0220, 0, -0.0100,
0.0652, 0.7186, 0.2362, 0, -0.0100,
0.0652, 0.3973, 0.5575, 0, -0.0100,
0, 0, 0, 1, 0,
];
} else if (baseFilter === 'astia') {
// Astia: soft, gentle, flattering — low contrast, pastel-leaning colors.
// Astia (Soft): gentler than standard — contrast below neutral (shadows
// lift, highlights ease off) and colour held back, with a faint warm/pink
// bias that flatters skin. The outdoor-portrait stock.
matrix = [
1.0, 0.02, 0.02, 0, 0.01,
0.01, 1.0, 0.02, 0, 0.02,
0.01, 0.01, 1.02, 0, 0.02,
0.9162, 0.0201, 0.0020, 0, 0.0428,
0.0058, 0.9030, 0.0020, 0, 0.0386,
0.0059, 0.0197, 0.8944, 0, 0.0400,
0, 0, 0, 1, 0,
];
} else if (baseFilter === 'eterna') {
@@ -115,14 +128,17 @@ export function getSkiaColorMatrix(baseFilter: BaseFilter, adj: ColorAdjustments
0, 0, 0, 1, 0,
];
} else if (baseFilter === 'monochrome') {
// Acros Monochrome: ITU-R BT.709 luma — pure grayscale, zero color leakage
const r = 0.2126;
const g = 0.7152;
const b = 0.0722;
// Acros: pure BT.709 grayscale, zero colour leakage. Contrast is a touch
// above neutral and eased around mid-grey, so the darks keep texture
// instead of plugging to black. (Grain/sharpness stay the user's call —
// a film sim here is the colour grade only.)
const r = 0.2339;
const g = 0.7867;
const b = 0.0794;
matrix = [
r, g, b, 0, 0,
r, g, b, 0, 0,
r, g, b, 0, 0,
r, g, b, 0, -0.05,
r, g, b, 0, -0.05,
r, g, b, 0, -0.05,
0, 0, 0, 1, 0,
];
}
+6 -3
View File
@@ -5,7 +5,7 @@ import * as MediaLibrary from 'expo-media-library/legacy';
import { Asset } from 'expo-asset';
import { Recipe, GPSInfo, FrameId, AspectRatio, ASPECT_RATIO_W_H, CropRect } from '../types';
import { getSkiaColorMatrix, applyExposureGain } from './colorUtils';
import { TONE_SKSL, getToneUniforms, toneIsActive } from './toneShader';
import { TONE_SKSL, getToneUniforms, toneIsActive, toneUniformArray } from './toneShader';
import { CINEMA_SKSL, getCinemaUniforms, cinemaIsActive } from './cinemaShader';
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';
@@ -218,7 +218,10 @@ export async function processAndExportPhoto(
// through the shader) when a real shader exists. If the RuntimeEffect fails
// to compile / makeShaderWithChildren returns null, paint has no shader and a
// drawRect would fill solid opaque color → black/blank output.
const tone = getToneUniforms(adjustments);
// The stock's own tone pass (Classic Neg split tone, Classic Chrome shadow
// crush) rides along here, so those two recipes export with the pass even
// when every user knob is 0.
const tone = getToneUniforms(adjustments, recipe.baseFilter);
const hasTone = toneIsActive(tone);
let toneShader: SkShader | null = null;
if (hasTone) {
@@ -235,7 +238,7 @@ export async function processAndExportPhoto(
FilterMode.Linear,
MipmapMode.None
);
toneShader = toneEffect.makeShaderWithChildren([tone.dr, tone.hl, tone.sh, tone.vib], [imageShader]);
toneShader = toneEffect.makeShaderWithChildren(toneUniformArray(tone), [imageShader]);
if (toneShader) {
paint.setShader(toneShader);
} else {
+4 -1
View File
@@ -64,7 +64,10 @@ export async function processAndExportPhotoNative(
getSkiaColorMatrix(recipe.baseFilter, adjustments),
adjustments.exposureCompensation ?? 0
);
const tone = getToneUniforms(adjustments);
// Base filter is passed so a stock's own tone pass is picked up, but the
// Kotlin side only reads toneDr/Hl/Sh — the split-tone RGB (Classic Neg) is
// preview/engine only. Nothing in the app calls nativeExport (dev probe).
const tone = getToneUniforms(adjustments, recipe.baseFilter);
const cinema = getCinemaUniforms(recipe.cinema);
// Final screen sharpen (engine #9): the sharpening knob (0..10) overrides
// the legacy boolean sharpen flag (camera captures keep it true → 0.5).
+54 -5
View File
@@ -1,4 +1,4 @@
import { ColorAdjustments } from '../types';
import { BaseFilter, ColorAdjustments } from '../types';
// Tone-domain adjustments (Fuji-style Dynamic Range + Highlight/Shadow).
// SkSL runtime effect over a child image shader.
@@ -28,13 +28,26 @@ import { ColorAdjustments } from '../types';
// vib - vibrance -1..1: chroma-masked saturation. It rides along in this shader
// (rather than the colour matrix) because it needs per-pixel chroma:
// already-vivid pixels move least, so skins/skies deepen without the neon
// clip a plain Saturation boost causes. All-zero still = no pass.
// clip a plain Saturation boost causes.
// shT/hlT - split tone (per-channel RGB bias, -1..1 each): a fixed cast applied
// to the shadows and/or the highlights only. A 4x5 colour matrix cannot do
// this — it is one linear map, so any cast it applies must also hit the
// midtones and the opposite end. Classic Neg wants green/cyan shadows with
// warm highlights at once, so the stock ships these values and the pass
// stays active for it even when every user knob is 0. All-zero still = no
// pass.
export const TONE_SKSL = `
uniform shader src;
uniform float dr;
uniform float hl;
uniform float sh;
uniform float vib;
uniform float shTr;
uniform float shTg;
uniform float shTb;
uniform float hlTr;
uniform float hlTg;
uniform float hlTb;
vec4 main(vec2 xy) {
vec4 c = src.eval(xy);
vec3 rgb = clamp(c.rgb, 0.0, 1.0);
@@ -52,6 +65,10 @@ vec4 main(vec2 xy) {
// cannot blow a dark saturated colour out to white.
float cg = clamp(o / max(t, 0.0004), 0.55, 1.35);
rgb = clamp(vec3(o) + (rgb - vec3(t)) * cg, 0.0, 1.0);
// Split tone (stock look): the shadows and the highlights may each carry
// their own tint, so the two ends of the curve can drift opposite ways
// (Classic Neg: green-cyan darks, warm brights) without touching mid-greys.
rgb = clamp(rgb + vec3(shTr, shTg, shTb) * shMask + vec3(hlTr, hlTg, hlTb) * hlMask, 0.0, 1.0);
// Vibrance: push the LESS-saturated pixels harder than the vivid ones.
float l2 = dot(rgb, vec3(0.2126, 0.7152, 0.0722));
float mx = max(max(rgb.r, rgb.g), rgb.b);
@@ -68,17 +85,49 @@ export interface ToneUniforms {
hl: number; // -1..1 (adjustments.highlight / 10)
sh: number; // -1..1 (adjustments.shadow / 10)
vib: number; // -1..1 (adjustments.vibrance / 10)
shT: [number, number, number]; // shadow split-tone RGB bias, -1..1
hlT: [number, number, number]; // highlight split-tone RGB bias, -1..1
}
export function getToneUniforms(adj: ColorAdjustments): ToneUniforms {
// Per-stock tone pass. Fuji's Classic stocks are not a plain colour matrix:
// Classic Neg splits its tone (green-cyan darks / warm brights) and Classic
// Chrome crushes the shadows hard while muting colour. Those two parts live
// here instead of in the 4x5 matrix, which cannot move one end of the curve
// without also moving the other.
const FILM_TONE: Partial<Record<BaseFilter, Partial<ToneUniforms>>> = {
'classic-chrome': { sh: -0.28 },
'classic-neg': { shT: [-0.018, 0.009, 0.013], hlT: [0.024, 0.008, -0.012] },
};
export function getToneUniforms(adj: ColorAdjustments, baseFilter?: BaseFilter): ToneUniforms {
const drRaw = adj.dynamicRange ?? 'auto';
const dr = drRaw === 'auto' || drRaw === 100 ? 0 : (drRaw - 100) / 300;
const hl = Math.max(-1, Math.min(1, (adj.highlight ?? 0) / 10));
const sh = Math.max(-1, Math.min(1, (adj.shadow ?? 0) / 10));
const vib = Math.max(-1, Math.min(1, (adj.vibrance ?? 0) / 10));
return { dr, hl, sh, vib };
const film = (baseFilter && FILM_TONE[baseFilter]) || {};
const shT: [number, number, number] = film.shT ?? [0, 0, 0];
const hlT: [number, number, number] = film.hlT ?? [0, 0, 0];
return { dr, hl: hl + (film.hl ?? 0), sh: sh + (film.sh ?? 0), vib, shT, hlT };
}
// Flat uniform buffer for `makeShaderWithChildren` / `<Shader uniforms>` — the
// order must match TONE_SKSL's declarations.
export function toneUniformArray(u: ToneUniforms): number[] {
return [u.dr, u.hl, u.sh, u.vib, u.shT[0], u.shT[1], u.shT[2], u.hlT[0], u.hlT[1], u.hlT[2]];
}
export function toneIsActive(u: ToneUniforms): boolean {
return u.dr !== 0 || u.hl !== 0 || u.sh !== 0 || u.vib !== 0;
return (
u.dr !== 0 ||
u.hl !== 0 ||
u.sh !== 0 ||
u.vib !== 0 ||
u.shT[0] !== 0 ||
u.shT[1] !== 0 ||
u.shT[2] !== 0 ||
u.hlT[0] !== 0 ||
u.hlT[1] !== 0 ||
u.hlT[2] !== 0
);
}