Polaroid print layout in viewfinder and export; split chip rows; drop cinematic; debounce EV bias
This commit is contained in:
@@ -233,75 +233,90 @@ export default function AdjustmentPanel({
|
||||
|
||||
case 'iq':
|
||||
return (
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{ alignItems: 'center', paddingHorizontal: 8, paddingVertical: 8 }}
|
||||
>
|
||||
{paramChips(paramDefs.iq)}
|
||||
{renderChip({ key: 'sep-dr', label: 'DYNAMIC RANGE', disabled: true, active: false, onPress: () => {} })}
|
||||
{choiceChips(
|
||||
'dr',
|
||||
[
|
||||
{ v: 'auto', d: 'AUTO' },
|
||||
{ v: '100', d: 'DR100' },
|
||||
{ v: '200', d: 'DR200' },
|
||||
{ v: '400', d: 'DR400' },
|
||||
],
|
||||
String(a.dynamicRange ?? 'auto'),
|
||||
(val) =>
|
||||
onUpdateAdjustments({
|
||||
dynamicRange: (val === 'auto' ? 'auto' : parseInt(val, 10)) as ColorAdjustments['dynamicRange'],
|
||||
})
|
||||
)}
|
||||
</ScrollView>
|
||||
<View>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{ alignItems: 'center', paddingHorizontal: 8, paddingVertical: 6 }}
|
||||
>
|
||||
{choiceChips(
|
||||
'dr',
|
||||
[
|
||||
{ v: 'auto', d: 'AUTO' },
|
||||
{ v: '100', d: 'DR100' },
|
||||
{ v: '200', d: 'DR200' },
|
||||
{ v: '400', d: 'DR400' },
|
||||
],
|
||||
String(a.dynamicRange ?? 'auto'),
|
||||
(val) =>
|
||||
onUpdateAdjustments({
|
||||
dynamicRange: (val === 'auto' ? 'auto' : parseInt(val, 10)) as ColorAdjustments['dynamicRange'],
|
||||
})
|
||||
)}
|
||||
</ScrollView>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{ alignItems: 'center', paddingHorizontal: 8, paddingVertical: 6 }}
|
||||
>
|
||||
{paramChips(paramDefs.iq)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
|
||||
case 'wb':
|
||||
return (
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{ alignItems: 'center', paddingHorizontal: 8, paddingVertical: 8 }}
|
||||
>
|
||||
{choiceChips(
|
||||
'wb',
|
||||
[
|
||||
{ v: 'auto', d: 'AUTO' },
|
||||
{ v: 'daylight', d: 'DAYLIGHT' },
|
||||
{ v: 'daylight-3r', d: 'DAYLIGHT -3R' },
|
||||
{ v: 'cloudy', d: 'CLOUDY' },
|
||||
{ v: 'shade', d: 'SHADE' },
|
||||
{ v: 'tungsten', d: 'TUNGSTEN' },
|
||||
{ v: 'fluorescent', d: 'FLUOR' },
|
||||
],
|
||||
wbPresetOf(),
|
||||
wbApply
|
||||
)}
|
||||
{paramChips(paramDefs.wb)}
|
||||
{renderChip({ key: 'sep-cx', label: 'COLOR CHROME', disabled: true, active: false, onPress: () => {} })}
|
||||
{choiceChips(
|
||||
'cx',
|
||||
[
|
||||
{ v: 'none', d: 'OFF' },
|
||||
{ v: 'weak', d: 'WEAK' },
|
||||
{ v: 'strong', d: 'STRONG' },
|
||||
],
|
||||
a.colorChrome || 'none',
|
||||
(val) => onUpdateAdjustments({ colorChrome: val as ColorAdjustments['colorChrome'] })
|
||||
)}
|
||||
{renderChip({ key: 'sep-cxb', label: 'CHROME BLUE', disabled: true, active: false, onPress: () => {} })}
|
||||
{choiceChips(
|
||||
'cxb',
|
||||
[
|
||||
{ v: 'none', d: 'OFF' },
|
||||
{ v: 'weak', d: 'WEAK' },
|
||||
{ v: 'strong', d: 'STRONG' },
|
||||
],
|
||||
a.colorChromeBlue || 'none',
|
||||
(val) => onUpdateAdjustments({ colorChromeBlue: val as ColorAdjustments['colorChromeBlue'] })
|
||||
)}
|
||||
</ScrollView>
|
||||
<View>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{ alignItems: 'center', paddingHorizontal: 8, paddingVertical: 6 }}
|
||||
>
|
||||
{renderChip({ key: 'sep-cx', label: 'COLOR CHROME', disabled: true, active: false, onPress: () => {} })}
|
||||
{choiceChips(
|
||||
'cx',
|
||||
[
|
||||
{ v: 'none', d: 'OFF' },
|
||||
{ v: 'weak', d: 'WEAK' },
|
||||
{ v: 'strong', d: 'STRONG' },
|
||||
],
|
||||
a.colorChrome || 'none',
|
||||
(val) => onUpdateAdjustments({ colorChrome: val as ColorAdjustments['colorChrome'] })
|
||||
)}
|
||||
{renderChip({ key: 'sep-cxb', label: 'CHROME BLUE', disabled: true, active: false, onPress: () => {} })}
|
||||
{choiceChips(
|
||||
'cxb',
|
||||
[
|
||||
{ v: 'none', d: 'OFF' },
|
||||
{ v: 'weak', d: 'WEAK' },
|
||||
{ v: 'strong', d: 'STRONG' },
|
||||
],
|
||||
a.colorChromeBlue || 'none',
|
||||
(val) => onUpdateAdjustments({ colorChromeBlue: val as ColorAdjustments['colorChromeBlue'] })
|
||||
)}
|
||||
</ScrollView>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{ alignItems: 'center', paddingHorizontal: 8, paddingVertical: 6 }}
|
||||
>
|
||||
{choiceChips(
|
||||
'wb',
|
||||
[
|
||||
{ v: 'auto', d: 'AUTO' },
|
||||
{ v: 'daylight', d: 'DAYLIGHT' },
|
||||
{ v: 'daylight-3r', d: 'DAYLIGHT -3R' },
|
||||
{ v: 'cloudy', d: 'CLOUDY' },
|
||||
{ v: 'shade', d: 'SHADE' },
|
||||
{ v: 'tungsten', d: 'TUNGSTEN' },
|
||||
{ v: 'fluorescent', d: 'FLUOR' },
|
||||
],
|
||||
wbPresetOf(),
|
||||
wbApply
|
||||
)}
|
||||
{paramChips(paramDefs.wb)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
|
||||
case 'filters':
|
||||
@@ -312,13 +327,6 @@ export default function AdjustmentPanel({
|
||||
contentContainerStyle={{ alignItems: 'center', paddingHorizontal: 8, paddingVertical: 8 }}
|
||||
>
|
||||
{paramChips(paramDefs.filters)}
|
||||
{renderChip({
|
||||
key: 'note',
|
||||
label: 'NR/CLARITY/SHARPEN APPLY AT EXPORT',
|
||||
disabled: true,
|
||||
active: false,
|
||||
onPress: () => {},
|
||||
})}
|
||||
</ScrollView>
|
||||
);
|
||||
|
||||
@@ -335,10 +343,9 @@ export default function AdjustmentPanel({
|
||||
selectedFrame,
|
||||
(val) => onUpdateFrame(val as FrameId)
|
||||
)}
|
||||
{renderChip({ key: 'sep-gps', label: 'GPS WATERMARK', disabled: true, active: false, onPress: () => {} })}
|
||||
{renderChip({
|
||||
key: 'gps',
|
||||
label: useGeotag ? 'GPS ON' : 'GPS OFF',
|
||||
label: useGeotag ? 'GPS WATERMARK ON' : 'GPS WATERMARK OFF',
|
||||
active: useGeotag,
|
||||
onPress: () => onToggleGeotag(!useGeotag),
|
||||
})}
|
||||
|
||||
+197
-63
@@ -25,6 +25,7 @@ import { Recipe, GPSInfo, FrameId } from '../types';
|
||||
import { getSkiaColorMatrix, applyExposureGain } from '../utils/colorUtils';
|
||||
import { TONE_SKSL, getToneUniforms } from '../utils/toneShader';
|
||||
import { CINEMA_SKSL, getCinemaUniforms, cinemaOffFlat } from '../utils/cinemaShader';
|
||||
import { polaroidLayout } from '../utils/frameUtils';
|
||||
|
||||
interface ViewfinderProps {
|
||||
mode: 'camera' | 'library';
|
||||
@@ -203,10 +204,16 @@ export default function Viewfinder({
|
||||
// CameraX cancels an in-flight bias change whenever another compensation
|
||||
// request lands (session reconfig, AE restart) — retry a few times, but
|
||||
// only while this value is still the latest one the user asked for.
|
||||
// The slider emits many values per drag: fire setExposureBias only after a
|
||||
// short quiet period, so a stream of EV changes collapses into one camera
|
||||
// call. Without this, each intermediate value queued its own retry chain
|
||||
// and the in-flight cancels (with AE re-lock on the Xiaomi) caused preview
|
||||
// flicker while dragging EV.
|
||||
const timers: ReturnType<typeof setTimeout>[] = [];
|
||||
let attempt = 0;
|
||||
let disposed = false;
|
||||
const trySet = () => {
|
||||
if (evStopsRef.current !== evStops) return; // stale — a newer drag is pending
|
||||
if (disposed || evStopsRef.current !== evStops) return; // stale — a newer drag is pending
|
||||
attempt += 1;
|
||||
controller
|
||||
.setExposureBias(clamped)
|
||||
@@ -220,8 +227,12 @@ export default function Viewfinder({
|
||||
}
|
||||
});
|
||||
};
|
||||
trySet();
|
||||
return () => timers.forEach((t) => clearTimeout(t));
|
||||
const debounce = setTimeout(trySet, 110);
|
||||
return () => {
|
||||
disposed = true;
|
||||
clearTimeout(debounce);
|
||||
timers.forEach((t) => clearTimeout(t));
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [controller, evStops]);
|
||||
|
||||
@@ -304,6 +315,11 @@ export default function Viewfinder({
|
||||
// the preview flicker. All live values flow through the synchronizables.
|
||||
type SkiaCameraOnFrame = NonNullable<ComponentProps<typeof SkiaCamera>['onFrame']>;
|
||||
const toneFailLogged = useMemo(() => createSynchronizable<boolean>(false), []);
|
||||
// RETRO POLAROID on the live camera: the card + photo window are drawn here,
|
||||
// in the frame pipeline, because only the worklet can scale the camera feed
|
||||
// into the polaroid window (a JS overlay could only mask the fullscreen feed,
|
||||
// which crops the scene instead of fitting it into the frame).
|
||||
const polaroidCam = mode === 'camera' && selectedFrame === 'polaroid';
|
||||
const handleFrame = useCallback<SkiaCameraOnFrame>(
|
||||
(frame, render) => {
|
||||
'worklet';
|
||||
@@ -314,39 +330,88 @@ export default function Viewfinder({
|
||||
const cinema = cinemaSync.getBlocking();
|
||||
const hasTone = toneEffect != null && (tone[0] !== 0 || tone[1] !== 0 || tone[2] !== 0);
|
||||
const hasCinema = cinemaEffect != null && cinema[0] > 0;
|
||||
if (hasTone || hasCinema) {
|
||||
try {
|
||||
const imageShader = frameTexture.makeShaderOptions(T_CLAMP, T_CLAMP, F_LINEAR, M_NONE);
|
||||
// drawRect, not drawImage: paint shaders only apply to geometry
|
||||
// fills — drawImage ignores the paint shader, which is why the
|
||||
// DR/Highlight/Shadow tone never showed on the live preview.
|
||||
let final = null;
|
||||
if (hasTone) {
|
||||
final = toneEffect.makeShaderWithChildren(tone, [imageShader]);
|
||||
}
|
||||
if (hasCinema) {
|
||||
const cinemaShader = cinemaEffect.makeShaderWithChildren(cinema, [final ?? imageShader]);
|
||||
if (cinemaShader != null) final = cinemaShader;
|
||||
}
|
||||
if (final != null) {
|
||||
const w = frameTexture.width();
|
||||
const h = frameTexture.height();
|
||||
paint.setShader(final);
|
||||
canvas.drawRect(Skia.XYWHRect(0, 0, w, h), paint);
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
if (!toneFailLogged.getBlocking()) {
|
||||
toneFailLogged.setBlocking(true);
|
||||
console.error('[camtone] shader failed, matrix-only: ' + e);
|
||||
const drawPass = () => {
|
||||
if (hasTone || hasCinema) {
|
||||
try {
|
||||
const imageShader = frameTexture.makeShaderOptions(T_CLAMP, T_CLAMP, F_LINEAR, M_NONE);
|
||||
// drawRect, not drawImage: paint shaders only apply to geometry
|
||||
// fills — drawImage ignores the paint shader, which is why the
|
||||
// DR/Highlight/Shadow tone never showed on the live preview.
|
||||
let final = null;
|
||||
if (hasTone) {
|
||||
final = toneEffect.makeShaderWithChildren(tone, [imageShader]);
|
||||
}
|
||||
if (hasCinema) {
|
||||
const cinemaShader = cinemaEffect.makeShaderWithChildren(cinema, [final ?? imageShader]);
|
||||
if (cinemaShader != null) final = cinemaShader;
|
||||
}
|
||||
if (final != null) {
|
||||
const w = frameTexture.width();
|
||||
const h = frameTexture.height();
|
||||
paint.setShader(final);
|
||||
canvas.drawRect(Skia.XYWHRect(0, 0, w, h), paint);
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
if (!toneFailLogged.getBlocking()) {
|
||||
toneFailLogged.setBlocking(true);
|
||||
console.error('[camtone] shader failed, matrix-only: ' + e);
|
||||
}
|
||||
}
|
||||
}
|
||||
canvas.drawImage(frameTexture, 0, 0, paint);
|
||||
};
|
||||
if (polaroidCam) {
|
||||
const W = frameTexture.width();
|
||||
const H = frameTexture.height();
|
||||
// Geometry mirror of utils/frameUtils.polaroidLayout('contain'),
|
||||
// inlined because worklet code cannot call across module imports.
|
||||
// Physical polaroid proportions (88x107 mm card, 79x79 mm print).
|
||||
const SIDE = (88 - 79) / 2 / 88; // 0.05114 per side of card width
|
||||
const DECK = (107 - 79 - (88 - 79) / 2) / 88; // 0.26705 bottom deck
|
||||
const WINW = 1 - 2 * SIDE; // 0.89773 window width
|
||||
const naturalAspect = SIDE + DECK + WINW / (W / H); // cardH / cardW
|
||||
const cardW = Math.min(W, H / naturalAspect);
|
||||
const cardH = cardW * naturalAspect;
|
||||
const cx = (W - cardW) / 2;
|
||||
const cy = (H - cardH) / 2;
|
||||
const top = SIDE * cardW;
|
||||
const maxWinH = cardH - top - DECK * cardW;
|
||||
let winW = WINW * cardW;
|
||||
let winH = winW / (W / H);
|
||||
if (winH > maxWinH) {
|
||||
winH = maxWinH;
|
||||
winW = winH * (W / H);
|
||||
}
|
||||
const wx = cx + (cardW - winW) / 2;
|
||||
const wy = cy + top;
|
||||
// Letterbox bars (black) around the print, then the white card.
|
||||
const bgPaint = Skia.Paint();
|
||||
bgPaint.setColor(Skia.Color('#000000'));
|
||||
canvas.drawRect(Skia.XYWHRect(0, 0, W, H), bgPaint);
|
||||
const cardPaint = Skia.Paint();
|
||||
cardPaint.setColor(Skia.Color('#faf9f6'));
|
||||
canvas.drawRect(Skia.XYWHRect(cx, cy, cardW, cardH), cardPaint);
|
||||
// Scale the whole live frame into the window: draw the pass over the
|
||||
// full frame rect while the canvas is scaled, so the complete camera
|
||||
// scene is squeezed into the polaroid window — never cropped.
|
||||
canvas.save();
|
||||
canvas.translate(wx, wy);
|
||||
canvas.scale(winW / W, winH / H);
|
||||
drawPass();
|
||||
canvas.restore();
|
||||
const seamPaint = Skia.Paint();
|
||||
seamPaint.setColor(Skia.Color('#e5e5e5'));
|
||||
seamPaint.setStyle(1);
|
||||
seamPaint.setStrokeWidth(1);
|
||||
canvas.drawRect(Skia.XYWHRect(wx + 0.5, wy + 0.5, winW - 1, winH - 1), seamPaint);
|
||||
return;
|
||||
}
|
||||
canvas.drawImage(frameTexture, 0, 0, paint);
|
||||
drawPass();
|
||||
});
|
||||
frame.dispose();
|
||||
},
|
||||
[colorMatrixSync, toneSync, cinemaSync, toneFailLogged]
|
||||
[colorMatrixSync, toneSync, cinemaSync, toneFailLogged, polaroidCam]
|
||||
);
|
||||
|
||||
const grainOpacity = adjustments.grain / 20;
|
||||
@@ -372,6 +437,17 @@ export default function Viewfinder({
|
||||
const dh = h * s;
|
||||
return { dx: (vw - dw) / 2, dy: (vh - dh) / 2, dw, dh };
|
||||
}, [skiaImage, vw, vh]);
|
||||
// Polaroid card geometry (JS/overlay space). The live camera worklet mirrors
|
||||
// the same math in frame-pixel space (see handleFrame) — both spaces share the
|
||||
// same aspect ratio, so the overlay rects line up with the drawn card.
|
||||
const polaroidGeo = useMemo(() => {
|
||||
if (selectedFrame !== 'polaroid') return null;
|
||||
const aspect =
|
||||
mode === 'library' && skiaImage && skiaImage.width() > 0 && skiaImage.height() > 0
|
||||
? skiaImage.width() / skiaImage.height()
|
||||
: vw / vh;
|
||||
return polaroidLayout(vw, vh, aspect, 'contain');
|
||||
}, [selectedFrame, mode, skiaImage, vw, vh]);
|
||||
const onLibTouchStart = (e: any) => {
|
||||
const ts = e.nativeEvent.touches;
|
||||
if (ts.length >= 2) {
|
||||
@@ -460,12 +536,14 @@ export default function Viewfinder({
|
||||
);
|
||||
|
||||
const borderWidth = Math.min(vw, vh) * 0.05;
|
||||
const sideBorder = Math.min(vw, vh) * 0.06;
|
||||
const bottomBorder = vh * 0.18;
|
||||
const barHeight = vh * 0.12;
|
||||
|
||||
const renderFrameOverlay = () => {
|
||||
if (selectedFrame === 'none') return null;
|
||||
// Polaroid is drawn by the camera frame pipeline (camera) or its own
|
||||
// contain-fit canvas (library) — the overlay only handles the frames that
|
||||
// sit on top of an already fullscreen picture.
|
||||
if (selectedFrame === 'polaroid') return null;
|
||||
|
||||
if (selectedFrame === 'classic-white') {
|
||||
return (
|
||||
@@ -478,26 +556,6 @@ export default function Viewfinder({
|
||||
);
|
||||
}
|
||||
|
||||
if (selectedFrame === 'polaroid') {
|
||||
return (
|
||||
<Group>
|
||||
<Rect x={0} y={0} width={vw} height={sideBorder} color="#faf9f6" />
|
||||
<Rect x={0} y={0} width={sideBorder} height={vh} color="#faf9f6" />
|
||||
<Rect x={vw - sideBorder} y={0} width={sideBorder} height={vh} color="#faf9f6" />
|
||||
<Rect x={0} y={vh - bottomBorder} width={vw} height={bottomBorder} color="#faf9f6" />
|
||||
<Rect
|
||||
x={sideBorder - 1}
|
||||
y={sideBorder - 1}
|
||||
width={vw - 2 * sideBorder + 2}
|
||||
height={vh - sideBorder - bottomBorder + 2}
|
||||
color="#e5e5e5"
|
||||
style="stroke"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
if (selectedFrame === 'cinematic') {
|
||||
return (
|
||||
<Group color="black">
|
||||
@@ -523,24 +581,26 @@ export default function Viewfinder({
|
||||
// with black letterbox — a screen-bottom baseline used to land below the
|
||||
// photo on the letterbox, off the image, so users could not see it. Compute
|
||||
// inside the fitted rect (imageFitRect) so the mark sits at the photo's
|
||||
// bottom-left corner; keep it above the bottom chrome/shutter rail.
|
||||
const area =
|
||||
mode === 'library' && imageFitRect
|
||||
// bottom-left corner; keep it above the bottom chrome/shutter rail. With a
|
||||
// polaroid frame the photo lives inside the card window — anchor to that.
|
||||
const win = polaroidGeo?.window ?? null;
|
||||
const area = win
|
||||
? { dx: win.x, dy: win.y, dw: win.w, dh: win.h }
|
||||
: mode === 'library' && imageFitRect
|
||||
? imageFitRect
|
||||
: { dx: 0, dy: 0, dw: vw, dh: vh };
|
||||
const iconSize = Math.round(vw * 0.032); // matches customFont/iconFont size
|
||||
const padBottom =
|
||||
selectedFrame === 'polaroid'
|
||||
? area.dh * 0.18 + 24 // polaroid deck below the photo
|
||||
: mode === 'camera'
|
||||
? Math.min(150, area.dh * 0.2) // keep clear of the bottom shutter deck
|
||||
: Math.round(iconSize * 1.5); // library: sit at the photo corner
|
||||
const padBottom = win
|
||||
? Math.round(iconSize * 1.4) // polaroid: just above the window's bottom edge
|
||||
: mode === 'camera'
|
||||
? Math.min(150, area.dh * 0.2) // keep clear of the bottom shutter deck
|
||||
: Math.round(iconSize * 1.5); // library: sit at the photo corner
|
||||
const yOffset =
|
||||
mode === 'library'
|
||||
? Math.min(area.dy + area.dh - padBottom, vh - 190) // never under the deck
|
||||
: area.dy + area.dh - padBottom;
|
||||
const xOffset =
|
||||
area.dx + (selectedFrame === 'polaroid' ? area.dw * 0.08 : Math.min(16, area.dw * 0.04));
|
||||
area.dx + (win ? area.dw * 0.05 : Math.min(16, area.dw * 0.04));
|
||||
// One clear space (0.6em ≈ a mono space) between each icon and its text.
|
||||
const gap = (w: number) => Math.round(Math.max(w, iconSize) + iconSize * 0.6);
|
||||
const pinW = iconFont.measureText('📍');
|
||||
@@ -562,7 +622,15 @@ export default function Viewfinder({
|
||||
<Canvas style={StyleSheet.absoluteFill} pointerEvents="none">
|
||||
{grainOpacity > 0 && noiseEffect && (
|
||||
<Group blendMode="overlay">
|
||||
<Rect x={0} y={0} width={vw} height={vh} opacity={grainOpacity}>
|
||||
{/* Grain belongs to the photo; on a polaroid card it must not spill
|
||||
onto the white deck, so it is clipped to the photo window. */}
|
||||
<Rect
|
||||
x={polaroidGeo ? polaroidGeo.window.x : 0}
|
||||
y={polaroidGeo ? polaroidGeo.window.y : 0}
|
||||
width={polaroidGeo ? polaroidGeo.window.w : vw}
|
||||
height={polaroidGeo ? polaroidGeo.window.h : vh}
|
||||
opacity={grainOpacity}
|
||||
>
|
||||
<Shader source={noiseEffect} />
|
||||
</Rect>
|
||||
</Group>
|
||||
@@ -695,6 +763,72 @@ export default function Viewfinder({
|
||||
const toneOn = toneParams[0] !== 0 || toneParams[1] !== 0 || toneParams[2] !== 0;
|
||||
const cinemaOn = cinemaEffect != null && !!cinemaParams;
|
||||
const useShaderPass = !!imageFitRect && ((toneEffect != null && toneOn) || cinemaOn);
|
||||
|
||||
// RETRO POLAROID (library): the whole picture is contain-fitted into the
|
||||
// card's photo window — nothing is masked or cropped, and the card is
|
||||
// centered on the black background like a physical print. The window keeps
|
||||
// the photo aspect ratio, so fit="fill" never distorts.
|
||||
if (mode === 'library' && polaroidGeo && skiaImage && libraryImageUri) {
|
||||
const win = polaroidGeo.window;
|
||||
const card = polaroidGeo.card;
|
||||
const winRect = rect(win.x, win.y, win.w, win.h);
|
||||
return (
|
||||
<View className="flex-1 relative overflow-hidden bg-black items-center justify-center">
|
||||
<Canvas style={StyleSheet.absoluteFill}>
|
||||
<Rect x={card.x} y={card.y} width={card.w} height={card.h} color="#faf9f6" />
|
||||
<Group>
|
||||
<ColorMatrix matrix={colorMatrix} />
|
||||
{useShaderPass ? (
|
||||
<Rect x={win.x} y={win.y} width={win.w} height={win.h}>
|
||||
{cinemaOn ? (
|
||||
<Shader source={cinemaEffect!} uniforms={cinemaParams!.uniforms}>
|
||||
{toneEffect && toneOn ? (
|
||||
<Shader
|
||||
source={toneEffect}
|
||||
uniforms={{ dr: toneParams[0], hl: toneParams[1], sh: toneParams[2] }}
|
||||
>
|
||||
<ImageShader image={skiaImage} fit="fill" tx="clamp" ty="clamp" rect={winRect} />
|
||||
</Shader>
|
||||
) : (
|
||||
<ImageShader image={skiaImage} fit="fill" tx="clamp" ty="clamp" rect={winRect} />
|
||||
)}
|
||||
</Shader>
|
||||
) : toneEffect && toneOn ? (
|
||||
<Shader
|
||||
source={toneEffect}
|
||||
uniforms={{ dr: toneParams[0], hl: toneParams[1], sh: toneParams[2] }}
|
||||
>
|
||||
<ImageShader image={skiaImage} fit="fill" tx="clamp" ty="clamp" rect={winRect} />
|
||||
</Shader>
|
||||
) : null}
|
||||
</Rect>
|
||||
) : (
|
||||
<SkiaImage image={skiaImage} fit="fill" rect={winRect} />
|
||||
)}
|
||||
</Group>
|
||||
{grainOpacity > 0 && noiseEffect && (
|
||||
<Group blendMode="overlay">
|
||||
<Rect x={win.x} y={win.y} width={win.w} height={win.h} opacity={grainOpacity}>
|
||||
<Shader source={noiseEffect} />
|
||||
</Rect>
|
||||
</Group>
|
||||
)}
|
||||
{/* Thin seam between image window and card */}
|
||||
<Rect
|
||||
x={win.x + 0.5}
|
||||
y={win.y + 0.5}
|
||||
width={win.w - 1}
|
||||
height={win.h - 1}
|
||||
color="#e5e5e5"
|
||||
style="stroke"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
{renderGPSWatermark()}
|
||||
</Canvas>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View className="flex-1 relative overflow-hidden bg-black items-center justify-center">
|
||||
{libraryImageUri && skiaImage ? (
|
||||
|
||||
+70
-12
@@ -7,7 +7,7 @@ import { Recipe, GPSInfo, FrameId } from '../types';
|
||||
import { getSkiaColorMatrix, applyExposureGain } from './colorUtils';
|
||||
import { TONE_SKSL, getToneUniforms, toneIsActive } from './toneShader';
|
||||
import { CINEMA_SKSL, getCinemaUniforms, cinemaIsActive } from './cinemaShader';
|
||||
import { drawFrameOnCanvas } from './frameUtils';
|
||||
import { drawFrameOnCanvas, polaroidLayout, POLAROID_CARD, POLAROID_WIN_W } from './frameUtils';
|
||||
import { patchJpegDpi } from './jpegDpi';
|
||||
|
||||
export interface ExportResult {
|
||||
@@ -256,8 +256,56 @@ export async function processAndExportPhoto(
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Render Frame Border (Polaroid / Classic White / Cinematic)
|
||||
drawFrameOnCanvas(canvas, width, height, frameId);
|
||||
// 7. Frame. RETRO POLAROID is not a border drawn on the photo: the whole
|
||||
// processed picture is composited, uncropped, into the window of a bigger
|
||||
// off-white card (real print proportions, see frameUtils) — the export IS
|
||||
// the card, with the photo scaled to the window so it keeps ~WIN_W of the
|
||||
// card width. Other frames (classic white border, legacy cinematic bars)
|
||||
// draw over the photo-sized canvas as before.
|
||||
let outputSurface: SkSurface = surface;
|
||||
let stampCanvas = canvas;
|
||||
let stampW = width;
|
||||
let stampH = height;
|
||||
let polaroidWindow: { x: number; y: number; w: number; h: number } | null = null;
|
||||
if (frameId === 'polaroid') {
|
||||
const photoImg = surface.makeImageSnapshot();
|
||||
const outW = width; // card width matches the photo width
|
||||
const outH = Math.round(
|
||||
width * (POLAROID_CARD.sideMargin + POLAROID_CARD.bottomDeck) + height * POLAROID_WIN_W
|
||||
);
|
||||
const cardSurface = createSurface(outW, outH);
|
||||
if (!cardSurface) {
|
||||
console.error('Failed to create polaroid card surface');
|
||||
return null;
|
||||
}
|
||||
const cardCanvas = cardSurface.getCanvas();
|
||||
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 win = pl.window;
|
||||
// Window aspect == photo aspect → fills exactly, no letterbox inside.
|
||||
const plainPaint = Skia.Paint();
|
||||
cardCanvas.drawImageRect(
|
||||
photoImg,
|
||||
Skia.XYWHRect(0, 0, width, height),
|
||||
Skia.XYWHRect(win.x, win.y, win.w, win.h),
|
||||
plainPaint
|
||||
);
|
||||
// Thin seam separating image window from the card.
|
||||
const seamPaint = Skia.Paint();
|
||||
seamPaint.setColor(Skia.Color('#e5e5e5'));
|
||||
seamPaint.setStyle(1);
|
||||
seamPaint.setStrokeWidth(1);
|
||||
cardCanvas.drawRect(Skia.XYWHRect(win.x + 0.5, win.y + 0.5, win.w - 1, win.h - 1), seamPaint);
|
||||
outputSurface = cardSurface;
|
||||
stampCanvas = cardCanvas;
|
||||
stampW = outW;
|
||||
stampH = outH;
|
||||
polaroidWindow = win;
|
||||
} else {
|
||||
drawFrameOnCanvas(canvas, width, height, frameId);
|
||||
}
|
||||
|
||||
// 8. Render GPS Geotag Watermark
|
||||
if (useGeotag && gpsInfo) {
|
||||
@@ -272,7 +320,7 @@ export async function processAndExportPhoto(
|
||||
const iconTypeface = Skia.Typeface.MakeFreeTypeFaceFromData(Skia.Data.fromBase64(iconFontData));
|
||||
|
||||
if (typeface && iconTypeface) {
|
||||
const fontSize = Math.round(width * 0.032); // 3.2% of image width
|
||||
const fontSize = Math.round(stampW * 0.032); // 3.2% of output width
|
||||
const font = Skia.Font(typeface, fontSize);
|
||||
const iconFont = Skia.Font(iconTypeface, fontSize);
|
||||
const textPaint = Skia.Paint();
|
||||
@@ -284,21 +332,31 @@ export async function processAndExportPhoto(
|
||||
const d = new Date(gpsInfo.timestamp);
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
const timestampStr = `${pad(d.getDate())}/${pad(d.getMonth() + 1)}/${d.getFullYear()} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
|
||||
const yOffset = height - (frameId === 'polaroid' ? height * 0.15 : fontSize * 1.5);
|
||||
const xOffset = frameId === 'polaroid' ? width * 0.08 : fontSize * 1.2;
|
||||
const gap = Math.round(fontSize * 1.6); // icon box (~1em) + one clear mono space (~0.6em)
|
||||
|
||||
canvas.drawText('📍', xOffset, yOffset - fontSize * 1.4, iconPaint, iconFont);
|
||||
canvas.drawText(locationName, xOffset + gap, yOffset - fontSize * 1.4, textPaint, font);
|
||||
canvas.drawText('📷', xOffset, yOffset, iconPaint, iconFont);
|
||||
canvas.drawText(timestampStr, xOffset + gap, yOffset, textPaint, font);
|
||||
// Polaroid: caption at the bottom-left inside the photo window (the
|
||||
// spot the deck edge meets the print). Other frames: bottom-left of
|
||||
// the image, clear of the edge.
|
||||
let baseX: number;
|
||||
let baseY: number;
|
||||
if (polaroidWindow) {
|
||||
baseX = polaroidWindow.x + polaroidWindow.w * 0.05;
|
||||
baseY = polaroidWindow.y + polaroidWindow.h - Math.round(fontSize * 1.4);
|
||||
} else {
|
||||
baseX = fontSize * 1.2;
|
||||
baseY = stampH - fontSize * 1.5;
|
||||
}
|
||||
|
||||
stampCanvas.drawText('📍', baseX, baseY - fontSize * 1.4, iconPaint, iconFont);
|
||||
stampCanvas.drawText(locationName, baseX + gap, baseY - fontSize * 1.4, textPaint, font);
|
||||
stampCanvas.drawText('📷', baseX, baseY, iconPaint, iconFont);
|
||||
stampCanvas.drawText(timestampStr, baseX + gap, baseY, textPaint, font);
|
||||
}
|
||||
}
|
||||
|
||||
// 9. Snapshot & Encode to JPEG (base64 string, no SkData round-trip)
|
||||
console.error(`[export] composite ${Date.now() - t0}ms`);
|
||||
let resultImage = surface.makeImageSnapshot();
|
||||
let resultImage = outputSurface.makeImageSnapshot();
|
||||
// Sharpening knob (0..10) overrides the legacy boolean sharpen flag:
|
||||
// legacy camera captures keep sharpen:true → 0.5 fallback when knob is 0.
|
||||
const sharpKnob = adjustments.sharpening ?? 0;
|
||||
|
||||
+66
-33
@@ -10,9 +10,74 @@ export const FRAMES: FrameDefinition[] = [
|
||||
{ id: 'none', name: 'NO FRAME' },
|
||||
{ id: 'classic-white', name: 'CLASSIC BORDER' },
|
||||
{ id: 'polaroid', name: 'RETRO POLAROID' },
|
||||
{ id: 'cinematic', name: 'CINEMATIC BARS' },
|
||||
];
|
||||
|
||||
// Real Polaroid print proportions (88 x 107 mm card, 79 x 79 mm image print).
|
||||
// All fractions are relative to the CARD WIDTH so the layout scales to any
|
||||
// pixel size while keeping the physical look.
|
||||
export const POLAROID_CARD = {
|
||||
// Horizontal + top margin: (88 - 79)/2 / 88 mm each side.
|
||||
sideMargin: (88 - 79) / 2 / 88, // ≈ 0.05114
|
||||
// Bottom deck: remaining height of the 107 mm card below the 79 mm print,
|
||||
// minus the top margin: (107 - 79 - 4.5) / 88 mm.
|
||||
bottomDeck: (107 - 79 - (88 - 79) / 2) / 88, // ≈ 0.26705
|
||||
} as const;
|
||||
// Image window width as a fraction of card width (79 / 88 mm).
|
||||
export const POLAROID_WIN_W = 1 - 2 * POLAROID_CARD.sideMargin; // ≈ 0.89773
|
||||
|
||||
export interface PolaroidLayout {
|
||||
card: { x: number; y: number; w: number; h: number };
|
||||
window: { x: number; y: number; w: number; h: number };
|
||||
}
|
||||
|
||||
// Geometry of a retro-polaroid card inside an arbitrary container. The photo is
|
||||
// NEVER cropped: the image window keeps the photo aspect ratio (photoW/photoH)
|
||||
// and the card grows/shrinks around it, so the whole picture always fits the
|
||||
// window. 'contain' fits the full card inside the container (centered, with
|
||||
// letterbox bars); 'fill' sizes the container exactly to the natural card
|
||||
// proportions (used at export where the canvas IS the card).
|
||||
export function polaroidLayout(
|
||||
contW: number,
|
||||
contH: number,
|
||||
photoAspect: number, // photo width / photo height (>1 = landscape)
|
||||
mode: 'contain' | 'fill' = 'contain'
|
||||
): PolaroidLayout {
|
||||
const S = POLAROID_CARD;
|
||||
// Height of the card once the photo window (width WIN_W of the card width,
|
||||
// height = that / photoAspect) is stacked below the top margin and above the
|
||||
// bottom deck: cardH = cardW*(sideMargin + bottomDeck) + cardW*WIN_W/photoAspect
|
||||
const naturalAspect = S.sideMargin + S.bottomDeck + POLAROID_WIN_W / photoAspect; // cardH/cardW
|
||||
|
||||
let cardW: number;
|
||||
let cardH: number;
|
||||
if (mode === 'fill') {
|
||||
cardW = contW;
|
||||
cardH = contH;
|
||||
} else {
|
||||
cardW = Math.min(contW, contH / naturalAspect);
|
||||
cardH = cardW * naturalAspect;
|
||||
}
|
||||
const cx = (contW - cardW) / 2;
|
||||
const cy = (contH - cardH) / 2;
|
||||
|
||||
const top = S.sideMargin * cardW;
|
||||
const side = S.sideMargin * cardW;
|
||||
let winW = POLAROID_WIN_W * cardW;
|
||||
let winH = winW / photoAspect;
|
||||
// Container aspect may leave less vertical room than the natural card (e.g.
|
||||
// phone camera feed) — shrink the window to the available space instead of
|
||||
// overflowing the card.
|
||||
const maxWinH = cardH - top - S.bottomDeck * cardW;
|
||||
if (winH > maxWinH) {
|
||||
winH = maxWinH;
|
||||
winW = winH * photoAspect;
|
||||
}
|
||||
return {
|
||||
card: { x: cx, y: cy, w: cardW, h: cardH },
|
||||
window: { x: cx + (cardW - winW) / 2, y: cy + top, w: winW, h: winH },
|
||||
};
|
||||
}
|
||||
|
||||
export function drawFrameOnCanvas(
|
||||
canvas: SkCanvas,
|
||||
width: number,
|
||||
@@ -38,38 +103,6 @@ export function drawFrameOnCanvas(
|
||||
canvas.drawRect(Skia.XYWHRect(0, 0, borderWidth, height), paint);
|
||||
// Right border
|
||||
canvas.drawRect(Skia.XYWHRect(width - borderWidth, 0, borderWidth, height), paint);
|
||||
} else if (frameId === 'polaroid') {
|
||||
// Polaroid style: thick white borders, thicker at the bottom
|
||||
paint.setColor(Skia.Color('#faf9f6')); // Off-white
|
||||
paint.setStyle(0); // Fill
|
||||
|
||||
const sideBorder = Math.min(width, height) * 0.06; // 6% borders on sides/top
|
||||
const bottomBorder = height * 0.18; // 18% border at bottom
|
||||
|
||||
// Top border
|
||||
canvas.drawRect(Skia.XYWHRect(0, 0, width, sideBorder), paint);
|
||||
// Left border
|
||||
canvas.drawRect(Skia.XYWHRect(0, 0, sideBorder, height), paint);
|
||||
// Right border
|
||||
canvas.drawRect(Skia.XYWHRect(width - sideBorder, 0, sideBorder, height), paint);
|
||||
// Bottom border
|
||||
canvas.drawRect(Skia.XYWHRect(0, height - bottomBorder, width, bottomBorder), paint);
|
||||
|
||||
// Inner thin border (grey line separating image and card)
|
||||
const innerPaint = Skia.Paint();
|
||||
innerPaint.setColor(Skia.Color('#e5e5e5'));
|
||||
innerPaint.setStrokeWidth(1);
|
||||
innerPaint.setStyle(1); // Stroke
|
||||
|
||||
canvas.drawRect(
|
||||
Skia.XYWHRect(
|
||||
sideBorder - 1,
|
||||
sideBorder - 1,
|
||||
width - 2 * sideBorder + 2,
|
||||
height - sideBorder - bottomBorder + 2
|
||||
),
|
||||
innerPaint
|
||||
);
|
||||
} else if (frameId === 'cinematic') {
|
||||
// Cinematic black bars on top and bottom
|
||||
paint.setColor(Skia.Color('#000000'));
|
||||
|
||||
Reference in New Issue
Block a user