Add wall frame overlay with transparent PNG window

This commit is contained in:
2026-09-08 17:56:50 +07:00
parent 669224fa39
commit a1769e8c84
5 changed files with 218 additions and 22 deletions
+64 -11
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, 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;
+52
View File
@@ -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