b4d5d2926b
Every member gets /photos — their own uploads, counted against a 12-photo cap, each card showing the tagline and the technical line the studio would print. The studio gains SAVE PHOTO n/12 in the top bar: it renders the full resolution look, stores the strip (tag/title/meta) with the upload so the landing reel frames it the same way, and refuses past the cap. EXPORT now burns that strip into the file: the amber #TAG over the photo's top-left plus a dark caption band below carrying the recipe name and the ISO / grain / warmth line. The live preview stays clean, and the saved upload stays clean too — the reel draws its own frame from the stored labels, so a burned band would tag the tag twice. Admins manage any photo through DELETE /api/photos/:id; members only their own. The users table's photo counts stay in step with the folder.
771 lines
30 KiB
TypeScript
771 lines
30 KiB
TypeScript
// Full-resolution render pipeline for the web build.
|
|
//
|
|
// Port of `src/utils/exportEngine.ts` (React Native / Skia JSI) onto CanvasKit,
|
|
// step for step, so the file a browser downloads matches the file the phone
|
|
// writes. What changed and why (W0 probe, measured — see skiaShim.ts header):
|
|
// - `Skia.Data.fromURI` + expo file APIs → the caller hands in the JPEG bytes
|
|
// and gets JPEG bytes back; the file save / gallery / MediaLibrary tail is
|
|
// gone (the browser downloads a Blob instead).
|
|
// - `MakeMatrixConvolution` does not exist in CanvasKit. The two convolution
|
|
// steps (CLARITY, output sharpen) run through `CLARITY_SKSL` from
|
|
// `toneShader.ts` — the same kernel `[0,-a,0,-a,1+4a,-a,0,-a,0]`, evaluated
|
|
// as a shader pass. `convolve` replaces the pixels outright (the shader's
|
|
// output is opaque and covers the same rect), so no clearing pass is needed.
|
|
// - The negative-side blurs stay on `ImageFilter.MakeBlur`, but a blur cannot
|
|
// be composited over the un-blurred content, so those passes snapshot, clear
|
|
// and redraw. Order matches RN's nested filter chain: denoise → clarity →
|
|
// mist → soften.
|
|
// - EXIF rewrite (`writeJpegExif`) is not ported yet; `patchJpegDpi(300)`
|
|
// alone keeps the print size metadata.
|
|
// ponytail: EXIF says "W4 if the web export needs the camera/lens tags".
|
|
|
|
import { Skia } from './skiaShim';
|
|
import type { Recipe, GPSInfo, FrameId, AspectRatio, CropRect } from '../../shared/types';
|
|
import { ASPECT_RATIO_W_H } from '../../shared/types';
|
|
import { getSkiaColorMatrix, applyExposureGain } from '../../shared/utils/colorUtils';
|
|
import {
|
|
TONE_SKSL,
|
|
GLOW_SKSL,
|
|
CLARITY_SKSL,
|
|
getToneUniforms,
|
|
toneIsActive,
|
|
toneUniformArray,
|
|
glowUniformArray,
|
|
} from '../../shared/utils/toneShader';
|
|
import { CINEMA_SKSL, getCinemaUniforms, cinemaIsActive } from '../../shared/utils/cinemaShader';
|
|
import {
|
|
drawFrameOnCanvas,
|
|
polaroidLayout,
|
|
POLAROID_CARD,
|
|
POLAROID_WIN_W,
|
|
wallframeLayout,
|
|
WALLFRAME_W,
|
|
WALLFRAME_H,
|
|
WALLFRAME_LAND_W,
|
|
WALLFRAME_LAND_H,
|
|
} from '../../shared/utils/frameUtils';
|
|
import { patchJpegDpi } from '../../shared/utils/jpegDpi';
|
|
import { applyPhotoRotation } from '../../shared/utils/skiaImage';
|
|
|
|
export interface RenderAssets {
|
|
textFont: ArrayBuffer; // assets/Cousine-Regular.ttf
|
|
emojiFont: ArrayBuffer; // assets/NotoEmoji-GPS.ttf
|
|
wallframe: Uint8Array; // wallframe.png
|
|
}
|
|
|
|
export interface RenderOptions {
|
|
dpi?: number;
|
|
sharpen?: boolean;
|
|
evFromCamera?: boolean;
|
|
aspect?: AspectRatio;
|
|
cropWH?: number;
|
|
cropRect?: CropRect | null;
|
|
watermark?: { text: string; x: number; y: number; color?: string; size?: number; font?: string | null } | null;
|
|
liteMark?: { text: string; x: number; y: number; color?: string; size?: number; font?: string | null } | null;
|
|
watermarkRotation?: number;
|
|
gpsWatermark?: { x: number; y: number; size: number; color?: string; showName?: boolean; showTime?: boolean } | null;
|
|
wallframeLandscape?: boolean;
|
|
frameWindowZoom?: { s: number; u: number; v: number } | null;
|
|
photoRotation?: 0 | 90 | 180 | 270;
|
|
photoStraighten?: number;
|
|
// The strip the FILE carries: the amber `#TAG` over the photo and a caption
|
|
// band appended below it, mirroring the landing page's `.lp-shot` +
|
|
// `.lp-frame-meta`. Left off the live preview, which must not show it.
|
|
caption?: { tag: string; title: string; meta: string } | null;
|
|
}
|
|
|
|
export interface RenderInput {
|
|
source: Uint8Array;
|
|
recipe: Recipe;
|
|
frameId: FrameId;
|
|
useGeotag?: boolean;
|
|
gpsInfo?: GPSInfo | null;
|
|
options?: RenderOptions;
|
|
assets: RenderAssets;
|
|
}
|
|
|
|
export interface RenderResult {
|
|
bytes: Uint8Array;
|
|
width: number;
|
|
height: number;
|
|
}
|
|
|
|
type SkDisposable = { dispose(): void };
|
|
|
|
function disposeAll(items: SkDisposable[]): void {
|
|
for (let i = items.length - 1; i >= 0; i--) {
|
|
try {
|
|
items[i].dispose();
|
|
} catch {
|
|
/* already released */
|
|
}
|
|
}
|
|
}
|
|
|
|
function release(owned: SkDisposable[], item: SkDisposable | null): void {
|
|
if (!item) return;
|
|
const i = owned.indexOf(item);
|
|
if (i >= 0) owned.splice(i, 1);
|
|
disposeAll([item]);
|
|
}
|
|
|
|
function createSurface(width: number, height: number) {
|
|
return Skia.Surface.MakeOffscreen(width, height) ?? Skia.Surface.Make(width, height);
|
|
}
|
|
|
|
// A WebGL surface batches draws; the readback paths (snapshot, encode) must see
|
|
// them submitted first. A no-op on the raster fallback.
|
|
function flush(surface: any): void {
|
|
if (typeof surface?.flush === 'function') surface.flush();
|
|
}
|
|
|
|
// --- shared runtime effects -------------------------------------------------
|
|
|
|
let sharpenEffect: any = null;
|
|
let toneEffect: any = null;
|
|
let cinemaEffect: any = null;
|
|
let glowEffect: any = null;
|
|
|
|
function effects() {
|
|
if (!sharpenEffect) sharpenEffect = Skia.RuntimeEffect.Make(CLARITY_SKSL);
|
|
if (!toneEffect) toneEffect = Skia.RuntimeEffect.Make(TONE_SKSL);
|
|
if (!cinemaEffect) cinemaEffect = Skia.RuntimeEffect.Make(CINEMA_SKSL);
|
|
if (!glowEffect) glowEffect = Skia.RuntimeEffect.Make(GLOW_SKSL);
|
|
return { sharpenEffect, toneEffect, cinemaEffect, glowEffect };
|
|
}
|
|
|
|
// CLARITY_SKSL uniforms are (a, px.x, px.y); px = one source pixel = 1 unit on
|
|
// a 1:1 export canvas, so the radius matches what the preview tuned.
|
|
function convolvePaint(srcImage: any, amount: number): any {
|
|
const { sharpenEffect: effect } = effects();
|
|
if (!effect) return null;
|
|
const paint = Skia.Paint();
|
|
const child = srcImage.makeShaderOptions(
|
|
Skia.TileMode.Clamp, Skia.TileMode.Clamp, Skia.FilterMode.Linear, Skia.MipmapMode.None
|
|
);
|
|
const shader = effect.makeShaderWithChildren([amount, 1, 1], [child]);
|
|
if (!shader) {
|
|
paint.dispose();
|
|
child.dispose();
|
|
return null;
|
|
}
|
|
paint.setShader(shader);
|
|
return { paint, shader, child };
|
|
}
|
|
|
|
// The convolution must sample the ALREADY GRADED pixels (RN runs it as the
|
|
// outermost filter of the draw), so the pass reads a snapshot of the surface
|
|
// and writes the sharpened result back over the same rect.
|
|
function convPass(canvas: any, surface: any, w: number, h: number, amount: number): void {
|
|
flush(surface);
|
|
const snap = surface.makeImageSnapshot();
|
|
if (!snap) return;
|
|
drawConvolved(canvas, snap, w, h, amount);
|
|
snap.dispose();
|
|
}
|
|
|
|
// Replaces the pixels under the rect with the convolved result.
|
|
function drawConvolved(canvas: any, srcImage: any, w: number, h: number, amount: number): void {
|
|
const built = convolvePaint(srcImage, amount);
|
|
if (!built) return;
|
|
canvas.drawRect(Skia.XYWHRect(0, 0, w, h), built.paint);
|
|
disposeAll([built.paint, built.shader, built.child]);
|
|
}
|
|
|
|
function convolveImage(image: any, amount: number): any {
|
|
const w = image.width();
|
|
const h = image.height();
|
|
const surf = createSurface(w, h);
|
|
if (!surf) return image;
|
|
try {
|
|
drawConvolved(surf.getCanvas(), image, w, h, amount);
|
|
return surf.makeImageSnapshot() ?? image;
|
|
} finally {
|
|
surf.dispose();
|
|
}
|
|
}
|
|
|
|
function screenSharpenImage(image: any, amount = 0.5): any {
|
|
return convolveImage(image, amount);
|
|
}
|
|
|
|
// The colour matrix, applied on its own into a fresh image. A SkPaint runs its
|
|
// shader before its colourFilter, so the graded pixels have to exist as an
|
|
// image before the tone/cinema pass can read them (see renderPhoto step 3).
|
|
function gradeThrough(image: any, w: number, h: number, colorFilter: any): any {
|
|
const surf = createSurface(w, h);
|
|
if (!surf) return null;
|
|
try {
|
|
const p = Skia.Paint();
|
|
p.setColorFilter(colorFilter);
|
|
surf.getCanvas().drawImage(image, 0, 0, p);
|
|
p.dispose();
|
|
return surf.makeImageSnapshot() ?? null;
|
|
} finally {
|
|
surf.dispose();
|
|
}
|
|
}
|
|
|
|
// Snapshot → clear → blur redraw: the blur must not sit on top of the sharp
|
|
// pixels it is meant to replace.
|
|
function drawBlurred(canvas: any, surface: any, w: number, h: number, sigma: number): void {
|
|
flush(surface);
|
|
const snap = surface.makeImageSnapshot();
|
|
const paint = Skia.Paint();
|
|
const filter = Skia.ImageFilter.MakeBlur(sigma, sigma, Skia.TileMode.Clamp, null);
|
|
paint.setImageFilter(filter);
|
|
canvas.clear(Skia.Color('transparent'));
|
|
canvas.drawImage(snap, 0, 0, paint);
|
|
disposeAll([paint, filter, snap]);
|
|
}
|
|
|
|
// --- fonts / artwork --------------------------------------------------------
|
|
|
|
let faces: { typeface: any; iconTypeface: any } | null = null;
|
|
let wallframeImage: any = null;
|
|
|
|
function loadFaces(assets: RenderAssets) {
|
|
if (faces) return faces;
|
|
const typeface = Skia.Typeface.MakeFreeTypeFaceFromData(assets.textFont);
|
|
const iconTypeface = Skia.Typeface.MakeFreeTypeFaceFromData(assets.emojiFont);
|
|
if (!typeface || !iconTypeface) return null;
|
|
faces = { typeface, iconTypeface };
|
|
// Named-family lookup for the custom watermark's FONT option: register the
|
|
// bundled face so at least that one name resolves on web.
|
|
try {
|
|
Skia.FontMgr.System().registerFace(typeface, 'Cousine');
|
|
} catch {
|
|
/* provider optional */
|
|
}
|
|
return faces;
|
|
}
|
|
|
|
// Both walls are one artwork on one code path: `wallframe` hangs the PNG turned
|
|
// 90° CW (the portrait opening), `wallframe-landscape` lays it down as it is.
|
|
const isWallframe = (id: FrameId) => id === 'wallframe' || id === 'wallframe-landscape';
|
|
|
|
function loadWallframe(assets: RenderAssets) {
|
|
if (wallframeImage) return wallframeImage;
|
|
wallframeImage = Skia.Image.MakeImageFromEncoded(assets.wallframe);
|
|
return wallframeImage;
|
|
}
|
|
|
|
export async function renderPhoto(input: RenderInput): Promise<RenderResult | null> {
|
|
const { recipe, frameId, options } = input;
|
|
const owned: SkDisposable[] = [];
|
|
const own = <T,>(obj: T | null | undefined): T => {
|
|
if (obj) owned.push(obj as unknown as SkDisposable);
|
|
return obj as T;
|
|
};
|
|
try {
|
|
const skImage0 = Skia.Image.MakeImageFromEncoded(input.source);
|
|
if (!skImage0) {
|
|
console.error('Failed to parse source image');
|
|
return null;
|
|
}
|
|
own(skImage0);
|
|
let skImage = skImage0;
|
|
|
|
const rotated = applyPhotoRotation(skImage, options?.photoRotation, options?.photoStraighten);
|
|
if (rotated && rotated !== skImage) {
|
|
own(rotated);
|
|
release(owned, skImage);
|
|
skImage = rotated;
|
|
}
|
|
let width = skImage.width();
|
|
let height = skImage.height();
|
|
|
|
// 1b. Composition / FRAME-tab crop — identical maths to the RN engine.
|
|
const aspectWH = options?.aspect && options.aspect !== 'full' ? ASPECT_RATIO_W_H[options.aspect] : null;
|
|
const cropRect = options?.cropRect ?? null;
|
|
const ratioWH = cropRect ? null : options?.cropWH ?? aspectWH;
|
|
if ((cropRect || ratioWH) && !isWallframe(frameId)) {
|
|
const curAspect = width / height;
|
|
let cropW = width;
|
|
let cropH = height;
|
|
let srcX = 0;
|
|
let srcY = 0;
|
|
if (cropRect) {
|
|
srcX = Math.max(0, Math.min(width - 1, Math.round(cropRect.x * width)));
|
|
srcY = Math.max(0, Math.min(height - 1, Math.round(cropRect.y * height)));
|
|
cropW = Math.max(1, Math.min(width - srcX, Math.round(cropRect.w * width)));
|
|
cropH = Math.max(1, Math.min(height - srcY, Math.round(cropRect.h * height)));
|
|
} else if (curAspect > (ratioWH as number)) {
|
|
cropW = Math.round(height * (ratioWH as number));
|
|
srcX = Math.round((width - cropW) / 2);
|
|
} else {
|
|
cropH = Math.round(width / (ratioWH as number));
|
|
srcY = Math.round((height - cropH) / 2);
|
|
}
|
|
const cropSurface = createSurface(cropW, cropH);
|
|
if (!cropSurface) return null;
|
|
own(cropSurface);
|
|
cropSurface.getCanvas().drawImageRect(
|
|
skImage,
|
|
Skia.XYWHRect(srcX, srcY, cropW, cropH),
|
|
Skia.XYWHRect(0, 0, cropW, cropH),
|
|
own(Skia.Paint())
|
|
);
|
|
const cropped = cropSurface.makeImageSnapshot();
|
|
if (!cropped) return null;
|
|
own(cropped);
|
|
release(owned, skImage);
|
|
release(owned, cropSurface);
|
|
skImage = cropped;
|
|
width = cropW;
|
|
height = cropH;
|
|
}
|
|
|
|
// 2. Render surface.
|
|
const surface = createSurface(width, height);
|
|
if (!surface) return null;
|
|
own(surface);
|
|
const canvas = surface.getCanvas();
|
|
const paint = own(Skia.Paint());
|
|
|
|
const adjustments = recipe.adjustments;
|
|
|
|
// 3. Colour matrix (+2^EV gain unless the camera already biased the shot).
|
|
// The matrix grades into its OWN image first, because a SkPaint runs its
|
|
// shader before its colourFilter: with both on one paint the exposure gain
|
|
// landed AFTER the tone pass, so +EXPOSURE took the 0.78 a rolled-back
|
|
// highlight came out at and pushed it straight back to 1.0 — the HIGHLIGHT
|
|
// slider looked dead the moment exposure went up (measured, see the order
|
|
// probe). The tone shader's child is the graded image, so the highlight
|
|
// mask finally sees the exposed value it is meant to pull back.
|
|
const evStops = adjustments.exposureCompensation ?? 0;
|
|
const evMatrix = options?.evFromCamera
|
|
? getSkiaColorMatrix(recipe.baseFilter, adjustments)
|
|
: applyExposureGain(getSkiaColorMatrix(recipe.baseFilter, adjustments), evStops);
|
|
const colorFilter = own(Skia.ColorFilter.MakeMatrix(evMatrix));
|
|
const matrixIsIdentity = evMatrix.every((v, i) => v === (i % 6 === 0 ? 1 : 0));
|
|
|
|
// 3b. Tone shader.
|
|
const tone = getToneUniforms(adjustments, recipe.baseFilter);
|
|
let toneShader: any = null;
|
|
// 3c. Cinema seasonal grade (cinema → tone → image).
|
|
const cinema = getCinemaUniforms(recipe.cinema);
|
|
const cinemaOn = !!cinema && cinemaIsActive(cinema.flat);
|
|
let paintShader: any = null;
|
|
let gradeImage = skImage;
|
|
const graded = (toneIsActive(tone) || cinemaOn) && !matrixIsIdentity;
|
|
if (graded) {
|
|
const snapshot = gradeThrough(skImage, width, height, colorFilter);
|
|
if (snapshot) {
|
|
own(snapshot);
|
|
gradeImage = snapshot;
|
|
}
|
|
}
|
|
const imageShaderOf = () =>
|
|
own(
|
|
gradeImage.makeShaderOptions(
|
|
Skia.TileMode.Clamp, Skia.TileMode.Clamp, Skia.FilterMode.Linear, Skia.MipmapMode.None
|
|
)
|
|
);
|
|
if (toneIsActive(tone)) {
|
|
const { toneEffect: effect } = effects();
|
|
if (effect) {
|
|
toneShader = own(effect.makeShaderWithChildren(toneUniformArray(tone), [imageShaderOf()]));
|
|
}
|
|
}
|
|
if (cinemaOn) {
|
|
const { cinemaEffect: effect } = effects();
|
|
if (effect) {
|
|
const cs = own(effect.makeShaderWithChildren(cinema.flat, [toneShader ?? imageShaderOf()]));
|
|
if (cs) paintShader = cs;
|
|
}
|
|
}
|
|
if (!paintShader && toneShader) paintShader = toneShader;
|
|
// Only one of the two may tint the pixels: the graded image already carries
|
|
// the matrix, an ungraded draw still needs it.
|
|
if (paintShader) paint.setShader(paintShader);
|
|
else if (!graded) paint.setColorFilter(colorFilter);
|
|
|
|
// 4. Denoise (blur only — CLARITY moved to its own pass below, see header).
|
|
if (adjustments.denoise > 0) {
|
|
const sigma = (adjustments.denoise / 10) * 0.6;
|
|
paint.setImageFilter(own(Skia.ImageFilter.MakeBlur(sigma, sigma, Skia.TileMode.Clamp, null)));
|
|
}
|
|
|
|
// 5. Draw the graded photo.
|
|
if (paintShader) {
|
|
canvas.drawRect(Skia.XYWHRect(0, 0, width, height), paint);
|
|
} else {
|
|
canvas.drawImage(skImage, 0, 0, paint);
|
|
}
|
|
|
|
// 4b. CLARITY (positive) — convolution outside the denoise blur, the same
|
|
// nesting RN builds with MakeMatrixConvolution(input = previous filter).
|
|
if (adjustments.clarity > 0) {
|
|
convPass(canvas, surface, width, height, (adjustments.clarity / 10) * 0.8);
|
|
} else if (adjustments.clarity < 0) {
|
|
const mistSigma = Math.abs(adjustments.clarity / 10) * 4;
|
|
drawBlurred(canvas, surface, width, height, mistSigma);
|
|
}
|
|
// Negative SHARPENING takes edge enhancement back out — after the conv, so
|
|
// it cannot blur away what the CLARITY pass just added.
|
|
const sharpKnob = adjustments.sharpening ?? 0;
|
|
if (sharpKnob < 0) {
|
|
drawBlurred(canvas, surface, width, height, (Math.abs(sharpKnob) / 10) * 3);
|
|
}
|
|
|
|
// 5b. HDF bright-pass bloom. The web demo shows the effect off, so both the
|
|
// blur radius and the screen alpha push further than the phone's: a wide
|
|
// halo around the highlights instead of a hairline glow.
|
|
if ((adjustments.hdf ?? 0) > 0) {
|
|
const strength = (adjustments.hdf ?? 0) / 10;
|
|
const sigma = width * (0.006 + 0.024 * strength);
|
|
const { glowEffect: effect } = effects();
|
|
const srcShader = paintShader ?? imageShaderOf();
|
|
const glowShader =
|
|
effect != null ? own(effect.makeShaderWithChildren(glowUniformArray(), [srcShader])) : null;
|
|
const glowPaint = own(Skia.Paint());
|
|
glowPaint.setBlendMode(Skia.BlendMode.Screen);
|
|
glowPaint.setAlphaf(0.28 + 0.52 * strength);
|
|
// The bloom reads the graded pixels now (step 3), so the matrix must not
|
|
// land on them a second time; it only wraps the filter when ungraded.
|
|
const bloomGrade = (inner?: any) =>
|
|
graded ? undefined : own(Skia.ImageFilter.MakeColorFilter(inner ?? colorFilter, null));
|
|
const bloomBlur = () => own(Skia.ImageFilter.MakeBlur(sigma, sigma, Skia.TileMode.Clamp, null));
|
|
if (glowShader != null) {
|
|
glowPaint.setShader(glowShader);
|
|
const grade = bloomGrade();
|
|
const blur = bloomBlur();
|
|
glowPaint.setImageFilter(grade ? own(Skia.ImageFilter.MakeCompose(blur, grade)) : blur);
|
|
canvas.drawRect(Skia.XYWHRect(0, 0, width, height), glowPaint);
|
|
} else {
|
|
const hiMatrix = [
|
|
2.5, 0, 0, 0, -1.5,
|
|
0, 2.5, 0, 0, -1.5,
|
|
0, 0, 2.5, 0, -1.5,
|
|
0, 0, 0, 1, 0,
|
|
];
|
|
const grade = bloomGrade(
|
|
own(Skia.ColorFilter.MakeCompose(own(Skia.ColorFilter.MakeMatrix(hiMatrix)), colorFilter))
|
|
);
|
|
const blur = bloomBlur();
|
|
glowPaint.setImageFilter(grade ? own(Skia.ImageFilter.MakeCompose(blur, grade)) : blur);
|
|
if (paintShader) {
|
|
glowPaint.setShader(paintShader);
|
|
canvas.drawRect(Skia.XYWHRect(0, 0, width, height), glowPaint);
|
|
} else {
|
|
canvas.drawImage(gradeImage, 0, 0, glowPaint);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 6. Monochrome grain overlay (negative NR re-grains, same ceiling as GRAIN).
|
|
const grainAmount = Math.min(
|
|
10,
|
|
Math.max(0, adjustments.grain) + Math.max(0, -(adjustments.denoise ?? 0))
|
|
);
|
|
if (grainAmount > 0) {
|
|
const grainPaint = own(Skia.Paint());
|
|
grainPaint.setBlendMode(Skia.BlendMode.Overlay);
|
|
grainPaint.setAlphaf(grainAmount / 20);
|
|
const noiseEffect = own(Skia.RuntimeEffect.Make(`
|
|
uniform float u;
|
|
float grainHash(vec2 q) {
|
|
return fract(sin(dot(q, vec2(12.9898, 78.233))) * 43758.5453);
|
|
}
|
|
vec4 main(vec2 pos) {
|
|
vec2 p = pos.xy / max(u, 1.0);
|
|
vec2 i = floor(p);
|
|
vec2 f = p - i;
|
|
f = f * f * (3.0 - 2.0 * f);
|
|
float g00 = grainHash(i);
|
|
float g10 = grainHash(i + vec2(1.0, 0.0));
|
|
float g01 = grainHash(i + vec2(0.0, 1.0));
|
|
float g11 = grainHash(i + vec2(1.0, 1.0));
|
|
return vec4(vec3(mix(mix(g00, g10, f.x), mix(g01, g11, f.x), f.y)), 1.0);
|
|
}
|
|
`));
|
|
const noiseShader = noiseEffect ? own(noiseEffect.makeShader([width / 1080])) : null;
|
|
if (noiseShader) {
|
|
grainPaint.setShader(noiseShader);
|
|
canvas.drawRect(Skia.XYWHRect(0, 0, width, height), grainPaint);
|
|
}
|
|
}
|
|
|
|
// 6b. Vignette.
|
|
if ((adjustments.vignette ?? 0) > 0) {
|
|
const v = Math.min(10, adjustments.vignette ?? 0) / 10;
|
|
const vignetteShader = own(Skia.Shader.MakeRadialGradient(
|
|
Skia.Point(width / 2, height / 2),
|
|
Math.hypot(width, height) / 2,
|
|
[Skia.Color('rgba(0,0,0,0)'), Skia.Color('rgba(0,0,0,0)'), Skia.Color('rgba(0,0,0,1)')],
|
|
[0, 0.45, 1],
|
|
Skia.TileMode.Clamp
|
|
));
|
|
if (vignetteShader) {
|
|
const vignettePaint = own(Skia.Paint());
|
|
vignettePaint.setShader(vignetteShader);
|
|
vignettePaint.setAlphaf(0.9 * v);
|
|
canvas.drawRect(Skia.XYWHRect(0, 0, width, height), vignettePaint);
|
|
}
|
|
}
|
|
|
|
release(owned, skImage);
|
|
|
|
// 7. Frame.
|
|
let outputSurface = surface;
|
|
let stampCanvas = canvas;
|
|
let stampW = width;
|
|
let stampH = height;
|
|
let cardSource: any = null;
|
|
if (frameId === 'polaroid') {
|
|
flush(surface);
|
|
const photoImg = own(surface.makeImageSnapshot());
|
|
cardSource = photoImg;
|
|
const outW = width;
|
|
const frameAspect =
|
|
ratioWH ??
|
|
(options?.photoRotation === 90 || options?.photoRotation === 270
|
|
? height / width
|
|
: width / height);
|
|
const outH = Math.round(
|
|
width * (POLAROID_CARD.sideMargin + POLAROID_CARD.bottomDeck) +
|
|
(width * POLAROID_WIN_W) / frameAspect
|
|
);
|
|
const cardSurface = createSurface(outW, outH);
|
|
if (!cardSurface) return null;
|
|
own(cardSurface);
|
|
const cardCanvas = cardSurface.getCanvas();
|
|
const bgPaint = own(Skia.Paint());
|
|
bgPaint.setColor(Skia.Color('#faf9f6'));
|
|
cardCanvas.drawRect(Skia.XYWHRect(0, 0, outW, outH), bgPaint);
|
|
const pl = polaroidLayout(outW, outH, frameAspect, 'contain');
|
|
const win = pl.window;
|
|
const plainPaint = own(Skia.Paint());
|
|
const pz = options?.frameWindowZoom ?? null;
|
|
const pwz = pz && pz.s > 1.001 ? pz : null;
|
|
const pVisW = pwz ? width / pwz.s : width;
|
|
const pVisH = pwz ? height / pwz.s : height;
|
|
const pSx = pwz ? Math.min(Math.max(width * pwz.u - pVisW / 2, 0), width - pVisW) : 0;
|
|
const pSy = pwz ? Math.min(Math.max(height * pwz.v - pVisH / 2, 0), height - pVisH) : 0;
|
|
const srcAspect = width / height;
|
|
let dstW = win.w;
|
|
let dstH = dstW / srcAspect;
|
|
if (dstH > win.h) {
|
|
dstH = win.h;
|
|
dstW = dstH * srcAspect;
|
|
}
|
|
cardCanvas.drawImageRect(
|
|
photoImg,
|
|
Skia.XYWHRect(pSx, pSy, pVisW, pVisH),
|
|
Skia.XYWHRect(win.x + (win.w - dstW) / 2, win.y + (win.h - dstH) / 2, dstW, dstH),
|
|
plainPaint
|
|
);
|
|
const seamPaint = own(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;
|
|
} else if (isWallframe(frameId)) {
|
|
flush(surface);
|
|
const photoImg = own(surface.makeImageSnapshot());
|
|
cardSource = photoImg;
|
|
const pw = photoImg.width();
|
|
const ph = photoImg.height();
|
|
const wallLand = frameId === 'wallframe-landscape' || options?.wallframeLandscape === true;
|
|
const outW = wallLand ? WALLFRAME_LAND_W : WALLFRAME_W;
|
|
const outH = wallLand ? WALLFRAME_LAND_H : WALLFRAME_H;
|
|
const cardSurface = createSurface(outW, outH);
|
|
if (!cardSurface) return null;
|
|
own(cardSurface);
|
|
const cardCanvas = cardSurface.getCanvas();
|
|
const wfImage = loadWallframe(input.assets);
|
|
if (!wfImage) {
|
|
console.error('Failed to decode wallframe.png');
|
|
return null;
|
|
}
|
|
const wl = wallframeLayout(outW, outH, 'fill', wallLand);
|
|
const win = wl.window;
|
|
const plainPaint = own(Skia.Paint());
|
|
const scale = Math.max(win.w / pw, win.h / ph);
|
|
const srcW = win.w / scale;
|
|
const srcH = win.h / scale;
|
|
const planeX = (pw - srcW) / 2;
|
|
const planeY = (ph - srcH) / 2;
|
|
const wz = options?.frameWindowZoom ?? null;
|
|
const wZoom = wz && wz.s > 1.001 ? wz : null;
|
|
const wVisW = wZoom ? srcW / wZoom.s : srcW;
|
|
const wVisH = wZoom ? srcH / wZoom.s : srcH;
|
|
const wSx = wZoom
|
|
? Math.min(Math.max(planeX + srcW * wZoom.u - wVisW / 2, planeX), planeX + srcW - wVisW)
|
|
: planeX;
|
|
const wSy = wZoom
|
|
? Math.min(Math.max(planeY + srcH * wZoom.v - wVisH / 2, planeY), planeY + srcH - wVisH)
|
|
: planeY;
|
|
cardCanvas.drawImageRect(
|
|
photoImg,
|
|
Skia.XYWHRect(wSx, wSy, wVisW, wVisH),
|
|
Skia.XYWHRect(win.x, win.y, win.w, win.h),
|
|
plainPaint
|
|
);
|
|
cardCanvas.save();
|
|
if (wallLand) {
|
|
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;
|
|
} else {
|
|
drawFrameOnCanvas(canvas, width, height, frameId);
|
|
}
|
|
if (cardSource) {
|
|
release(owned, cardSource);
|
|
release(owned, surface);
|
|
}
|
|
|
|
// 8. GPS + custom watermark.
|
|
const customWm = options?.watermark ?? null;
|
|
const liteWm = options?.liteMark ?? null;
|
|
if ((input.useGeotag && input.gpsInfo) || customWm || liteWm) {
|
|
const loaded = loadFaces(input.assets);
|
|
if (loaded) {
|
|
const { typeface, iconTypeface } = loaded;
|
|
const fontSize = Math.round(stampW * 0.032);
|
|
const textPaint = own(Skia.Paint());
|
|
textPaint.setColor(Skia.Color('#f59e0b'));
|
|
const iconPaint = own(Skia.Paint());
|
|
iconPaint.setColor(Skia.Color('#ffffff'));
|
|
|
|
if (input.useGeotag && input.gpsInfo) {
|
|
const locationName = input.gpsInfo.locality || 'STREET VIEW';
|
|
const d = new Date(input.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 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 = own(Skia.Font(typeface, gpsSize));
|
|
const gpsIconFont = own(Skia.Font(iconTypeface, gpsSize));
|
|
const gpsGap = Math.round(gpsSize * 1.6);
|
|
const baseX = clamp01(gpsPos?.x ?? 0.05) * stampW;
|
|
const baseY = clamp01(gpsPos?.y ?? 0.1) * stampH;
|
|
const lines: { icon: string; text: string }[] = [];
|
|
if (gpsPos?.showName !== false) lines.push({ icon: '📍', text: locationName });
|
|
if (gpsPos?.showTime !== false) lines.push({ icon: '📷', text: timestampStr });
|
|
textPaint.setColor(Skia.Color(gpsPos?.color ?? '#f59e0b'));
|
|
const wmRot = options?.watermarkRotation ?? 0;
|
|
stampCanvas.save();
|
|
if (wmRot) stampCanvas.rotate(wmRot, baseX, baseY);
|
|
lines.forEach((line, i) => {
|
|
const rowY = baseY + Math.round(gpsSize * (1.1 + i * 1.2));
|
|
stampCanvas.drawText(line.icon, baseX, rowY, iconPaint, gpsIconFont);
|
|
stampCanvas.drawText(line.text, baseX + gpsGap, rowY, textPaint, gpsFont);
|
|
});
|
|
stampCanvas.restore();
|
|
}
|
|
|
|
if (customWm || liteWm) {
|
|
const clamp01 = (v: number) => Math.max(0, Math.min(1, v));
|
|
const wmRot = options?.watermarkRotation ?? 0;
|
|
for (const mark of [customWm, liteWm]) {
|
|
if (!mark) continue;
|
|
const wmSize = Math.round(fontSize * (mark.size ?? 1));
|
|
const wmFace = mark.font ? Skia.FontMgr.System().matchFamilyStyle(mark.font) : null;
|
|
const wmFont = own(Skia.Font(wmFace ?? typeface, wmSize));
|
|
const wmPaint = own(Skia.Paint());
|
|
wmPaint.setColor(Skia.Color(mark.color ?? '#f59e0b'));
|
|
const tx = clamp01(mark.x) * stampW;
|
|
const ty = clamp01(mark.y) * stampH + Math.round(wmSize * 0.35);
|
|
stampCanvas.save();
|
|
if (wmRot) stampCanvas.rotate(wmRot, tx, ty);
|
|
stampCanvas.drawText(mark.text, tx, ty, wmPaint, wmFont);
|
|
stampCanvas.restore();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 9. Snapshot, optional output sharpen, JPEG encode + DPI patch.
|
|
flush(outputSurface);
|
|
let resultImage = own(outputSurface.makeImageSnapshot());
|
|
const sharpenAmount =
|
|
sharpKnob > 0 ? (sharpKnob / 10) * 0.8 : sharpKnob < 0 ? 0 : options?.sharpen ? 0.5 : 0;
|
|
if (sharpenAmount > 0) {
|
|
const sharpened = screenSharpenImage(resultImage, sharpenAmount);
|
|
if (sharpened && sharpened !== resultImage) {
|
|
own(sharpened);
|
|
release(owned, resultImage);
|
|
resultImage = sharpened;
|
|
}
|
|
}
|
|
// 9b. The strip the file carries (export only, never the preview). Drawn
|
|
// onto a taller surface so every frame variant is treated the same: the
|
|
// tagline sits on the photo's top-left, the caption band hangs below.
|
|
const caption = options?.caption ?? null;
|
|
if (caption) {
|
|
const W = resultImage.width();
|
|
const band = Math.round(W * 0.155);
|
|
const capSurface = createSurface(W, resultImage.height() + band);
|
|
const loaded = loadFaces(input.assets);
|
|
if (capSurface && loaded) {
|
|
own(capSurface);
|
|
const cc = capSurface.getCanvas();
|
|
cc.drawImage(resultImage, 0, 0, own(Skia.Paint()));
|
|
|
|
const pad = Math.round(W * 0.03);
|
|
// Amber over a black drop, so the tagline still reads on a bright sky.
|
|
const tagSize = Math.round(W * 0.026);
|
|
const tagFont = own(Skia.Font(loaded.typeface, tagSize));
|
|
const shadow = own(Skia.Paint());
|
|
shadow.setColor(Skia.Color('#000000'));
|
|
const amber = own(Skia.Paint());
|
|
amber.setColor(Skia.Color('#f59e0b'));
|
|
cc.drawText(caption.tag, pad + 1, pad + tagSize + 1, shadow, tagFont);
|
|
cc.drawText(caption.tag, pad, pad + tagSize, amber, tagFont);
|
|
|
|
const bandTop = resultImage.height();
|
|
const bandPaint = own(Skia.Paint());
|
|
bandPaint.setColor(Skia.Color('#0b0b0b'));
|
|
cc.drawRect(Skia.XYWHRect(0, bandTop, W, band), bandPaint);
|
|
const titleSize = Math.round(W * 0.042);
|
|
const metaSize = Math.round(W * 0.026);
|
|
const titleFont = own(Skia.Font(loaded.typeface, titleSize));
|
|
const metaFont = own(Skia.Font(loaded.typeface, metaSize));
|
|
const titlePaint = own(Skia.Paint());
|
|
titlePaint.setColor(Skia.Color('#f2f2f2'));
|
|
const metaPaint = own(Skia.Paint());
|
|
metaPaint.setColor(Skia.Color('#9a9a9a'));
|
|
cc.drawText(caption.title, pad, bandTop + pad + titleSize, titlePaint, titleFont);
|
|
cc.drawText(caption.meta, pad, bandTop + pad + titleSize + Math.round(metaSize * 1.7), metaPaint, metaFont);
|
|
|
|
flush(capSurface);
|
|
const composed = own(capSurface.makeImageSnapshot());
|
|
release(owned, resultImage);
|
|
resultImage = composed;
|
|
} else if (capSurface) {
|
|
capSurface.dispose();
|
|
}
|
|
}
|
|
|
|
let bytes = resultImage.encodeToBytes(Skia.ImageFormat.JPEG, 95);
|
|
if (!bytes || bytes.length === 0) {
|
|
console.error('Failed to encode image to JPEG');
|
|
return null;
|
|
}
|
|
if (options?.dpi && options.dpi > 0) {
|
|
bytes = patchJpegDpi(bytes, options.dpi);
|
|
}
|
|
return { bytes, width: resultImage.width(), height: resultImage.height() };
|
|
} catch (error) {
|
|
console.error('Render failed:', error);
|
|
return null;
|
|
} finally {
|
|
disposeAll(owned);
|
|
}
|
|
}
|