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({ {grainOpacity > 0 && noiseEffect && ( - + {/* 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. */} + @@ -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 ( + + + + + + {useShaderPass ? ( + + {cinemaOn ? ( + + {toneEffect && toneOn ? ( + + + + ) : ( + + )} + + ) : toneEffect && toneOn ? ( + + + + ) : null} + + ) : ( + + )} + + {grainOpacity > 0 && noiseEffect && ( + + + + + + )} + {/* Thin seam between image window and card */} + + {renderGPSWatermark()} + + + ); + } + return ( {libraryImageUri && skiaImage ? ( diff --git a/src/utils/exportEngine.ts b/src/utils/exportEngine.ts index 5b71905..751f1b5 100644 --- a/src/utils/exportEngine.ts +++ b/src/utils/exportEngine.ts @@ -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; diff --git a/src/utils/frameUtils.ts b/src/utils/frameUtils.ts index 11a1b71..3e9c175 100644 --- a/src/utils/frameUtils.ts +++ b/src/utils/frameUtils.ts @@ -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'));