Add wall frame overlay with transparent PNG window
This commit is contained in:
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 255 KiB |
Reference in New Issue
Block a user