Polaroid print layout in viewfinder and export; split chip rows; drop cinematic; debounce EV bias

This commit is contained in:
2026-09-08 16:30:29 +07:00
parent 7f7ceeddc6
commit bfdc964f65
4 changed files with 414 additions and 182 deletions
+81 -74
View File
@@ -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
View File
@@ -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 ? (