android: port the web studio's HSL selective colour mixer

The rail gains an HSL tab between FX and FRAME, and the tab opens the mixer
ported from the web studio: eight hue bands, each with hue/sat/lum knobs on a
-10..+10 ruler, plus three whole-image moves behind an IMAGE divider. Band
weights are linear tents anchored on HSL_BANDS and summing to 1 at every hue, so
neighbouring bands hand over at exactly half strength and no hue falls in a gap.
Pixels under 8% saturation are gated out of the weights (rgb2hsl calls a neutral
red, so an ungated one would ride the RED band), while the whole-image LUM move
is added after the gate and still lifts a picture that -SAT has pulled to grey.
The mixer runs last in TONE_SKSL, so a band edit is judged on the colour the user
actually sampled.

The eyedropper is library-only: PICK arms a layer over the picture, a tap reads
the rendered pixel off the canvas, points the ruler at the nearest band and
hangs a panel carrying that band's three knobs. A live preview has no pixel to
read, so in camera mode the chip greys out - the mixer itself still runs there,
which is why the camera worklet's tone buffer had to grow from 10 slots to 40: a
missed slot would have pushed NaN into every uniform and taken the whole tone
pass down, not just HSL.

HSL is a LITE feature (the web studio builds it for guests too), and MONOCHROME
switches the mixer off outright - a mono stock has no hue to select. Checked on a
Xiaomi thor against an 8-band chart: +10 whole-image HUE rotates all eight bands
by +30 deg, -10 on one band's SAT flattens that band and leaves the other seven
alone, and a whole-image -SAT drops the camera preview's mean chroma 13.7 -> 0.4
with +LUM still lifting it 116 -> 166. Cut as 1.2.4 / versionCode 6.
This commit is contained in:
2026-09-21 18:28:32 +07:00
parent c8e2b6ea5a
commit cbd0178cda
12 changed files with 757 additions and 27 deletions
+75 -2
View File
@@ -10,7 +10,7 @@ import AsyncStorage from '@react-native-async-storage/async-storage';
import TopBar from './src/components/TopBar';
import Viewfinder, { type ViewfinderHandle } from './src/components/Viewfinder';
import AdjustmentPanel from './src/components/AdjustmentPanel';
import AdjustmentPanel, { HslPickPanel } from './src/components/AdjustmentPanel';
import CameraControls from './src/components/CameraControls';
import ToolRail, { TabId } from './src/components/ToolRail';
import SettingsModal from './src/components/SettingsModal';
@@ -19,9 +19,10 @@ import LibraryPickerModal from './src/components/LibraryPickerModal';
import QrScanModal from './src/components/QrScanModal';
import ProModal from './src/components/ProModal';
import { Recipe, ColorAdjustments, CustomWatermark, GpsWatermark, FrameId, GPSInfo, AspectRatio, MeterMode, PhotoEntry, ShutterSound, FlashMode, StartupMode, CropRatio, CropRect, CROP_W_H, DEFAULT_CROP_RECT } from './src/types';
import { Recipe, ColorAdjustments, CustomWatermark, GpsWatermark, FrameId, GPSInfo, AspectRatio, MeterMode, PhotoEntry, ShutterSound, FlashMode, StartupMode, CropRatio, CropRect, CROP_W_H, DEFAULT_CROP_RECT, HslBand, HslBandId } from './src/types';
import { getAllRecipes, saveCustomRecipe, updateCustomRecipe, deleteCustomRecipe, getFavoriteIds, setFavoriteId } from './src/utils/storageUtils';
import { DEFAULT_ADJUSTMENTS, FILM_SIMS, filmSimToRecipe } from './src/utils/defaultRecipes';
import { HSL_BANDS, nearestHslBand, rgbToHsl } from './src/utils/colorUtils';
import { getCurrentGPS, requestLocationPermissions, reverseGeocode, withDeadline } from './src/utils/locationUtils';
// Types are erased at build time, so this one is free.
import type { PickedPhotoAsset } from './src/utils/photoGps';
@@ -144,6 +145,16 @@ export default function App() {
// Custom temporary overrides for the sliders
const [adjustments, setAdjustments] = useState<ColorAdjustments>({ ...DEFAULT_ADJUSTMENTS });
// HSL mixer: the band ruler and the eyedropper are shared by the chip row
// (AdjustmentPanel) and by the pick layer over the picture (Viewfinder), so
// they live here.
const [hslBand, setHslBand] = useState<HslBandId>('red');
const [picking, setPicking] = useState(false);
const [hslSample, setHslSample] = useState<{ r: number; g: number; b: number } | null>(null);
// Where on the picture the sample was read: fractions of the image, the
// floating panel's anchor.
const [hslPickedAt, setHslPickedAt] = useState<{ fx: number; fy: number } | null>(null);
const [selectedFrame, setSelectedFrame] = useState<FrameId>('none');
// PRO only: the GPS stamp is a paid look, so a LITE session starts without
// it and `handleToggleGeotag` refuses to turn it on.
@@ -876,6 +887,40 @@ export default function App() {
});
};
// One band triple, as stored. A band back at [0,0,0] is DELETED, and the
// field goes back to `undefined` (not {}) when the last one leaves: the
// shallow differs() behind resetDirty then reads a clean mixer as clean.
const setBandKnob = (which: 0 | 1 | 2, v: number) => {
const band = adjustments.hslBands?.[hslBand] ?? ([0, 0, 0] as HslBand);
const next: HslBand = [band[0], band[1], band[2]];
next[which] = Math.round(v);
const bands = { ...adjustments.hslBands };
if (next[0] === 0 && next[1] === 0 && next[2] === 0) delete bands[hslBand];
else bands[hslBand] = next;
handleUpdateAdjustments({ hslBands: Object.keys(bands).length ? bands : undefined });
};
// The mixer's three whole-image knobs: one door for all three, so the ruler
// and the field it writes are the same value wherever they were opened.
const setHslGlobal = (which: 0 | 1 | 2, v: number) => {
const key = which === 0 ? 'hslHue' : which === 1 ? 'hslSat' : 'hslLum';
const clamped = Math.max(-10, Math.min(10, Math.round(v)));
handleUpdateAdjustments({ [key]: clamped } as Partial<ColorAdjustments>);
};
// The eyedropper's report: print the colour it read, point the ruler at that
// colour's band, hang the panel on the spot it came from, and put the tool
// away — one pick, one colour.
const pickColor = (
rgb: { r: number; g: number; b: number },
at: { fx: number; fy: number }
) => {
setHslSample(rgb);
setHslBand(nearestHslBand(rgbToHsl(rgb.r, rgb.g, rgb.b).h));
setHslPickedAt(at);
setPicking(false);
};
const handleUpdateFrame = (frameId: FrameId) => commit(false, () => applyFrame(frameId));
const applyFrame = (frameId: FrameId) => {
setSelectedFrame(frameId);
@@ -1835,6 +1880,10 @@ export default function App() {
const handleToggleTab = (tab: TabId) => {
setOpenTab((prev) => (prev === tab ? null : tab));
setOpenParamKey(null);
// The eyedropper belongs to the HSL row: leave the tab (or close the panel)
// and the layer goes with it, so an invisible pick layer cannot sit over
// the picture swallowing pan and zoom.
setPicking(false);
};
// While a panel parameter row is open, a vertical drag anywhere on the live
@@ -1914,6 +1963,22 @@ export default function App() {
setOpenParamKey(null);
setWmEditNonce((n) => n + 1);
}}
// HSL eyedropper: the viewer owns the pick layer and hangs the panel
// it is handed; the state behind both is the HSL row's, above.
picking={picking}
hslPanelAt={hslPickedAt}
onPickColor={pickColor}
hslPanel={
openTab === 'hsl' && hslSample && hslPickedAt ? (
<HslPickPanel
sample={hslSample}
bandLabel={HSL_BANDS.find((b) => b.id === hslBand)?.label ?? ''}
band={shownAdjustments.hslBands?.[hslBand] ?? [0, 0, 0]}
onKnob={setBandKnob}
onClose={() => setHslPickedAt(null)}
/>
) : undefined
}
/>
</View>
@@ -2014,6 +2079,14 @@ export default function App() {
wmRotation={shown.wmRotation}
onRotateWm={() => commit(false, () => setWmRotation((r) => (r + 90) % 360))}
onWmPanelChange={setWmPanelOpen}
hslBand={hslBand}
onHslBand={setHslBand}
picking={picking}
onPicking={setPicking}
hslSample={hslSample}
onSetHslGlobal={setHslGlobal}
// A live preview has no pixel to read, so PICK is library-only.
canPick={mode === 'library'}
/>
</View>
)}
+18
View File
@@ -1,5 +1,23 @@
# Release notes
## 1.2.4 (versionCode 6)
HSL
- New HSL tab in the tool rail: the web studio's 8-band selective colour mixer. Each band (RED, ORANGE, YELLOW, GREEN, AQUA, BLUE, PURPLE, MAGENTA) carries hue, saturation and lightness knobs, -10..+10; -10 on SAT is grey, full HUE is a 30 deg turn, full LUM is +0.25.
- Band weights are linear tents anchored on the 8 hues and summing to 1 at every hue, so neighbouring bands hand over at exactly half strength and no hue falls in a gap. Pixels under 8% saturation are gated out of the weights (rgb2hsl calls them red, and an ungated neutral would ride the RED band), while the whole-image LUM move is added after the gate, so a picture pulled to grey by -SAT still answers +LUM.
- The mixer runs last in TONE_SKSL, after split-tone/kv and vibrance, so a band edit is judged on the colour the user actually sampled.
- Library mode gains an eyedropper: PICK arms a layer over the picture, a tap reads the rendered pixel, points the ruler at the nearest band and hangs a panel with that band's three knobs. In camera mode PICK is greyed out - a live preview has no pixel to read - but the mixer still runs in the preview.
- HUE / SAT / LUM IMAGE move every hue at once, behind an IMAGE divider in the same chip row.
Rail
- Tool rail order: PRESETS, FAVORITED, WB, LIGHT, FX, HSL, FRAME.
Notes
- HSL is a LITE feature: both builds get it, no PRO gate.
- MONOCHROME disables the mixer outright - a mono stock has no hue to select.
- The camera worklet's tone buffer grew from 10 to 40 slots; a missed slot would have pushed NaN into every uniform and broken the whole tone pass, not just HSL.
- Measured on a Xiaomi thor against an 8-band chart: +10 whole-image HUE rotates all eight bands by exactly +30 deg, -10 on a band's SAT flattens that band to grey while the other seven stay put, and a whole-image -SAT drops the camera preview's mean chroma from 13.7 to 0.4 with +LUM still lifting it 116 -> 166.
## 1.2.3 (versionCode 5)
Shutter
+2 -2
View File
@@ -92,8 +92,8 @@ android {
applicationId 'com.locphamtran.recipescamera'
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 5
versionName "1.2.3"
versionCode 6
versionName "1.2.4"
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
+1 -1
View File
@@ -2,7 +2,7 @@
"expo": {
"name": "RecipesCam",
"slug": "recipescamera",
"version": "1.2.3",
"version": "1.2.4",
"orientation": "portrait",
"icon": "./RecipesCamIcon.png",
"userInterfaceStyle": "light",
+218 -2
View File
@@ -4,7 +4,8 @@ import Slider from '@react-native-community/slider';
import * as Haptics from 'expo-haptics';
import { Star, Trash2 } from 'lucide-react-native';
import { Skia } from '@shopify/react-native-skia';
import { Recipe, ColorAdjustments, CustomWatermark, GpsWatermark, FrameId, BaseFilter, CropRatio } from '../types';
import { Recipe, ColorAdjustments, CustomWatermark, GpsWatermark, FrameId, BaseFilter, CropRatio, HslBand, HslBandId } from '../types';
import { HSL_BANDS, hslToHex, rgbToHsl } from '../utils/colorUtils';
import { FRAMES } from '../utils/frameUtils';
import { PRO_FRAMES } from '../utils/entitlement';
import { PARAM_DEFS, ContinuousParamDef } from '../utils/paramDefs';
@@ -17,7 +18,7 @@ import RecipeCreateModal from './RecipeCreateModal';
// Tapping a parameter chip toggles its slider row; chips stay visible.
// Darkroom-flavoured: amber = active/edited, red = destructive, mono labels.
type TabId = 'recipes' | 'favorites' | 'iq' | 'wb' | 'filters' | 'frame';
type TabId = 'recipes' | 'favorites' | 'iq' | 'wb' | 'filters' | 'hsl' | 'frame';
// Choice-group keys that open a strip above the chip row: color chrome groups
// (wb tab) and the D.RANGE selector (iq tab).
type GroupKey =
@@ -152,6 +153,21 @@ interface AdjustmentPanelProps {
// FRAME → WATERMARK sub-panel is open: App needs this to arm the mark's
// drag/pinch layer on the image (the old `activeTab === 'watermark'`).
onWmPanelChange: (open: boolean) => void;
// HSL tab. The mixer's UI state lives in App because the eyedropper in the
// Viewfinder writes into the same four values this row reads: which band the
// ruler is on, whether PICK is armed, and the colour it last read.
hslBand: HslBandId;
onHslBand: (id: HslBandId) => void;
picking: boolean;
onPicking: (on: boolean) => void;
hslSample: { r: number; g: number; b: number } | null;
// The three IMAGE knobs are whole-picture moves, not a band's, so they go
// through one entry point — the ruler and the field it writes cannot then
// disagree about which one moved.
onSetHslGlobal: (which: 0 | 1 | 2, v: number) => void;
// PICK has nothing to read off a live preview, so in camera mode the chip
// greys out rather than arming a layer that would do nothing.
canPick: boolean;
// BACK (undo) and PEEK (hold to compare) both work on the whole LOOK, not on
// one parameter, and both left this panel: BACK is a button in the top bar,
// PEEK is a hold on the picture itself (TopBar, Viewfinder).
@@ -169,6 +185,7 @@ interface ChipDef {
onPressStar?: () => void;
disabled?: boolean; // informational label, not tappable
amberValue?: boolean; // amber text driven by edited value, not selection
color?: string; // swatch dot: a chip naming a colour has to show it
pro?: boolean; // a PRO feature: LITE draws the corner tag on it
}
@@ -187,6 +204,11 @@ interface ParamDef {
const haptic = () => Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
// The eyedropper's floating panel is a fixed width so the Viewfinder can hang
// it centred on the picked point (it measures the height itself, which the
// three slider rows decide).
const HSL_PANEL_W = 244;
// WB preset → engine pair. AUTO and DAYLIGHT both land on the neutral
// 5500K / 0 pair (the engine has no scene meter), so the pair alone cannot say
// which chip is active — the last tapped preset is remembered to disambiguate.
@@ -281,6 +303,13 @@ export default function AdjustmentPanel({
wmRotation,
onRotateWm,
onWmPanelChange,
hslBand,
onHslBand,
picking,
onPicking,
hslSample,
onSetHslGlobal,
canPick,
}: AdjustmentPanelProps) {
// Double-tap on the mark in the viewfinder bumps wmEditNonce -> focus the
// input again so the text can be retyped after the keyboard was dismissed.
@@ -421,6 +450,17 @@ export default function AdjustmentPanel({
className={`flex-row items-center rounded-full border px-3 py-1.5 ${tone}`}
activeOpacity={0.7}
>
{c.color ? (
<View
style={{
width: 10,
height: 10,
borderRadius: 999,
backgroundColor: c.color,
marginRight: 6,
}}
/>
) : null}
<Text className={`font-mono text-[11px] font-bold tracking-wide ${textTone}`}>
{c.label}
</Text>
@@ -506,6 +546,26 @@ export default function AdjustmentPanel({
: p
),
filters: toRows(PARAM_DEFS.filters),
// The mixer's three whole-image knobs are not PARAM_DEFS entries: they
// read and write the picture's own move rather than a flat field of
// adjustments, and unlike the panel on the image they do not belong to the
// band the ruler points at. Bound here so the existing slider row, its
// amber value and its back button all work unchanged.
hsl: [0, 1, 2].map((i) => {
const own = [a.hslHue ?? 0, a.hslSat ?? 0, a.hslLum ?? 0];
const name = i === 0 ? 'HUE' : i === 1 ? 'SAT' : 'LUM';
return {
key: i === 0 ? 'hsl.h' : i === 1 ? 'hsl.s' : 'hsl.l',
label: `${name} IMAGE`,
value: own[i],
default: 0,
min: -10,
max: 10,
step: 1,
display: (v: number) => (v > 0 ? `+${v}` : String(v)),
onChange: (v: number) => onSetHslGlobal(i as 0 | 1 | 2, v),
};
}),
// FRAME carries one slider row: the fine STRAIGHTEN angle. The ROTATE
// strip's STRAIGHTEN option opens it, exactly like COLOR TEMP's.
frame: [
@@ -909,6 +969,40 @@ export default function AdjustmentPanel({
...paramChips(paramDefs.filters),
]);
case 'hsl': {
// PICK arms the eyedropper; the eight colour chips pick the band the
// ruler edits; the three knobs below the divider move the whole
// picture. Row above picks ONE colour, the IMAGE knobs move them all.
return chipRow(
[
{
key: 'hsl-pick',
label: 'PICK',
active: picking,
disabled: !canPick,
onPress: () => onPicking(!picking),
},
...HSL_BANDS.map((b): ChipDef => {
const band = a.hslBands?.[b.id];
const moved = !!band && (band[0] !== 0 || band[1] !== 0 || band[2] !== 0);
return {
key: `hsl-band-${b.id}`,
label: b.label,
color: hslToHex(b.hue, 70, 50),
active: hslBand === b.id,
amberValue: hslBand !== b.id && moved,
onPress: () => onHslBand(b.id),
};
}),
// Divider: the row above is colour, the knobs after it are the
// whole image.
{ key: 'hsl-image', label: 'IMAGE', disabled: true, active: false, onPress: () => {} },
...paramChips(paramDefs.hsl),
],
8
);
}
case 'frame':
// FRAME -> WATERMARK: the sub-panel row (breadcrumb back to the frames).
// It stays up while ANY watermark option strip is open — the strips are
@@ -975,9 +1069,42 @@ export default function AdjustmentPanel({
// ---- the single open slider row (ABOVE the chips row) ----------------------
const openParamDef = paramDefs[activeTab].find((p) => p.key === openParam) ?? null;
// HSL readout: what the eyedropper last saw, and the band the ruler is on.
const bandName = HSL_BANDS.find((b) => b.id === hslBand)?.label ?? '';
const sampleHsl = hslSample ? rgbToHsl(hslSample.r, hslSample.g, hslSample.b) : null;
return (
<View>
{activeTab === 'hsl' && (
<View className="flex-row items-center px-3 py-1">
{hslSample && sampleHsl ? (
<>
<View
style={{
width: 22,
height: 22,
borderRadius: 4,
borderWidth: 1,
borderColor: '#3f3f46',
backgroundColor: `rgb(${hslSample.r}, ${hslSample.g}, ${hslSample.b})`,
}}
/>
<Text className="ml-2 font-mono text-[11px] text-zinc-400">
R {hslSample.r} · G {hslSample.g} · B {hslSample.b}
</Text>
<Text className="ml-2 font-mono text-[11px] text-zinc-400">
{Math.round(sampleHsl.h)}° · {Math.round(sampleHsl.s * 100)}% ·{' '}
{Math.round(sampleHsl.l * 100)}%
</Text>
<Text className="ml-2 font-mono text-[11px] font-bold text-amber-400">{bandName}</Text>
</>
) : (
<Text className="font-mono text-[10px] tracking-widest text-zinc-500">
BẤM PICK RỒI CHỌN MỘT MÀU TRÊN ẢNH
</Text>
)}
</View>
)}
{openParamDef && (
<View
key={openParamDef.key}
@@ -1122,3 +1249,92 @@ export default function AdjustmentPanel({
</View>
);
}
// The floating panel the eyedropper hangs over the picked spot: the sample
// colour after the selected band's knobs, its name and hex, and the three
// knobs that edit that band. Lives here rather than in the Viewfinder so it
// reuses this file's Slider row and haptics; App builds the node and hands it
// to the Viewfinder, which only positions it.
export function HslPickPanel({
sample,
bandLabel,
band,
onKnob,
onClose,
}: {
sample: { r: number; g: number; b: number };
bandLabel: string;
band: HslBand;
onKnob: (which: 0 | 1 | 2, v: number) => void;
onClose: () => void;
}) {
// What this band does to THIS colour, not to the wheel in general: the same
// +10 on HUE lands differently on olive and on sky.
const picked = rgbToHsl(sample.r, sample.g, sample.b);
const mixed = hslToHex(
picked.h + band[0],
Math.max(0, Math.min(100, picked.s * 100 + band[1])),
Math.max(0, Math.min(100, picked.l * 100 + band[2]))
);
return (
<View
className="rounded-xl border border-zinc-700/80 bg-black/90 px-3 py-2"
style={{ width: HSL_PANEL_W }}
>
<View className="flex-row items-center">
<View
style={{
width: 28,
height: 28,
borderRadius: 6,
borderWidth: 1,
borderColor: '#3f3f46',
backgroundColor: mixed,
}}
/>
<View className="ml-2 flex-1">
<Text className="font-mono text-[11px] font-bold text-amber-400">{bandLabel}</Text>
<Text className="font-mono text-[10px] text-zinc-400">{mixed.toUpperCase()}</Text>
</View>
<TouchableOpacity
onPress={() => {
haptic();
onClose();
}}
className="p-1.5"
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
activeOpacity={0.7}
>
<Text className="font-mono text-sm font-bold text-zinc-400">✕</Text>
</TouchableOpacity>
</View>
{(['HUE', 'SAT', 'LUM'] as const).map((name, i) => (
<View key={name} className="flex-row items-center">
<Text className="w-9 font-mono text-[10px] font-bold tracking-widest text-zinc-400">
{name}
</Text>
<Slider
style={{ flex: 1, height: 30 }}
value={band[i]}
minimumValue={-10}
maximumValue={10}
step={1}
onValueChange={(v) => onKnob(i as 0 | 1 | 2, v)}
onSlidingComplete={haptic}
minimumTrackTintColor="#f59e0b"
maximumTrackTintColor="#27272a"
thumbTintColor="#f59e0b"
/>
<Text
className={`w-7 text-right font-mono text-[10px] font-bold ${
band[i] !== 0 ? 'text-amber-500' : 'text-zinc-500'
}`}
>
{band[i] > 0 ? `+${band[i]}` : band[i]}
</Text>
</View>
))}
</View>
);
}
+3 -2
View File
@@ -2,7 +2,7 @@ import React from 'react';
import { View, Text, TouchableOpacity, ScrollView } from 'react-native';
import * as Haptics from 'expo-haptics';
export type TabId = 'recipes' | 'favorites' | 'iq' | 'wb' | 'filters' | 'frame';
export type TabId = 'recipes' | 'favorites' | 'iq' | 'wb' | 'filters' | 'hsl' | 'frame';
interface ToolRailProps {
activeTab: TabId | null;
@@ -12,9 +12,10 @@ interface ToolRailProps {
const TOOLS: { id: TabId; label: string }[] = [
{ id: 'recipes', label: 'PRESETS' },
{ id: 'favorites', label: 'FAVORITED' },
{ id: 'iq', label: 'LIGHT' },
{ id: 'wb', label: 'WB' },
{ id: 'iq', label: 'LIGHT' },
{ id: 'filters', label: 'FX' },
{ id: 'hsl', label: 'HSL' },
{ id: 'frame', label: 'FRAME' },
];
+167 -15
View File
@@ -27,8 +27,11 @@ import {
FilterMode,
MipmapMode,
FontStyle,
ColorType,
AlphaType,
useCanvasRef,
} from '@shopify/react-native-skia';
import { Lock } from 'lucide-react-native';
import { Lock, Pipette } 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, toneUniformArray, GLOW_SKSL, glowUniformArray, GLOW_UNIFORMS, CLARITY_SKSL, clarityUniforms } from '../utils/toneShader';
@@ -163,6 +166,18 @@ interface ViewfinderProps {
frameTabActive?: boolean;
// WALL FRAME hung landscape (4000x3117) instead of portrait.
wallLandscape?: boolean;
// HSL eyedropper. The pick layer covers exactly the picture and reads ONE
// pixel off the rendered frame — the colour the user sees, after every other
// knob — not the file's. Library only: a live preview has no pixel to read.
picking?: boolean;
onPickColor?: (
rgb: { r: number; g: number; b: number },
at: { fx: number; fy: number }
) => void;
// The band panel the pick hangs at `hslPanelAt` (0..1 fractions of the
// image). The caller builds it, this viewer only places it.
hslPanel?: React.ReactNode;
hslPanelAt?: { fx: number; fy: number } | null;
}
export interface ViewfinderHandle {
@@ -336,6 +351,10 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
photoStraighten = 0,
wmRotation = 0,
wallLandscape = false,
picking = false,
onPickColor,
hslPanel,
hslPanelAt = null,
}: ViewfinderProps,
ref
) {
@@ -496,12 +515,17 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
// 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).
// Tone uniforms for the preview worklet; all-zero = no tone pass (see
// toneUniformArray for the layout: dr/hl/sh/vib, the two split-tone triples,
// the two Chrome depths, then the HSL mixer's hslOn + 8x3 bands + gh/gs/gl).
// 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));
// Seeded with the FULL toneUniformArray length: the worklet reads this buffer
// positionally, so a short one hands the mixer's slots `undefined` -> NaN
// uniforms -> the whole tone pass (not just HSL) dies on the live preview.
const toneSync = useMemo(
() => createSynchronizable<number[]>(new Array(10).fill(0)),
() => createSynchronizable<number[]>(new Array(40).fill(0)),
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
@@ -1275,7 +1299,8 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
(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 ||
tone[10] !== 0 || tone[11] !== 0);
tone[10] !== 0 || tone[11] !== 0 ||
tone[12] !== 0); // hslOn: the eight bands live in 13..36, the global moves in 37..39
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
@@ -2565,15 +2590,77 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
};
// The view zoom composes OVER the committed crop (outer·inner), exactly like
// the canvas draws it.
const libCropTouchStyle = touchZoomStyle(
libCropView
? {
s: viewZoom.s * libCropView.k,
tx: viewZoom.s * libCropView.ox + viewZoom.tx,
ty: viewZoom.s * libCropView.oy + viewZoom.ty,
}
: viewZoom
const libTouchZoom = libCropView
? {
s: viewZoom.s * libCropView.k,
tx: viewZoom.s * libCropView.ox + viewZoom.tx,
ty: viewZoom.s * libCropView.oy + viewZoom.ty,
}
: viewZoom;
const libCropTouchStyle = touchZoomStyle(libTouchZoom);
const libTouchZoomStyle = touchZoomStyle(libTouchZoom);
// ---- HSL eyedropper -------------------------------------------------------
// The pick layer sits exactly over the picture, so a touch reaches it instead
// of the pan/zoom layer below (it is mounted after that one). A touch maps to
// a CANVAS pixel through the same zoom/crop transform the touch layer
// carries, and the one pixel under it is read off the rendered frame — the
// colour the user sees, not the file's original.
// ponytail: one snapshot per pick session, read with readPixels(1 px). If the
// delay ever shows on a real device, decode the skia image once instead — the
// sample is then the UNgraded colour, which is a different feature.
const canvasRef = useCanvasRef();
const pickShotRef = useRef<{ width: () => number; height: () => number; readPixels: any } | null>(
null
);
const [pickPos, setPickPos] = useState<{ x: number; y: number } | null>(null);
const [pickPanelSize, setPickPanelSize] = useState({ w: 0, h: 0 });
// The picture as drawn (the camera's ratio band is a camera-only concept, so
// in the library the fit rect IS the picture).
const pickRect = mode === 'library' && !regionBand ? imageFitRect : null;
useEffect(() => {
if (!picking) {
pickShotRef.current = null;
setPickPos(null);
return;
}
// The frame has settled a beat after the chip press: snapshot once here and
// every tap of the session reads off that same frame.
const id = setTimeout(() => {
pickShotRef.current = canvasRef.current?.makeImageSnapshot() ?? null;
}, 150);
return () => clearTimeout(id);
}, [picking]);
const onPickTouch = (e: any) => {
const r = pickRect;
if (!r || !(r.dw > 0 && r.dh > 0)) return;
const { locationX: lx, locationY: ly } = e.nativeEvent;
if (!(lx >= 0 && lx <= r.dw && ly >= 0 && ly <= r.dh)) return;
setPickPos((p) => (p && Math.abs(p.x - lx) < 1 && Math.abs(p.y - ly) < 1 ? p : { x: lx, y: ly }));
const shot = pickShotRef.current ?? canvasRef.current?.makeImageSnapshot() ?? null;
if (!shot) return;
pickShotRef.current = shot;
// The layer and the picture carry the same transform, so the touch reaches
// the canvas the same way: layout units, then the snapshot's own density.
const dens = shot.width() / Math.max(vw, 1);
const cx = Math.max(
0,
Math.min(shot.width() - 1, Math.floor((libTouchZoom.s * (r.dx + lx) + libTouchZoom.tx) * dens))
);
const cy = Math.max(
0,
Math.min(shot.height() - 1, Math.floor((libTouchZoom.s * (r.dy + ly) + libTouchZoom.ty) * dens))
);
const px = shot.readPixels(cx, cy, {
width: 1,
height: 1,
colorType: ColorType.RGBA_8888,
alphaType: AlphaType.Unpremul,
});
if (!px || px.length < 3) return;
onPickColor?.({ r: px[0], g: px[1], b: px[2] }, { fx: lx / r.dw, fy: ly / r.dh });
};
// Export-facing summary of the current framed-window photo reposition. The
// gesture state (libZoom) is canvas-absolute; u/v convert it to the fraction
@@ -3014,6 +3101,15 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
hlTb: toneParams[9],
cc: toneParams[10],
ccb: toneParams[11],
// The mixer's arrays are uniform ARRAYS (`float hslH[8]`), so they go in as
// eight-number slots off the same flat buffer the camera and export use.
hslOn: toneParams[12],
hslH: toneParams.slice(13, 21),
hslS: toneParams.slice(21, 29),
hslL: toneParams.slice(29, 37),
gh: toneParams[37],
gs: toneParams[38],
gl: toneParams[39],
};
const cinemaOn = cinemaEffect != null && !!cinemaParams;
const useShaderPass = !!imageFitRect && ((toneEffect != null && toneOn) || cinemaOn);
@@ -3225,7 +3321,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
<View className="flex-1 relative overflow-hidden bg-black items-center justify-center">
{libraryImageUri && skiaImage ? (
<>
<Canvas style={StyleSheet.absoluteFill}>
<Canvas ref={canvasRef} style={StyleSheet.absoluteFill}>
<Group matrix={viewZoomMatrix}>
<Group matrix={libViewMatrix}>
<ColorMatrix matrix={colorMatrix} />
@@ -3312,6 +3408,62 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
onResponderRelease={onLibTouchEnd}
onResponderTerminate={onLibTouchEnd}
/>
{/* HSL eyedropper: the layer covers the picture alone and takes the
gesture whole, so pan and zoom cannot fire from inside it. */}
{picking && pickRect ? (
<View
style={[
{
position: 'absolute',
left: pickRect.dx,
top: pickRect.dy,
width: pickRect.dw,
height: pickRect.dh,
},
libTouchZoomStyle,
]}
onStartShouldSetResponder={() => true}
onMoveShouldSetResponder={() => true}
onResponderGrant={onPickTouch}
onResponderMove={onPickTouch}
>
{pickPos ? (
<View
pointerEvents="none"
style={{ position: 'absolute', left: pickPos.x - 24, top: pickPos.y - 24 }}
>
<Pipette size={22} color="#ffffff" />
</View>
) : null}
</View>
) : null}
{/* The band panel, hung on the spot the colour was read from. Placed
in SCREEN coordinates so it keeps its size while the picture is
zoomed, and it drops below the point unless the point is low
enough for the panel to fall off the picture. */}
{hslPanel && hslPanelAt && pickRect ? (
<View pointerEvents="box-none" className="absolute inset-0">
<View
style={{
position: 'absolute',
left: libTouchZoom.s * (pickRect.dx + hslPanelAt.fx * pickRect.dw) + libTouchZoom.tx,
top: libTouchZoom.s * (pickRect.dy + hslPanelAt.fy * pickRect.dh) + libTouchZoom.ty,
transform: [
{ translateX: -pickPanelSize.w / 2 },
{ translateY: hslPanelAt.fy > 0.55 ? -(pickPanelSize.h + 14) : 14 },
],
}}
onLayout={(e) => {
const { width, height } = e.nativeEvent.layout;
setPickPanelSize((p) =>
p.w === width && p.h === height ? p : { w: width, h: height }
);
}}
>
{hslPanel}
</View>
</View>
) : null}
</>
) : (
<View className="items-center justify-center p-6">
+13
View File
@@ -64,6 +64,12 @@ export type CinemaSeason = 'spring' | 'summer' | 'autumn' | 'winter';
export type BaseFilter = 'classic-neg' | 'classic-chrome' | 'classic-vivid' | 'provia' | 'velvia' | 'astia' | 'eterna' | 'monochrome' | 'leica' | 'leica-vivid' | 'none';
// Selective colour mixer: the eight hue bands the HSL tab edits, and the
// [hue, sat, lum] triple each band carries (-10..+10 per knob). A band left at
// [0,0,0] is dropped from the recipe rather than stored.
export type HslBandId = 'red' | 'orange' | 'yellow' | 'green' | 'aqua' | 'blue' | 'purple' | 'magenta';
export type HslBand = [number, number, number];
export interface ColorAdjustments {
exposure: number; // -10 to +10 (mapped to matrix multiplier or offset)
contrast: number; // -10 to +10
@@ -84,6 +90,13 @@ export interface ColorAdjustments {
hdf?: number; // 0 to 10 (Highlight Diffusion Filter: glow on the highlights only)
vignette?: number; // 0 to 10 (darken the four corners; 0 = off)
vibrance?: number; // -10 to +10 (chroma-masked saturation: vivid colours move least)
// Selective colour: per-band [hue, sat, lum] moves, -10..+10. One nested field
// rather than 24 flat ones — that is what makes it the single adjustment the
// recipe importer has to sanitize by hand (see sanitizeHslBands).
hslBands?: Partial<Record<HslBandId, HslBand>>;
hslHue?: number; // -10..+10 whole-image hue turn
hslSat?: number; // -10..+10 whole-image saturation scale
hslLum?: number; // -10..+10 whole-image lightness offset
exposureCompensation: number; // -3 to +3 EV. Camera: AE bias (hardware). Library: 2^EV matrix gain.
}
+99 -1
View File
@@ -1,4 +1,102 @@
import { ColorAdjustments, BaseFilter } from '../types';
import { ColorAdjustments, BaseFilter, HslBand, HslBandId } from '../types';
// The eight bands of the selective colour mixer, with the hue (degrees on the
// HSL wheel) each band sits at. The anchors are deliberately uneven: red /
// orange / yellow sit 30 degrees apart because that is where the eye reads
// detail, while the blue side is one 60-degree stretch. TONE_SKSL builds its
// eight weights from this same table, so the chip's anchor and the shader's
// mask cannot drift apart.
//
// `label` is the chip's text — app copy, not i18n.
export const HSL_BANDS: { id: HslBandId; hue: number; label: string }[] = [
{ id: 'red', hue: 0, label: 'RED' },
{ id: 'orange', hue: 30, label: 'ORANGE' },
{ id: 'yellow', hue: 60, label: 'YELLOW' },
{ id: 'green', hue: 120, label: 'GREEN' },
{ id: 'aqua', hue: 180, label: 'AQUA' },
{ id: 'blue', hue: 240, label: 'BLUE' },
{ id: 'purple', hue: 280, label: 'PURPLE' },
{ id: 'magenta', hue: 320, label: 'MAGENTA' },
];
// Hue distance (degrees) to the two neighbouring bands, which is what the
// shader's tent window needs. Derived from HSL_BANDS so the two cannot argue: a
// band's own weight is 1 at its anchor and 0 at the anchors next door, which
// makes the eight weights a partition of unity over the hue wheel (they sum to
// 1 for every hue).
export function hslBandGaps(): { id: HslBandId; hue: number; left: number; right: number }[] {
const n = HSL_BANDS.length;
return HSL_BANDS.map((b, i) => {
const prev = HSL_BANDS[(i - 1 + n) % n].hue;
const next = HSL_BANDS[(i + 1) % n].hue;
// Red (0 deg) wraps: its left neighbour is magenta at 320, i.e. 40 ahead.
return { id: b.id, hue: b.hue, left: (b.hue - prev + 360) % 360, right: (next - b.hue + 360) % 360 };
});
}
// 0..255 sRGB -> HSL, hue in degrees and saturation/lightness 0..1 — the
// inverse of the shader's hsl2rgb, and what the eyedropper readout prints.
export function rgbToHsl(r: number, g: number, b: number): { h: number; s: number; l: number } {
const R = r / 255;
const G = g / 255;
const B = b / 255;
const mx = Math.max(R, G, B);
const mn = Math.min(R, G, B);
const l = (mx + mn) / 2;
const d = mx - mn;
if (d < 1e-6) return { h: 0, s: 0, l };
const s = l > 0.5 ? d / (2 - mx - mn) : d / (mx + mn);
let h: number;
if (mx === R) h = ((G - B) / d + (G < B ? 6 : 0)) * 60;
else if (mx === G) h = ((B - R) / d + 2) * 60;
else h = ((R - G) / d + 4) * 60;
return { h, s, l };
}
// HSL -> #rrggbb for the band chips: a chip naming a colour has to show that
// colour, and a hex string is the one format RN takes directly.
export function hslToHex(h: number, s: number, l: number): string {
const k = (n: number) => (n + h / 30) % 12;
const a = (s / 100) * Math.min(l / 100, 1 - l / 100);
const chan = (n: number) =>
Math.max(0, Math.min(255, Math.round(255 * (l / 100 - a * Math.max(-1, Math.min(k(n) - 3, 9 - k(n), 1))))))
.toString(16)
.padStart(2, '0');
return `#${chan(0)}${chan(8)}${chan(4)}`;
}
// Which band a sampled hue belongs to: the nearest anchor. The bands overlap in
// the shader, so this only decides which band the ruler is pointing at.
export function nearestHslBand(hue: number): HslBandId {
const wrapped = ((hue % 360) + 360) % 360;
let best = HSL_BANDS[0];
let bestD = 361;
for (const band of HSL_BANDS) {
const d = Math.abs(((wrapped - band.hue + 540) % 360) - 180);
if (d < bestD) {
bestD = d;
best = band;
}
}
return best.id;
}
// One band triple as stored: integers -10..10, and an all-zero band dropped so a
// recipe only carries what the user actually moved.
const clamp10 = (v: unknown): number =>
typeof v === 'number' && Number.isFinite(v) ? Math.max(-10, Math.min(10, Math.round(v))) : 0;
export function sanitizeHslBands(raw: unknown): Partial<Record<HslBandId, HslBand>> | undefined {
if (!raw || typeof raw !== 'object') return undefined;
const out: Partial<Record<HslBandId, HslBand>> = {};
for (const band of HSL_BANDS) {
const v = (raw as Record<string, unknown>)[band.id];
if (!Array.isArray(v) || v.length !== 3) continue;
const triple: HslBand = [clamp10(v[0]), clamp10(v[1]), clamp10(v[2])];
if (triple[0] || triple[1] || triple[2]) out[band.id] = triple;
}
return Object.keys(out).length ? out : undefined;
}
// Kelvin to RGB compensation for White Balance.
// The WB setting names the light the camera is told to expect, so its gain is
+6
View File
@@ -20,6 +20,12 @@ export const DEFAULT_ADJUSTMENTS: ColorAdjustments = {
colorChromeBlue: 'none',
dynamicRange: 'auto',
sharpening: 0,
// The mixer's three whole-image moves. hslBands is deliberately absent: it is
// optional and `undefined` is what "no band moved" means — an empty object
// would make differs(adjustments, DEFAULT_ADJUSTMENTS) report a dirty recipe.
hslHue: 0,
hslSat: 0,
hslLum: 0,
exposureCompensation: 0,
};
+6
View File
@@ -1,5 +1,6 @@
import { Recipe, BaseFilter, FrameId, ColorAdjustments } from '../types';
import { DEFAULT_ADJUSTMENTS, FILM_SIMS } from './defaultRecipes';
import { sanitizeHslBands } from './colorUtils';
// Shared-recipe file: a tiny XML envelope around a scrambled hex payload of the
// recipe JSON, so a shared recipe imports back into the app on another phone.
@@ -94,6 +95,11 @@ export function importRecipeXml(xml: string): Omit<Recipe, 'id' | 'isCustom'> {
const name = raw.name.trim().slice(0, 40) || 'Imported recipe';
const baseFilter: BaseFilter = BASE_FILTERS.includes(raw.baseFilter) ? raw.baseFilter : 'none';
const adjustments: ColorAdjustments = { ...DEFAULT_ADJUSTMENTS, ...(raw.adjustments as Partial<ColorAdjustments>) };
// The mixer is the one adjustment that arrives as a nested object, so it is
// the one that needs its own gate: unknown band ids are dropped, every value
// is clamped to the knob's range, and a band left at zero is dropped instead
// of carried around for nothing.
adjustments.hslBands = sanitizeHslBands(raw.adjustments.hslBands);
const frameId: FrameId = FRAMES.includes(raw.frameId) ? raw.frameId : 'none';
return { name, baseFilter, adjustments, frameId, useGeotag: !!raw.useGeotag };
}
+149 -2
View File
@@ -1,4 +1,5 @@
import { BaseFilter, ColorAdjustments } from '../types';
import { HSL_BANDS, hslBandGaps } from './colorUtils';
// Tone-domain adjustments (Fuji-style Dynamic Range + Highlight/Shadow).
// SkSL runtime effect over a child image shader.
@@ -29,6 +30,17 @@ import { BaseFilter, ColorAdjustments } from '../types';
// (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.
// hslOn/hslH/hslS/hslL - selective colour mixer: eight hue bands, each with a
// hue shift, a saturation scale and a lightness offset (-1..1, from the
// -10..10 knobs). Which band owns which pixel is decided HERE, per pixel
// from the pixel's hue — so unlike everything above, the eight bands are
// not one global move and cannot live in a colour matrix. See the band
// block at the end of TONE_SKSL.
// gh/gs/gl - the mixer's whole-image moves: those same three quantities applied
// to the WHOLE frame, so they are just the accumulator's seed values and
// every hue takes them at full weight. Lightness is NOT gated on
// saturation (unlike the bands), so a frame pulled to grey by -SAT still
// answers +LUM.
// 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
@@ -36,6 +48,17 @@ import { BaseFilter, ColorAdjustments } from '../types';
// 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.
// The mixer's eight band lines inside TONE_SKSL, generated from HSL_BANDS so the
// anchors and gaps in the shader are the numbers the chips were built from. Each
// line reads its band's three values at a CONSTANT index — SkSL can only index a
// uniform array by a constant, so this block is unrolled rather than looped.
const BAND_BLOCK = hslBandGaps()
.map(
(b, i) => ` float w${i} = bandW(hd, ${b.hue.toFixed(1)}, ${b.left.toFixed(1)}, ${b.right.toFixed(1)}) * gate;
acc += vec3(w${i} * hslH[${i}], w${i} * hslS[${i}], w${i} * hslL[${i}]);\n`
)
.join('');
export const TONE_SKSL = `
uniform shader src;
uniform float dr;
@@ -50,6 +73,57 @@ uniform float hlTg;
uniform float hlTb;
uniform float cc;
uniform float ccb;
uniform float hslOn;
uniform float hslH[8];
uniform float hslS[8];
uniform float hslL[8];
uniform float gh;
uniform float gs;
uniform float gl;
// sRGB <-> HSL. The mixer works in HSL because that is the space the knobs are
// named after: a hue shift must not change how light a colour is, and a
// lightness move must not change its hue — exactly what scaling R,G,B gets
// wrong.
vec3 rgb2hsl(vec3 c) {
float mx = max(max(c.r, c.g), c.b);
float mn = min(min(c.r, c.g), c.b);
float l = (mx + mn) * 0.5;
float d = mx - mn;
if (d < 0.00001) return vec3(0.0, 0.0, l);
float s = l > 0.5 ? d / max(0.00001, 2.0 - mx - mn) : d / max(0.00001, mx + mn);
float h;
if (mx == c.r) h = (c.g - c.b) / d + (c.g < c.b ? 6.0 : 0.0);
else if (mx == c.g) h = (c.b - c.r) / d + 2.0;
else h = (c.r - c.g) / d + 4.0;
return vec3(h / 6.0, s, l);
}
float hueChannel(float p, float q, float t) {
t = fract(t);
if (t < 1.0 / 6.0) return p + (q - p) * 6.0 * t;
if (t < 0.5) return q;
if (t < 2.0 / 3.0) return p + (q - p) * (2.0 / 3.0 - t) * 6.0;
return p;
}
vec3 hsl2rgb(vec3 hsl) {
if (hsl.y < 0.00001) return vec3(hsl.z);
float q = hsl.z < 0.5 ? hsl.z * (1.0 + hsl.y) : hsl.z + hsl.y - hsl.z * hsl.y;
float p = 2.0 * hsl.z - q;
return vec3(
hueChannel(p, q, hsl.x + 1.0 / 3.0),
hueChannel(p, q, hsl.x),
hueChannel(p, q, hsl.x - 1.0 / 3.0)
);
}
// How much of a hue one band owns: full at the band's anchor, falling linearly
// to zero at the anchors next door (the gaps are uneven — red is 30 deg from
// orange and 40 deg from magenta). The linearity is the point: two neighbouring
// tents cross at exactly 0.5 at the midpoint, so the eight weights sum to 1 at
// every hue. No pixel is counted twice, no pixel falls in a gap, and a hue
// sitting on an anchor takes its band's full value instead of part of it.
float bandW(float hue, float anchor, float gapL, float gapR) {
float d = mod(hue - anchor + 180.0, 360.0) - 180.0;
return d <= 0.0 ? max(0.0, 1.0 + d / gapL) : max(0.0, 1.0 - d / gapR);
}
vec4 main(vec2 xy) {
vec4 c = src.eval(xy);
vec3 rgb = clamp(c.rgb, 0.0, 1.0);
@@ -102,7 +176,36 @@ vec4 main(vec2 xy) {
float mn = min(min(rgb.r, rgb.g), rgb.b);
float chroma = mx > 0.0001 ? (mx - mn) / mx : 0.0;
float kv = 1.0 + vib * 0.75 * (1.0 - chroma);
return vec4(clamp(mix(vec3(l2), rgb, kv), 0.0, 1.0), c.a);
rgb = clamp(mix(vec3(l2), rgb, kv), 0.0, 1.0);
// Selective colour by hue band — the LAST move, so a band edit is judged on
// the colour the user actually sampled from the render.
//
// Grey pixels are dropped before any weight is read: rgb2hsl hands them hue 0,
// so without the gate EVERY neutral pixel in the frame would be taken for pure
// red and drift with the red band. Below 8% saturation there is also no hue
// worth moving.
//
// The three accumulators are band values scaled by how much of the hue each
// band owns, so a hue halfway between two anchors takes a proportional mix of
// both edits — the blend the weights already add up to. Hue is a turn (plus or
// minus 30 deg at full), saturation a scale (0 = grey at -10) and lightness an
// additive offset (plus or minus 0.25 at full), which is why the ramp cannot
// invert.
if (hslOn > 0.5) {
vec3 hsl = rgb2hsl(rgb);
float gate = smoothstep(0.0, 0.08, hsl.y);
float hd = hsl.x * 360.0;
// The whole-image moves are the seed: every hue takes the hue turn and the
// saturation scale at full weight, and the bands add their share on top.
// Lightness is added below OUTSIDE the gate, so it still lifts a colour that
// -SAT has already pulled to grey.
vec3 acc = vec3(gh, gs, 0.0) * gate;
${BAND_BLOCK} hsl.x = fract(hsl.x + acc.x * (30.0 / 360.0));
hsl.y = clamp(hsl.y * (1.0 + acc.y), 0.0, 1.0);
hsl.z = clamp(hsl.z + (acc.z + gl) * 0.25, 0.0, 1.0);
rgb = hsl2rgb(hsl);
}
return vec4(clamp(rgb, 0.0, 1.0), c.a);
}
`;
@@ -179,6 +282,13 @@ export interface ToneUniforms {
hlT: [number, number, number]; // highlight split-tone RGB bias, -1..1
cc: number; // 0..1 Color Chrome depth (0 = 'none')
ccb: number; // 0..1 Color Chrome FX Blue depth (0 = 'none')
hslOn: number; // 1 when a band or a whole-image move is set (0 = skip the mixer)
hslH: number[]; // 8 x -1..1 in HSL_BANDS order (+/-30 deg hue at full)
hslS: number[]; // 8 x -1..1 (saturation scale, -1 = grey)
hslL: number[]; // 8 x -1..1 (additive lightness, +/-0.25 at full)
gh: number; // -1..1 whole-image hue turn (+/-30 deg at full)
gs: number; // -1..1 whole-image saturation scale
gl: number; // -1..1 whole-image lightness offset (+/-0.25 at full, ungated)
}
// Per-stock tone pass. Fuji's Classic stocks are not a plain colour matrix:
@@ -221,6 +331,31 @@ export function getToneUniforms(adj: ColorAdjustments, baseFilter?: BaseFilter):
!colour || v === 'none' || v == null ? 0 : v === 'strong' ? 0.9 : 0.45;
const blueDepth = (v: ColorAdjustments['colorChromeBlue'] | undefined) =>
!colour || v === 'none' || v == null ? 0 : v === 'strong' ? 1.0 : 0.5;
// Selective colour: one slot per band, in HSL_BANDS order, so the flat buffer
// lines up with the shader's arrays. A band the user has not moved keeps three
// zeroes and only costs its own slots.
const bands = adj.hslBands ?? {};
const tenth = (v: unknown) =>
typeof v === 'number' && Number.isFinite(v) ? Math.max(-1, Math.min(1, v / 10)) : 0;
const hslH: number[] = [];
const hslS: number[] = [];
const hslL: number[] = [];
let hslOn = 0;
for (const band of HSL_BANDS) {
const v = bands[band.id];
const [h, s, l] = v ? [tenth(v[0]), tenth(v[1]), tenth(v[2])] : [0, 0, 0];
hslH.push(h);
hslS.push(s);
hslL.push(l);
if (h || s || l) hslOn = 1;
}
// A stock black-and-white has no hue to select on.
if (!colour) hslOn = 0;
// The mixer's whole-image move: every hue takes it at full weight.
const gh = tenth(adj.hslHue);
const gs = tenth(adj.hslSat);
const gl = tenth(adj.hslLum);
if (colour && (gh || gs || gl)) hslOn = 1;
return {
dr,
hl: hl + (film.hl ?? 0),
@@ -230,17 +365,29 @@ export function getToneUniforms(adj: ColorAdjustments, baseFilter?: BaseFilter):
hlT,
cc: chromeDepth(adj.colorChrome),
ccb: blueDepth(adj.colorChromeBlue),
hslOn,
hslH,
hslS,
hslL,
gh,
gs,
gl,
};
}
// 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], u.cc, u.ccb];
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], u.cc, u.ccb,
u.hslOn, ...u.hslH, ...u.hslS, ...u.hslL, u.gh, u.gs, u.gl,
];
}
export function toneIsActive(u: ToneUniforms): boolean {
return (
u.hslOn !== 0 ||
u.dr !== 0 ||
u.hl !== 0 ||
u.sh !== 0 ||