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 ? (
+70 -12
View File
@@ -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
View File
@@ -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'));