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],
};
}