Let the GPS watermark be placed, sized and hung landscape
Three WATERMARK/FRAME behaviours that share the stamp geometry: * GPS mark: drag it anywhere on the frame/photo area and pinch it bigger or smaller. The 0..1 anchor plus the size multiplier ride along in the export options, so a framed file (whose canvas IS the card/frame) burns the mark in at exactly the fraction the preview showed. * The photo's own pinch zoom stays available while the WATERMARK editor is closed, so a two-finger zoom is never swallowed by the mark while its panel is open. * WALL FRAME gains a WALL PORTRAIT / WALL LANDSCAPE chip: the artwork hangs in its own 4000x3117 orientation instead of the default 90-degree rotation.
This commit is contained in:
+41
-32
@@ -7,7 +7,7 @@ import { Recipe, GPSInfo, FrameId, AspectRatio, ASPECT_RATIO_W_H } from '../type
|
||||
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_W, WALLFRAME_H } from './frameUtils';
|
||||
import { drawFrameOnCanvas, polaroidLayout, POLAROID_CARD, POLAROID_WIN_W, wallframeLayout, WALLFRAME_W, WALLFRAME_H, WALLFRAME_LAND_W, WALLFRAME_LAND_H } from './frameUtils';
|
||||
import { patchJpegDpi } from './jpegDpi';
|
||||
import { rotateSkImage90 } from './skiaImage';
|
||||
|
||||
@@ -50,6 +50,12 @@ export interface ExportOptions {
|
||||
// BOTH the GPS lines and the custom text, each around its own anchor (the
|
||||
// same anchor Viewfinder rotates around, so the preview matches the file).
|
||||
watermarkRotation?: number;
|
||||
// WATERMARK tab drag + pinch on the GPS lines: 0..1 fractions of the same
|
||||
// area the custom mark uses (a framed export's canvas IS the frame) plus a
|
||||
// multiplier on the base 3.2%-of-width size. Absent = the old top-left look.
|
||||
gpsWatermark?: { x: number; y: number; size: number } | null;
|
||||
// WALL FRAME hung the other way round (4000x3117, artwork un-rotated).
|
||||
wallframeLandscape?: boolean;
|
||||
// Library framed-window photo reposition (Viewfinder.getFrameWindowZoom):
|
||||
// after pinch/drag inside a polaroid card window or a wall-frame opening,
|
||||
// s = scale and u/v = the fraction of the photo's cover plane sitting at the
|
||||
@@ -403,8 +409,9 @@ 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 ROTATED frame PNG at its native
|
||||
// portrait size (WALLFRAME_W x WALLFRAME_H), with the photo cover-cropped
|
||||
// card width. WALL FRAME: the export IS the frame PNG at its native size —
|
||||
// portrait (rotated 90° CW) or landscape, per the FRAME tab's ORIENTATION
|
||||
// chip — 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.
|
||||
@@ -412,7 +419,6 @@ export async function processAndExportPhoto(
|
||||
let stampCanvas = canvas;
|
||||
let stampW = width;
|
||||
let stampH = height;
|
||||
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
|
||||
@@ -474,15 +480,15 @@ export async function processAndExportPhoto(
|
||||
stampCanvas = cardCanvas;
|
||||
stampW = outW;
|
||||
stampH = outH;
|
||||
framedWindow = win;
|
||||
} else if (frameId === 'wallframe') {
|
||||
// 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_W; // 3117
|
||||
const outH = WALLFRAME_H; // 4000
|
||||
const wallLand = options?.wallframeLandscape === true;
|
||||
const outW = wallLand ? WALLFRAME_LAND_W : WALLFRAME_W; // 3117 / 4000
|
||||
const outH = wallLand ? WALLFRAME_LAND_H : WALLFRAME_H; // 4000 / 3117
|
||||
const cardSurface = createSurface(outW, outH);
|
||||
if (!cardSurface) {
|
||||
console.error('Failed to create wallframe surface');
|
||||
@@ -512,7 +518,7 @@ export async function processAndExportPhoto(
|
||||
console.error('Failed to decode wallframe.png');
|
||||
return null;
|
||||
}
|
||||
const wl = wallframeLayout(outW, outH, 'fill');
|
||||
const wl = wallframeLayout(outW, outH, 'fill', wallLand);
|
||||
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.
|
||||
@@ -544,15 +550,19 @@ export async function processAndExportPhoto(
|
||||
// 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);
|
||||
if (wallLand) {
|
||||
// Artwork is already landscape: outW/outH ARE its own size.
|
||||
cardCanvas.drawImage(wfImage, 0, 0, plainPaint);
|
||||
} else {
|
||||
cardCanvas.translate(outW, 0);
|
||||
cardCanvas.rotate(90, 0, 0);
|
||||
cardCanvas.drawImage(wfImage, 0, 0, plainPaint);
|
||||
}
|
||||
cardCanvas.restore();
|
||||
outputSurface = cardSurface;
|
||||
stampCanvas = cardCanvas;
|
||||
stampW = outW;
|
||||
stampH = outH;
|
||||
framedWindow = win;
|
||||
} else {
|
||||
drawFrameOnCanvas(canvas, width, height, frameId);
|
||||
}
|
||||
@@ -574,41 +584,40 @@ export async function processAndExportPhoto(
|
||||
|
||||
if (typeface && iconTypeface) {
|
||||
const fontSize = Math.round(stampW * 0.032); // 3.2% of output width
|
||||
const font = Skia.Font(typeface, fontSize);
|
||||
const iconFont = Skia.Font(iconTypeface, fontSize);
|
||||
const textPaint = Skia.Paint();
|
||||
textPaint.setColor(Skia.Color('#f59e0b')); // Amber 500
|
||||
const iconPaint = Skia.Paint();
|
||||
iconPaint.setColor(Skia.Color('#ffffff')); // icons white, distinct from text
|
||||
|
||||
// GPS lines keep the historical bottom-left anchor (framed -> inside
|
||||
// the photo window; other frames -> bottom-left of the image).
|
||||
// GPS block: its TOP-LEFT corner sits at the dragged fraction of the
|
||||
// same area the viewfinder maps onto (a framed export's canvas IS the
|
||||
// card/frame), so the preview and the file agree. Defaults = the old
|
||||
// fixed top-left look.
|
||||
if (useGeotag && gpsInfo) {
|
||||
const locationName = gpsInfo.locality || 'STREET VIEW';
|
||||
const d = new Date(gpsInfo.timestamp);
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
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)
|
||||
|
||||
let baseX: number;
|
||||
let baseY: number;
|
||||
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;
|
||||
}
|
||||
const gpsPos = options?.gpsWatermark ?? null;
|
||||
const clamp01 = (v: number) => Math.max(0, Math.min(1, v));
|
||||
const gpsSize = Math.round(fontSize * (gpsPos?.size ?? 1));
|
||||
const gpsFont = Skia.Font(typeface, gpsSize);
|
||||
const gpsIconFont = Skia.Font(iconTypeface, gpsSize);
|
||||
const gpsGap = Math.round(gpsSize * 1.6); // icon box (~1em) + one clear mono space (~0.6em)
|
||||
const baseX = clamp01(gpsPos?.x ?? 0.05) * stampW;
|
||||
const baseY = clamp01(gpsPos?.y ?? 0.1) * stampH;
|
||||
const row1Y = baseY + Math.round(gpsSize * 1.1);
|
||||
const row2Y = baseY + Math.round(gpsSize * 2.3);
|
||||
|
||||
// Spin the whole two-line block around its top-left (the anchor the
|
||||
// viewfinder uses). 0 = the historical upright look.
|
||||
const wmRot = options?.watermarkRotation ?? 0;
|
||||
stampCanvas.save();
|
||||
if (wmRot) stampCanvas.rotate(wmRot, baseX, baseY - Math.round(fontSize * 2.4));
|
||||
stampCanvas.drawText('📍', baseX, baseY - fontSize * 1.4, iconPaint, iconFont);
|
||||
stampCanvas.drawText(locationName, baseX + gap, baseY - fontSize * 1.4, textPaint, font);
|
||||
stampCanvas.drawText('📷', baseX, baseY, iconPaint, iconFont);
|
||||
stampCanvas.drawText(timestampStr, baseX + gap, baseY, textPaint, font);
|
||||
if (wmRot) stampCanvas.rotate(wmRot, baseX, baseY);
|
||||
stampCanvas.drawText('📍', baseX, row1Y, iconPaint, gpsIconFont);
|
||||
stampCanvas.drawText(locationName, baseX + gpsGap, row1Y, textPaint, gpsFont);
|
||||
stampCanvas.drawText('📷', baseX, row2Y, iconPaint, gpsIconFont);
|
||||
stampCanvas.drawText(timestampStr, baseX + gpsGap, row2Y, textPaint, gpsFont);
|
||||
stampCanvas.restore();
|
||||
}
|
||||
|
||||
|
||||
+32
-12
@@ -53,6 +53,17 @@ export const WALLFRAME_WIN = {
|
||||
w: 1766 / 3117, // ≈ 0.56657
|
||||
h: 2647 / 4000, // 0.66175
|
||||
} as const;
|
||||
// Landscape hang: the PNG's OWN orientation, so nothing is rotated — the
|
||||
// canvas is the artwork (4000x3117) and the opening is the source window as
|
||||
// fractions of it. Same 3:2 mat opening, hung the other way round.
|
||||
export const WALLFRAME_LAND_W = WALLFRAME_PNG_W; // 4000
|
||||
export const WALLFRAME_LAND_H = WALLFRAME_PNG_H; // 3117
|
||||
export const WALLFRAME_LAND_WIN = {
|
||||
left: 675 / WALLFRAME_PNG_W, // 0.16875
|
||||
top: 675 / WALLFRAME_PNG_H, // ≈ 0.21688
|
||||
w: 2647 / WALLFRAME_PNG_W, // 0.66175
|
||||
h: 1766 / WALLFRAME_PNG_H, // ≈ 0.56657
|
||||
} as const;
|
||||
|
||||
export interface WallframeLayout {
|
||||
frame: { x: number; y: number; w: number; h: number };
|
||||
@@ -63,36 +74,45 @@ export interface WallframeLayout {
|
||||
drawMatrix: number[];
|
||||
}
|
||||
|
||||
// Geometry of the ROTATED wall-frame inside an arbitrary container. The frame
|
||||
// is contain-fitted ('contain', centered, letterboxed) for the live viewfinder,
|
||||
// Geometry of the 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).
|
||||
// `landscape` hangs the artwork the other way round: same PNG, no rotation.
|
||||
export function wallframeLayout(
|
||||
contW: number,
|
||||
contH: number,
|
||||
mode: 'contain' | 'fill' = 'contain'
|
||||
mode: 'contain' | 'fill' = 'contain',
|
||||
landscape = false
|
||||
): WallframeLayout {
|
||||
const FW = landscape ? WALLFRAME_LAND_W : WALLFRAME_W;
|
||||
const FH = landscape ? WALLFRAME_LAND_H : WALLFRAME_H;
|
||||
const WIN = landscape ? WALLFRAME_LAND_WIN : WALLFRAME_WIN;
|
||||
let frameW: number;
|
||||
let frameH: number;
|
||||
if (mode === 'fill') {
|
||||
frameW = contW;
|
||||
frameH = contH;
|
||||
} else {
|
||||
const s = Math.min(contW / WALLFRAME_W, contH / WALLFRAME_H);
|
||||
frameW = WALLFRAME_W * s;
|
||||
frameH = WALLFRAME_H * s;
|
||||
const s = Math.min(contW / FW, contH / FH);
|
||||
frameW = FW * s;
|
||||
frameH = FH * s;
|
||||
}
|
||||
const fx = (contW - frameW) / 2;
|
||||
const fy = (contH - frameH) / 2;
|
||||
const scale = frameW / WALLFRAME_W; // == frameH / WALLFRAME_H
|
||||
const scale = frameW / FW; // == frameH / FH
|
||||
return {
|
||||
frame: { x: fx, y: fy, w: frameW, h: frameH },
|
||||
window: {
|
||||
x: fx + WALLFRAME_WIN.left * frameW,
|
||||
y: fy + WALLFRAME_WIN.top * frameH,
|
||||
w: WALLFRAME_WIN.w * frameW,
|
||||
h: WALLFRAME_WIN.h * frameH,
|
||||
x: fx + WIN.left * frameW,
|
||||
y: fy + WIN.top * frameH,
|
||||
w: WIN.w * frameW,
|
||||
h: WIN.h * frameH,
|
||||
},
|
||||
drawMatrix: [0, -scale, fx + frameW, scale, 0, fy, 0, 0, 1],
|
||||
// Portrait: the source PNG must be turned 90° CW onto the canvas.
|
||||
// Landscape: it already sits the right way up — scale and centre it.
|
||||
drawMatrix: landscape
|
||||
? [scale, 0, fx, 0, scale, fy, 0, 0, 1]
|
||||
: [0, -scale, fx + frameW, scale, 0, fy, 0, 0, 1],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user