Edit the watermark in place, rotate the frame photo, place a custom watermark
Watermark editing no longer covers the photo: the preview letterboxes the image inside its fit rect while the deck is open, and the idle hint is gone. The FRAME tab can rotate a library photo 90 degrees clockwise. Rotation swaps the pixel dimensions instead of the frame, so the polaroid/wall window keeps its aspect and every consumer (cover crop, watermark area, export) picks up the new orientation from width()/height() with no extra layout branch. The rotate surface is CPU-backed on purpose - a GPU snapshot loses its pixels before the next frame and renders the photo black. Custom watermark text can now be dragged anywhere on the photo, double-tapped to re-edit, and is only baked into the file once ACCEPT is pressed; the export engine takes the same position. It also gained COLOR/SIZE/FONT chips, with the font list read from the OS via Skia's FontMgr so any installed family can be used.
This commit is contained in:
@@ -54,6 +54,13 @@ export default function App() {
|
|||||||
// the bottom deck stays under the keyboard. Track the IME height ourselves and
|
// the bottom deck stays under the keyboard. Track the IME height ourselves and
|
||||||
// lift the deck by it (covers the custom-watermark text input).
|
// lift the deck by it (covers the custom-watermark text input).
|
||||||
const [keyboardHeight, setKeyboardHeight] = useState(0);
|
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<number | null>(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(() => {
|
useEffect(() => {
|
||||||
const show = Keyboard.addListener('keyboardDidShow', (e) => setKeyboardHeight(e.endCoordinates.height));
|
const show = Keyboard.addListener('keyboardDidShow', (e) => setKeyboardHeight(e.endCoordinates.height));
|
||||||
const hide = Keyboard.addListener('keyboardDidHide', () => setKeyboardHeight(0));
|
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.
|
// 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 DEFAULT_CUSTOM_WM: CustomWatermark = { enabled: false, text: '', x: 0.5, y: 0.5 };
|
||||||
const [customWm, setCustomWm] = useState<CustomWatermark>({ ...DEFAULT_CUSTOM_WM });
|
const [customWm, setCustomWm] = useState<CustomWatermark>({ ...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<CustomWatermark>({
|
||||||
|
...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.
|
// Composition aspect-ratio (FULL / 4:3 / 3:2 upright) + RAW DNG sidecar.
|
||||||
const [aspectRatio, setAspectRatio] = useState<AspectRatio>('full');
|
const [aspectRatio, setAspectRatio] = useState<AspectRatio>('full');
|
||||||
const [rawEnabled, setRawEnabled] = useState(false);
|
const [rawEnabled, setRawEnabled] = useState(false);
|
||||||
@@ -320,6 +341,9 @@ export default function App() {
|
|||||||
text: typeof raw.text === 'string' ? raw.text : '',
|
text: typeof raw.text === 'string' ? raw.text : '',
|
||||||
x: clamp01(raw.x, 0.5),
|
x: clamp01(raw.x, 0.5),
|
||||||
y: clamp01(raw.y, 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) {
|
if (session.selectedRecipeId) {
|
||||||
@@ -549,6 +573,7 @@ export default function App() {
|
|||||||
if (!result.canceled && result.assets && result.assets.length > 0) {
|
if (!result.canceled && result.assets && result.assets.length > 0) {
|
||||||
const asset = result.assets[0];
|
const asset = result.assets[0];
|
||||||
setLibraryImageUri(asset.uri);
|
setLibraryImageUri(asset.uri);
|
||||||
|
setPhotoRotation(0);
|
||||||
setMode('library');
|
setMode('library');
|
||||||
// Clear old GPS — will be replaced if the photo (or device) has location data
|
// Clear old GPS — will be replaced if the photo (or device) has location data
|
||||||
setGpsInfo(null);
|
setGpsInfo(null);
|
||||||
@@ -583,12 +608,27 @@ export default function App() {
|
|||||||
// Thumbnail/preview target for the camera-mode right button: newest photo.
|
// Thumbnail/preview target for the camera-mode right button: newest photo.
|
||||||
const lastPhotoUri = photoHistory.length > 0 ? photoHistory[photoHistory.length - 1].uri : null;
|
const lastPhotoUri = photoHistory.length > 0 ? photoHistory[photoHistory.length - 1].uri : null;
|
||||||
|
|
||||||
// Watermark handed to the export engines (Skia + native): null while disabled
|
// Watermark handed to the export engines (Skia + native): the ACCEPTED mark
|
||||||
// or empty, else trimmed text + position. Trimming here keeps the stamped
|
// (not the live draft), null while disabled or empty, else trimmed text +
|
||||||
// text identical to what the preview drew.
|
// position. Trimming here keeps the stamped text identical to what the
|
||||||
const wmForExport = (): { text: string; x: number; y: number } | null =>
|
// preview drew.
|
||||||
customWm.enabled && customWm.text.trim().length > 0
|
const wmForExport = (): {
|
||||||
? { text: customWm.text.trim(), x: customWm.x, y: customWm.y }
|
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;
|
: null;
|
||||||
|
|
||||||
const handleCapture = async () => {
|
const handleCapture = async () => {
|
||||||
@@ -751,6 +791,7 @@ export default function App() {
|
|||||||
// Pinch/drag inside a polaroid card window or wall-frame opening:
|
// Pinch/drag inside a polaroid card window or wall-frame opening:
|
||||||
// save the same crop the preview showed (null = centered 1x).
|
// save the same crop the preview showed (null = centered 1x).
|
||||||
frameWindowZoom: viewfinderRef.current?.getFrameWindowZoom?.() ?? null,
|
frameWindowZoom: viewfinderRef.current?.getFrameWindowZoom?.() ?? null,
|
||||||
|
photoRotation,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -920,6 +961,8 @@ export default function App() {
|
|||||||
useGeotag={useGeotag}
|
useGeotag={useGeotag}
|
||||||
gpsInfo={gpsInfo}
|
gpsInfo={gpsInfo}
|
||||||
libraryImageUri={libraryImageUri}
|
libraryImageUri={libraryImageUri}
|
||||||
|
availableHeight={deckTop}
|
||||||
|
photoRotation={photoRotation}
|
||||||
cameraPermissionGranted={cameraPermissionGranted}
|
cameraPermissionGranted={cameraPermissionGranted}
|
||||||
onRequestCameraPermission={requestCameraPermission}
|
onRequestCameraPermission={requestCameraPermission}
|
||||||
photoOutput={photoOutput}
|
photoOutput={photoOutput}
|
||||||
@@ -933,6 +976,7 @@ export default function App() {
|
|||||||
imageAdjustTarget={imageAdjustTarget}
|
imageAdjustTarget={imageAdjustTarget}
|
||||||
customWm={customWm}
|
customWm={customWm}
|
||||||
onWmPositionChange={(x, y) => setCustomWm((p) => ({ ...p, x, y }))}
|
onWmPositionChange={(x, y) => setCustomWm((p) => ({ ...p, x, y }))}
|
||||||
|
onWmEdit={() => setWmEditNonce((n) => n + 1)}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -940,7 +984,11 @@ export default function App() {
|
|||||||
<TopBar mode={mode} recipeName={activeRecipe.name} onSaveRecipe={handleSaveRecipe} />
|
<TopBar mode={mode} recipeName={activeRecipe.name} onSaveRecipe={handleSaveRecipe} />
|
||||||
|
|
||||||
{/* Bottom deck: chip panel (progressive disclosure) -> tool rail -> shutter */}
|
{/* Bottom deck: chip panel (progressive disclosure) -> tool rail -> shutter */}
|
||||||
<View className="absolute left-0 right-0 bottom-0 z-10" style={{ paddingBottom: keyboardHeight }}>
|
<View
|
||||||
|
className="absolute left-0 right-0 bottom-0 z-10"
|
||||||
|
style={{ paddingBottom: keyboardHeight }}
|
||||||
|
onLayout={(e) => setDeckTop(e.nativeEvent.layout.y)}
|
||||||
|
>
|
||||||
{openTab !== null && (
|
{openTab !== null && (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
@@ -956,6 +1004,8 @@ export default function App() {
|
|||||||
currentRecipeId={selectedRecipe?.id || ''}
|
currentRecipeId={selectedRecipe?.id || ''}
|
||||||
adjustments={adjustments}
|
adjustments={adjustments}
|
||||||
selectedFrame={selectedFrame}
|
selectedFrame={selectedFrame}
|
||||||
|
photoRotation={photoRotation}
|
||||||
|
onToggleRotation={() => setPhotoRotation((r) => (r === 0 ? 90 : 0))}
|
||||||
useGeotag={useGeotag}
|
useGeotag={useGeotag}
|
||||||
openParam={openParamKey}
|
openParam={openParamKey}
|
||||||
onOpenParamChange={setOpenParamKey}
|
onOpenParamChange={setOpenParamKey}
|
||||||
@@ -968,7 +1018,9 @@ export default function App() {
|
|||||||
onSaveRecipe={handleSaveRecipe}
|
onSaveRecipe={handleSaveRecipe}
|
||||||
onDeleteRecipe={handleDeleteRecipe}
|
onDeleteRecipe={handleDeleteRecipe}
|
||||||
customWm={customWm}
|
customWm={customWm}
|
||||||
onUpdateCustomWm={setCustomWm}
|
onUpdateCustomWm={handleUpdateCustomWm}
|
||||||
|
onAcceptCustomWm={() => setCustomWmApplied({ ...customWm })}
|
||||||
|
wmEditNonce={wmEditNonce}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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 { View, Text, TextInput, TouchableOpacity, ScrollView } from 'react-native';
|
||||||
import Slider from '@react-native-community/slider';
|
import Slider from '@react-native-community/slider';
|
||||||
import * as Haptics from 'expo-haptics';
|
import * as Haptics from 'expo-haptics';
|
||||||
import { Trash2 } from 'lucide-react-native';
|
import { Trash2 } from 'lucide-react-native';
|
||||||
|
import { Skia } from '@shopify/react-native-skia';
|
||||||
import { Recipe, ColorAdjustments, CustomWatermark, FrameId, BaseFilter } from '../types';
|
import { Recipe, ColorAdjustments, CustomWatermark, FrameId, BaseFilter } from '../types';
|
||||||
import { FRAMES } from '../utils/frameUtils';
|
import { FRAMES } from '../utils/frameUtils';
|
||||||
import { PARAM_DEFS, ContinuousParamDef } from '../utils/paramDefs';
|
import { PARAM_DEFS, ContinuousParamDef } from '../utils/paramDefs';
|
||||||
@@ -18,7 +19,7 @@ import RecipeCreateModal from './RecipeCreateModal';
|
|||||||
type TabId = 'recipes' | 'iq' | 'wb' | 'filters' | 'frame' | 'watermark';
|
type TabId = 'recipes' | 'iq' | 'wb' | 'filters' | 'frame' | 'watermark';
|
||||||
// Choice-group keys that open a strip above the chip row: color chrome groups
|
// Choice-group keys that open a strip above the chip row: color chrome groups
|
||||||
// (wb tab) and the D.RANGE selector (iq tab).
|
// (wb tab) and the D.RANGE selector (iq tab).
|
||||||
type GroupKey = 'cx' | 'cxb' | 'dr';
|
type GroupKey = 'cx' | 'cxb' | 'dr' | 'wmColor' | 'wmSize' | 'wmFont';
|
||||||
|
|
||||||
interface AdjustmentPanelProps {
|
interface AdjustmentPanelProps {
|
||||||
activeTab: TabId;
|
activeTab: TabId;
|
||||||
@@ -43,6 +44,10 @@ interface AdjustmentPanelProps {
|
|||||||
onSelectRecipe: (recipe: Recipe) => void;
|
onSelectRecipe: (recipe: Recipe) => void;
|
||||||
onUpdateAdjustments: (adjustments: Partial<ColorAdjustments>) => void;
|
onUpdateAdjustments: (adjustments: Partial<ColorAdjustments>) => void;
|
||||||
onUpdateFrame: (frameId: FrameId) => 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;
|
onToggleGeotag: (enabled: boolean) => void;
|
||||||
onSaveRecipe: (name: string) => void;
|
onSaveRecipe: (name: string) => void;
|
||||||
onDeleteRecipe: (id: string) => void;
|
onDeleteRecipe: (id: string) => void;
|
||||||
@@ -50,6 +55,11 @@ interface AdjustmentPanelProps {
|
|||||||
// input and the export options all read the same object.
|
// input and the export options all read the same object.
|
||||||
customWm: CustomWatermark;
|
customWm: CustomWatermark;
|
||||||
onUpdateCustomWm: (wm: CustomWatermark) => void;
|
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 {
|
interface ChipDef {
|
||||||
@@ -90,6 +100,25 @@ const WB_PAIRS: Record<string, { t: number; ti: number }> = {
|
|||||||
fluorescent: { t: 4000, ti: 3 },
|
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({
|
export default function AdjustmentPanel({
|
||||||
activeTab,
|
activeTab,
|
||||||
recipes,
|
recipes,
|
||||||
@@ -104,11 +133,28 @@ export default function AdjustmentPanel({
|
|||||||
onSelectRecipe,
|
onSelectRecipe,
|
||||||
onUpdateAdjustments,
|
onUpdateAdjustments,
|
||||||
onUpdateFrame,
|
onUpdateFrame,
|
||||||
|
photoRotation,
|
||||||
onToggleGeotag,
|
onToggleGeotag,
|
||||||
onDeleteRecipe,
|
onDeleteRecipe,
|
||||||
customWm,
|
customWm,
|
||||||
onUpdateCustomWm,
|
onUpdateCustomWm,
|
||||||
|
onAcceptCustomWm,
|
||||||
|
wmEditNonce,
|
||||||
|
onToggleRotation,
|
||||||
}: AdjustmentPanelProps) {
|
}: 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<TextInput>(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
|
// 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
|
// 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).
|
// 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: '200', d: 'DR200' },
|
||||||
{ v: '400', d: 'DR400' },
|
{ 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<string>();
|
||||||
|
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<GroupKey, { label: string; off: string; value: string; options: { v: string; d: string }[]; onPick: (v: string) => void }> = {
|
const groupDefs: Record<GroupKey, { label: string; off: string; value: string; options: { v: string; d: string }[]; onPick: (v: string) => void }> = {
|
||||||
cx: {
|
cx: {
|
||||||
label: 'COLOR CHROME', off: 'none', value: a.colorChrome || 'none', options: chromeOptions,
|
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'],
|
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 => ({
|
const groupChip = (g: GroupKey): ChipDef => ({
|
||||||
key: `grp-${g}`,
|
key: `grp-${g}`,
|
||||||
@@ -416,6 +491,12 @@ export default function AdjustmentPanel({
|
|||||||
selectedFrame,
|
selectedFrame,
|
||||||
(val) => onUpdateFrame(val as FrameId)
|
(val) => onUpdateFrame(val as FrameId)
|
||||||
)}
|
)}
|
||||||
|
{renderChip({
|
||||||
|
key: 'rotate',
|
||||||
|
label: photoRotation === 0 ? 'ROTATE 90' : 'ROTATE 0',
|
||||||
|
active: photoRotation !== 0,
|
||||||
|
onPress: onToggleRotation,
|
||||||
|
})}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -438,6 +519,16 @@ export default function AdjustmentPanel({
|
|||||||
active: customWm.enabled,
|
active: customWm.enabled,
|
||||||
onPress: () => onUpdateCustomWm({ ...customWm, enabled: !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'))}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -542,6 +633,7 @@ export default function AdjustmentPanel({
|
|||||||
{activeTab === 'watermark' && customWm.enabled && (
|
{activeTab === 'watermark' && customWm.enabled && (
|
||||||
<View className="border-b border-amber-500/30 bg-amber-500/5 px-3 py-2">
|
<View className="border-b border-amber-500/30 bg-amber-500/5 px-3 py-2">
|
||||||
<TextInput
|
<TextInput
|
||||||
|
ref={wmInputRef}
|
||||||
value={customWm.text}
|
value={customWm.text}
|
||||||
onChangeText={(text) => onUpdateCustomWm({ ...customWm, text })}
|
onChangeText={(text) => onUpdateCustomWm({ ...customWm, text })}
|
||||||
placeholder="TYPE WATERMARK 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"
|
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}
|
maxLength={80}
|
||||||
/>
|
/>
|
||||||
<Text className="mt-1 font-mono text-[9px] font-bold tracking-widest text-amber-400/80">
|
|
||||||
{customWm.text.trim().length > 0
|
|
||||||
? 'DRAG ON IMAGE TO MOVE — TAP TO PLACE'
|
|
||||||
: 'TYPE TEXT ABOVE, THEN DRAG ON IMAGE TO PLACE'}
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
{chipsRow}
|
{chipsRow}
|
||||||
|
|||||||
+110
-16
@@ -22,6 +22,7 @@ import {
|
|||||||
TileMode,
|
TileMode,
|
||||||
FilterMode,
|
FilterMode,
|
||||||
MipmapMode,
|
MipmapMode,
|
||||||
|
FontStyle,
|
||||||
} from '@shopify/react-native-skia';
|
} from '@shopify/react-native-skia';
|
||||||
import { Lock } from 'lucide-react-native';
|
import { Lock } from 'lucide-react-native';
|
||||||
import { Recipe, GPSInfo, FrameId, AspectRatio, ColorAdjustments, ASPECT_RATIO_W_H } from '../types';
|
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 { TONE_SKSL, getToneUniforms } from '../utils/toneShader';
|
||||||
import { CINEMA_SKSL, getCinemaUniforms, cinemaOffFlat } from '../utils/cinemaShader';
|
import { CINEMA_SKSL, getCinemaUniforms, cinemaOffFlat } from '../utils/cinemaShader';
|
||||||
import { polaroidLayout, wallframeLayout, WALLFRAME_PNG_W, WALLFRAME_PNG_H } from '../utils/frameUtils';
|
import { polaroidLayout, wallframeLayout, WALLFRAME_PNG_W, WALLFRAME_PNG_H } from '../utils/frameUtils';
|
||||||
|
import { rotateSkImage90 } from '../utils/skiaImage';
|
||||||
|
|
||||||
interface ViewfinderProps {
|
interface ViewfinderProps {
|
||||||
mode: 'camera' | 'library';
|
mode: 'camera' | 'library';
|
||||||
@@ -40,6 +42,16 @@ interface ViewfinderProps {
|
|||||||
useGeotag: boolean;
|
useGeotag: boolean;
|
||||||
gpsInfo: GPSInfo | null;
|
gpsInfo: GPSInfo | null;
|
||||||
libraryImageUri: string | 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;
|
cameraPermissionGranted: boolean;
|
||||||
onRequestCameraPermission: () => void;
|
onRequestCameraPermission: () => void;
|
||||||
photoOutput: CameraPhotoOutput;
|
photoOutput: CameraPhotoOutput;
|
||||||
@@ -159,6 +171,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
|||||||
useGeotag,
|
useGeotag,
|
||||||
gpsInfo,
|
gpsInfo,
|
||||||
libraryImageUri,
|
libraryImageUri,
|
||||||
|
availableHeight,
|
||||||
cameraPermissionGranted,
|
cameraPermissionGranted,
|
||||||
onRequestCameraPermission,
|
onRequestCameraPermission,
|
||||||
photoOutput,
|
photoOutput,
|
||||||
@@ -172,12 +185,17 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
|||||||
imageAdjustTarget,
|
imageAdjustTarget,
|
||||||
customWm = { enabled: false, text: '', x: 0.5, y: 0.5 },
|
customWm = { enabled: false, text: '', x: 0.5, y: 0.5 },
|
||||||
onWmPositionChange,
|
onWmPositionChange,
|
||||||
|
onWmEdit,
|
||||||
|
photoRotation = 0,
|
||||||
}: ViewfinderProps,
|
}: ViewfinderProps,
|
||||||
ref
|
ref
|
||||||
) {
|
) {
|
||||||
// Fullscreen hero: the viewfinder fills the entire screen, every overlay
|
// Fullscreen hero: the viewfinder fills the entire screen, every overlay
|
||||||
// rect derives from window size.
|
// 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();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
// Composition ratio band. With a ratio active the photo area is a centered
|
// Composition ratio band. With a ratio active the photo area is a centered
|
||||||
@@ -251,7 +269,14 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
|||||||
}
|
}
|
||||||
}, [device]);
|
}, [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
|
// Wall-frame PNG artwork (project root). Loaded lazily so other frames pay
|
||||||
// no asset cost; source switches to '' when unselected (useImage → null).
|
// no asset cost; source switches to '' when unselected (useImage → null).
|
||||||
const wallImage = useImage(selectedFrame === 'wallframe' ? require('../../wallframe.png') : '');
|
const wallImage = useImage(selectedFrame === 'wallframe' ? require('../../wallframe.png') : '');
|
||||||
@@ -859,14 +884,24 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
|||||||
// pole-like, whereas the real print is a shorter 3:4 card letterboxed on
|
// 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
|
// screen). ponytail: verified on Xiaomi quality-mode captures (4:3); if the
|
||||||
// session negotiates 16:9 photos, change to 9/16.
|
// 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 =
|
const polaroidAspect =
|
||||||
ratioWH ??
|
ratioWH ?? (mode === 'library' && framePhotoAspect > 0 ? framePhotoAspect : 3 / 4);
|
||||||
(mode === 'library' && skiaImage && skiaImage.width() > 0 && skiaImage.height() > 0
|
|
||||||
? skiaImage.width() / skiaImage.height()
|
|
||||||
: 3 / 4);
|
|
||||||
// Photo draw fit inside the polaroid window: cover when the window aspect
|
// Photo draw fit inside the polaroid window: cover when the window aspect
|
||||||
// (ratio) differs from the source photo, fill otherwise (aspects match).
|
// (ratio) differs from the source photo, fill otherwise (aspects match). A
|
||||||
const polaroidFit: 'cover' | 'fill' = ratioWH ? 'cover' : 'fill';
|
// 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(() => {
|
const polaroidGeo = useMemo(() => {
|
||||||
if (selectedFrame !== 'polaroid') return null;
|
if (selectedFrame !== 'polaroid') return null;
|
||||||
return polaroidLayout(vw, vh, polaroidAspect, 'contain');
|
return polaroidLayout(vw, vh, polaroidAspect, 'contain');
|
||||||
@@ -992,13 +1027,27 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
|||||||
// Photo/window area the 0..1 fractions map onto — identical rule to
|
// Photo/window area the 0..1 fractions map onto — identical rule to
|
||||||
// renderGPSWatermark so both marks share the user-visible coordinate space.
|
// renderGPSWatermark so both marks share the user-visible coordinate space.
|
||||||
const wmArea = () => {
|
const wmArea = () => {
|
||||||
const win = polaroidGeo?.window ?? wallGeo?.window ?? null;
|
// The FRAME, not just the photo window: the mark may be dragged anywhere
|
||||||
if (win) return { dx: win.x, dy: win.y, dw: win.w, dh: win.h };
|
// 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 (regionBand) return { dx: region.x, dy: region.y, dw: region.w, dh: region.h };
|
||||||
if (mode === 'library' && imageFitRect) return imageFitRect;
|
if (mode === 'library' && imageFitRect) return imageFitRect;
|
||||||
return { dx: 0, dy: 0, dw: vw, dh: vh };
|
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 wmFracFromXY = (x: number, y: number) => {
|
||||||
const area = wmArea();
|
const area = wmArea();
|
||||||
const dw = area.dw || 1;
|
const dw = area.dw || 1;
|
||||||
@@ -1008,15 +1057,45 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
|||||||
y: Math.max(0, Math.min(1, (y - area.dy) / dh)),
|
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) => {
|
const placeWm = (x: number, y: number) => {
|
||||||
if (!wmArmed) return;
|
if (!wmArmed) return;
|
||||||
setWmDragPos(wmFracFromXY(x, y));
|
const pos = wmFracFromXY(x, y);
|
||||||
|
wmDragPosRef.current = pos;
|
||||||
|
setWmDragPos(pos);
|
||||||
};
|
};
|
||||||
const commitWm = () => {
|
const commitWm = () => {
|
||||||
if (!wmArmed || !onWmPositionChange) return;
|
const pos = wmDragPosRef.current;
|
||||||
if (wmDragPos) onWmPositionChange(wmDragPos.x, wmDragPos.y);
|
wmDragPosRef.current = null;
|
||||||
setWmDragPos(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
|
// Library framed (polaroid/wall) returns reuse the SAME responder handlers as
|
||||||
// plain library (see the framed JSX below): single-finger drags place/move
|
// plain library (see the framed JSX below): single-finger drags place/move
|
||||||
// the text watermark while armed, pinch + double-tap repositions the photo
|
// the text watermark while armed, pinch + double-tap repositions the photo
|
||||||
@@ -1086,6 +1165,21 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
|||||||
// Watermark armed: a tap places + commits the text and skips the double-tap
|
// Watermark armed: a tap places + commits the text and skips the double-tap
|
||||||
// zoom bookkeeping (double-tap zoom is disabled while placing).
|
// zoom bookkeeping (double-tap zoom is disabled while placing).
|
||||||
if (wmArmed && libZoom.s <= 1.01) {
|
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);
|
placeWm(x, y);
|
||||||
commitWm();
|
commitWm();
|
||||||
return;
|
return;
|
||||||
@@ -1250,7 +1344,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
|||||||
};
|
};
|
||||||
|
|
||||||
const renderCustomWatermark = () => {
|
const renderCustomWatermark = () => {
|
||||||
if (!wmArmed || !customFont) return null;
|
if (!wmArmed || !wmFont) return null;
|
||||||
const area = wmArea();
|
const area = wmArea();
|
||||||
const fracX = wmDragPos?.x ?? customWm.x;
|
const fracX = wmDragPos?.x ?? customWm.x;
|
||||||
const fracY = wmDragPos?.y ?? customWm.y;
|
const fracY = wmDragPos?.y ?? customWm.y;
|
||||||
@@ -1259,7 +1353,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
|||||||
const tx = area.dx + fracX * area.dw;
|
const tx = area.dx + fracX * area.dw;
|
||||||
const ty = area.dy + fracY * area.dh + Math.round(wmFontSize * 0.35);
|
const ty = area.dy + fracY * area.dh + Math.round(wmFontSize * 0.35);
|
||||||
return (
|
return (
|
||||||
<SkiaText x={tx} y={ty} text={customWm.text.trim()} font={customFont} color="#f59e0b" />
|
<SkiaText x={tx} y={ty} text={customWm.text.trim()} font={wmFont} color={wmColor} />
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -87,4 +87,10 @@ export interface CustomWatermark {
|
|||||||
text: string;
|
text: string;
|
||||||
x: number;
|
x: number;
|
||||||
y: 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
|
||||||
}
|
}
|
||||||
|
|||||||
+50
-11
@@ -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 { File } from 'expo-file-system';
|
||||||
import * as FileSystem from 'expo-file-system/legacy';
|
import * as FileSystem from 'expo-file-system/legacy';
|
||||||
import * as MediaLibrary from 'expo-media-library/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 { CINEMA_SKSL, getCinemaUniforms, cinemaIsActive } from './cinemaShader';
|
||||||
import { drawFrameOnCanvas, polaroidLayout, POLAROID_CARD, POLAROID_WIN_W, wallframeLayout, WALLFRAME_W, WALLFRAME_H } from './frameUtils';
|
import { drawFrameOnCanvas, polaroidLayout, POLAROID_CARD, POLAROID_WIN_W, wallframeLayout, WALLFRAME_W, WALLFRAME_H } from './frameUtils';
|
||||||
import { patchJpegDpi } from './jpegDpi';
|
import { patchJpegDpi } from './jpegDpi';
|
||||||
|
import { rotateSkImage90 } from './skiaImage';
|
||||||
|
|
||||||
export interface ExportResult {
|
export interface ExportResult {
|
||||||
uri: string;
|
uri: string;
|
||||||
@@ -37,7 +38,14 @@ export interface ExportOptions {
|
|||||||
// Custom text watermark: typed text + 0..1 fractions of the photo area
|
// Custom text watermark: typed text + 0..1 fractions of the photo area
|
||||||
// (BASELINE anchor + 0.35em drop — same formula as the viewfinder overlay,
|
// (BASELINE anchor + 0.35em drop — same formula as the viewfinder overlay,
|
||||||
// so the export lands where the preview showed it). Amber mono, no icons.
|
// 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):
|
// Library framed-window photo reposition (Viewfinder.getFrameWindowZoom):
|
||||||
// after pinch/drag inside a polaroid card window or a wall-frame opening,
|
// 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
|
// 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 /
|
// visible through the opening, so the file matches the preview. Null /
|
||||||
// undefined = centered 1x (the historical full-image draw).
|
// undefined = centered 1x (the historical full-image draw).
|
||||||
frameWindowZoom?: { s: number; u: number; v: number } | null;
|
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
|
// Lightroom-style "Screen" output sharpen: subtle unsharp mask. A 3x3
|
||||||
@@ -108,6 +120,9 @@ export async function processAndExportPhoto(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
let skImage = skImage0;
|
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 width = skImage.width();
|
||||||
let height = skImage.height();
|
let height = skImage.height();
|
||||||
// 1b. Aspect-ratio crop (composition): center-largest upright crop of the
|
// 1b. Aspect-ratio crop (composition): center-largest upright crop of the
|
||||||
@@ -324,8 +339,13 @@ export async function processAndExportPhoto(
|
|||||||
if (frameId === 'polaroid') {
|
if (frameId === 'polaroid') {
|
||||||
const photoImg = surface.makeImageSnapshot();
|
const photoImg = surface.makeImageSnapshot();
|
||||||
const outW = width; // card width matches the photo width
|
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(
|
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);
|
const cardSurface = createSurface(outW, outH);
|
||||||
if (!cardSurface) {
|
if (!cardSurface) {
|
||||||
@@ -336,9 +356,11 @@ export async function processAndExportPhoto(
|
|||||||
const bgPaint = Skia.Paint();
|
const bgPaint = Skia.Paint();
|
||||||
bgPaint.setColor(Skia.Color('#faf9f6')); // off-white card
|
bgPaint.setColor(Skia.Color('#faf9f6')); // off-white card
|
||||||
cardCanvas.drawRect(Skia.XYWHRect(0, 0, outW, outH), bgPaint);
|
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;
|
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
|
// With a framed-window zoom the source rect shrinks to the visible part
|
||||||
// (window keeps showing the same geometry as the viewfinder).
|
// (window keeps showing the same geometry as the viewfinder).
|
||||||
const plainPaint = Skia.Paint();
|
const plainPaint = Skia.Paint();
|
||||||
@@ -352,10 +374,17 @@ export async function processAndExportPhoto(
|
|||||||
const pSy = pwz
|
const pSy = pwz
|
||||||
? Math.min(Math.max(height * pwz.v - pVisH / 2, 0), height - pVisH)
|
? Math.min(Math.max(height * pwz.v - pVisH / 2, 0), height - pVisH)
|
||||||
: 0;
|
: 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(
|
cardCanvas.drawImageRect(
|
||||||
photoImg,
|
photoImg,
|
||||||
Skia.XYWHRect(pSx, pSy, pVisW, pVisH),
|
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
|
plainPaint
|
||||||
);
|
);
|
||||||
// Thin seam separating image window from the card.
|
// 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
|
// center, not its baseline) — the exact formula Viewfinder uses, so
|
||||||
// preview == export.
|
// preview == export.
|
||||||
if (customWm) {
|
if (customWm) {
|
||||||
const area = framedWindow
|
// The mark lives on the FRAME: a framed export's canvas IS the
|
||||||
? { dx: framedWindow.x, dy: framedWindow.y, dw: framedWindow.w, dh: framedWindow.h }
|
// card/frame (see the branches above), so the whole stamp is the
|
||||||
: { dx: 0, dy: 0, dw: stampW, dh: stampH };
|
// 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 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 tx = area.dx + clamp01(customWm.x) * area.dw;
|
||||||
const ty = area.dy + clamp01(customWm.y) * area.dh + Math.round(fontSize * 0.35);
|
const ty = area.dy + clamp01(customWm.y) * area.dh + Math.round(wmSize * 0.35);
|
||||||
stampCanvas.drawText(customWm.text, tx, ty, textPaint, font);
|
stampCanvas.drawText(customWm.text, tx, ty, wmPaint, wmFont);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 <Canvas> 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user