diff --git a/App.tsx b/App.tsx index b7ce447..ec1f6a3 100644 --- a/App.tsx +++ b/App.tsx @@ -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({ ...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('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('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); + }; + + // 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 ? ( + b.id === hslBand)?.label ?? ''} + band={shownAdjustments.hslBands?.[hslBand] ?? [0, 0, 0]} + onKnob={setBandKnob} + onClose={() => setHslPickedAt(null)} + /> + ) : undefined + } /> @@ -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'} /> )} diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 322e40c..a10052d 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -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 diff --git a/android/app/build.gradle b/android/app/build.gradle index 844463f..8db343c 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -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'}\"" diff --git a/app.json b/app.json index 5e3adc1..8e15ff3 100644 --- a/app.json +++ b/app.json @@ -2,7 +2,7 @@ "expo": { "name": "RecipesCam", "slug": "recipescamera", - "version": "1.2.3", + "version": "1.2.4", "orientation": "portrait", "icon": "./RecipesCamIcon.png", "userInterfaceStyle": "light", diff --git a/src/components/AdjustmentPanel.tsx b/src/components/AdjustmentPanel.tsx index a759f15..03d4911 100644 --- a/src/components/AdjustmentPanel.tsx +++ b/src/components/AdjustmentPanel.tsx @@ -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 ? ( + + ) : null} {c.label} @@ -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 ( + {activeTab === 'hsl' && ( + + {hslSample && sampleHsl ? ( + <> + + + R {hslSample.r} · G {hslSample.g} · B {hslSample.b} + + + {Math.round(sampleHsl.h)}° · {Math.round(sampleHsl.s * 100)}% ·{' '} + {Math.round(sampleHsl.l * 100)}% + + {bandName} + + ) : ( + + BẤM PICK RỒI CHỌN MỘT MÀU TRÊN ẢNH + + )} + + )} {openParamDef && ( ); } + + +// 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 ( + + + + + {bandLabel} + {mixed.toUpperCase()} + + { + haptic(); + onClose(); + }} + className="p-1.5" + hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} + activeOpacity={0.7} + > + ✕ + + + {(['HUE', 'SAT', 'LUM'] as const).map((name, i) => ( + + + {name} + + onKnob(i as 0 | 1 | 2, v)} + onSlidingComplete={haptic} + minimumTrackTintColor="#f59e0b" + maximumTrackTintColor="#27272a" + thumbTintColor="#f59e0b" + /> + + {band[i] > 0 ? `+${band[i]}` : band[i]} + + + ))} + + ); +} diff --git a/src/components/ToolRail.tsx b/src/components/ToolRail.tsx index 5a119f2..ddfde9e 100644 --- a/src/components/ToolRail.tsx +++ b/src/components/ToolRail.tsx @@ -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' }, ]; diff --git a/src/components/Viewfinder.tsx b/src/components/Viewfinder.tsx index 8552edf..1e529e2 100644 --- a/src/components/Viewfinder.tsx +++ b/src/components/Viewfinder.tsx @@ -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(function Viewfi photoStraighten = 0, wmRotation = 0, wallLandscape = false, + picking = false, + onPickColor, + hslPanel, + hslPanelAt = null, }: ViewfinderProps, ref ) { @@ -496,12 +515,17 @@ const Viewfinder = forwardRef(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(new Array(10).fill(0)), + () => createSynchronizable(new Array(40).fill(0)), // eslint-disable-next-line react-hooks/exhaustive-deps [] ); @@ -1275,7 +1299,8 @@ const Viewfinder = forwardRef(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(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(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(function Viewfi {libraryImageUri && skiaImage ? ( <> - + @@ -3312,6 +3408,62 @@ const Viewfinder = forwardRef(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 ? ( + true} + onMoveShouldSetResponder={() => true} + onResponderGrant={onPickTouch} + onResponderMove={onPickTouch} + > + {pickPos ? ( + + + + ) : null} + + ) : 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 ? ( + + 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} + + + ) : null} ) : ( diff --git a/src/types/index.ts b/src/types/index.ts index 8280081..bda8012 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -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>; + 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. } diff --git a/src/utils/colorUtils.ts b/src/utils/colorUtils.ts index 1de5316..e8118da 100644 --- a/src/utils/colorUtils.ts +++ b/src/utils/colorUtils.ts @@ -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> | undefined { + if (!raw || typeof raw !== 'object') return undefined; + const out: Partial> = {}; + for (const band of HSL_BANDS) { + const v = (raw as Record)[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 diff --git a/src/utils/defaultRecipes.ts b/src/utils/defaultRecipes.ts index 700978f..636009b 100644 --- a/src/utils/defaultRecipes.ts +++ b/src/utils/defaultRecipes.ts @@ -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, }; diff --git a/src/utils/recipeShare.ts b/src/utils/recipeShare.ts index 61c3c31..766ef3e 100644 --- a/src/utils/recipeShare.ts +++ b/src/utils/recipeShare.ts @@ -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 { 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) }; + // 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 }; } diff --git a/src/utils/toneShader.ts b/src/utils/toneShader.ts index 2008728..08ecd56 100644 --- a/src/utils/toneShader.ts +++ b/src/utils/toneShader.ts @@ -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` / `` — 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 ||