Polaroid print layout in viewfinder and export; split chip rows; drop cinematic; debounce EV bias
This commit is contained in:
+70
-12
@@ -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 } from './frameUtils';
|
||||
import { drawFrameOnCanvas, polaroidLayout, POLAROID_CARD, POLAROID_WIN_W } from './frameUtils';
|
||||
import { patchJpegDpi } from './jpegDpi';
|
||||
|
||||
export interface ExportResult {
|
||||
@@ -256,8 +256,56 @@ export async function processAndExportPhoto(
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Render Frame Border (Polaroid / Classic White / Cinematic)
|
||||
drawFrameOnCanvas(canvas, width, height, frameId);
|
||||
// 7. Frame. RETRO POLAROID is not a border drawn on the photo: the whole
|
||||
// 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.
|
||||
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;
|
||||
if (frameId === 'polaroid') {
|
||||
const photoImg = surface.makeImageSnapshot();
|
||||
const outW = width; // card width matches the photo width
|
||||
const outH = Math.round(
|
||||
width * (POLAROID_CARD.sideMargin + POLAROID_CARD.bottomDeck) + height * POLAROID_WIN_W
|
||||
);
|
||||
const cardSurface = createSurface(outW, outH);
|
||||
if (!cardSurface) {
|
||||
console.error('Failed to create polaroid card surface');
|
||||
return null;
|
||||
}
|
||||
const cardCanvas = cardSurface.getCanvas();
|
||||
const bgPaint = Skia.Paint();
|
||||
bgPaint.setColor(Skia.Color('#faf9f6')); // off-white card
|
||||
cardCanvas.drawRect(Skia.XYWHRect(0, 0, outW, outH), bgPaint);
|
||||
const pl = polaroidLayout(outW, outH, width / height, 'contain');
|
||||
const win = pl.window;
|
||||
// Window aspect == photo aspect → fills exactly, no letterbox inside.
|
||||
const plainPaint = Skia.Paint();
|
||||
cardCanvas.drawImageRect(
|
||||
photoImg,
|
||||
Skia.XYWHRect(0, 0, width, height),
|
||||
Skia.XYWHRect(win.x, win.y, win.w, win.h),
|
||||
plainPaint
|
||||
);
|
||||
// Thin seam separating image window from the card.
|
||||
const seamPaint = Skia.Paint();
|
||||
seamPaint.setColor(Skia.Color('#e5e5e5'));
|
||||
seamPaint.setStyle(1);
|
||||
seamPaint.setStrokeWidth(1);
|
||||
cardCanvas.drawRect(Skia.XYWHRect(win.x + 0.5, win.y + 0.5, win.w - 1, win.h - 1), seamPaint);
|
||||
outputSurface = cardSurface;
|
||||
stampCanvas = cardCanvas;
|
||||
stampW = outW;
|
||||
stampH = outH;
|
||||
polaroidWindow = win;
|
||||
} else {
|
||||
drawFrameOnCanvas(canvas, width, height, frameId);
|
||||
}
|
||||
|
||||
// 8. Render GPS Geotag Watermark
|
||||
if (useGeotag && gpsInfo) {
|
||||
@@ -272,7 +320,7 @@ export async function processAndExportPhoto(
|
||||
const iconTypeface = Skia.Typeface.MakeFreeTypeFaceFromData(Skia.Data.fromBase64(iconFontData));
|
||||
|
||||
if (typeface && iconTypeface) {
|
||||
const fontSize = Math.round(width * 0.032); // 3.2% of image width
|
||||
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();
|
||||
@@ -284,21 +332,31 @@ export async function processAndExportPhoto(
|
||||
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 yOffset = height - (frameId === 'polaroid' ? height * 0.15 : fontSize * 1.5);
|
||||
const xOffset = frameId === 'polaroid' ? width * 0.08 : fontSize * 1.2;
|
||||
const gap = Math.round(fontSize * 1.6); // icon box (~1em) + one clear mono space (~0.6em)
|
||||
|
||||
canvas.drawText('📍', xOffset, yOffset - fontSize * 1.4, iconPaint, iconFont);
|
||||
canvas.drawText(locationName, xOffset + gap, yOffset - fontSize * 1.4, textPaint, font);
|
||||
canvas.drawText('📷', xOffset, yOffset, iconPaint, iconFont);
|
||||
canvas.drawText(timestampStr, xOffset + gap, yOffset, textPaint, font);
|
||||
// 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.
|
||||
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);
|
||||
} else {
|
||||
baseX = fontSize * 1.2;
|
||||
baseY = stampH - fontSize * 1.5;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// 9. Snapshot & Encode to JPEG (base64 string, no SkData round-trip)
|
||||
console.error(`[export] composite ${Date.now() - t0}ms`);
|
||||
let resultImage = surface.makeImageSnapshot();
|
||||
let resultImage = outputSurface.makeImageSnapshot();
|
||||
// Sharpening knob (0..10) overrides the legacy boolean sharpen flag:
|
||||
// legacy camera captures keep sharpen:true → 0.5 fallback when knob is 0.
|
||||
const sharpKnob = adjustments.sharpening ?? 0;
|
||||
|
||||
+66
-33
@@ -10,9 +10,74 @@ export const FRAMES: FrameDefinition[] = [
|
||||
{ id: 'none', name: 'NO FRAME' },
|
||||
{ id: 'classic-white', name: 'CLASSIC BORDER' },
|
||||
{ id: 'polaroid', name: 'RETRO POLAROID' },
|
||||
{ id: 'cinematic', name: 'CINEMATIC BARS' },
|
||||
];
|
||||
|
||||
// Real Polaroid print proportions (88 x 107 mm card, 79 x 79 mm image print).
|
||||
// All fractions are relative to the CARD WIDTH so the layout scales to any
|
||||
// pixel size while keeping the physical look.
|
||||
export const POLAROID_CARD = {
|
||||
// Horizontal + top margin: (88 - 79)/2 / 88 mm each side.
|
||||
sideMargin: (88 - 79) / 2 / 88, // ≈ 0.05114
|
||||
// Bottom deck: remaining height of the 107 mm card below the 79 mm print,
|
||||
// minus the top margin: (107 - 79 - 4.5) / 88 mm.
|
||||
bottomDeck: (107 - 79 - (88 - 79) / 2) / 88, // ≈ 0.26705
|
||||
} as const;
|
||||
// Image window width as a fraction of card width (79 / 88 mm).
|
||||
export const POLAROID_WIN_W = 1 - 2 * POLAROID_CARD.sideMargin; // ≈ 0.89773
|
||||
|
||||
export interface PolaroidLayout {
|
||||
card: { x: number; y: number; w: number; h: number };
|
||||
window: { x: number; y: number; w: number; h: number };
|
||||
}
|
||||
|
||||
// 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
|
||||
// window. 'contain' fits the full card inside the container (centered, with
|
||||
// letterbox bars); 'fill' sizes the container exactly to the natural card
|
||||
// proportions (used at export where the canvas IS the card).
|
||||
export function polaroidLayout(
|
||||
contW: number,
|
||||
contH: number,
|
||||
photoAspect: number, // photo width / photo height (>1 = landscape)
|
||||
mode: 'contain' | 'fill' = 'contain'
|
||||
): PolaroidLayout {
|
||||
const S = POLAROID_CARD;
|
||||
// Height of the card once the photo window (width WIN_W of the card width,
|
||||
// height = that / photoAspect) is stacked below the top margin and above the
|
||||
// bottom deck: cardH = cardW*(sideMargin + bottomDeck) + cardW*WIN_W/photoAspect
|
||||
const naturalAspect = S.sideMargin + S.bottomDeck + POLAROID_WIN_W / photoAspect; // cardH/cardW
|
||||
|
||||
let cardW: number;
|
||||
let cardH: number;
|
||||
if (mode === 'fill') {
|
||||
cardW = contW;
|
||||
cardH = contH;
|
||||
} else {
|
||||
cardW = Math.min(contW, contH / naturalAspect);
|
||||
cardH = cardW * naturalAspect;
|
||||
}
|
||||
const cx = (contW - cardW) / 2;
|
||||
const cy = (contH - cardH) / 2;
|
||||
|
||||
const top = S.sideMargin * cardW;
|
||||
const side = S.sideMargin * cardW;
|
||||
let winW = POLAROID_WIN_W * cardW;
|
||||
let winH = winW / photoAspect;
|
||||
// Container aspect may leave less vertical room than the natural card (e.g.
|
||||
// phone camera feed) — shrink the window to the available space instead of
|
||||
// overflowing the card.
|
||||
const maxWinH = cardH - top - S.bottomDeck * cardW;
|
||||
if (winH > maxWinH) {
|
||||
winH = maxWinH;
|
||||
winW = winH * photoAspect;
|
||||
}
|
||||
return {
|
||||
card: { x: cx, y: cy, w: cardW, h: cardH },
|
||||
window: { x: cx + (cardW - winW) / 2, y: cy + top, w: winW, h: winH },
|
||||
};
|
||||
}
|
||||
|
||||
export function drawFrameOnCanvas(
|
||||
canvas: SkCanvas,
|
||||
width: number,
|
||||
@@ -38,38 +103,6 @@ export function drawFrameOnCanvas(
|
||||
canvas.drawRect(Skia.XYWHRect(0, 0, borderWidth, height), paint);
|
||||
// Right border
|
||||
canvas.drawRect(Skia.XYWHRect(width - borderWidth, 0, borderWidth, height), paint);
|
||||
} else if (frameId === 'polaroid') {
|
||||
// Polaroid style: thick white borders, thicker at the bottom
|
||||
paint.setColor(Skia.Color('#faf9f6')); // Off-white
|
||||
paint.setStyle(0); // Fill
|
||||
|
||||
const sideBorder = Math.min(width, height) * 0.06; // 6% borders on sides/top
|
||||
const bottomBorder = height * 0.18; // 18% border at bottom
|
||||
|
||||
// Top border
|
||||
canvas.drawRect(Skia.XYWHRect(0, 0, width, sideBorder), paint);
|
||||
// Left border
|
||||
canvas.drawRect(Skia.XYWHRect(0, 0, sideBorder, height), paint);
|
||||
// Right border
|
||||
canvas.drawRect(Skia.XYWHRect(width - sideBorder, 0, sideBorder, height), paint);
|
||||
// Bottom border
|
||||
canvas.drawRect(Skia.XYWHRect(0, height - bottomBorder, width, bottomBorder), paint);
|
||||
|
||||
// Inner thin border (grey line separating image and card)
|
||||
const innerPaint = Skia.Paint();
|
||||
innerPaint.setColor(Skia.Color('#e5e5e5'));
|
||||
innerPaint.setStrokeWidth(1);
|
||||
innerPaint.setStyle(1); // Stroke
|
||||
|
||||
canvas.drawRect(
|
||||
Skia.XYWHRect(
|
||||
sideBorder - 1,
|
||||
sideBorder - 1,
|
||||
width - 2 * sideBorder + 2,
|
||||
height - sideBorder - bottomBorder + 2
|
||||
),
|
||||
innerPaint
|
||||
);
|
||||
} else if (frameId === 'cinematic') {
|
||||
// Cinematic black bars on top and bottom
|
||||
paint.setColor(Skia.Color('#000000'));
|
||||
|
||||
Reference in New Issue
Block a user