diff --git a/src/components/Viewfinder.tsx b/src/components/Viewfinder.tsx index 25f3fad..5024a26 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, wallframeLayout } from '../utils/frameUtils'; +import { polaroidLayout, wallframeLayout, WALLFRAME_PNG_W, WALLFRAME_PNG_H } from '../utils/frameUtils'; interface ViewfinderProps { mode: 'camera' | 'library'; @@ -556,10 +556,11 @@ 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 { frame, drawMatrix } = geo; + // Letterbox the live feed black outside the frame rect, then draw the + // artwork ROTATED into that rect: its transparent window shows the feed + // (cover-cropped center, WYSIWYG with the export crop) and the mat covers + // the rest of the screen — the feed never fills the whole display. const black = '#000000'; return ( @@ -567,7 +568,9 @@ export default function Viewfinder({ - + + + ); } @@ -856,13 +859,14 @@ 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). + // WALL FRAME (library): the photo is cover-cropped (center) into the frame's + // fixed 2:3 window (rotated artwork) — the mat opening of a real frame hides + // the print edges. The window never letterboxes a mismatched aspect; the + // artwork PNG is drawn rotated 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 drawMatrix = wallGeo.drawMatrix; const winRect = rect(win.x, win.y, win.w, win.h); return ( @@ -905,10 +909,12 @@ export default function Viewfinder({ )} - {/* 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. */} - + {/* Artwork (rotated) 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()} diff --git a/src/utils/exportEngine.ts b/src/utils/exportEngine.ts index f6095a5..d9db869 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, wallframeLayout, WALLFRAME_IMG_W, WALLFRAME_IMG_H } from './frameUtils'; +import { drawFrameOnCanvas, polaroidLayout, POLAROID_CARD, POLAROID_WIN_W, wallframeLayout, WALLFRAME_W, WALLFRAME_H } from './frameUtils'; import { patchJpegDpi } from './jpegDpi'; export interface ExportResult { @@ -260,10 +260,11 @@ 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. 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. + // card width. WALL FRAME: the export IS the ROTATED frame PNG at its native + // portrait size (WALLFRAME_W x WALLFRAME_H), with the photo cover-cropped + // (center) into the fixed 2:3 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; @@ -306,13 +307,13 @@ export async function processAndExportPhoto( stampH = outH; 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. + // Export at the ROTATED frame's native size (the frame is the product, so + // the file is exactly frame-sized — never the photo's own dimensions). const photoImg = surface.makeImageSnapshot(); const pw = photoImg.width(); const ph = photoImg.height(); - const outW = WALLFRAME_IMG_W; - const outH = WALLFRAME_IMG_H; + const outW = WALLFRAME_W; // 3117 + const outH = WALLFRAME_H; // 4000 const cardSurface = createSurface(outW, outH); if (!cardSurface) { console.error('Failed to create wallframe surface'); @@ -350,8 +351,13 @@ export async function processAndExportPhoto( plainPaint ); // Artwork over the crop: its transparent window reveals the photo, the - // mat covers the rest. + // mat covers the rest. The source PNG is landscape — rotate it 90° CW + // onto the portrait canvas (same affine as wallframeLayout.drawMatrix). + cardCanvas.save(); + cardCanvas.translate(outW, 0); + cardCanvas.rotate(90, 0, 0); cardCanvas.drawImage(wfImage, 0, 0, plainPaint); + cardCanvas.restore(); outputSurface = cardSurface; stampCanvas = cardCanvas; stampW = outW; diff --git a/src/utils/frameUtils.ts b/src/utils/frameUtils.ts index 779a89c..672c064 100644 --- a/src/utils/frameUtils.ts +++ b/src/utils/frameUtils.ts @@ -32,28 +32,40 @@ export interface PolaroidLayout { } // 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 +// 4000x3117 RGBA, transparent window cols 675..3322 x rows 675..2441). Only the +// geometry lives here; the bitmap itself is drawn by the caller. The phone is +// portrait, so the app shows the artwork ROTATED 90° CW (original top edge → +// right edge): a portrait frame whose transparent window is a fixed 2:3 +// vertical opening. The photo is cover-cropped (center) into that opening — the +// mat of a real frame hides the print edges. WALLFRAME_W/H and WALLFRAME_WIN +// are the rotated display orientation; the affine in wallframeLayout maps the +// source PNG pixels onto the frame rect so the drawn opening and the photo +// window always line up. +export const WALLFRAME_PNG_W = 4000; // source PNG width +export const WALLFRAME_PNG_H = 3117; // source PNG height +export const WALLFRAME_W = 3117; // rotated display width (= PNG height) +export const WALLFRAME_H = 4000; // rotated display height (= PNG width) +// Transparent window as fractions of the rotated image, derived from the same +// affine used to draw the bitmap: X = w - (y/3117)w, Y = (x/4000)h. +export const WALLFRAME_WIN = { + left: 676 / 3117, // ≈ 0.21688 + top: 675 / 4000, // 0.16875 + w: 1766 / 3117, // ≈ 0.56657 + h: 2647 / 4000, // 0.66175 } as const; export interface WallframeLayout { frame: { x: number; y: number; w: number; h: number }; window: { x: number; y: number; w: number; h: number }; + // Row-major Skia matrix that rotates the source PNG (WALLFRAME_PNG_W/H) 90° CW + // into the frame rect. Feed it to (viewfinder) or concat it on + // the canvas (export) before drawing the bitmap. + drawMatrix: 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). +// Geometry of the ROTATED wall-frame inside an arbitrary container. The frame +// is contain-fitted ('contain', centered, letterboxed) for the live viewfinder, +// or fills the container exactly ('fill', export canvas already at frame size). export function wallframeLayout( contW: number, contH: number, @@ -65,20 +77,22 @@ export function wallframeLayout( 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 s = Math.min(contW / WALLFRAME_W, contH / WALLFRAME_H); + frameW = WALLFRAME_W * s; + frameH = WALLFRAME_H * s; } const fx = (contW - frameW) / 2; const fy = (contH - frameH) / 2; + const scale = frameW / WALLFRAME_W; // == frameH / WALLFRAME_H 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, + x: fx + WALLFRAME_WIN.left * frameW, + y: fy + WALLFRAME_WIN.top * frameH, + w: WALLFRAME_WIN.w * frameW, + h: WALLFRAME_WIN.h * frameH, }, + drawMatrix: [0, -scale, fx + frameW, scale, 0, fy, 0, 0, 1], }; }