Add the CREATE RECIPES tab to the web app
The phone's RecipeCreateModal becomes a rail tab with the same rows, seeding from the look on screen and clamping the same way. SAVE RECIPE applies the new look, lists it under RECIPES and, when signed in, stores it on the account; a guest's copy stays in memory and goes away with the page. Signed-in users can also export the recipe as the app's encrypted .recipe file (shared/utils /recipeShare.ts vendored byte-identical from the RN project).
This commit is contained in:
+1
-1
@@ -45,7 +45,7 @@ project (`@shopify/react-native-skia` is aliased to `src/engine/skiaShim.ts` in
|
||||
`vite.config.ts` + `tsconfig.json`, so those files compile unchanged):
|
||||
|
||||
`cinemaShader` `colorUtils` `defaultRecipes` `exifWrite` `frameUtils` `jpegDpi`
|
||||
`paramDefs` `skiaImage` `toneShader`.
|
||||
`paramDefs` `recipeShare` `skiaImage` `toneShader`.
|
||||
|
||||
The landing page needs no CDN: `frontend/public/assets/Syne.woff2` is the
|
||||
bundled display face and `frontend/public/assets/samples/s*.jpg` are the six
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { Recipe, BaseFilter, FrameId, ColorAdjustments } from '../types';
|
||||
import { DEFAULT_ADJUSTMENTS, FILM_SIMS } from './defaultRecipes';
|
||||
|
||||
// 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.
|
||||
//
|
||||
// ponytail: obfuscation, not cryptography — the key ships inside the app and
|
||||
// the keystream is a plain xorshift. It stops hand-editing and casual peeking,
|
||||
// which is all a shared look needs; swap in expo-crypto/AES at the same two
|
||||
// functions if the format ever has to resist a determined attacker.
|
||||
const KEY = 'RecipesCam::recipe-share::v1';
|
||||
const ALGORITHM = 'xor16-v1';
|
||||
|
||||
const BASE_FILTERS: BaseFilter[] = [...FILM_SIMS.map((s) => s.baseFilter), 'none'];
|
||||
const FRAMES: FrameId[] = ['none', 'polaroid', 'classic-white', 'cinematic', 'wallframe'];
|
||||
|
||||
const hash = (s: string): number => {
|
||||
let h = 0x811c9dc5;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
h ^= s.charCodeAt(i);
|
||||
h = Math.imul(h, 0x01000193);
|
||||
}
|
||||
return h >>> 0;
|
||||
};
|
||||
|
||||
// xorshift32: one 16-bit word of keystream per call.
|
||||
const stream = (seed: number) => {
|
||||
let s = seed >>> 0 || 0x9e3779b9;
|
||||
return () => {
|
||||
s ^= s << 13;
|
||||
s >>>= 0;
|
||||
s ^= s >>> 17;
|
||||
s ^= s << 5;
|
||||
s >>>= 0;
|
||||
return s & 0xffff;
|
||||
};
|
||||
};
|
||||
|
||||
// 16-bit code units, hex-encoded: no encoder needed, every UTF-16 char (names
|
||||
// with Vietnamese diacritics included) survives the round trip.
|
||||
const scramble = (text: string, salt: number): string => {
|
||||
const k = stream(hash(`${KEY}|${salt}`));
|
||||
let out = '';
|
||||
for (let i = 0; i < text.length; i++) out += (text.charCodeAt(i) ^ k()).toString(16).padStart(4, '0');
|
||||
return out;
|
||||
};
|
||||
|
||||
const unscramble = (hex: string, salt: number): string => {
|
||||
if (hex.length % 4 !== 0) throw new Error('Recipe file is damaged.');
|
||||
const k = stream(hash(`${KEY}|${salt}`));
|
||||
let out = '';
|
||||
for (let i = 0; i < hex.length; i += 4) out += String.fromCharCode(parseInt(hex.slice(i, i + 4), 16) ^ k());
|
||||
return out;
|
||||
};
|
||||
|
||||
// The shareable half of a recipe: never the local id, and only known fields, so
|
||||
// a file from a newer version cannot smuggle anything into storage.
|
||||
export function exportRecipeXml(recipe: Recipe): string {
|
||||
const shareable = {
|
||||
name: recipe.name,
|
||||
baseFilter: recipe.baseFilter,
|
||||
adjustments: recipe.adjustments,
|
||||
frameId: recipe.frameId,
|
||||
useGeotag: recipe.useGeotag,
|
||||
};
|
||||
const salt = Math.floor(Math.random() * 0xffffffff) >>> 0;
|
||||
return [
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
`<recipescam-recipe version="1" algorithm="${ALGORITHM}" salt="${salt.toString(16)}">`,
|
||||
` <payload>${scramble(JSON.stringify(shareable), salt)}</payload>`,
|
||||
'</recipescam-recipe>',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
// Throws with a user-readable reason. Fields are validated and clamped to what
|
||||
// this build understands: a recipe from a newer app imports as best it can
|
||||
// instead of poisoning the store.
|
||||
export function importRecipeXml(xml: string): Omit<Recipe, 'id' | 'isCustom'> {
|
||||
const salt = /salt="([0-9a-fA-F]+)"/.exec(xml)?.[1];
|
||||
const payload = /<payload>([0-9a-fA-F\s]+)<\/payload>/.exec(xml)?.[1]?.replace(/\s+/g, '');
|
||||
if (!salt || !payload) throw new Error('Not a RecipesCam recipe file.');
|
||||
|
||||
let raw: any;
|
||||
try {
|
||||
raw = JSON.parse(unscramble(payload, parseInt(salt, 16)));
|
||||
} catch {
|
||||
raw = null;
|
||||
}
|
||||
if (!raw || typeof raw !== 'object' || typeof raw.name !== 'string' || !raw.adjustments || typeof raw.adjustments !== 'object') {
|
||||
throw new Error('Recipe file is damaged or from another app.');
|
||||
}
|
||||
|
||||
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>) };
|
||||
const frameId: FrameId = FRAMES.includes(raw.frameId) ? raw.frameId : 'none';
|
||||
return { name, baseFilter, adjustments, frameId, useGeotag: !!raw.useGeotag };
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { ChipRow, SliderRow, type ChipDef, type SliderDef } from './ui/ChipColum
|
||||
import { ImageStage } from './ui/ImageStage';
|
||||
import { TopBar } from './ui/TopBar';
|
||||
import { AuthModal } from './ui/AuthModal';
|
||||
import { RecipeCreatePanel, type RecipeDraft } from './ui/RecipeCreatePanel';
|
||||
import { useI18n } from './i18n/I18nProvider';
|
||||
import { loadEngine } from './engine/assets';
|
||||
import { renderPhoto, type RenderAssets } from './engine/exportEngine';
|
||||
@@ -12,6 +13,7 @@ import { api, type SavedRecipe, type User } from './api';
|
||||
import { TIERS } from './config/tiers';
|
||||
import { DEFAULT_ADJUSTMENTS, DEFAULT_RECIPES, FILM_SIMS, filmSimToRecipe } from '../shared/utils/defaultRecipes';
|
||||
import { PARAM_DEFS, type ContinuousParamDef } from '../shared/utils/paramDefs';
|
||||
import { exportRecipeXml } from '../shared/utils/recipeShare';
|
||||
import { writeJpegExif } from '../shared/utils/exifWrite';
|
||||
import { CROP_W_H, type CropRatio, type FrameId, type GPSInfo, type Recipe } from '../shared/types';
|
||||
import type { MsgKey } from './i18n/vi';
|
||||
@@ -120,6 +122,9 @@ export function Workspace() {
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [saved, setSaved] = useState<SavedRecipe[]>([]);
|
||||
// Recipes made in this session, guest ones included. A guest's copy lives
|
||||
// here and nowhere else, so reloading the page is what deletes it.
|
||||
const [created, setCreated] = useState<Recipe[]>([]);
|
||||
// null = closed. The landing's register link is /app?auth=1, so it opens the
|
||||
// dialog straight on the sign-up tab.
|
||||
const [authMode, setAuthMode] = useState<'login' | 'signup' | null>(() =>
|
||||
@@ -300,6 +305,41 @@ export function Workspace() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [recipe, user, t]);
|
||||
|
||||
// CREATE tab: a new recipe is applied at once and listed under RECIPES. A
|
||||
// signed-in account also stores it; a guest's copy stays in `created`, which
|
||||
// is exactly what "forgotten when you leave" means.
|
||||
const addCreatedRecipe = useCallback(
|
||||
async (draft: RecipeDraft) => {
|
||||
const entry: Recipe = { ...draft, id: `local-${Date.now()}`, isCustom: true };
|
||||
setCreated((list) => [entry, ...list]);
|
||||
applyRecipe(entry);
|
||||
if (!user) return;
|
||||
try {
|
||||
const stored = await api.createRecipe(entry.name, entry);
|
||||
setSaved((list) => [stored.recipe, ...list]);
|
||||
} catch (err) {
|
||||
setError(t('err.generic', { msg: String(err) }));
|
||||
}
|
||||
},
|
||||
[user, applyRecipe, t]
|
||||
);
|
||||
|
||||
// The phone's encrypted .recipe envelope (`shared/utils/recipeShare` is the
|
||||
// app's own file, byte-identical). Signed in only — a guest's recipe is never
|
||||
// written to disk.
|
||||
const exportRecipeFile = useCallback(
|
||||
(draft: RecipeDraft) => {
|
||||
if (!user) return;
|
||||
const xml = exportRecipeXml({ ...draft, id: 'share' });
|
||||
download(
|
||||
new TextEncoder().encode(xml),
|
||||
`${(draft.name || 'recipe').replace(/\W+/g, '-').toLowerCase()}.recipe`,
|
||||
'application/xml'
|
||||
);
|
||||
},
|
||||
[user]
|
||||
);
|
||||
|
||||
const exportPhoto = useCallback(async () => {
|
||||
if (!engine || !source) return;
|
||||
setExporting(true);
|
||||
@@ -660,6 +700,9 @@ export function Workspace() {
|
||||
return isWmPanel(openGroup)
|
||||
? [{ key: 'wm-back', label: '< FRAME', onClick: () => setOpenGroup(null) }, ...wmChips()]
|
||||
: framesRow();
|
||||
// CREATE draws its own form (ui/RecipeCreatePanel) instead of a chip row.
|
||||
case 'create':
|
||||
return [];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
@@ -671,12 +714,21 @@ export function Workspace() {
|
||||
...choiceChips(g, groupDefs[g].options, groupDefs[g].value, groupDefs[g].onPick),
|
||||
];
|
||||
|
||||
const recipeStrip: ChipDef[] = DEFAULT_RECIPES.map((preset) => ({
|
||||
key: preset.id,
|
||||
label: preset.name,
|
||||
active: recipe.name === preset.name && !recipe.isCustom,
|
||||
onClick: () => applyRecipe({ ...preset }),
|
||||
}));
|
||||
// RECIPES = what this session made (newest first) + the bundled presets.
|
||||
const recipeStrip: ChipDef[] = [
|
||||
...created.map((entry) => ({
|
||||
key: entry.id,
|
||||
label: entry.name,
|
||||
active: recipe.id === entry.id,
|
||||
onClick: () => applyRecipe(entry),
|
||||
})),
|
||||
...DEFAULT_RECIPES.map((preset) => ({
|
||||
key: preset.id,
|
||||
label: preset.name,
|
||||
active: recipe.name === preset.name && !recipe.isCustom,
|
||||
onClick: () => applyRecipe({ ...preset }),
|
||||
})),
|
||||
];
|
||||
|
||||
const status = busy ? busy : rendered ? t('meta.rendered', { ms: rendered.ms, w: rendered.w, h: rendered.h }) : t('meta.ready');
|
||||
|
||||
@@ -723,7 +775,18 @@ export function Workspace() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* 4. FRAME -> WATERMARK sub-panel: the mark's own text + place */}
|
||||
{/* 4. CREATE -> the phone's recipe form */}
|
||||
{tab === 'create' ? (
|
||||
<RecipeCreatePanel
|
||||
currentBase={recipe.baseFilter}
|
||||
current={recipe.adjustments}
|
||||
signedIn={!!user}
|
||||
onSave={(draft) => void addCreatedRecipe(draft)}
|
||||
onExport={exportRecipeFile}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* 5. FRAME -> WATERMARK sub-panel: the mark's own text + place */}
|
||||
{tab === 'frame' && isWmPanel(openGroup) ? (
|
||||
<div className="strip wm-extra">
|
||||
{markOn ? (
|
||||
@@ -775,7 +838,7 @@ export function Workspace() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* 5. main chip row, RESET pinned outside the scroll */}
|
||||
{/* 6. main chip row, RESET pinned outside the scroll */}
|
||||
<div className="main-row">
|
||||
<ChipRow chips={[resetChip()]} />
|
||||
<ChipRow chips={chips} />
|
||||
|
||||
@@ -48,8 +48,8 @@ export async function readGps(bytes: Uint8Array): Promise<GPSInfo | null> {
|
||||
}
|
||||
}
|
||||
|
||||
export function download(bytes: Uint8Array, filename: string): void {
|
||||
const url = URL.createObjectURL(new Blob([bytes as BlobPart], { type: 'image/jpeg' }));
|
||||
export function download(bytes: Uint8Array, filename: string, type = 'image/jpeg'): void {
|
||||
const url = URL.createObjectURL(new Blob([bytes as BlobPart], { type }));
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
|
||||
@@ -32,6 +32,15 @@ export const en: Dict = {
|
||||
'tab.wb': 'WB',
|
||||
'tab.fx': 'FX',
|
||||
'tab.frame': 'FRAME',
|
||||
'tab.create': 'CREATE RECIPES',
|
||||
|
||||
'create.title': 'CREATE RECIPES',
|
||||
'create.save': 'SAVE RECIPE',
|
||||
'create.export': 'EXPORT .RECIPE FILE',
|
||||
'create.guestHint':
|
||||
'Guest: the recipe stays in RECIPES until you close the page. Log in to save it to your account and export it.',
|
||||
'create.userHint':
|
||||
'Saved to your account. Export the .recipe file to share it — encrypted exactly like the app’s.',
|
||||
|
||||
'sec.filmSims': 'PHOTO STYLE',
|
||||
'sec.recipes': 'RECIPES',
|
||||
|
||||
@@ -35,6 +35,15 @@ export const vi = {
|
||||
'tab.wb': 'WB',
|
||||
'tab.fx': 'FX',
|
||||
'tab.frame': 'FRAME',
|
||||
'tab.create': 'CREATE RECIPES',
|
||||
|
||||
'create.title': 'TẠO CÔNG THỨC',
|
||||
'create.save': 'LƯU CÔNG THỨC',
|
||||
'create.export': 'XUẤT TỆP .RECIPE',
|
||||
'create.guestHint':
|
||||
'Khách: công thức chỉ nằm trong RECIPES tới khi đóng trang. Đăng nhập để lưu vào tài khoản và xuất tệp.',
|
||||
'create.userHint':
|
||||
'Đã lưu vào tài khoản. Xuất tệp .recipe để chia sẻ — tệp được mã hóa đúng như trên app.',
|
||||
|
||||
'sec.filmSims': 'PHOTO STYLE',
|
||||
'sec.recipes': 'RECIPES',
|
||||
|
||||
@@ -121,6 +121,29 @@
|
||||
input[type="range"] { width: 100%; accent-color: var(--accent); }
|
||||
.hint { color: var(--text-dim); font-size: 12px; line-height: 1.5; }
|
||||
|
||||
/* --- CREATE RECIPES form (the phone's RecipeCreateModal, unrolled) ------- */
|
||||
.create-form {
|
||||
border-top: 1px solid var(--border-soft);
|
||||
padding-top: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.create-label {
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--text-dim);
|
||||
font-weight: 600;
|
||||
}
|
||||
.create-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }
|
||||
.create-num { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
|
||||
.create-num span { font-size: 10px; letter-spacing: 0.12em; color: var(--text-dim); font-weight: 600; }
|
||||
.create-num.wide { grid-column: 1 / -1; }
|
||||
.create-shift { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
||||
.create-shift .field { width: 64px; }
|
||||
.create-actions { display: flex; gap: 6px; margin-top: 4px; }
|
||||
.create-actions .btn { flex: 1 1 auto; }
|
||||
|
||||
/* --- stage -------------------------------------------------------------- */
|
||||
.stage {
|
||||
flex: 1 1 auto;
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
import { useState } from 'react';
|
||||
import { ChipRow, type ChipDef } from './ChipColumn';
|
||||
import { useI18n } from '../i18n/I18nProvider';
|
||||
import { DEFAULT_ADJUSTMENTS, FILM_SIMS } from '../../shared/utils/defaultRecipes';
|
||||
import type { BaseFilter, ColorAdjustments, Recipe } from '../../shared/types';
|
||||
|
||||
// The phone's RecipeCreateModal, minus the modal: the same rows in the same
|
||||
// order with the same clamping. CREATE opens on the look live on screen (the
|
||||
// phone's rule — a recipe is a snapshot of what the user is looking at).
|
||||
|
||||
export type RecipeDraft = Omit<Recipe, 'id' | 'isCustom'>;
|
||||
|
||||
// WB presets mirror the phone's WB-tab chips but keep pure presets (no -R
|
||||
// variants — those are the separate Red/Blue offset boxes below).
|
||||
const WB_PRESETS: { key: string; label: string; temperature: number; tint: number }[] = [
|
||||
{ key: 'auto', label: 'AUTO', temperature: 5500, tint: 0 },
|
||||
{ key: 'daylight', label: 'DAYLIGHT', temperature: 5500, tint: 0 },
|
||||
{ key: 'cloudy', label: 'CLOUDY', temperature: 6500, tint: 1 },
|
||||
{ key: 'shade', label: 'SHADE', temperature: 8000, tint: 2 },
|
||||
{ key: 'tungsten', label: 'TUNGSTEN', temperature: 3200, tint: 0 },
|
||||
{ key: 'fluor', label: 'FLUOR', temperature: 4000, tint: 3 },
|
||||
];
|
||||
const WB_KEYS = WB_PRESETS.map((w) => w.key);
|
||||
|
||||
const DR_OPTS = [
|
||||
{ v: 'auto', d: 'AUTO' },
|
||||
{ v: '100', d: 'DR100' },
|
||||
{ v: '200', d: 'DR200' },
|
||||
{ v: '400', d: 'DR400' },
|
||||
];
|
||||
const CC_OPTS = [
|
||||
{ v: 'none', d: 'OFF' },
|
||||
{ v: 'weak', d: 'WEAK' },
|
||||
{ v: 'strong', d: 'STRONG' },
|
||||
];
|
||||
const GRAIN_OPTS = [
|
||||
{ v: '0', d: 'OFF' },
|
||||
{ v: '3', d: 'WEAK' },
|
||||
{ v: '6', d: 'STRONG' },
|
||||
];
|
||||
|
||||
const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v));
|
||||
const int = (s: string, fallback: number, lo: number, hi: number) => {
|
||||
const n = parseInt(s, 10);
|
||||
return Number.isFinite(n) ? clamp(n, lo, hi) : fallback;
|
||||
};
|
||||
|
||||
const wbKeyOf = (s: ColorAdjustments) =>
|
||||
WB_KEYS.find((k) => {
|
||||
const p = WB_PRESETS.find((w) => w.key === k)!;
|
||||
return p.temperature === s.temperature && p.tint === s.tint;
|
||||
}) ?? 'custom';
|
||||
|
||||
export function RecipeCreatePanel({
|
||||
currentBase,
|
||||
current,
|
||||
signedIn,
|
||||
onSave,
|
||||
onExport,
|
||||
}: {
|
||||
currentBase?: BaseFilter;
|
||||
current?: ColorAdjustments | null;
|
||||
signedIn: boolean;
|
||||
onSave: (draft: RecipeDraft) => void;
|
||||
onExport: (draft: RecipeDraft) => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
|
||||
const seedBase: BaseFilter =
|
||||
currentBase && FILM_SIMS.some((s) => s.baseFilter === currentBase) ? currentBase : 'provia';
|
||||
const seedAdj: ColorAdjustments =
|
||||
current ?? FILM_SIMS.find((s) => s.baseFilter === seedBase)?.adjustments ?? DEFAULT_ADJUSTMENTS;
|
||||
|
||||
// Every sim's standard values as form state — used to seed the form when the
|
||||
// SIMULATION chip changes.
|
||||
const valuesOf = (b: BaseFilter, src?: ColorAdjustments) => {
|
||||
const s = src ?? FILM_SIMS.find((x) => x.baseFilter === b)?.adjustments ?? DEFAULT_ADJUSTMENTS;
|
||||
return {
|
||||
dr: String(s.dynamicRange ?? 'auto'),
|
||||
grain: String(s.grain ?? 0),
|
||||
cc: s.colorChrome ?? 'none',
|
||||
ccb: s.colorChromeBlue ?? 'none',
|
||||
wbKey: wbKeyOf(s),
|
||||
highlight: String(s.highlight),
|
||||
shadow: String(s.shadow),
|
||||
color: String(s.saturation),
|
||||
nr: String(s.denoise),
|
||||
sharp: String(s.sharpening),
|
||||
clarity: String(s.clarity),
|
||||
ev: String(s.exposureCompensation),
|
||||
};
|
||||
};
|
||||
|
||||
const [sim, setSim] = useState<BaseFilter>(seedBase);
|
||||
const [name, setName] = useState('');
|
||||
const [dr, setDr] = useState<string>(seedAdj.dynamicRange == null ? 'auto' : String(seedAdj.dynamicRange));
|
||||
const [grain, setGrain] = useState<string>(String(seedAdj.grain ?? 0));
|
||||
const [cc, setCc] = useState<string>(seedAdj.colorChrome ?? 'none');
|
||||
const [ccb, setCcb] = useState<string>(seedAdj.colorChromeBlue ?? 'none');
|
||||
const [wbKey, setWbKey] = useState<string>(wbKeyOf(seedAdj));
|
||||
const [wbRed, setWbRed] = useState(String(seedAdj.wbRed ?? 0));
|
||||
const [wbBlue, setWbBlue] = useState(String(seedAdj.wbBlue ?? 0));
|
||||
const [fHigh, setFHigh] = useState(String(seedAdj.highlight));
|
||||
const [fShadow, setFShadow] = useState(String(seedAdj.shadow));
|
||||
const [fColor, setFColor] = useState(String(seedAdj.saturation));
|
||||
const [fNr, setFNr] = useState(String(seedAdj.denoise));
|
||||
const [fSharp, setFSharp] = useState(String(seedAdj.sharpening));
|
||||
const [fClarity, setFClarity] = useState(String(seedAdj.clarity));
|
||||
const [fEv, setFEv] = useState(String(seedAdj.exposureCompensation));
|
||||
|
||||
// Switching the simulation reloads its standard values into the form.
|
||||
const selectSim = (b: BaseFilter) => {
|
||||
setSim(b);
|
||||
const v = valuesOf(b);
|
||||
setDr(v.dr);
|
||||
setGrain(v.grain);
|
||||
setCc(v.cc);
|
||||
setCcb(v.ccb);
|
||||
setWbKey(v.wbKey);
|
||||
setFHigh(v.highlight);
|
||||
setFShadow(v.shadow);
|
||||
setFColor(v.color);
|
||||
setFNr(v.nr);
|
||||
setFSharp(v.sharp);
|
||||
setFClarity(v.clarity);
|
||||
setFEv(v.ev);
|
||||
};
|
||||
|
||||
// Same assembly as the phone's handleSave: the live look rides along for every
|
||||
// knob the form has no row for, so the saved recipe matches the screen.
|
||||
const build = (): RecipeDraft => {
|
||||
const wb = WB_PRESETS.find((w) => w.key === wbKey);
|
||||
const simAdj = FILM_SIMS.find((s) => s.baseFilter === sim)?.adjustments ?? DEFAULT_ADJUSTMENTS;
|
||||
const adjustments: ColorAdjustments = {
|
||||
...DEFAULT_ADJUSTMENTS,
|
||||
...seedAdj,
|
||||
temperature: wb ? wb.temperature : seedAdj.temperature,
|
||||
tint: wb ? wb.tint : seedAdj.tint,
|
||||
wbRed: int(wbRed, 0, -9, 9),
|
||||
wbBlue: int(wbBlue, 0, -9, 9),
|
||||
highlight: int(fHigh, simAdj.highlight, -10, 10),
|
||||
shadow: int(fShadow, simAdj.shadow, -10, 10),
|
||||
saturation: int(fColor, simAdj.saturation, -10, 10),
|
||||
denoise: int(fNr, simAdj.denoise, -10, 10),
|
||||
sharpening: int(fSharp, simAdj.sharpening, -10, 10),
|
||||
clarity: int(fClarity, simAdj.clarity, -10, 10),
|
||||
grain: int(grain, seedAdj.grain, 0, 10),
|
||||
colorChrome: cc as ColorAdjustments['colorChrome'],
|
||||
colorChromeBlue: ccb as ColorAdjustments['colorChromeBlue'],
|
||||
dynamicRange: (dr === 'auto' ? 'auto' : parseInt(dr, 10)) as ColorAdjustments['dynamicRange'],
|
||||
exposureCompensation: int(fEv, simAdj.exposureCompensation, -3, 3),
|
||||
};
|
||||
return { name: name.trim() || 'NEW RECIPE', baseFilter: sim, adjustments, frameId: 'none', useGeotag: true };
|
||||
};
|
||||
|
||||
const opts = (
|
||||
prefix: string,
|
||||
options: { v: string; d: string }[],
|
||||
value: string,
|
||||
onPick: (v: string) => void
|
||||
): ChipDef[] =>
|
||||
options.map((o) => ({
|
||||
key: `${prefix}:${o.v}`,
|
||||
label: o.d,
|
||||
active: value === o.v,
|
||||
onClick: () => onPick(o.v),
|
||||
}));
|
||||
|
||||
const num = (
|
||||
label: string,
|
||||
value: string,
|
||||
set: (s: string) => void,
|
||||
ph?: string
|
||||
) => (
|
||||
<label className="create-num" key={label}>
|
||||
<span>{label}</span>
|
||||
<input
|
||||
className="field"
|
||||
data-key={`create-${label}`}
|
||||
value={value}
|
||||
placeholder={ph ?? '0'}
|
||||
inputMode="numeric"
|
||||
onChange={(e) => set(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="create-form" data-key="create-form">
|
||||
<h2>{t('create.title')}</h2>
|
||||
|
||||
<label className="create-num wide">
|
||||
<span>NAME</span>
|
||||
<input
|
||||
className="field"
|
||||
data-key="create-name"
|
||||
value={name}
|
||||
placeholder="Recipe name (e.g. Retro Film)"
|
||||
maxLength={40}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<span className="create-label">SIMULATION</span>
|
||||
<ChipRow
|
||||
chips={FILM_SIMS.map((s) => ({
|
||||
key: s.id,
|
||||
label: s.name,
|
||||
active: sim === s.baseFilter,
|
||||
onClick: () => selectSim(s.baseFilter),
|
||||
}))}
|
||||
/>
|
||||
|
||||
<span className="create-label">DYNAMIC RANGE</span>
|
||||
<ChipRow chips={opts('dr', DR_OPTS, dr, setDr)} />
|
||||
|
||||
<div className="create-grid">
|
||||
{[
|
||||
{ label: 'HIGHLIGHT', value: fHigh, set: setFHigh },
|
||||
{ label: 'SHADOW', value: fShadow, set: setFShadow },
|
||||
{ label: 'COLOR', value: fColor, set: setFColor },
|
||||
{ label: 'NOISE RED.', value: fNr, set: setFNr, ph: '-10..10' },
|
||||
{ label: 'SHARPENING', value: fSharp, set: setFSharp, ph: '-10..10' },
|
||||
{ label: 'CLARITY', value: fClarity, set: setFClarity },
|
||||
{ label: 'EXPOSURE COMP.', value: fEv, set: setFEv, ph: 'EV' },
|
||||
].map((f) => num(f.label, f.value, f.set, f.ph))}
|
||||
</div>
|
||||
|
||||
<span className="create-label">GRAIN EFFECT</span>
|
||||
<ChipRow chips={opts('grain', GRAIN_OPTS, grain, setGrain)} />
|
||||
|
||||
<span className="create-label">COLOR CHROME EFFECT</span>
|
||||
<ChipRow chips={opts('cc', CC_OPTS, cc, setCc)} />
|
||||
|
||||
<span className="create-label">COLOR CHROME EFFECT BLUE</span>
|
||||
<ChipRow chips={opts('ccb', CC_OPTS, ccb, setCcb)} />
|
||||
|
||||
<span className="create-label">WHITE BALANCE</span>
|
||||
<ChipRow
|
||||
chips={[
|
||||
...opts('wb', WB_PRESETS.map((w) => ({ v: w.key, d: w.label })), wbKey, setWbKey),
|
||||
...(wbKey === 'custom'
|
||||
? [{ key: 'wb:custom', label: `${seedAdj.temperature}K`, active: true, onClick: () => setWbKey('custom') }]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="create-shift">
|
||||
<span className="create-label">WB SHIFT</span>
|
||||
<span className="create-label">RED</span>
|
||||
<input
|
||||
className="field"
|
||||
data-key="create-wbRed"
|
||||
value={wbRed}
|
||||
placeholder="-9..9"
|
||||
inputMode="numeric"
|
||||
onChange={(e) => setWbRed(e.target.value)}
|
||||
/>
|
||||
<span className="create-label">BLUE</span>
|
||||
<input
|
||||
className="field"
|
||||
data-key="create-wbBlue"
|
||||
value={wbBlue}
|
||||
placeholder="-9..9"
|
||||
inputMode="numeric"
|
||||
onChange={(e) => setWbBlue(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="create-shift">
|
||||
<span className="create-label">ISO</span>
|
||||
<ChipRow chips={[{ key: 'iso-auto', label: 'AUTO (camera default)', disabled: true, onClick: () => {} }]} />
|
||||
</div>
|
||||
|
||||
<div className="create-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary"
|
||||
data-key="create-save"
|
||||
onClick={() => onSave(build())}
|
||||
>
|
||||
{t('create.save')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
data-key="create-export"
|
||||
disabled={!signedIn}
|
||||
title={signedIn ? t('create.export') : t('create.guestHint')}
|
||||
onClick={() => onExport(build())}
|
||||
>
|
||||
{t('create.export')}
|
||||
</button>
|
||||
</div>
|
||||
<p className="hint">{signedIn ? t('create.userHint') : t('create.guestHint')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { useI18n } from '../i18n/I18nProvider';
|
||||
|
||||
// The app's six tabs, in the same order as the phone rail. The rail is vertical
|
||||
// on desktop and becomes the bottom bar under 860px (see styles/app.css).
|
||||
export type TabId = 'presets' | 'favorited' | 'light' | 'wb' | 'fx' | 'frame';
|
||||
export type TabId = 'presets' | 'favorited' | 'light' | 'wb' | 'fx' | 'frame' | 'create';
|
||||
|
||||
export const TABS: { id: TabId; label: MsgKey; glyph: string }[] = [
|
||||
{ id: 'presets', label: 'tab.presets', glyph: '◉' },
|
||||
@@ -12,6 +12,7 @@ export const TABS: { id: TabId; label: MsgKey; glyph: string }[] = [
|
||||
{ id: 'wb', label: 'tab.wb', glyph: '◐' },
|
||||
{ id: 'fx', label: 'tab.fx', glyph: '✦' },
|
||||
{ id: 'frame', label: 'tab.frame', glyph: '▣' },
|
||||
{ id: 'create', label: 'tab.create', glyph: '✎' },
|
||||
];
|
||||
|
||||
export function ToolRail({ tab, onTab }: { tab: TabId; onTab: (tab: TabId) => void }) {
|
||||
|
||||
Reference in New Issue
Block a user