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
+100 -9
View File
@@ -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 (
<Group>
<Rect x={0} y={0} width={vw} height={frame.y} color={black} />
<Rect x={0} y={frame.y + frame.h} width={vw} height={vh - frame.y - frame.h} color={black} />
<Rect x={0} y={frame.y} width={frame.x} height={frame.h} color={black} />
<Rect x={frame.x + frame.w} y={frame.y} width={vw - frame.x - frame.w} height={frame.h} color={black} />
<SkiaImage image={wallImage} x={frame.x} y={frame.y} width={frame.w} height={frame.h} fit="fill" />
</Group>
);
}
if (selectedFrame === 'classic-white') {
return (
<Group color="white">
@@ -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 (
<>
<Canvas style={StyleSheet.absoluteFill} pointerEvents="none">
{grainOpacity > 0 && noiseEffect && (
<Group blendMode="overlay">
{/* 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. */}
<Rect
x={polaroidGeo ? polaroidGeo.window.x : 0}
y={polaroidGeo ? polaroidGeo.window.y : 0}
width={polaroidGeo ? polaroidGeo.window.w : vw}
height={polaroidGeo ? polaroidGeo.window.h : vh}
x={framedWin ? framedWin.x : 0}
y={framedWin ? framedWin.y : 0}
width={framedWin ? framedWin.w : vw}
height={framedWin ? framedWin.h : vh}
opacity={grainOpacity}
>
<Shader source={noiseEffect} />
@@ -644,6 +675,7 @@ export default function Viewfinder({
</View>
</>
);
};
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 (
<View className="flex-1 relative overflow-hidden bg-black items-center justify-center">
<Canvas style={StyleSheet.absoluteFill}>
<Rect x={0} y={0} width={vw} height={vh} color="#000000" />
<Group>
<ColorMatrix matrix={colorMatrix} />
{useShaderPass ? (
<Rect x={win.x} y={win.y} width={win.w} height={win.h}>
{cinemaOn ? (
<Shader source={cinemaEffect!} uniforms={cinemaParams!.uniforms}>
{toneEffect && toneOn ? (
<Shader
source={toneEffect}
uniforms={{ dr: toneParams[0], hl: toneParams[1], sh: toneParams[2] }}
>
<ImageShader image={skiaImage} fit="cover" tx="clamp" ty="clamp" rect={winRect} />
</Shader>
) : (
<ImageShader image={skiaImage} fit="cover" tx="clamp" ty="clamp" rect={winRect} />
)}
</Shader>
) : toneEffect && toneOn ? (
<Shader
source={toneEffect}
uniforms={{ dr: toneParams[0], hl: toneParams[1], sh: toneParams[2] }}
>
<ImageShader image={skiaImage} fit="cover" tx="clamp" ty="clamp" rect={winRect} />
</Shader>
) : null}
</Rect>
) : (
<SkiaImage image={skiaImage} fit="cover" rect={winRect} />
)}
</Group>
{grainOpacity > 0 && noiseEffect && (
<Group blendMode="overlay">
<Rect x={win.x} y={win.y} width={win.w} height={win.h} opacity={grainOpacity}>
<Shader source={noiseEffect} />
</Rect>
</Group>
)}
{/* 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. */}
<SkiaImage image={wallImage} x={frame.x} y={frame.y} width={frame.w} height={frame.h} fit="fill" />
{renderGPSWatermark()}
</Canvas>
</View>
);
}
return (
<View className="flex-1 relative overflow-hidden bg-black items-center justify-center">
{libraryImageUri && skiaImage ? (
+2 -2
View File
@@ -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;
}
+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
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 255 KiB