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:
@@ -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
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user