diff --git a/src/components/AdjustmentPanel.tsx b/src/components/AdjustmentPanel.tsx
index 13c635a..6e00126 100644
--- a/src/components/AdjustmentPanel.tsx
+++ b/src/components/AdjustmentPanel.tsx
@@ -233,75 +233,90 @@ export default function AdjustmentPanel({
case 'iq':
return (
-
- {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'],
- })
- )}
-
+
+
+ {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'],
+ })
+ )}
+
+
+ {paramChips(paramDefs.iq)}
+
+
);
case 'wb':
return (
-
- {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'] })
- )}
-
+
+
+ {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'] })
+ )}
+
+
+ {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)}
+
+
);
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: () => {},
- })}
);
@@ -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),
})}
diff --git a/src/components/Viewfinder.tsx b/src/components/Viewfinder.tsx
index 15f705a..3071004 100644
--- a/src/components/Viewfinder.tsx
+++ b/src/components/Viewfinder.tsx
@@ -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[] = [];
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['onFrame']>;
const toneFailLogged = useMemo(() => createSynchronizable(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(
(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 (
-
-
-
-
-
-
-
- );
- }
-
if (selectedFrame === 'cinematic') {
return (
@@ -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({