diff --git a/src/components/Viewfinder.tsx b/src/components/Viewfinder.tsx index 9d5c944..25f3fad 100644 --- a/src/components/Viewfinder.tsx +++ b/src/components/Viewfinder.tsx @@ -26,7 +26,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'; +import { polaroidLayout, wallframeLayout } from '../utils/frameUtils'; interface ViewfinderProps { mode: 'camera' | 'library'; @@ -102,6 +102,9 @@ export default function Viewfinder({ const device = useCameraDevice('back'); const skiaImage = useImage(libraryImageUri || ''); + // Wall-frame PNG artwork (project root). Loaded lazily so other frames pay + // no asset cost; source switches to '' when unselected (useImage → null). + const wallImage = useImage(selectedFrame === 'wallframe' ? require('../../wallframe.png') : ''); const customFont = useFont( require('../../assets/Cousine-Regular.ttf'), Math.round(vw * 0.032) @@ -409,6 +412,13 @@ export default function Viewfinder({ : 3 / 4; return polaroidLayout(vw, vh, aspect, 'contain'); }, [selectedFrame, mode, skiaImage, vw, vh]); + // Wall-frame PNG geometry (JS/overlay space). Fixed landscape 3:2 window — + // the PNG is contain-fitted like a physical frame; photo content behind it + // is cover-cropped by the mat opening (never letterboxed inside the window). + const wallGeo = useMemo(() => { + if (selectedFrame !== 'wallframe') return null; + return wallframeLayout(vw, vh, 'contain'); + }, [selectedFrame, vw, vh]); const onLibTouchStart = (e: any) => { const ts = e.nativeEvent.touches; if (ts.length >= 2) { @@ -543,6 +553,25 @@ export default function Viewfinder({ ); } + if (selectedFrame === 'wallframe') { + const geo = wallGeo; + if (!geo || !wallImage) return null; // PNG still loading → bare feed + const { frame } = geo; + // Letterbox the live feed black outside the PNG, then draw the artwork: + // its transparent window lets the fullscreen feed show through (the mat + // opening = center crop of the feed, WYSIWYG with the export crop). + const black = '#000000'; + return ( + + + + + + + + ); + } + if (selectedFrame === 'classic-white') { return ( @@ -582,7 +611,7 @@ export default function Viewfinder({ // photo corner. With a polaroid frame the photo lives inside the card // window — anchor to that. Always clear the floating TopBar (recipe name + // SAVE), which ends around insets.top + 56 dp. - const win = polaroidGeo?.window ?? null; + const win = polaroidGeo?.window ?? wallGeo?.window ?? null; const area = win ? { dx: win.x, dy: win.y, dw: win.w, dh: win.h } : mode === 'library' && imageFitRect @@ -612,18 +641,20 @@ export default function Viewfinder({ ); }; - const renderCameraOverlays = () => ( + const renderCameraOverlays = () => { + const framedWin = polaroidGeo?.window ?? wallGeo?.window ?? null; + return ( <> {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. */} + {/* Grain belongs to the photo; on a framed photo it must not spill + onto the mat, so it is clipped to the photo window. */} @@ -644,6 +675,7 @@ export default function Viewfinder({ ); + }; if (mode === 'camera') { if (!cameraPermissionGranted) { @@ -824,6 +856,65 @@ export default function Viewfinder({ ); } + // WALL FRAME (library): the photo is cover-cropped (center) into the PNG's + // fixed 3:2 window — the mat opening of a real frame hides the print edges. + // The window never letterboxes a mismatched aspect; the artwork PNG is drawn + // over the crop afterwards (its transparent window reveals the photo). + if (mode === 'library' && wallGeo && wallImage && skiaImage && libraryImageUri) { + const win = wallGeo.window; + const frame = wallGeo.frame; + const winRect = rect(win.x, win.y, win.w, win.h); + return ( + + + + + + {useShaderPass ? ( + + {cinemaOn ? ( + + {toneEffect && toneOn ? ( + + + + ) : ( + + )} + + ) : toneEffect && toneOn ? ( + + + + ) : null} + + ) : ( + + )} + + {grainOpacity > 0 && noiseEffect && ( + + + + + + )} + {/* Artwork over the crop: transparent window reveals it, mat covers the + rest. Drawn last so semi-transparent mat edges blend over the photo + instead of the reverse. */} + + {renderGPSWatermark()} + + + ); + } + return ( {libraryImageUri && skiaImage ? ( diff --git a/src/types/index.ts b/src/types/index.ts index ad2bc74..db06e35 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,4 +1,4 @@ -export type FrameId = 'none' | 'polaroid' | 'classic-white' | 'cinematic'; +export type FrameId = 'none' | 'polaroid' | 'classic-white' | 'cinematic' | 'wallframe'; // Cinematic seasonal atmosphere grade. Not a fake re-light: it reads the // photo's EXISTING exposure — warm tint + soft glow on areas already lit, @@ -29,7 +29,7 @@ export interface Recipe { isCustom?: boolean; baseFilter: 'classic-neg' | 'provia' | 'velvia' | 'monochrome' | 'none'; adjustments: ColorAdjustments; - frameId?: FrameId; // e.g. 'none', 'polaroid', 'classic-white', 'cinematic' + frameId?: FrameId; // e.g. 'none', 'polaroid', 'classic-white', 'cinematic', 'wallframe' cinema?: CinemaSeason | null; // seasonal atmosphere grade (optional) useGeotag: boolean; } diff --git a/src/utils/exportEngine.ts b/src/utils/exportEngine.ts index 751f1b5..f6095a5 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, polaroidLayout, POLAROID_CARD, POLAROID_WIN_W } from './frameUtils'; +import { drawFrameOnCanvas, polaroidLayout, POLAROID_CARD, POLAROID_WIN_W, wallframeLayout, WALLFRAME_IMG_W, WALLFRAME_IMG_H } from './frameUtils'; import { patchJpegDpi } from './jpegDpi'; export interface ExportResult { @@ -260,13 +260,15 @@ export async function processAndExportPhoto( // 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. + // card width. WALL FRAME: the export IS the frame PNG at native resolution, + // with the photo cover-cropped (center) into the fixed 3:2 window (the mat + // opening of a real frame). 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; + let framedWindow: { 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 @@ -302,7 +304,59 @@ export async function processAndExportPhoto( stampCanvas = cardCanvas; stampW = outW; stampH = outH; - polaroidWindow = win; + framedWindow = win; + } else if (frameId === 'wallframe') { + // Export at the PNG's native resolution: the mat artwork stays crisp and + // the photo crop inside the window is as large as the asset allows. + const photoImg = surface.makeImageSnapshot(); + const pw = photoImg.width(); + const ph = photoImg.height(); + const outW = WALLFRAME_IMG_W; + const outH = WALLFRAME_IMG_H; + const cardSurface = createSurface(outW, outH); + if (!cardSurface) { + console.error('Failed to create wallframe surface'); + return null; + } + const cardCanvas = cardSurface.getCanvas(); + // Decode the frame PNG artwork (bundled asset → base64, same path as the + // GPS fonts below — embedded assets may not expose a readable file URI). + const wfAsset = Asset.fromModule(require('../../wallframe.png')); + await wfAsset.downloadAsync(); + const wfDataStr = wfAsset.localUri + ? await FileSystem.readAsStringAsync(wfAsset.localUri, { encoding: FileSystem.EncodingType.Base64 }) + : null; + if (!wfDataStr) { + console.error('Failed to load wallframe.png data'); + return null; + } + const wfImage = Skia.Image.MakeImageFromEncoded(Skia.Data.fromBase64(wfDataStr)); + if (!wfImage) { + console.error('Failed to decode wallframe.png'); + return null; + } + const wl = wallframeLayout(outW, outH, 'fill'); + const win = wl.window; + // Cover-crop (center): scale the processed photo so it fills the fixed + // 3:2 window and cut the overflowing edges equally on both sides. + const plainPaint = Skia.Paint(); + const scale = Math.max(win.w / pw, win.h / ph); + const srcW = win.w / scale; + const srcH = win.h / scale; + cardCanvas.drawImageRect( + photoImg, + Skia.XYWHRect((pw - srcW) / 2, (ph - srcH) / 2, srcW, srcH), + Skia.XYWHRect(win.x, win.y, win.w, win.h), + plainPaint + ); + // Artwork over the crop: its transparent window reveals the photo, the + // mat covers the rest. + cardCanvas.drawImage(wfImage, 0, 0, plainPaint); + outputSurface = cardSurface; + stampCanvas = cardCanvas; + stampW = outW; + stampH = outH; + framedWindow = win; } else { drawFrameOnCanvas(canvas, width, height, frameId); } @@ -334,14 +388,13 @@ export async function processAndExportPhoto( const timestampStr = `${pad(d.getDate())}/${pad(d.getMonth() + 1)}/${d.getFullYear()} ${pad(d.getHours())}:${pad(d.getMinutes())}`; const gap = Math.round(fontSize * 1.6); // icon box (~1em) + one clear mono space (~0.6em) - // 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. + // Framed (polaroid/wallframe): caption at the bottom-left inside the + // photo window. Other frames: bottom-left of the image, clear of 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); + if (framedWindow) { + baseX = framedWindow.x + framedWindow.w * 0.05; + baseY = framedWindow.y + framedWindow.h - Math.round(fontSize * 1.4); } else { baseX = fontSize * 1.2; baseY = stampH - fontSize * 1.5; diff --git a/src/utils/frameUtils.ts b/src/utils/frameUtils.ts index 3e9c175..779a89c 100644 --- a/src/utils/frameUtils.ts +++ b/src/utils/frameUtils.ts @@ -10,6 +10,7 @@ export const FRAMES: FrameDefinition[] = [ { id: 'none', name: 'NO FRAME' }, { id: 'classic-white', name: 'CLASSIC BORDER' }, { id: 'polaroid', name: 'RETRO POLAROID' }, + { id: 'wallframe', name: 'WALL FRAME' }, ]; // Real Polaroid print proportions (88 x 107 mm card, 79 x 79 mm image print). @@ -30,6 +31,57 @@ export interface PolaroidLayout { window: { x: number; y: number; w: number; h: number }; } +// Wall frame: the PNG artwork lives at the project root (wallframe.png, +// 4000x3117 RGBA, transparent window 675..3322 x 675..2441). Only the geometry +// lives here; the bitmap itself is drawn by the caller (Viewfinder overlay / +// export engine). The window is FIXED 3:2 landscape, so the photo is +// cover-cropped (center) into it — the mat of a real frame hides the print +// edges. Fractions are relative to the full PNG. +export const WALLFRAME_IMG_W = 4000; +export const WALLFRAME_IMG_H = 3117; +export const WALLFRAME = { + winLeft: 675 / 4000, // 0.16875 + winTop: 675 / 3117, // ≈ 0.21655 + winW: 2648 / 4000, // 0.662 + winH: 1767 / 3117, // ≈ 0.56689 +} as const; + +export interface WallframeLayout { + frame: { x: number; y: number; w: number; h: number }; + window: { x: number; y: number; w: number; h: number }; +} + +// Geometry of the wall-frame PNG inside an arbitrary container. The PNG is +// contain-fitted ('contain', centered, letterboxed) for the live viewfinder, or +// stretched to fill exactly ('fill', export canvas already has PNG aspect). +export function wallframeLayout( + contW: number, + contH: number, + mode: 'contain' | 'fill' = 'contain' +): WallframeLayout { + let frameW: number; + let frameH: number; + if (mode === 'fill') { + frameW = contW; + frameH = contH; + } else { + const s = Math.min(contW / WALLFRAME_IMG_W, contH / WALLFRAME_IMG_H); + frameW = WALLFRAME_IMG_W * s; + frameH = WALLFRAME_IMG_H * s; + } + const fx = (contW - frameW) / 2; + const fy = (contH - frameH) / 2; + return { + frame: { x: fx, y: fy, w: frameW, h: frameH }, + window: { + x: fx + WALLFRAME.winLeft * frameW, + y: fy + WALLFRAME.winTop * frameH, + w: WALLFRAME.winW * frameW, + h: WALLFRAME.winH * frameH, + }, + }; +} + // 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 diff --git a/wallframe.png b/wallframe.png new file mode 100644 index 0000000..c3cfd0a Binary files /dev/null and b/wallframe.png differ