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 ? (
|
||||
|
||||
Reference in New Issue
Block a user