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:
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'}\""
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"expo": {
|
||||
"name": "RecipesCam",
|
||||
"slug": "recipescamera",
|
||||
"version": "1.2.3",
|
||||
"version": "1.2.4",
|
||||
"orientation": "portrait",
|
||||
"icon": "./RecipesCamIcon.png",
|
||||
"userInterfaceStyle": "light",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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">
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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
@@ -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 ||
|
||||
|
||||
Reference in New Issue
Block a user