diff --git a/App.tsx b/App.tsx index b4668e7..350f923 100644 --- a/App.tsx +++ b/App.tsx @@ -54,6 +54,13 @@ export default function App() { // the bottom deck stays under the keyboard. Track the IME height ourselves and // lift the deck by it (covers the custom-watermark text input). const [keyboardHeight, setKeyboardHeight] = useState(0); + // Top edge of the bottom deck (its own onLayout) -> the height left for the + // photo. Library mode letterboxes the image above it so no watermark + // position hides under an open panel. Null until the first layout pass. + const [deckTop, setDeckTop] = useState(null); + // Library photo rotation (FRAME tab), 0 or 90deg clockwise; feeds the + // preview source and the export alike. + const [photoRotation, setPhotoRotation] = useState<0 | 90>(0); useEffect(() => { const show = Keyboard.addListener('keyboardDidShow', (e) => setKeyboardHeight(e.endCoordinates.height)); const hide = Keyboard.addListener('keyboardDidHide', () => setKeyboardHeight(0)); @@ -76,6 +83,20 @@ export default function App() { // live Viewfinder overlay, the panel input and every export share one state. const DEFAULT_CUSTOM_WM: CustomWatermark = { enabled: false, text: '', x: 0.5, y: 0.5 }; const [customWm, setCustomWm] = useState({ ...DEFAULT_CUSTOM_WM }); + // The mark the EXPORT burns in. The chip above is the live draft (typing + + // dragging update it); only ACCEPT in the WATERMARK tab copies the draft in + // here — until then the file keeps the previous stamp (or none). + const [customWmApplied, setCustomWmApplied] = useState({ + ...DEFAULT_CUSTOM_WM, + }); + // Bumped by a double-tap on the mark: the panel re-focuses the text input. + const [wmEditNonce, setWmEditNonce] = useState(0); + // Draft edit: toggling the chip OFF also drops any accepted stamp, so a + // switched-off watermark can never come back in an export. + const handleUpdateCustomWm = (wm: CustomWatermark) => { + setCustomWm(wm); + if (!wm.enabled) setCustomWmApplied({ ...DEFAULT_CUSTOM_WM }); + }; // Composition aspect-ratio (FULL / 4:3 / 3:2 upright) + RAW DNG sidecar. const [aspectRatio, setAspectRatio] = useState('full'); const [rawEnabled, setRawEnabled] = useState(false); @@ -320,6 +341,9 @@ export default function App() { text: typeof raw.text === 'string' ? raw.text : '', x: clamp01(raw.x, 0.5), y: clamp01(raw.y, 0.5), + color: typeof raw.color === 'string' ? raw.color : undefined, + size: typeof raw.size === 'number' && Number.isFinite(raw.size) ? raw.size : undefined, + font: typeof raw.font === 'string' ? raw.font : null, }); } if (session.selectedRecipeId) { @@ -549,6 +573,7 @@ export default function App() { if (!result.canceled && result.assets && result.assets.length > 0) { const asset = result.assets[0]; setLibraryImageUri(asset.uri); + setPhotoRotation(0); setMode('library'); // Clear old GPS — will be replaced if the photo (or device) has location data setGpsInfo(null); @@ -583,12 +608,27 @@ export default function App() { // Thumbnail/preview target for the camera-mode right button: newest photo. const lastPhotoUri = photoHistory.length > 0 ? photoHistory[photoHistory.length - 1].uri : null; - // Watermark handed to the export engines (Skia + native): null while disabled - // or empty, else trimmed text + position. Trimming here keeps the stamped - // text identical to what the preview drew. - const wmForExport = (): { text: string; x: number; y: number } | null => - customWm.enabled && customWm.text.trim().length > 0 - ? { text: customWm.text.trim(), x: customWm.x, y: customWm.y } + // Watermark handed to the export engines (Skia + native): the ACCEPTED mark + // (not the live draft), null while disabled or empty, else trimmed text + + // position. Trimming here keeps the stamped text identical to what the + // preview drew. + const wmForExport = (): { + text: string; + x: number; + y: number; + color?: string; + size?: number; + font?: string | null; + } | null => + customWmApplied.enabled && customWmApplied.text.trim().length > 0 + ? { + text: customWmApplied.text.trim(), + x: customWmApplied.x, + y: customWmApplied.y, + color: customWmApplied.color, + size: customWmApplied.size, + font: customWmApplied.font, + } : null; const handleCapture = async () => { @@ -751,6 +791,7 @@ export default function App() { // Pinch/drag inside a polaroid card window or wall-frame opening: // save the same crop the preview showed (null = centered 1x). frameWindowZoom: viewfinderRef.current?.getFrameWindowZoom?.() ?? null, + photoRotation, } ); @@ -920,6 +961,8 @@ export default function App() { useGeotag={useGeotag} gpsInfo={gpsInfo} libraryImageUri={libraryImageUri} + availableHeight={deckTop} + photoRotation={photoRotation} cameraPermissionGranted={cameraPermissionGranted} onRequestCameraPermission={requestCameraPermission} photoOutput={photoOutput} @@ -933,6 +976,7 @@ export default function App() { imageAdjustTarget={imageAdjustTarget} customWm={customWm} onWmPositionChange={(x, y) => setCustomWm((p) => ({ ...p, x, y }))} + onWmEdit={() => setWmEditNonce((n) => n + 1)} /> @@ -940,7 +984,11 @@ export default function App() { {/* Bottom deck: chip panel (progressive disclosure) -> tool rail -> shutter */} - + setDeckTop(e.nativeEvent.layout.y)} + > {openTab !== null && ( setPhotoRotation((r) => (r === 0 ? 90 : 0))} useGeotag={useGeotag} openParam={openParamKey} onOpenParamChange={setOpenParamKey} @@ -968,7 +1018,9 @@ export default function App() { onSaveRecipe={handleSaveRecipe} onDeleteRecipe={handleDeleteRecipe} customWm={customWm} - onUpdateCustomWm={setCustomWm} + onUpdateCustomWm={handleUpdateCustomWm} + onAcceptCustomWm={() => setCustomWmApplied({ ...customWm })} + wmEditNonce={wmEditNonce} /> )} diff --git a/src/components/AdjustmentPanel.tsx b/src/components/AdjustmentPanel.tsx index e17eb4e..e245dcf 100644 --- a/src/components/AdjustmentPanel.tsx +++ b/src/components/AdjustmentPanel.tsx @@ -1,8 +1,9 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import { View, Text, TextInput, TouchableOpacity, ScrollView } from 'react-native'; import Slider from '@react-native-community/slider'; import * as Haptics from 'expo-haptics'; import { Trash2 } from 'lucide-react-native'; +import { Skia } from '@shopify/react-native-skia'; import { Recipe, ColorAdjustments, CustomWatermark, FrameId, BaseFilter } from '../types'; import { FRAMES } from '../utils/frameUtils'; import { PARAM_DEFS, ContinuousParamDef } from '../utils/paramDefs'; @@ -18,7 +19,7 @@ import RecipeCreateModal from './RecipeCreateModal'; type TabId = 'recipes' | 'iq' | 'wb' | 'filters' | 'frame' | 'watermark'; // 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 = 'cx' | 'cxb' | 'dr'; +type GroupKey = 'cx' | 'cxb' | 'dr' | 'wmColor' | 'wmSize' | 'wmFont'; interface AdjustmentPanelProps { activeTab: TabId; @@ -43,6 +44,10 @@ interface AdjustmentPanelProps { onSelectRecipe: (recipe: Recipe) => void; onUpdateAdjustments: (adjustments: Partial) => void; onUpdateFrame: (frameId: FrameId) => void; + // FRAME tab: turn the inserted library photo 90deg (landscape <-> portrait) + // so it fits the chosen frame better. + photoRotation: 0 | 90; + onToggleRotation: () => void; onToggleGeotag: (enabled: boolean) => void; onSaveRecipe: (name: string) => void; onDeleteRecipe: (id: string) => void; @@ -50,6 +55,11 @@ interface AdjustmentPanelProps { // input and the export options all read the same object. customWm: CustomWatermark; onUpdateCustomWm: (wm: CustomWatermark) => void; + // ACCEPT: copy the live draft into the mark the export burns in. Edits stay a + // draft (viewfinder preview only) until this is pressed. + onAcceptCustomWm: () => void; + // Bumped by a double-tap on the mark: put the caret back in the text input. + wmEditNonce: number; } interface ChipDef { @@ -90,6 +100,25 @@ const WB_PAIRS: Record = { fluorescent: { t: 4000, ti: 3 }, }; +// WATERMARK tab styling choices: swatches for TEXT COLOR, multipliers on the +// base 3.2%-of-frame-width size for TEXT SIZE. +const WM_DEFAULT_COLOR = '#f59e0b'; +const WM_COLORS: { v: string; d: string }[] = [ + { v: '#f59e0b', d: 'AMBER' }, + { v: '#ffffff', d: 'WHITE' }, + { v: '#111111', d: 'BLACK' }, + { v: '#ef4444', d: 'RED' }, + { v: '#3b82f6', d: 'BLUE' }, + { v: '#22c55e', d: 'GREEN' }, + { v: '#a1a1aa', d: 'GREY' }, +]; +const WM_SIZES: { v: string; d: string }[] = [ + { v: '0.6', d: 'S' }, + { v: '1', d: 'M' }, + { v: '1.6', d: 'L' }, + { v: '2.5', d: 'XL' }, +]; + export default function AdjustmentPanel({ activeTab, recipes, @@ -104,11 +133,28 @@ export default function AdjustmentPanel({ onSelectRecipe, onUpdateAdjustments, onUpdateFrame, + photoRotation, onToggleGeotag, onDeleteRecipe, customWm, onUpdateCustomWm, + onAcceptCustomWm, + wmEditNonce, + onToggleRotation, }: 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. + const wmInputRef = useRef(null); + useEffect(() => { + if (wmEditNonce > 0) { + // BACK hides the IME but leaves the input focused, so a bare focus() is a + // no-op and the keyboard never returns. Blur first so the re-focus is a + // real focus change the IME reacts to. + wmInputRef.current?.blur(); + const id = setTimeout(() => wmInputRef.current?.focus(), 60); + return () => clearTimeout(id); + } + }, [wmEditNonce]); // The single choice-group whose strip (OFF/WEAK/STRONG, or the DR ladder) is // open above the chips (same one-row-at-a-time pattern as openParam — which // lives in App now, since an open row also arms image-drag adjustment). @@ -280,6 +326,21 @@ export default function AdjustmentPanel({ { v: '200', d: 'DR200' }, { v: '400', d: 'DR400' }, ]; + // WATERMARK tab styling strips. FONT lists every family the PLATFORM font + // manager exposes (the device's own system fonts); DEFAULT keeps the face + // the mark shipped with. + const wmFontOptions = useMemo(() => { + const mgr = Skia.FontMgr.System(); + const names = new Set(); + for (let i = 0; i < mgr.countFamilies() && names.size < 120; i++) { + const n = mgr.getFamilyName(i); + if (n && !/fallback/i.test(n)) names.add(n); + } + return [ + { v: 'DEFAULT', d: 'DEFAULT' }, + ...[...names].sort().map((n) => ({ v: n, d: n })), + ]; + }, []); const groupDefs: Record void }> = { cx: { label: 'COLOR CHROME', off: 'none', value: a.colorChrome || 'none', options: chromeOptions, @@ -296,6 +357,20 @@ export default function AdjustmentPanel({ dynamicRange: (v === 'auto' ? 'auto' : parseInt(v, 10)) as ColorAdjustments['dynamicRange'], }), }, + wmColor: { + label: 'TEXT COLOR', off: WM_DEFAULT_COLOR, + value: customWm.color ?? WM_DEFAULT_COLOR, options: WM_COLORS, + onPick: (v) => onUpdateCustomWm({ ...customWm, color: v }), + }, + wmSize: { + label: 'TEXT SIZE', off: '1', value: String(customWm.size ?? 1), options: WM_SIZES, + onPick: (v) => onUpdateCustomWm({ ...customWm, size: Number(v) }), + }, + wmFont: { + label: 'TEXT FONT', off: 'DEFAULT', value: customWm.font ?? 'DEFAULT', + options: wmFontOptions, + onPick: (v) => onUpdateCustomWm({ ...customWm, font: v === 'DEFAULT' ? null : v }), + }, }; const groupChip = (g: GroupKey): ChipDef => ({ key: `grp-${g}`, @@ -416,6 +491,12 @@ export default function AdjustmentPanel({ selectedFrame, (val) => onUpdateFrame(val as FrameId) )} + {renderChip({ + key: 'rotate', + label: photoRotation === 0 ? 'ROTATE 90' : 'ROTATE 0', + active: photoRotation !== 0, + onPress: onToggleRotation, + })} ); @@ -438,6 +519,16 @@ export default function AdjustmentPanel({ active: customWm.enabled, onPress: () => onUpdateCustomWm({ ...customWm, enabled: !customWm.enabled }), })} + {customWm.enabled && + renderChip({ + key: 'custom-wm-accept', + label: 'ACCEPT', + active: true, + onPress: onAcceptCustomWm, + })} + {customWm.enabled && renderChip(groupChip('wmColor'))} + {customWm.enabled && renderChip(groupChip('wmSize'))} + {customWm.enabled && renderChip(groupChip('wmFont'))} ); @@ -542,6 +633,7 @@ export default function AdjustmentPanel({ {activeTab === 'watermark' && customWm.enabled && ( onUpdateCustomWm({ ...customWm, text })} placeholder="TYPE WATERMARK TEXT" @@ -549,11 +641,6 @@ export default function AdjustmentPanel({ className="rounded-md border border-amber-500/70 bg-black/40 px-3 py-2 font-mono text-sm font-bold text-amber-300" maxLength={80} /> - - {customWm.text.trim().length > 0 - ? 'DRAG ON IMAGE TO MOVE — TAP TO PLACE' - : 'TYPE TEXT ABOVE, THEN DRAG ON IMAGE TO PLACE'} - )} {chipsRow} diff --git a/src/components/Viewfinder.tsx b/src/components/Viewfinder.tsx index 0286802..3dd2467 100644 --- a/src/components/Viewfinder.tsx +++ b/src/components/Viewfinder.tsx @@ -22,6 +22,7 @@ import { TileMode, FilterMode, MipmapMode, + FontStyle, } from '@shopify/react-native-skia'; import { Lock } from 'lucide-react-native'; import { Recipe, GPSInfo, FrameId, AspectRatio, ColorAdjustments, ASPECT_RATIO_W_H } from '../types'; @@ -29,6 +30,7 @@ import { getSkiaColorMatrix, applyExposureGain } from '../utils/colorUtils'; import { TONE_SKSL, getToneUniforms } from '../utils/toneShader'; import { CINEMA_SKSL, getCinemaUniforms, cinemaOffFlat } from '../utils/cinemaShader'; import { polaroidLayout, wallframeLayout, WALLFRAME_PNG_W, WALLFRAME_PNG_H } from '../utils/frameUtils'; +import { rotateSkImage90 } from '../utils/skiaImage'; interface ViewfinderProps { mode: 'camera' | 'library'; @@ -40,6 +42,16 @@ interface ViewfinderProps { useGeotag: boolean; gpsInfo: GPSInfo | null; libraryImageUri: string | null; + // Library only: screen height still visible above the bottom deck. The whole + // viewfinder geometry is letterboxed into it so no part of the photo (and no + // custom-watermark position) hides under an open panel. Camera ignores this: + // its feed rects are normalized against the real window. + availableHeight?: number | null; + // Library only: user rotation of the inserted photo, 0 or 90deg clockwise. + photoRotation?: 0 | 90; + // WATERMARK tab: double-tap ON the mark re-opens the text editor (App bumps + // a nonce so the panel puts the caret back in its input). + onWmEdit?: () => void; cameraPermissionGranted: boolean; onRequestCameraPermission: () => void; photoOutput: CameraPhotoOutput; @@ -159,6 +171,7 @@ const Viewfinder = forwardRef(function Viewfi useGeotag, gpsInfo, libraryImageUri, + availableHeight, cameraPermissionGranted, onRequestCameraPermission, photoOutput, @@ -172,12 +185,17 @@ const Viewfinder = forwardRef(function Viewfi imageAdjustTarget, customWm = { enabled: false, text: '', x: 0.5, y: 0.5 }, onWmPositionChange, + onWmEdit, + photoRotation = 0, }: ViewfinderProps, ref ) { // Fullscreen hero: the viewfinder fills the entire screen, every overlay // rect derives from window size. - const { width: vw, height: vh } = useWindowDimensions(); + const { width: vw, height: winH } = useWindowDimensions(); + // Library: everything below this line is the bottom deck, so the photo is + // letterboxed above it (top-anchored: same fractions, same export). + const vh = mode === 'library' && availableHeight && availableHeight > 0 ? availableHeight : winH; const insets = useSafeAreaInsets(); // Composition ratio band. With a ratio active the photo area is a centered @@ -251,7 +269,14 @@ const Viewfinder = forwardRef(function Viewfi } }, [device]); - const skiaImage = useImage(libraryImageUri || ''); + const loadedImage = useImage(libraryImageUri || ''); + // FRAME-tab rotation: pre-rotate the source, so the width()/height() swap + // flows through every derived rect (polaroid window aspect, cover crop, + // watermark area) and the export matches the preview for free. + const skiaImage = useMemo( + () => (photoRotation === 90 ? rotateSkImage90(loadedImage) : loadedImage), + [loadedImage, photoRotation] + ); // Wall-frame PNG artwork (project root). Loaded lazily so other frames pay // no asset cost; source switches to '' when unselected (useImage → null). const wallImage = useImage(selectedFrame === 'wallframe' ? require('../../wallframe.png') : ''); @@ -859,14 +884,24 @@ const Viewfinder = forwardRef(function Viewfi // pole-like, whereas the real print is a shorter 3:4 card letterboxed on // screen). ponytail: verified on Xiaomi quality-mode captures (4:3); if the // session negotiates 16:9 photos, change to 9/16. + // The frame keys off the ORIGINAL (pre-rotation) source: rotating the print + // inside the frame must never resize the frame itself. + const framePhotoAspect = + loadedImage && loadedImage.width() > 0 && loadedImage.height() > 0 + ? loadedImage.width() / loadedImage.height() + : 0; const polaroidAspect = - ratioWH ?? - (mode === 'library' && skiaImage && skiaImage.width() > 0 && skiaImage.height() > 0 - ? skiaImage.width() / skiaImage.height() - : 3 / 4); + ratioWH ?? (mode === 'library' && framePhotoAspect > 0 ? framePhotoAspect : 3 / 4); // Photo draw fit inside the polaroid window: cover when the window aspect - // (ratio) differs from the source photo, fill otherwise (aspects match). - const polaroidFit: 'cover' | 'fill' = ratioWH ? 'cover' : 'fill'; + // (ratio) differs from the source photo, fill otherwise (aspects match). A + // print rotated inside the frame no longer matches the (un-rotated) window + // aspect, so it is contain-fitted — letterboxed inside the window, never + // stretched, and the card keeps its dimensions. + const polaroidFit: 'cover' | 'contain' | 'fill' = ratioWH + ? 'cover' + : photoRotation === 90 + ? 'contain' + : 'fill'; const polaroidGeo = useMemo(() => { if (selectedFrame !== 'polaroid') return null; return polaroidLayout(vw, vh, polaroidAspect, 'contain'); @@ -992,13 +1027,27 @@ const Viewfinder = forwardRef(function Viewfi // Photo/window area the 0..1 fractions map onto — identical rule to // renderGPSWatermark so both marks share the user-visible coordinate space. const wmArea = () => { - const win = polaroidGeo?.window ?? wallGeo?.window ?? null; - if (win) return { dx: win.x, dy: win.y, dw: win.w, dh: win.h }; + // The FRAME, not just the photo window: the mark may be dragged anywhere + // on the card / frame (the flat bottom deck is where a caption belongs). + // A framed export's canvas IS that frame, so the same 0..1 fractions + // carry over to the burn-in. + const box = polaroidGeo?.card ?? wallGeo?.frame ?? null; + if (box) return { dx: box.x, dy: box.y, dw: box.w, dh: box.h }; if (regionBand) return { dx: region.x, dy: region.y, dw: region.w, dh: region.h }; if (mode === 'library' && imageFitRect) return imageFitRect; return { dx: 0, dy: 0, dw: vw, dh: vh }; }; - const wmFontSize = Math.round(vw * 0.032); // matches customFont/iconFont size + const wmFontSize = Math.round(vw * 0.032 * (customWm.size ?? 1)); + const wmColor = customWm.color ?? '#f59e0b'; + // FONT chip: an OS family resolved through the platform font manager; no + // family picked -> the bundled face customFont was built from, re-sized to + // the current SIZE so one code path serves both. + const wmFont = useMemo(() => { + const face = customWm.font + ? Skia.FontMgr.System().matchFamilyStyle(customWm.font, FontStyle.Normal) + : customFont?.getTypeface() ?? null; + return face ? Skia.Font(face, wmFontSize) : null; + }, [customWm.font, wmFontSize, customFont]); const wmFracFromXY = (x: number, y: number) => { const area = wmArea(); const dw = area.dw || 1; @@ -1008,15 +1057,45 @@ const Viewfinder = forwardRef(function Viewfi y: Math.max(0, Math.min(1, (y - area.dy) / dh)), }; }; + // placeWm/commitWm are called back-to-back inside one gesture handler, so + // reading the wmDragPos state would commit the PREVIOUS render's value (a + // tap with no move events committed nothing at all). Mirror it in a ref. + const wmDragPosRef = useRef<{ x: number; y: number } | null>(null); const placeWm = (x: number, y: number) => { if (!wmArmed) return; - setWmDragPos(wmFracFromXY(x, y)); + const pos = wmFracFromXY(x, y); + wmDragPosRef.current = pos; + setWmDragPos(pos); }; const commitWm = () => { - if (!wmArmed || !onWmPositionChange) return; - if (wmDragPos) onWmPositionChange(wmDragPos.x, wmDragPos.y); + const pos = wmDragPosRef.current; + wmDragPosRef.current = null; setWmDragPos(null); + if (!wmArmed || !onWmPositionChange || !pos) return; + onWmPositionChange(pos.x, pos.y); }; + // Bounding box of the drawn mark (same anchor maths as renderCustomWatermark) + // so a double-tap ON the text re-opens the editor instead of moving it. + const wmBox = () => { + if (!wmArmed || !wmFont) return null; + const area = wmArea(); + const fracX = wmDragPos?.x ?? customWm.x; + const fracY = wmDragPos?.y ?? customWm.y; + const tx = area.dx + fracX * area.dw; + const ty = area.dy + fracY * area.dh + Math.round(wmFontSize * 0.35); + const w = Math.max(wmFont.measureText(customWm.text.trim()).width, wmFontSize * 2); + return { x: tx, y: ty - wmFontSize, w, h: Math.round(wmFontSize * 1.4) }; + }; + const wmBoxHit = ( + box: { x: number; y: number; w: number; h: number } | null, + x: number, + y: number + ) => + !!box && + x >= box.x - 8 && + x <= box.x + box.w + 8 && + y >= box.y - 8 && + y <= box.y + box.h + 8; // Library framed (polaroid/wall) returns reuse the SAME responder handlers as // plain library (see the framed JSX below): single-finger drags place/move // the text watermark while armed, pinch + double-tap repositions the photo @@ -1086,6 +1165,21 @@ const Viewfinder = forwardRef(function Viewfi // Watermark armed: a tap places + commits the text and skips the double-tap // zoom bookkeeping (double-tap zoom is disabled while placing). if (wmArmed && libZoom.s <= 1.01) { + const now = Date.now(); + const prev = lastTapRef.current; + lastTapRef.current = { x, y, t: now }; + // Second tap ON the mark (keyboard hidden, still placing): back to the + // text editor — the mark is only embedded by ACCEPT in the panel. + if ( + prev && + now - prev.t < 350 && + Math.hypot(x - prev.x, y - prev.y) < 50 && + wmBoxHit(wmBox(), x, y) + ) { + lastTapRef.current = null; + onWmEdit?.(); + return; + } placeWm(x, y); commitWm(); return; @@ -1250,7 +1344,7 @@ const Viewfinder = forwardRef(function Viewfi }; const renderCustomWatermark = () => { - if (!wmArmed || !customFont) return null; + if (!wmArmed || !wmFont) return null; const area = wmArea(); const fracX = wmDragPos?.x ?? customWm.x; const fracY = wmDragPos?.y ?? customWm.y; @@ -1259,7 +1353,7 @@ const Viewfinder = forwardRef(function Viewfi const tx = area.dx + fracX * area.dw; const ty = area.dy + fracY * area.dh + Math.round(wmFontSize * 0.35); return ( - + ); }; diff --git a/src/types/index.ts b/src/types/index.ts index 2efd838..c68bb40 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -87,4 +87,10 @@ export interface CustomWatermark { text: string; x: number; y: number; + // Styling set by the COLOR / SIZE / FONT chips. Absent on marks saved before + // those controls existed — the renderers then fall back to amber, 1x and the + // bundled Cousine face, i.e. exactly the previous look. + color?: string; + size?: number; // multiplier on the base 3.2%-of-width text size + font?: string | null; // OS font family; null/absent = bundled font } diff --git a/src/utils/exportEngine.ts b/src/utils/exportEngine.ts index e5072f5..e23bda9 100644 --- a/src/utils/exportEngine.ts +++ b/src/utils/exportEngine.ts @@ -1,4 +1,4 @@ -import { Skia, ImageFormat, BlendMode, TileMode, FilterMode, MipmapMode, type SkImage, type SkShader, type SkSurface } from '@shopify/react-native-skia'; +import { Skia, ImageFormat, BlendMode, TileMode, FilterMode, MipmapMode, FontStyle, type SkImage, type SkShader, type SkSurface } from '@shopify/react-native-skia'; import { File } from 'expo-file-system'; import * as FileSystem from 'expo-file-system/legacy'; import * as MediaLibrary from 'expo-media-library/legacy'; @@ -9,6 +9,7 @@ import { TONE_SKSL, getToneUniforms, toneIsActive } from './toneShader'; import { CINEMA_SKSL, getCinemaUniforms, cinemaIsActive } from './cinemaShader'; import { drawFrameOnCanvas, polaroidLayout, POLAROID_CARD, POLAROID_WIN_W, wallframeLayout, WALLFRAME_W, WALLFRAME_H } from './frameUtils'; import { patchJpegDpi } from './jpegDpi'; +import { rotateSkImage90 } from './skiaImage'; export interface ExportResult { uri: string; @@ -37,7 +38,14 @@ export interface ExportOptions { // Custom text watermark: typed text + 0..1 fractions of the photo area // (BASELINE anchor + 0.35em drop — same formula as the viewfinder overlay, // so the export lands where the preview showed it). Amber mono, no icons. - watermark?: { text: string; x: number; y: number } | null; + watermark?: { + text: string; + x: number; + y: number; + color?: string; + size?: number; + font?: string | null; + } | null; // Library framed-window photo reposition (Viewfinder.getFrameWindowZoom): // after pinch/drag inside a polaroid card window or a wall-frame opening, // s = scale and u/v = the fraction of the photo's cover plane sitting at the @@ -45,6 +53,10 @@ export interface ExportOptions { // visible through the opening, so the file matches the preview. Null / // undefined = centered 1x (the historical full-image draw). frameWindowZoom?: { s: number; u: number; v: number } | null; + // Library FRAME-tab rotation (90 = clockwise). Applied to the decoded source + // before the ratio crop, so the frame layout and cover maths see exactly the + // orientation the preview showed. + photoRotation?: 0 | 90; } // Lightroom-style "Screen" output sharpen: subtle unsharp mask. A 3x3 @@ -108,6 +120,9 @@ export async function processAndExportPhoto( return null; } let skImage = skImage0; + // FRAME-tab rotation first: the pixel-dimension swap then propagates + // through the ratio crop, frame layout and cover maths unchanged. + if (options?.photoRotation === 90) skImage = rotateSkImage90(skImage) ?? skImage; let width = skImage.width(); let height = skImage.height(); // 1b. Aspect-ratio crop (composition): center-largest upright crop of the @@ -324,8 +339,13 @@ export async function processAndExportPhoto( if (frameId === 'polaroid') { const photoImg = surface.makeImageSnapshot(); const outW = width; // card width matches the photo width + // Card proportions follow the UN-rotated source, so rotating the print + // inside the frame never resizes the frame (same rule as the viewfinder). + const frameAspect = + ratioWH ?? (options?.photoRotation === 90 ? height / width : width / height); const outH = Math.round( - width * (POLAROID_CARD.sideMargin + POLAROID_CARD.bottomDeck) + height * POLAROID_WIN_W + width * (POLAROID_CARD.sideMargin + POLAROID_CARD.bottomDeck) + + (width * POLAROID_WIN_W) / frameAspect ); const cardSurface = createSurface(outW, outH); if (!cardSurface) { @@ -336,9 +356,11 @@ export async function processAndExportPhoto( const bgPaint = Skia.Paint(); bgPaint.setColor(Skia.Color('#faf9f6')); // off-white card cardCanvas.drawRect(Skia.XYWHRect(0, 0, outW, outH), bgPaint); - const pl = polaroidLayout(outW, outH, width / height, 'contain'); + const pl = polaroidLayout(outW, outH, frameAspect, 'contain'); const win = pl.window; - // Window aspect == photo aspect → fills exactly, no letterbox inside. + // Window aspect == the un-rotated photo aspect → an un-rotated export + // fills it exactly; a rotated print is contain-fitted (centered, + // letterboxed) inside it, never stretched. // With a framed-window zoom the source rect shrinks to the visible part // (window keeps showing the same geometry as the viewfinder). const plainPaint = Skia.Paint(); @@ -352,10 +374,17 @@ export async function processAndExportPhoto( const pSy = pwz ? Math.min(Math.max(height * pwz.v - pVisH / 2, 0), height - pVisH) : 0; + const srcAspect = width / height; + let dstW = win.w; + let dstH = dstW / srcAspect; + if (dstH > win.h) { + dstH = win.h; + dstW = dstH * srcAspect; + } cardCanvas.drawImageRect( photoImg, Skia.XYWHRect(pSx, pSy, pVisW, pVisH), - Skia.XYWHRect(win.x, win.y, win.w, win.h), + Skia.XYWHRect(win.x + (win.w - dstW) / 2, win.y + (win.h - dstH) / 2, dstW, dstH), plainPaint ); // Thin seam separating image window from the card. @@ -505,13 +534,23 @@ export async function processAndExportPhoto( // center, not its baseline) — the exact formula Viewfinder uses, so // preview == export. if (customWm) { - const area = framedWindow - ? { dx: framedWindow.x, dy: framedWindow.y, dw: framedWindow.w, dh: framedWindow.h } - : { dx: 0, dy: 0, dw: stampW, dh: stampH }; + // The mark lives on the FRAME: a framed export's canvas IS the + // card/frame (see the branches above), so the whole stamp is the + // area — the same 0..1 fractions the viewfinder drag produced. + const area = { dx: 0, dy: 0, dw: stampW, dh: stampH }; const clamp01 = (v: number) => Math.max(0, Math.min(1, v)); + const wmSize = Math.round(fontSize * (customWm.size ?? 1)); + const wmFont = customWm.font + ? Skia.Font( + Skia.FontMgr.System().matchFamilyStyle(customWm.font, FontStyle.Normal), + wmSize + ) + : Skia.Font(typeface, wmSize); + const wmPaint = Skia.Paint(); + wmPaint.setColor(Skia.Color(customWm.color ?? '#f59e0b')); const tx = area.dx + clamp01(customWm.x) * area.dw; - const ty = area.dy + clamp01(customWm.y) * area.dh + Math.round(fontSize * 0.35); - stampCanvas.drawText(customWm.text, tx, ty, textPaint, font); + const ty = area.dy + clamp01(customWm.y) * area.dh + Math.round(wmSize * 0.35); + stampCanvas.drawText(customWm.text, tx, ty, wmPaint, wmFont); } } } diff --git a/src/utils/skiaImage.ts b/src/utils/skiaImage.ts new file mode 100644 index 0000000..e6d628a --- /dev/null +++ b/src/utils/skiaImage.ts @@ -0,0 +1,27 @@ +import { Skia, type SkImage } from '@shopify/react-native-skia'; + +// Turn a decoded image 90deg clockwise into a fresh SkImage. Used by the FRAME +// tab so a library photo can go landscape <-> portrait before it enters a frame: +// every consumer reads width()/height(), so the pixel-dimension swap carries the +// new orientation through the polaroid window aspect, the cover crop and the +// watermark areas without a single extra layout branch. +// ponytail: offscreen surface, same fallback as exportEngine.createSurface; a +// matrix-based draw would avoid the copy if this ever shows up in a profile. +export function rotateSkImage90(image: SkImage | null): SkImage | null { + if (!image) return null; + const w = image.width(); + const h = image.height(); + if (!(w > 0 && h > 0)) return null; + // CPU-backed on purpose: a GPU (MakeOffscreen) snapshot keeps its pixels in a + // context texture that is gone by the time the draws a frame later — + // the preview then renders the photo completely black. Raster pixels survive + // the trip to the render thread. + const surface = Skia.Surface.Make(h, w); + if (!surface) return image; // keep the unrotated photo on allocation failure + const canvas = surface.getCanvas(); + canvas.clear(Skia.Color('transparent')); + canvas.translate(h, 0); + canvas.rotate(90, 0, 0); + canvas.drawImage(image, 0, 0); + return surface.makeImageSnapshot() ?? image; +}