From c8e2b6ea5a74b7d11db51a4d61c844d185e07eef Mon Sep 17 00:00:00 2001 From: locphamtran Date: Mon, 21 Sep 2026 17:20:42 +0700 Subject: [PATCH] android: add the CLASSIC VIVIDIPES sim and the FX MONOCHROME switch Straight off PLAN-WEB-TO-ANDROID.md: the sim keeps Classic Chrome's blue row verbatim and takes Velvia's red and green rows, so skies stay muted while everything else reads loud, and it carries Chrome's shadow pull. MONOCHROME is a switch, not a look: it swaps the base filter for the mono stock and swaps right back, leaving adjustments untouched so a knob turned while it is on survives the round trip. It goes through commit(), so UNDO undoes it. --- App.tsx | 25 +++++++++++++++++++++++++ src/components/AdjustmentPanel.tsx | 13 ++++++++++++- src/types/index.ts | 2 +- src/utils/colorUtils.ts | 10 ++++++++++ src/utils/defaultRecipes.ts | 1 + src/utils/toneShader.ts | 3 +++ 6 files changed, 52 insertions(+), 2 deletions(-) diff --git a/App.tsx b/App.tsx index 1d4f3b3..b7ce447 100644 --- a/App.tsx +++ b/App.tsx @@ -764,6 +764,29 @@ export default function App() { lastStepRef.current = 0; setUndoDepth(0); }; + // FX's MONOCHROME switch: not a look of its own — it swaps the base filter for + // the mono stock and swaps right back. Adjustments are never touched, so a knob + // turned while it is on survives the round trip. + const monoOn = selectedRecipe?.baseFilter === 'monochrome'; + const monoPrevRef = useRef(null); + + const handleToggleMono = () => { + commit(true, () => { + if (selectedRecipe?.baseFilter === 'monochrome') { + const back = monoPrevRef.current ?? filmSimToRecipe(FILM_SIMS.find((f) => f.id === 'sim-provia')!); + monoPrevRef.current = null; + setSelectedRecipe(back); + } else { + monoPrevRef.current = selectedRecipe; + setSelectedRecipe({ + ...(selectedRecipe ?? filmSimToRecipe(FILM_SIMS[0])), + id: 'mono', + name: 'MONOCHROME', + baseFilter: 'monochrome', + }); + } + }); + }; const applyLook = (l: Look) => { setSelectedRecipe(l.recipe); setAdjustments(l.adjustments); @@ -1963,6 +1986,8 @@ export default function App() { openParam={openParamKey} onOpenParamChange={setOpenParamKey} currentBaseFilter={selectedRecipe?.baseFilter} + monoOn={monoOn} + onToggleMono={handleToggleMono} onCreateRecipe={handleCreateRecipe} editingRecipe={editingRecipe} onCloseEdit={() => setEditingRecipe(null)} diff --git a/src/components/AdjustmentPanel.tsx b/src/components/AdjustmentPanel.tsx index f526143..a759f15 100644 --- a/src/components/AdjustmentPanel.tsx +++ b/src/components/AdjustmentPanel.tsx @@ -80,6 +80,10 @@ interface AdjustmentPanelProps { // Base filter of the currently applied recipe — seeds the CREATE modal's // SIMULATION row so quick-input starts from the active look. currentBaseFilter?: BaseFilter; + // FX tab MONOCHROME: a switch, not a look — on it swaps the base filter for + // the mono stock, off it hands back the one that was there. + monoOn: boolean; + onToggleMono: () => void; // CREATE quick-input saves a brand-new recipe (name + adjustments from the // modal) and applies it, unlike onSaveRecipe which snapshots the live state. onCreateRecipe: (recipe: Omit) => void; @@ -243,6 +247,8 @@ export default function AdjustmentPanel({ openParam, onOpenParamChange, currentBaseFilter, + monoOn, + onToggleMono, onCreateRecipe, editingRecipe, onCloseEdit, @@ -896,7 +902,12 @@ export default function AdjustmentPanel({ ]); case 'filters': - return chipRow(paramChips(paramDefs.filters)); + // MONOCHROME leads the strip: a switch that swaps the base filter for + // the mono stock and swaps right back, so it never touches a knob. + return chipRow([ + { key: 'mono', label: 'MONOCHROME', active: monoOn, amberValue: monoOn, onPress: onToggleMono }, + ...paramChips(paramDefs.filters), + ]); case 'frame': // FRAME -> WATERMARK: the sub-panel row (breadcrumb back to the frames). diff --git a/src/types/index.ts b/src/types/index.ts index 0a0fff6..8280081 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -62,7 +62,7 @@ export const MIN_CROP_FRAC = 0.08; // cool tint in shade, plus a seasonal atmospheric haze/fog veil. export type CinemaSeason = 'spring' | 'summer' | 'autumn' | 'winter'; -export type BaseFilter = 'classic-neg' | 'classic-chrome' | 'provia' | 'velvia' | 'astia' | 'eterna' | 'monochrome' | 'leica' | 'leica-vivid' | 'none'; +export type BaseFilter = 'classic-neg' | 'classic-chrome' | 'classic-vivid' | 'provia' | 'velvia' | 'astia' | 'eterna' | 'monochrome' | 'leica' | 'leica-vivid' | 'none'; export interface ColorAdjustments { exposure: number; // -10 to +10 (mapped to matrix multiplier or offset) diff --git a/src/utils/colorUtils.ts b/src/utils/colorUtils.ts index 4a69a24..1de5316 100644 --- a/src/utils/colorUtils.ts +++ b/src/utils/colorUtils.ts @@ -120,6 +120,16 @@ export function getSkiaColorMatrix(baseFilter: BaseFilter, adj: ColorAdjustments 0.0652, 0.3973, 0.5575, 0, -0.0100, 0, 0, 0, 1, 0, ]; + } else if (baseFilter === 'classic-vivid') { + // Classic Vivid: the Classic Chrome blue row kept verbatim — skies stay the + // same muted teal/cyan — with the red and green rows taken from Velvia, so + // everything that is not blue reads loud. + matrix = [ + 1.3895, -0.2540, -0.0256, 0, -0.0550, // R — velvia + -0.0772, 1.2355, -0.0261, 0, -0.0661, // G — velvia + 0.0652, 0.3973, 0.5575, 0, -0.0100, // B — classic-chrome (nguyên bản) + 0, 0, 0, 1, 0, + ]; } else if (baseFilter === 'astia') { // Astia (Soft): gentler than standard — contrast below neutral (shadows // lift, highlights ease off) and colour held back, with a faint warm/pink diff --git a/src/utils/defaultRecipes.ts b/src/utils/defaultRecipes.ts index 22cb950..700978f 100644 --- a/src/utils/defaultRecipes.ts +++ b/src/utils/defaultRecipes.ts @@ -41,6 +41,7 @@ export const FILM_SIMS: FilmSim[] = [ { id: 'sim-provia', name: 'PROVIPES', baseFilter: 'provia', adjustments: { ...DEFAULT_ADJUSTMENTS } }, { id: 'sim-velvia', name: 'VELVIPES', baseFilter: 'velvia', adjustments: { ...DEFAULT_ADJUSTMENTS } }, { id: 'sim-classic-chrome', name: 'CLASSIC CHRIPES', baseFilter: 'classic-chrome', adjustments: { ...DEFAULT_ADJUSTMENTS } }, + { id: 'sim-classic-vivid', name: 'CLASSIC VIVIDIPES', baseFilter: 'classic-vivid', adjustments: { ...DEFAULT_ADJUSTMENTS } }, { id: 'sim-classic-neg', name: 'CLASSIC NEGIPES', baseFilter: 'classic-neg', adjustments: { ...DEFAULT_ADJUSTMENTS } }, { id: 'sim-astia', name: 'ASTIPES', baseFilter: 'astia', adjustments: { ...DEFAULT_ADJUSTMENTS } }, { id: 'sim-eterna', name: 'ETERNIPES', baseFilter: 'eterna', adjustments: { ...DEFAULT_ADJUSTMENTS } }, diff --git a/src/utils/toneShader.ts b/src/utils/toneShader.ts index 2325f36..2008728 100644 --- a/src/utils/toneShader.ts +++ b/src/utils/toneShader.ts @@ -188,6 +188,9 @@ export interface ToneUniforms { // without also moving the other. const FILM_TONE: Partial>> = { 'classic-chrome': { sh: -0.28 }, + // Classic Vivid keeps Chrome's dived shadow: the shadow pull belongs to the + // stock, not to the matrix rows it borrows from Velvia. + 'classic-vivid': { sh: -0.28 }, 'classic-neg': { shT: [-0.018, 0.009, 0.013], hlT: [0.024, 0.008, -0.012] }, // Acros. A black-and-white stock IS its grey ramp, so this entry only shapes // the two ENDS and leaves the middle an identity: a smooth shadow toe that