Add self-contained docker/ stack for the web UI

`docker/` now holds the whole web build — frontend (Vite + React + CanvasKit),
backend (Fastify + SQLite) and the compose file — so the folder can be moved to
another machine and run without the React Native project:

    cd docker && cp .env.example .env && docker compose up -d --build

Only `${WEB_PORT:-8090}` is published; nginx serves the SPA and proxies /api to
the `api` container over Docker's DNS. Photos never reach the server.

The shared render code is vendored into `docker/frontend/shared/` and aliased to
a CanvasKit shim, so the app's own frameUtils/toneShader/jpegDpi run unchanged.

Fix the all-black render on GPU surfaces: `MakeWebGLCanvasSurface` creates a
separate WebGL context per call, and a texture from one context cannot be
sampled by a surface on another — so any pass that drew a snapshot onto a second
surface (output sharpen, screen sharpen, polaroid/wallframe cards) came out
solid black, while the raster fallback was correct. Use one shared
GrDirectContext + MakeRenderTarget instead.

Verified in headless Chromium against the running stack: 12MP JPEG in, preview
mean=120.5 sd=60.5, export 2048x1536 mean=107.2 sd=62.1, JFIF density 300/300,
EXIF present, no console errors; health/signup/login/me/recipes all 2xx through
the nginx proxy.
This commit is contained in:
2026-09-17 17:43:03 +07:00
parent 608901bd0f
commit 8c6e7930db
57 changed files with 9359 additions and 2 deletions
@@ -0,0 +1,138 @@
import { CinemaSeason } from '../types';
// Cinematic seasonal atmosphere grade. Deliberately NOT a fake re-light: a
// still photo has no depth, so "moving the sun" would look plastic. Instead
// this reads the photo's EXISTING exposure and pushes it a certain way:
// - lit areas get the season's warm/golden tint + a soft sun glow (halation)
// - shadow side gets a complementary cool tint
// - a seasonal haze/fog veil blends toward a fog color (strongest in winter)
// That reads as "sunlight / morning mist" without inventing new light.
//
// Uniforms are 16 flat floats (scalar form keeps the JSX <Shader> uniforms
// object and the worklet/export flat arrays in the exact same order):
// strength, contrast, saturation, haze,
// warmR, warmG, warmB, warmAmt,
// coolR, coolG, coolB, coolAmt,
// fogR, fogG, fogB, fogAmt
export const CINEMA_SKSL = `
uniform shader src;
uniform float strength;
uniform float contrast;
uniform float saturation;
uniform float haze;
uniform float warmR;
uniform float warmG;
uniform float warmB;
uniform float warmAmt;
uniform float coolR;
uniform float coolG;
uniform float coolB;
uniform float coolAmt;
uniform float fogR;
uniform float fogG;
uniform float fogB;
uniform float fogAmt;
vec4 main(vec2 xy) {
vec4 c = src.eval(xy);
float t = clamp(dot(c.rgb, vec3(0.2126, 0.7152, 0.0722)), 0.0, 1.0);
vec3 r = c.rgb;
// Filmic-ish gentle contrast on the luma (keeps hue).
r = mix(r, clamp((r - 0.5) * (1.0 + contrast * strength) + 0.5, 0.0, 1.0), strength);
// Saturation.
float l = dot(r, vec3(0.2126, 0.7152, 0.0722));
r = mix(vec3(l), r, mix(1.0, saturation, strength));
// Season tints follow the EXISTING light: warm on lit areas, cool in shade.
float lit = smoothstep(0.30, 1.0, t);
float shad = 1.0 - smoothstep(0.0, 0.45, t);
vec3 warm = vec3(warmR, warmG, warmB);
vec3 cool = vec3(coolR, coolG, coolB);
r *= 1.0 + (warm - 1.0) * lit * warmAmt * strength;
r *= 1.0 + (cool - 1.0) * shad * coolAmt * strength;
// Soft sun glow at the brightest points (halation of existing highlights).
float glow = smoothstep(0.65, 1.0, t);
r += warm * glow * warmAmt * 0.45 * strength;
// Atmospheric haze/fog veil (lightest shadow side => misty air).
vec3 fog = vec3(fogR, fogG, fogB);
r = mix(r, fog, fogAmt * strength * (0.3 + 0.7 * shad));
return vec4(clamp(r, 0.0, 1.0), c.a);
}
`;
export interface CinemaUniformSet {
season: CinemaSeason;
// 16 flat floats — order matches CINEMA_SKSL declaration order above.
flat: number[];
// Name-keyed, for the JSX <Shader uniforms={...}> form.
uniforms: Record<string, number>;
}
const ZERO: Record<string, number> = {};
const SEASONS: Record<CinemaSeason, { name: string; values: number[] }> = {
// Soft fresh light, gentle warm haze, barely-there cool in shade.
spring: {
name: 'CINEMA SPRING',
values: [
0.6, 0.08, 1.04, 0.18,
1.02, 0.998, 0.95, 0.3,
0.97, 0.995, 1.03, 0.12,
0.95, 0.96, 0.98, 0.16,
],
},
// Golden hour-ish high sun: strong warm on lit, bluish shade, crisp.
summer: {
name: 'CINEMA SUMMER',
values: [
0.75, 0.2, 1.12, 0.1,
1.08, 1.04, 0.88, 0.4,
0.93, 0.97, 1.06, 0.2,
1.0, 0.97, 0.88, 0.05,
],
},
// Amber low sun + warm atmospheric haze.
autumn: {
name: 'CINEMA AUTUMN',
values: [
0.8, 0.16, 1.1, 0.24,
1.12, 1.02, 0.78, 0.42,
1.02, 0.98, 0.88, 0.16,
1.0, 0.93, 0.75, 0.18,
],
},
// Cold winter fog: desaturated, blue-grey, heavy mist, no warm sun.
winter: {
name: 'CINEMA WINTER',
values: [
0.65, -0.02, 0.88, 0.38,
0.99, 0.995, 1.02, 0.06,
0.94, 0.97, 1.08, 0.3,
0.9, 0.93, 0.97, 0.45,
],
},
};
const KEYS = [
'strength', 'contrast', 'saturation', 'haze',
'warmR', 'warmG', 'warmB', 'warmAmt',
'coolR', 'coolG', 'coolB', 'coolAmt',
'fogR', 'fogG', 'fogB', 'fogAmt',
] as const;
export function getCinemaUniforms(season: CinemaSeason | null | undefined): CinemaUniformSet | null {
if (!season) return null;
const s = SEASONS[season];
if (!s) return null;
const uniforms: Record<string, number> = {};
KEYS.forEach((k, i) => (uniforms[k] = s.values[i]));
return { season, flat: s.values, uniforms };
}
// Zeroed 16-float block — used to turn the cinema pass OFF in the worklet
// synchronizable (strength === 0 means inactive there).
export function cinemaOffFlat(): number[] {
return [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
}
export function cinemaIsActive(flat: number[] | null | undefined): boolean {
return !!flat && flat.length === 16 && flat[0] > 0;
}
+313
View File
@@ -0,0 +1,313 @@
import { ColorAdjustments, BaseFilter } from '../types';
// Kelvin to RGB compensation for White Balance.
// The WB setting names the light the camera is told to expect, so its gain is
// the INVERSE of the Planckian (blackbody) colour at K: set 10000K under a
// daylight scene (5500K) and the camera thinks the light is bluer than it is
// and warms the picture up; set 2500K and it goes cool/blue. Pinned to unity at
// 5500K, the app's neutral reference.
//
// Two things a hand-drawn linear ramp gets wrong, both of them visible on skin:
// - moving the three channels independently (the old one lifted green 20% at
// 10000K) shifts LUMINANCE as well as colour, so a warm shot went yellow-
// green rather than orange. Here every gain is divided by its own Rec.709
// luma, so a neutral pixel keeps its level and only the cast moves.
// - the excursion was arbitrary. These are the physical ratios of the sRGB
// blackbody colours, tamed by a square root: the raw linear-light ratio
// (2.1x on blue at 2500K) over-corrects once it lands on already tone-mapped
// pixels, which is exactly the domain a 4x5 colour matrix works in.
export function kelvinToRGB(kelvin: number): { r: number; g: number; b: number } {
const k = Math.min(10000, Math.max(2500, kelvin));
const light = planckianSRGB(k);
const ref = planckianSRGB(5500);
return normalizeGainLuma([
Math.sqrt(ref.r / Math.max(light.r, 1e-4)),
Math.sqrt(ref.g / Math.max(light.g, 1e-4)),
Math.sqrt(ref.b / Math.max(light.b, 1e-4)),
]);
}
// sRGB-encoded colour of a blackbody radiator at K, 0..1 (Tanner Helland's fit
// of the Planckian locus; exact enough from 1000K up, and exactly neutral where
// the app pins unity). Blue collapses to 0 below ~1900K, hence the guard in the
// caller.
function planckianSRGB(kelvin: number): { r: number; g: number; b: number } {
const t = kelvin / 100;
const r = t <= 66 ? 255 : 329.698727446 * Math.pow(t - 60, -0.1332047592);
const g =
t <= 66
? 99.4708025861 * Math.log(t) - 161.1195681661
: 288.1221695283 * Math.pow(t - 60, -0.0755148492);
const b = t >= 66 ? 255 : t <= 19 ? 0 : 138.5177312231 * Math.log(t - 10) - 305.0447927307;
const norm = (v: number) => Math.min(1, Math.max(0, v / 255));
return { r: norm(r), g: norm(g), b: norm(b) };
}
// Divide a set of channel gains by their own luma: the CAST moves, the exposure
// does not. Channels still clip where they must — this only keeps a neutral
// pixel sitting at its neutral level.
function normalizeGainLuma(gain: number[]): { r: number; g: number; b: number } {
const luma = 0.2126 * gain[0] + 0.7152 * gain[1] + 0.0722 * gain[2];
const s = luma > 0 ? 1 / luma : 1;
return { r: gain[0] * s, g: gain[1] * s, b: gain[2] * s };
}
// Generate a 4x5 ColorMatrix (array of 20 floats) based on base style and
// adjustments. Tone-domain knobs (dynamicRange/highlight/shadow) and the
// exposure-compensation EV gain are intentionally NOT part of the matrix —
// dynamicRange/highlight/shadow run as a tone shader (see toneShader.ts),
// and EV is applied either as hardware AE bias (camera) or by the caller via
// applyExposureGain (library), never doubled.
export function getSkiaColorMatrix(baseFilter: BaseFilter, adj: ColorAdjustments): number[] {
const exposure = adj.exposure;
const contrast = adj.contrast;
const saturation = adj.saturation;
const temperature = adj.temperature;
const tint = adj.tint;
// colorChrome/colorChromeBlue are deliberately unused here — the chrome
// effects run in the tone shader (per-pixel chroma), not the matrix.
// 1. Start with Identity Matrix
let matrix = [
1, 0, 0, 0, 0,
0, 1, 0, 0, 0,
0, 0, 1, 0, 0,
0, 0, 0, 1, 0,
];
// 2. Base Filters
if (baseFilter === 'classic-neg') {
// Classic Neg (Superia-like): restrained colour, higher contrast around
// mid-grey, reds a touch deeper. Its signature split tone — green/cyan
// darks drifting to warm brights — cannot live in a linear matrix (one cast
// would hit both ends), so it ships in FILM_TONE (toneShader.ts) instead.
matrix = [
0.9961, 0.0773, 0.0078, 0, -0.0406,
0.0226, 1.0298, 0.0076, 0, -0.0300,
0.0224, 0.0750, 0.9520, 0, -0.0247,
0, 0, 0, 1, 0,
];
} else if (baseFilter === 'provia') {
// Provia (Standard): the reference stock — balanced, faithful, easy on the
// eye. Mild saturation and a shallow contrast lift around mid-grey, with no
// channel cast, so neutrals stay neutral and only the chroma deepens.
matrix = [
1.1492, -0.0901, -0.0091, 0, -0.0250,
-0.0268, 1.0859, -0.0091, 0, -0.0250,
-0.0268, -0.0901, 1.1669, 0, -0.0250,
0, 0, 0, 1, 0,
];
} else if (baseFilter === 'velvia') {
// Velvia (Vivid): the loud one — saturation pushed very hard onto green,
// blue and red alike, on top of strong contrast with deep, dense darks.
// Saturated reds, skies and greens are meant to ride the clip; that hard
// edge is the look.
matrix = [
1.3895, -0.2540, -0.0256, 0, -0.0550,
-0.0772, 1.2355, -0.0261, 0, -0.0661,
-0.0775, -0.2603, 1.4756, 0, -0.0689,
0, 0, 0, 1, 0,
];
} else if (baseFilter === 'classic-chrome') {
// Classic Chrome: magazine-reportage look. Saturation is pulled well down
// while the G/B rows are crossed so blue skies lean teal/cyan (green comes
// up towards blue, with blue kept high). The heavy shadow crush that goes
// with it is the `sh` entry in FILM_TONE — a matrix alone cannot deepen one
// end of the curve without dragging the rest.
matrix = [
0.7792, 0.2188, 0.0220, 0, -0.0100,
0.0652, 0.7186, 0.2362, 0, -0.0100,
0.0652, 0.3973, 0.5575, 0, -0.0100,
0, 0, 0, 1, 0,
];
} else if (baseFilter === 'astia') {
// Astia (Soft): gentler than standard — contrast below neutral (shadows
// lift, highlights ease off) and colour held back, with a faint warm/pink
// bias that flatters skin. The outdoor-portrait stock.
matrix = [
0.9162, 0.0201, 0.0020, 0, 0.0428,
0.0058, 0.9030, 0.0020, 0, 0.0386,
0.0059, 0.0197, 0.8944, 0, 0.0400,
0, 0, 0, 1, 0,
];
} else if (baseFilter === 'eterna') {
// Eterna: cinema — flat (low contrast), muted color, gentle highlight rolloff.
matrix = [
0.94, 0.02, 0.02, 0, 0.02,
0.02, 0.96, 0.02, 0, 0.02,
0.02, 0.02, 0.94, 0, 0.02,
0, 0, 0, 1, 0,
];
} else if (baseFilter === 'leica') {
// LC STREETLIFE CLASSIC — the rangefinder character rather than a saturation
// push. What makes this file read as one is the separation BETWEEN the
// hues, so the deepening lives in the off-diagonal terms, not in a global
// chroma gain:
// - the three rows sum to 1.00/1.00/0.995, so greys stay exactly grey and
// every saturated colour gains ~5% through channel separation; Velvia
// pushes 39%, this stays quiet ("dậm đà, không gắt").
// - a small uniform offset deepens the darks: solid shadows that keep their
// texture, no lifted black and no crushed toe.
// - red up / blue down with green held at 1.00 is the warm European cast:
// it lands on skin as warmth, never as the yellow-green a warm gain on
// all three channels would give.
matrix = [
1.060, -0.045, -0.010, 0, -0.010,
-0.030, 1.055, -0.025, 0, -0.008,
-0.025, -0.020, 1.040, 0, -0.005,
0, 0, 0, 1, 0,
];
} else if (baseFilter === 'leica-vivid') {
// LC STREETLIFE VIVID — the same slanted channel separation pushed into the
// digital era: ~20% of chroma gain (still well under Velvia's 39%, so it
// sparkles instead of shouting) on the same warm R>G>B bias CLASSIC has, so
// VIVID is a brighter read of one look rather than a second, neutral one.
// No positive lift here: the brightness is the sim's own exposure value
// (defaultRecipes), which keeps the matrix inside 0..1 and pure white out of
// the clip — white lands at most at 1.000 on the red row.
matrix = [
1.125, -0.100, -0.010, 0, -0.015,
-0.055, 1.110, -0.055, 0, -0.005,
-0.050, -0.070, 1.100, 0, -0.005,
0, 0, 0, 1, 0,
];
} else if (baseFilter === 'monochrome') {
// Acros: pure BT.709 grayscale, zero colour leakage, and NO gain or offset.
// The weights sum to 1.0 and the offset is 0, so this matrix is a straight
// luminance readout: nothing clips at either end and every grey step the
// sensor recorded survives. (An earlier 1.10x / -0.05 version plugged every
// black below ~5% and blew every white above ~95% — exactly the fine
// graduation this stock is prized for.) The deep black and the highlight
// shoulder live in FILM_TONE (toneShader.ts), where the curve can be shaped
// one end at a time without touching the midtones. (Grain/sharpness stay
// the user's call — a film sim here is the colour grade only.)
const r = 0.2126;
const g = 0.7152;
const b = 0.0722;
matrix = [
r, g, b, 0, 0,
r, g, b, 0, 0,
r, g, b, 0, 0,
0, 0, 0, 1, 0,
];
}
// 3. Apply Saturation adjustment (standard color matrix transformation)
if (baseFilter !== 'monochrome' && saturation !== 0) {
const s = 1 + (saturation / 10) * 0.5; // -10 maps to 0.5x, +10 maps to 1.5x saturation
const invS = 1 - s;
const r = 0.213 * invS;
const g = 0.715 * invS;
const b = 0.072 * invS;
const satMat = [
r + s, g, b, 0, 0,
r, g + s, b, 0, 0,
r, g, b + s, 0, 0,
0, 0, 0, 1, 0,
];
matrix = multiplyMatrices(satMat, matrix);
}
// 4. Color Chrome / Color Chrome FX Blue are NOT here: both deepen only the
// already-chromatic pixels, which needs per-pixel chroma (see TONE_SKSL) — a
// matrix gain on R/B is a global saturation push that also moves greys, and a
// blue-only gain tints the white point. `colorChrome`/`colorChromeBlue` are
// consumed by getToneUniforms instead.
// 5. White Balance (Kelvin Temperature and Tint) — skip for monochrome to prevent color leakage
if (baseFilter !== 'monochrome') {
const rgbTemp = kelvinToRGB(temperature);
// Tint is the green↔magenta axis, and it is symmetric: + pushes magenta
// (red and blue up, green down), - pushes green (green up, red and blue
// down). The old version only ever ADDED a channel (magenta lifted R+B,
// green lifted G), so the knob brightened the frame instead of tinting it.
// Normalising the product keeps that at a tint shift, not an exposure one.
const tintMagenta = (tint / 10) * 0.08;
const wbGain = normalizeGainLuma([
rgbTemp.r * (1 + tintMagenta),
rgbTemp.g * (1 - tintMagenta),
rgbTemp.b * (1 + tintMagenta),
]);
const wbMat = [
wbGain.r, 0, 0, 0, 0,
0, wbGain.g, 0, 0, 0,
0, 0, wbGain.b, 0, 0,
0, 0, 0, 1, 0,
];
matrix = multiplyMatrices(wbMat, matrix);
}
// 5b. Fuji WB fine-tune Red/Blue axis (the CREATE modal's R/B offsets). These
// axes are independent channel gains on top of the Kelvin/tint preset: each
// step nudges its channel ~1.1% toward/away from neutral, matching the
// perceptible size of one Fuji fine-tune step (-9..+9).
const wbRed = adj.wbRed ?? 0;
const wbBlue = adj.wbBlue ?? 0;
if (baseFilter !== 'monochrome' && (wbRed !== 0 || wbBlue !== 0)) {
const shiftMat = [
1 + (wbRed / 9) * 0.1, 0, 0, 0, 0,
0, 1, 0, 0, 0,
0, 0, 1 + (wbBlue / 9) * 0.1, 0, 0,
0, 0, 0, 1, 0,
];
matrix = multiplyMatrices(shiftMat, matrix);
}
// 6. Exposure & Contrast — applied to ALL base filters (monochrome included) so
// both keep affecting mono shots. Contrast scales around mid-grey 0.5.
if (exposure !== 0 || contrast !== 0) {
const expScale = 1 + (exposure / 10) * 0.2; // -10 → 0.8x, +10 → 1.2x
const expOffset = (exposure / 10) * 0.15;
const conScale = 1 + (contrast / 10) * 0.25; // -10 → 0.75x, +10 → 1.25x
const conOffset = 0.5 * (1 - conScale);
const adjMat = [
expScale * conScale, 0, 0, 0, expOffset + conOffset * expScale,
0, expScale * conScale, 0, 0, expOffset + conOffset * expScale,
0, 0, expScale * conScale, 0, expOffset + conOffset * expScale,
0, 0, 0, 1, 0,
];
matrix = multiplyMatrices(adjMat, matrix);
}
return matrix;
}
// Apply photographic EV compensation (stops) as a linear gain: 2^EV on every
// RGB channel (offsets scale too, matching true exposure). Only used for
// stills that were NOT exposed with a hardware AE bias (library images).
export function applyExposureGain(matrix: number[], evStops: number): number[] {
if (!evStops) return matrix;
const s = Math.pow(2, evStops);
const gainMat = [
s, 0, 0, 0, 0,
0, s, 0, 0, 0,
0, 0, s, 0, 0,
0, 0, 0, 1, 0,
];
return multiplyMatrices(gainMat, matrix);
}
// 4x5 Matrix multiplication utility: A * B
function multiplyMatrices(a: number[], b: number[]): number[] {
const result = new Array(20).fill(0);
for (let r = 0; r < 4; r++) {
for (let c = 0; c < 4; c++) {
result[r * 5 + c] =
a[r * 5 + 0] * b[0 * 5 + c] +
a[r * 5 + 1] * b[1 * 5 + c] +
a[r * 5 + 2] * b[2 * 5 + c] +
a[r * 5 + 3] * b[3 * 5 + c];
}
// Handle the 5th column translation
result[r * 5 + 4] =
a[r * 5 + 0] * b[0 * 5 + 4] +
a[r * 5 + 1] * b[1 * 5 + 4] +
a[r * 5 + 2] * b[2 * 5 + 4] +
a[r * 5 + 3] * b[3 * 5 + 4] +
a[r * 5 + 4];
}
return result;
}
@@ -0,0 +1,303 @@
import { Recipe, ColorAdjustments } from '../types';
// Fallback values for every adjustable knob. Old recipes/sessions persisted in
// AsyncStorage lack the newer fields — merge this under stored JSON to keep
// every adjustment well-defined (see storageUtils.getAllRecipes / App session).
export const DEFAULT_ADJUSTMENTS: ColorAdjustments = {
exposure: 0,
contrast: 0,
saturation: 0,
temperature: 5500,
tint: 0,
wbRed: 0,
wbBlue: 0,
highlight: 0,
shadow: 0,
denoise: 0,
clarity: 0,
grain: 0,
colorChrome: 'none',
colorChromeBlue: 'none',
dynamicRange: 'auto',
sharpening: 0,
exposureCompensation: 0,
};
// Standard Fuji film simulations. These are the fixed "look" presets shown at
// the front of the PRESETS chip row: tapping one applies the simulation — its
// baseFilter colour matrix plus whatever the stock itself carries outside the
// matrix (its FILM_TONE curve, and a grain amount when the grain IS the look,
// as on Acros). Every other knob still resets to its neutral default (0 /
// auto), so a sim is a clean starting point, never a baked recipe; any further
// look (DR, CC, contrast, more grain) is added by the user afterwards.
export interface FilmSim {
id: string; // Recipe-like id used only while the sim is the current selection
name: string;
baseFilter: Recipe['baseFilter'];
adjustments: ColorAdjustments;
}
export const FILM_SIMS: FilmSim[] = [
{ id: 'sim-provia', name: 'PROVIPES', baseFilter: 'provia', adjustments: { ...DEFAULT_ADJUSTMENTS } },
{ id: 'sim-velvia', name: 'VELVIPES', baseFilter: 'velvia', adjustments: { ...DEFAULT_ADJUSTMENTS } },
{ id: 'sim-classic-chrome', name: 'CLASSIC CHRIPES', baseFilter: 'classic-chrome', adjustments: { ...DEFAULT_ADJUSTMENTS } },
{ id: 'sim-classic-neg', name: 'CLASSIC NEGIPES', baseFilter: 'classic-neg', adjustments: { ...DEFAULT_ADJUSTMENTS } },
{ id: 'sim-astia', name: 'ASTIPES', baseFilter: 'astia', adjustments: { ...DEFAULT_ADJUSTMENTS } },
{ id: 'sim-eterna', name: 'ETERNIPES', baseFilter: 'eterna', adjustments: { ...DEFAULT_ADJUSTMENTS } },
{ id: 'sim-acros', name: 'ACRIPES', baseFilter: 'monochrome', adjustments: { ...DEFAULT_ADJUSTMENTS, grain: 3 } },
// The rangefinder digital look (M/Q/SL) is not a film stock, so it ships as its own
// pair instead of one look that has to compromise:
// CLASSIC — the stock character: deep solid darks, natural shadows, rich but
// quiet colour. Its micro-contrast is CLARITY (a local unsharp mask in both
// renderers), never a global saturation boost, and a whisker of grain
// keeps it feeling like film rather than digital sharpness.
// VIVID — the same channel separation opened up for the digital era: lifted
// exposure, lifted shadows, a little colour. It reads bright and flattering
// straight off the shutter, no editing pass needed.
// Both share the 'leica'/'leica-vivid' matrices; the tonal half is the sim's
// own adjustments, which every apply path now carries into the panel.
{ id: 'sim-leica', name: 'LC STREETLIFE CLASSIC', baseFilter: 'leica', adjustments: { ...DEFAULT_ADJUSTMENTS, clarity: 2, grain: 1 } },
{ id: 'sim-leica-vivid', name: 'LC STREETLIFE VIVID', baseFilter: 'leica-vivid', adjustments: { ...DEFAULT_ADJUSTMENTS, exposure: 2, saturation: 2, clarity: 2, shadow: 1, highlight: -1 } },
];
// Film sim → full Recipe (never persisted; sims are applied on the fly).
export function filmSimToRecipe(sim: FilmSim): Recipe {
return {
id: sim.id,
name: sim.name,
baseFilter: sim.baseFilter,
adjustments: { ...sim.adjustments },
frameId: 'none',
useGeotag: true,
};
}
export const DEFAULT_RECIPES: Recipe[] = [
{
id: 'classic-neg-default',
name: 'CLASSIC NEGIPES.',
baseFilter: 'classic-neg',
adjustments: {
exposure: 2,
contrast: 1,
saturation: -1,
temperature: 6300,
tint: 2,
highlight: -2,
shadow: 1,
denoise: 1,
clarity: 2,
grain: 4,
colorChrome: 'weak',
colorChromeBlue: 'none',
dynamicRange: 'auto',
sharpening: 0,
exposureCompensation: 0,
},
frameId: 'none',
useGeotag: true,
},
{
id: 'velvia-default',
name: 'VELVIPES VIVID',
baseFilter: 'velvia',
adjustments: {
exposure: 0,
contrast: 2,
saturation: 3,
temperature: 5500,
tint: 0,
highlight: 1,
shadow: -1,
denoise: 2,
clarity: 4,
grain: 1,
colorChrome: 'strong',
colorChromeBlue: 'none',
dynamicRange: 'auto',
sharpening: 0,
exposureCompensation: 0,
},
frameId: 'none',
useGeotag: true,
},
{
id: 'provia-default',
name: 'PROVIPES STD',
baseFilter: 'provia',
adjustments: {
exposure: 1,
contrast: 0,
saturation: 0,
temperature: 5000,
tint: 0,
highlight: 0,
shadow: 0,
denoise: 2,
clarity: 1,
grain: 2,
colorChrome: 'none',
colorChromeBlue: 'none',
dynamicRange: 'auto',
sharpening: 0,
exposureCompensation: 0,
},
frameId: 'none',
useGeotag: true,
},
{
id: 'acros-default',
name: 'ACRIPES MONO',
baseFilter: 'monochrome',
adjustments: {
exposure: 0,
// Low: the deep black comes from FILM_TONE's toe and the micro-contrast
// from CLARITY, both of which add separation WITHOUT flattening the grey
// ramp. The old 4 / highlight 3 / shadow -2 stack was three global
// contrast moves on top of a matrix that was already clipping — it
// bought its deep black by throwing away every step below 9% and every
// one above 91%.
contrast: 2,
saturation: 0,
temperature: 5000,
tint: 0,
highlight: 0,
shadow: 0,
denoise: 0,
clarity: 3,
grain: 5,
colorChrome: 'none',
colorChromeBlue: 'none',
dynamicRange: 'auto',
sharpening: 0,
exposureCompensation: 0,
},
frameId: 'none',
useGeotag: true,
},
{
id: 'retro-amber-default',
name: 'RETRO AMBER 🏮',
baseFilter: 'classic-neg',
adjustments: {
exposure: 3,
contrast: 2,
saturation: 1,
temperature: 7500,
tint: -2,
highlight: -3,
shadow: 2,
denoise: 3,
clarity: -2,
grain: 5,
colorChrome: 'strong',
colorChromeBlue: 'none',
dynamicRange: 'auto',
sharpening: 0,
exposureCompensation: 0,
},
frameId: 'classic-white',
useGeotag: true,
},
{
id: 'cinema-spring-default',
name: 'CINEMA SPRING',
baseFilter: 'none',
adjustments: {
exposure: 0,
contrast: 1,
saturation: 1,
temperature: 5800,
tint: 0,
highlight: -1,
shadow: 1,
denoise: 2,
clarity: 1,
grain: 3,
colorChrome: 'none',
colorChromeBlue: 'none',
dynamicRange: 'auto',
sharpening: 0,
exposureCompensation: 0,
},
frameId: 'none',
cinema: 'spring',
useGeotag: true,
},
{
id: 'cinema-summer-default',
name: 'CINEMA SUMMER',
baseFilter: 'none',
adjustments: {
exposure: 0,
contrast: 2,
saturation: 2,
temperature: 6000,
tint: -1,
highlight: 1,
shadow: -1,
denoise: 1,
clarity: 2,
grain: 2,
colorChrome: 'weak',
colorChromeBlue: 'none',
dynamicRange: 'auto',
sharpening: 0,
exposureCompensation: 0,
},
frameId: 'none',
cinema: 'summer',
useGeotag: true,
},
{
id: 'cinema-autumn-default',
name: 'CINEMA AUTUMN',
baseFilter: 'none',
adjustments: {
exposure: 0,
contrast: 1,
saturation: 2,
temperature: 7200,
tint: -2,
highlight: -2,
shadow: 1,
denoise: 2,
clarity: 0,
grain: 4,
colorChrome: 'weak',
colorChromeBlue: 'none',
dynamicRange: 'auto',
sharpening: 0,
exposureCompensation: 0,
},
frameId: 'none',
cinema: 'autumn',
useGeotag: true,
},
{
id: 'cinema-winter-default',
name: 'CINEMA WINTER',
baseFilter: 'none',
adjustments: {
exposure: 1,
contrast: -1,
saturation: -2,
temperature: 4500,
tint: 2,
highlight: -2,
shadow: 2,
denoise: 3,
clarity: -2,
grain: 3,
colorChrome: 'none',
colorChromeBlue: 'weak',
dynamicRange: 'auto',
sharpening: 0,
exposureCompensation: 0,
},
frameId: 'none',
cinema: 'winter',
useGeotag: true,
},
];
+475
View File
@@ -0,0 +1,475 @@
// Pure JPEG/EXIF writer — no imports, so the codec can be exercised directly.
//
// Skia re-encodes the photo, which drops the original EXIF block (see
// jpegDpi.ts). This rebuilds the APP1/EXIF segment: it reads the SOURCE
// photo's TIFF, keeps every tag it does not own, and stamps the fields this app
// is responsible for — software + version, capture date + zone, GPS, pixel
// dimensions — plus the baseline tags a conforming EXIF block must carry
// (ExifVersion, FlashPixVersion, ComponentsConfiguration, ColorSpace, Interop).
// Same in-place byte approach as jpegDpi.ts: no dependency, no native module.
//
// ponytail: IFD1 (the embedded thumbnail) and MakerNote are dropped. A carried
// thumbnail would show the PRE-export pixels, and MakerNote holds absolute
// offsets that a relocated TIFF invalidates. Read them back when a use case
// needs them.
// Stamped into every file this app writes (kept in step with app.json).
export const SOFTWARE = 'RecipesCam 1.2';
export interface ExifStamp {
software?: string;
// Shutter instant. null/undefined = keep the loaded photo's own
// DateTimeOriginal and only fill it in when the source has none.
dateTime?: Date | null;
gps?: { latitude: number; longitude: number } | null;
// Place name shown on the Gallery frame (EXIF 0x889d). Stock photos carry it
// whenever the capture was geotagged; ours have to state it from the same
// info the watermark prints.
locationName?: string | null;
width?: number | null;
height?: number | null;
dpi?: number | null;
// Device identity for the Gallery watermark blob (ref. watermarkBlob). Only
// used when the source photo carries no blob of its own.
device?: DeviceIdentity | null;
}
/** Raw device identity for the HyperOS Gallery watermark tag (0x889e). */
export interface DeviceIdentity {
device: string; // ro.product.device codename, e.g. "aurora"
marketName: string; // ro.product.marketname, e.g. "Xiaomi 14 Ultra"
manufacturer: string; // Build.MANUFACTURER, e.g. "Xiaomi"
model: string; // Build.MODEL — fallback when marketName is empty
}
interface Entry {
tag: number;
type: number;
count: number;
data: Uint8Array;
ref?: 'exif' | 'gps' | 'interop'; // IFD pointer entry — value patched during layout
}
const BYTE = 1, ASCII = 2, SHORT = 3, LONG = 4, RATIONAL = 5, UNDEFINED = 7, SLONG = 9, SRATIONAL = 10;
const TYPE_SIZE: Record<number, number> = {
[BYTE]: 1, [ASCII]: 1, [SHORT]: 2, [LONG]: 4, [RATIONAL]: 8,
[UNDEFINED]: 1, [SLONG]: 4, [SRATIONAL]: 8,
};
// Tags this module owns, or that would dangle once the TIFF is rebuilt.
const DROP = new Set<number>([
0x0112, // Orientation — the exported pixels are already upright (stamped back as 1)
0x0100, 0x0101, // ImageWidth / ImageLength (stamped from the real export size)
0x011a, 0x011b, 0x0128, // XResolution / YResolution / ResolutionUnit (stamped when dpi is set)
0x0131, 0x0132, // Software / DateTime
0x8769, 0x8825, 0xa005, 0x014a, // Exif / GPS / Interop / SubIFD pointers (rebuilt)
0x0200, 0x0201, 0x0202, // thumbnail offset/length
0x927c, // MakerNote — internal offsets do not survive relocation
]);
// --- little-endian writers -------------------------------------------------
function u16w(b: Uint8Array, p: number, v: number): void {
b[p] = v & 0xff;
b[p + 1] = (v >> 8) & 0xff;
}
function u32w(b: Uint8Array, p: number, v: number): void {
b[p] = v & 0xff;
b[p + 1] = (v >> 8) & 0xff;
b[p + 2] = (v >> 16) & 0xff;
b[p + 3] = (v >> 24) & 0xff;
}
// --- entry builders --------------------------------------------------------
// ASCII-typed byte string, NUL-terminated as EXIF requires. The bytes are UTF-8:
// a place name ("TAM KỲ, ĐÀ NẴNG") or a market name can carry accents, and the
// charCodeAt & 0xff shortcut would mangle them into latin-1. Pure-ASCII text
// encodes byte-for-byte the same as before.
function asciiEntry(tag: number, text: string): Entry {
const bytes: number[] = [];
for (const ch of text) {
const cp = ch.codePointAt(0) as number;
if (cp < 0x80) bytes.push(cp);
else if (cp < 0x800) bytes.push(0xc0 | (cp >> 6), 0x80 | (cp & 0x3f));
else if (cp < 0x10000) bytes.push(0xe0 | (cp >> 12), 0x80 | ((cp >> 6) & 0x3f), 0x80 | (cp & 0x3f));
else bytes.push(0xf0 | (cp >> 18), 0x80 | ((cp >> 12) & 0x3f), 0x80 | ((cp >> 6) & 0x3f), 0x80 | (cp & 0x3f));
}
bytes.push(0);
return { tag, type: ASCII, count: bytes.length, data: new Uint8Array(bytes) };
}
function shortEntry(tag: number, v: number): Entry {
const data = new Uint8Array(2);
u16w(data, 0, v);
return { tag, type: SHORT, count: 1, data };
}
// UNDEFINED value: a raw byte string whose length IS the count (ExifVersion etc).
function undefinedEntry(tag: number, text: string): Entry {
const data = new Uint8Array(text.length);
for (let i = 0; i < text.length; i++) data[i] = text.charCodeAt(i) & 0xff;
return { tag, type: UNDEFINED, count: data.length, data };
}
function longEntry(tag: number, v: number): Entry {
const data = new Uint8Array(4);
u32w(data, 0, v);
return { tag, type: LONG, count: 1, data };
}
function rationalEntry(tag: number, num: number, den: number, type = RATIONAL): Entry {
const data = new Uint8Array(8);
u32w(data, 0, num);
u32w(data, 4, den);
return { tag, type, count: 1, data };
}
// GPS coordinate: 3 RATIONALs (degrees, minutes, seconds), denominator 10000
// for the seconds so ~0.1 m survives.
function gpsCoordEntry(tag: number, value: number): Entry {
const abs = Math.abs(value);
let deg = Math.floor(abs);
let min = Math.floor((abs - deg) * 60);
let sec = Math.round(((abs - deg) * 60 - min) * 60 * 10000);
if (sec >= 600000) { sec = 0; min += 1; }
if (min >= 60) { min = 0; deg += 1; }
const data = new Uint8Array(24);
u32w(data, 0, deg); u32w(data, 4, 1);
u32w(data, 8, min); u32w(data, 12, 1);
u32w(data, 16, sec); u32w(data, 20, 10000);
return { tag, type: RATIONAL, count: 3, data };
}
// A big-endian source TIFF hands us big-endian value bytes; they are re-emitted
// into a little-endian TIFF, so every numeric value must be swapped first
// (ASCII/BYTE/UNDEFINED are byte strings and stay as they are).
function swapToLittleEndian(data: Uint8Array, type: number): Uint8Array {
const unit = type === SHORT ? 2 : type === LONG || type === SLONG ? 4 : type === RATIONAL || type === SRATIONAL ? 4 : 0;
if (!unit) return data;
// `new Uint8Array(data)` (not data.slice()) — the input may be a Node Buffer,
// whose slice() aliases the source and would corrupt the caller's bytes.
const out = new Uint8Array(data);
for (let p = 0; p + unit <= out.length; p += unit) {
for (let i = 0; i < unit >> 1; i++) {
const a = out[p + i];
out[p + i] = out[p + unit - 1 - i];
out[p + unit - 1 - i] = a;
}
}
return out;
}
function asciiValue(e: Entry | undefined): string | null {
if (!e || e.type !== ASCII || e.data.length === 0) return null;
let s = '';
for (let i = 0; i < e.data.length && e.data[i] !== 0; i++) s += String.fromCharCode(e.data[i]);
return s || null;
}
// "YYYY:MM:DD HH:MM:SS" — the EXIF date format (colons, not dashes).
function exifDate(d: Date): string {
const p = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}:${p(d.getMonth() + 1)}:${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
}
// "±HH:MM" — the OffsetTime* tag format. Framing tools read it to place a
// capture correctly, and it is what makes the date unambiguous without GPS.
function tzOffset(d: Date): string {
const mins = -d.getTimezoneOffset();
const a = Math.abs(mins);
const p = (n: number) => String(n).padStart(2, '0');
return `${mins < 0 ? '-' : '+'}${p(Math.floor(a / 60))}:${p(a % 60)}`;
}
// The watermark blob HyperOS Gallery reads back (EXIF tag 0x889e) before it
// will open its watermark editor: without it, a photo edited here fails with
// "cannot recognize the parameters". Only the stock camera app writes this tag
// — CameraX/HAL never does — so a camera capture has to synthesize it from the
// same props the stock app derives it from. Shape matches the stock bytes:
// {"buildDevice":"aurora",...,"waterLogo":"XIAOMI","waterName":"14 Ultra"}.
function watermarkBlob(d: DeviceIdentity): string {
const brand = d.manufacturer.trim();
const market = d.marketName.trim();
// "Xiaomi 14 Ultra" -> "14 Ultra": Gallery prints waterName, not the brand.
const waterName = market.toLowerCase().startsWith(`${brand.toLowerCase()} `)
? market.slice(brand.length + 1)
: market || d.model;
return JSON.stringify({
buildDevice: d.device,
customize: '',
cvLensName: '',
filterName: '',
livephotoInfo: '',
version: 2,
waterLogo: brand.toUpperCase(),
waterName,
});
}
// --- parse -----------------------------------------------------------------
interface ParsedTiff {
ifd0: Entry[];
exif: Entry[];
gps: Entry[];
interop: Entry[];
}
function parseTiff(tiff: Uint8Array): ParsedTiff {
const none: ParsedTiff = { ifd0: [], exif: [], gps: [], interop: [] };
if (tiff.length < 8) return none;
const le = tiff[0] === 0x49 && tiff[1] === 0x49;
const be = tiff[0] === 0x4d && tiff[1] === 0x4d;
if (!le && !be) return none;
const u16 = (p: number): number => (le ? tiff[p] | (tiff[p + 1] << 8) : (tiff[p] << 8) | tiff[p + 1]);
const u32 = (p: number): number =>
le
? (tiff[p] | (tiff[p + 1] << 8) | (tiff[p + 2] << 16) | (tiff[p + 3] << 24)) >>> 0
: ((tiff[p] << 24) | (tiff[p + 1] << 16) | (tiff[p + 2] << 8) | tiff[p + 3]) >>> 0;
const read = (off: number): { list: Entry[]; ptr: Record<number, number> } => {
const out: { list: Entry[]; ptr: Record<number, number> } = { list: [], ptr: {} };
if (off <= 0 || off + 2 > tiff.length) return out;
const n = u16(off);
for (let i = 0; i < n; i++) {
const p = off + 2 + i * 12;
if (p + 12 > tiff.length) break;
const tag = u16(p);
const type = u16(p + 2);
const count = u32(p + 4);
if (tag === 0x8769 || tag === 0x8825 || tag === 0xa005) {
out.ptr[tag] = u32(p + 8); // sub-IFD pointers are rebuilt, never copied
continue;
}
const size = TYPE_SIZE[type];
if (!size || DROP.has(tag)) continue;
const len = size * count;
if (len <= 0 || len > 4 * 1024 * 1024) continue; // ignore absurd/overflowing counts
if (len <= 4) {
out.list.push({ tag, type, count, data: be ? swapToLittleEndian(tiff.slice(p + 8, p + 8 + len), type) : tiff.slice(p + 8, p + 8 + len) });
} else {
const o = u32(p + 8);
if (o + len > tiff.length) continue;
const raw = tiff.slice(o, o + len);
out.list.push({ tag, type, count, data: be ? swapToLittleEndian(raw, type) : raw });
}
}
return out;
};
const a = read(u32(4));
const exifRead = a.ptr[0x8769] ? read(a.ptr[0x8769]) : { list: [], ptr: {} };
return {
ifd0: a.list,
exif: exifRead.list,
gps: a.ptr[0x8825] ? read(a.ptr[0x8825]).list : [],
interop: exifRead.ptr[0xa005] ? read(exifRead.ptr[0xa005]).list : [],
};
}
// Locate the APP1/EXIF segment: start/end bound the whole segment, tiff the
// payload after the "Exif\0\0" signature.
function findExifSegment(jpeg: Uint8Array): { start: number; end: number; tiff: Uint8Array } | null {
if (jpeg.length < 4 || jpeg[0] !== 0xff || jpeg[1] !== 0xd8) return null;
let i = 2;
while (i + 1 < jpeg.length && jpeg[i] === 0xff) {
const start = i;
while (jpeg[i] === 0xff) i++;
const marker = jpeg[i];
i++;
if (marker === 0xd9 || marker === 0xda) return null; // EOI / SOS — headers only
if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) continue;
if (i + 2 > jpeg.length) return null;
const len = (jpeg[i] << 8) | jpeg[i + 1];
if (len < 2 || i + len > jpeg.length) return null;
const dataStart = i + 2;
if (
marker === 0xe1 && len >= 8 &&
jpeg[dataStart] === 0x45 && jpeg[dataStart + 1] === 0x78 && jpeg[dataStart + 2] === 0x69 &&
jpeg[dataStart + 3] === 0x66 && jpeg[dataStart + 4] === 0x00 && jpeg[dataStart + 5] === 0x00
) {
return { start, end: i + len, tiff: jpeg.subarray(dataStart + 6, i + len) };
}
i += len;
}
return null;
}
// --- serialize -------------------------------------------------------------
function serializeTiff(ifd0: Entry[], exif: Entry[], gps: Entry[], interop: Entry[]): Uint8Array {
const ifdLen = (n: number) => 2 + n * 12 + 4;
const outLen = (es: Entry[]) => {
let s = 0;
for (const e of es) if (e.data.length > 4) s += (e.data.length + 1) & ~1;
return s;
};
const o0 = 8;
const d0 = o0 + ifdLen(ifd0.length);
const oE = d0 + outLen(ifd0);
const dE = oE + ifdLen(exif.length);
const oG = dE + outLen(exif);
const dG = oG + ifdLen(gps.length);
const oI = dG + outLen(gps);
const dI = oI + ifdLen(interop.length);
const t = new Uint8Array(dI + outLen(interop));
t[0] = 0x49; t[1] = 0x49; t[2] = 0x2a; t[3] = 0x00; // "II", 42
u32w(t, 4, o0);
const writeIfd = (off: number, es: Entry[], dataOff: number): void => {
u16w(t, off, es.length);
let c = dataOff;
es.forEach((e, i) => {
const p = off + 2 + i * 12;
u16w(t, p, e.tag);
u16w(t, p + 2, e.type);
u32w(t, p + 4, e.count);
if (e.ref === 'exif') { u32w(t, p + 8, oE); return; }
if (e.ref === 'gps') { u32w(t, p + 8, oG); return; }
if (e.ref === 'interop') { u32w(t, p + 8, oI); return; }
if (e.data.length <= 4) { t.set(e.data, p + 8); return; }
u32w(t, p + 8, c);
t.set(e.data, c);
c += (e.data.length + 1) & ~1; // values start on a word boundary
});
u32w(t, off + 2 + es.length * 12, 0); // no IFD1
};
writeIfd(o0, ifd0, d0);
writeIfd(oE, exif, dE);
writeIfd(oG, gps, dG);
writeIfd(oI, interop, dI);
return t;
}
/**
* Rebuilds the JPEG's APP1/EXIF block: `sourceJpeg` (the loaded photo) donates
* every tag this module does not own; `stamp` sets the ones the app owns.
* Returns a new array; input is never modified. A JPEG that cannot take the
* segment (not a JPEG, EXIF larger than 64 KB) comes back untouched.
*/
export function writeJpegExif(jpeg: Uint8Array, stamp: ExifStamp, sourceJpeg?: Uint8Array | null): Uint8Array {
if (jpeg.length < 4 || jpeg[0] !== 0xff || jpeg[1] !== 0xd8) return jpeg;
// An unreadable source (content:// uri, IO error) must not wipe the tags the
// jpeg already carries, so fall back to the jpeg's own EXIF block.
const src = findExifSegment(sourceJpeg && sourceJpeg.length > 4 ? sourceJpeg : jpeg);
const parsed = src ? parseTiff(src.tiff) : { ifd0: [], exif: [], gps: [], interop: [] };
const ifd0 = new Map(parsed.ifd0.map((e) => [e.tag, e]));
const exif = new Map(parsed.exif.map((e) => [e.tag, e]));
const gps = new Map(parsed.gps.map((e) => [e.tag, e]));
const interop = new Map(parsed.interop.map((e) => [e.tag, e]));
const now = exifDate(stamp.dateTime ?? new Date());
const keepSourceDate = !stamp.dateTime;
const sourceDate = asciiValue(ifd0.get(0x0132)) ?? asciiValue(exif.get(0x9003));
const dateStr = keepSourceDate ? sourceDate ?? now : now;
if (!keepSourceDate || !ifd0.has(0x0132)) ifd0.set(0x0132, asciiEntry(0x0132, dateStr));
if (!keepSourceDate || !exif.has(0x9003)) exif.set(0x9003, asciiEntry(0x9003, dateStr));
if (!keepSourceDate || !exif.has(0x9004)) exif.set(0x9004, asciiEntry(0x9004, dateStr));
if (!keepSourceDate) {
// We own the timestamp for camera shots, so we also state its zone.
const off = asciiEntry(0x9010, tzOffset(stamp.dateTime as Date));
ifd0.set(0x9010, off); // OffsetTime (0th IFD)
exif.set(0x9011, { ...off, tag: 0x9011 }); // OffsetTimeOriginal
exif.set(0x9012, { ...off, tag: 0x9012 }); // OffsetTimeDigitized
}
ifd0.set(0x0131, asciiEntry(0x0131, stamp.software ?? SOFTWARE));
ifd0.set(0x0112, shortEntry(0x0112, 1)); // upright — the pixels are already straight
// Baseline tags a conforming EXIF block is expected to carry. Framing apps
// reject a file that omits them, and HALs (the emulator's especially) do not
// always supply them, so fill the gaps and keep whatever the source had.
if (!exif.has(0x9000)) exif.set(0x9000, undefinedEntry(0x9000, '0230')); // ExifVersion
if (!exif.has(0xa000)) exif.set(0xa000, undefinedEntry(0xa000, '0100')); // FlashPixVersion
if (!exif.has(0x9101)) exif.set(0x9101, { tag: 0x9101, type: UNDEFINED, count: 4, data: new Uint8Array([1, 2, 3, 0]) }); // YCbCr
if (!exif.has(0xa001)) exif.set(0xa001, shortEntry(0xa001, 65535)); // ColorSpace = uncalibrated
if (!interop.has(0x0001)) interop.set(0x0001, asciiEntry(0x0001, 'R98')); // InteropIndex
if (!interop.has(0x0002)) interop.set(0x0002, undefinedEntry(0x0002, '0100')); // InteropVersion
if (stamp.width && stamp.height) {
ifd0.set(0x0100, longEntry(0x0100, stamp.width));
ifd0.set(0x0101, longEntry(0x0101, stamp.height));
exif.set(0xa002, longEntry(0xa002, stamp.width)); // PixelXDimension
exif.set(0xa003, longEntry(0xa003, stamp.height)); // PixelYDimension
}
if (stamp.dpi && stamp.dpi > 0) {
ifd0.set(0x011a, rationalEntry(0x011a, stamp.dpi, 1));
ifd0.set(0x011b, rationalEntry(0x011b, stamp.dpi, 1));
ifd0.set(0x0128, shortEntry(0x0128, 2)); // resolution unit = inch
}
// HyperOS Gallery refuses to open its watermark editor without 0x889e, and a
// CameraX capture never carries it. Never overwrite a blob the source has.
const xiaomi = !!stamp.device && /xiaomi|redmi|poco/i.test(stamp.device.manufacturer);
if (stamp.device && xiaomi && !exif.has(0x889e)) {
exif.set(0x889e, asciiEntry(0x889e, watermarkBlob(stamp.device)));
}
// Stock Xiaomi photos name the device in 0x9a00 and in Model (0x0110) as the
// MARKET name ("Xiaomi 14 Ultra"); the framing tool matches those against its
// known-device list and reports an unknown device otherwise. A CameraX capture
// only has the raw codename, so state the market name. A source that already
// carries 0x9a00 is a stock photo of some other device — leave it alone.
if (stamp.device && xiaomi && !ifd0.has(0x9a00)) {
const market = (stamp.device.marketName || stamp.device.model).trim();
if (market) {
ifd0.set(0x9a00, asciiEntry(0x9a00, market));
ifd0.set(0x0110, asciiEntry(0x0110, market));
}
}
// Place name the frame prints (0x889d) — stock writes it in both IFDs.
const place = stamp.locationName?.trim();
if (place) {
if (!ifd0.has(0x889d)) ifd0.set(0x889d, asciiEntry(0x889d, place));
if (!exif.has(0x889d)) exif.set(0x889d, asciiEntry(0x889d, place));
}
if (stamp.gps) {
const { latitude: lat, longitude: lon } = stamp.gps;
gps.set(0x0001, asciiEntry(0x0001, lat >= 0 ? 'N' : 'S'));
gps.set(0x0002, gpsCoordEntry(0x0002, lat));
gps.set(0x0003, asciiEntry(0x0003, lon >= 0 ? 'E' : 'W'));
gps.set(0x0004, gpsCoordEntry(0x0004, lon));
}
const list0 = [...ifd0.values()];
if (exif.size > 0) list0.push({ tag: 0x8769, type: LONG, count: 1, data: new Uint8Array(4), ref: 'exif' });
if (gps.size > 0) list0.push({ tag: 0x8825, type: LONG, count: 1, data: new Uint8Array(4), ref: 'gps' });
list0.sort((a, b) => a.tag - b.tag);
const listE = [...exif.values()].sort((a, b) => a.tag - b.tag);
const listG = [...gps.values()].sort((a, b) => a.tag - b.tag);
if (interop.size > 0) listE.push({ tag: 0xa005, type: LONG, count: 1, data: new Uint8Array(4), ref: 'interop' });
listE.sort((a, b) => a.tag - b.tag);
const listI = [...interop.values()].sort((a, b) => a.tag - b.tag);
const tiff = serializeTiff(list0, listE, listG, listI);
const payload = 2 + 6 + tiff.length;
if (payload > 0xffff) return jpeg; // APP1 length is a 16-bit field
const app1 = new Uint8Array(2 + payload);
app1[0] = 0xff; app1[1] = 0xe1;
// APP1 length is a big-endian 16-bit field (the TIFF block after it is little-endian)
app1[2] = (payload >> 8) & 0xff;
app1[3] = payload & 0xff;
app1.set([0x45, 0x78, 0x69, 0x66, 0x00, 0x00], 4); // "Exif\0\0"
app1.set(tiff, 10);
const old = findExifSegment(jpeg);
const head = old ? jpeg.subarray(0, old.start) : jpeg.subarray(0, 2);
const tail = old ? jpeg.subarray(old.end) : jpeg.subarray(2);
const out = new Uint8Array(head.length + app1.length + tail.length);
out.set(head, 0);
out.set(app1, head.length);
out.set(tail, head.length + app1.length);
return out;
}
+204
View File
@@ -0,0 +1,204 @@
import { SkCanvas, SkPaint, Skia, BlendMode } from '@shopify/react-native-skia';
import { FrameId } from '../types';
export interface FrameDefinition {
id: FrameId;
name: string;
}
export const FRAMES: FrameDefinition[] = [
{ id: 'none', name: 'NO FRAME' },
{ id: 'classic-white', name: 'CLASSIC BORDER' },
{ id: 'polaroid', name: 'RETRO INSTANT' },
{ id: 'wallframe', name: 'WALL FRAME' },
];
// 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 };
}
// Wall frame: the PNG artwork lives at the project root (wallframe.png,
// 4000x3117 RGBA, transparent window cols 675..3322 x rows 675..2441). Only the
// geometry lives here; the bitmap itself is drawn by the caller. The phone is
// portrait, so the app shows the artwork ROTATED 90° CW (original top edge →
// right edge): a portrait frame whose transparent window is a fixed 2:3
// vertical opening. The photo is cover-cropped (center) into that opening — the
// mat of a real frame hides the print edges. WALLFRAME_W/H and WALLFRAME_WIN
// are the rotated display orientation; the affine in wallframeLayout maps the
// source PNG pixels onto the frame rect so the drawn opening and the photo
// window always line up.
export const WALLFRAME_PNG_W = 4000; // source PNG width
export const WALLFRAME_PNG_H = 3117; // source PNG height
export const WALLFRAME_W = 3117; // rotated display width (= PNG height)
export const WALLFRAME_H = 4000; // rotated display height (= PNG width)
// Transparent window as fractions of the rotated image, derived from the same
// affine used to draw the bitmap: X = w - (y/3117)w, Y = (x/4000)h.
export const WALLFRAME_WIN = {
left: 676 / 3117, // ≈ 0.21688
top: 675 / 4000, // 0.16875
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 };
window: { x: number; y: number; w: number; h: number };
// Row-major Skia matrix that rotates the source PNG (WALLFRAME_PNG_W/H) 90° CW
// into the frame rect. Feed it to <Group matrix> (viewfinder) or concat it on
// the canvas (export) before drawing the bitmap.
drawMatrix: number[];
}
// 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',
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 / FW, contH / FH);
frameW = FW * s;
frameH = FH * s;
}
const fx = (contW - frameW) / 2;
const fy = (contH - frameH) / 2;
const scale = frameW / FW; // == frameH / FH
return {
frame: { x: fx, y: fy, w: frameW, h: frameH },
window: {
x: fx + WIN.left * frameW,
y: fy + WIN.top * frameH,
w: WIN.w * frameW,
h: WIN.h * frameH,
},
// 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],
};
}
// 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,
height: number,
frameId: FrameId
) {
if (frameId === 'none') return;
const paint = Skia.Paint();
if (frameId === 'classic-white') {
// A clean white border around the picture
paint.setColor(Skia.Color('#ffffff'));
paint.setStyle(0); // Fill
const borderWidth = Math.min(width, height) * 0.05; // 5% border
// Top border
canvas.drawRect(Skia.XYWHRect(0, 0, width, borderWidth), paint);
// Bottom border
canvas.drawRect(Skia.XYWHRect(0, height - borderWidth, width, borderWidth), paint);
// Left border
canvas.drawRect(Skia.XYWHRect(0, 0, borderWidth, height), paint);
// Right border
canvas.drawRect(Skia.XYWHRect(width - borderWidth, 0, borderWidth, height), paint);
} else if (frameId === 'cinematic') {
// Cinematic black bars on top and bottom
paint.setColor(Skia.Color('#000000'));
paint.setStyle(0); // Fill
const barHeight = height * 0.12; // 12% cinematic letterbox height
// Top letterbox
canvas.drawRect(Skia.XYWHRect(0, 0, width, barHeight), paint);
// Bottom letterbox
canvas.drawRect(Skia.XYWHRect(0, height - barHeight, width, barHeight), paint);
}
}
+126
View File
@@ -0,0 +1,126 @@
// Pure JPEG DPI metadata patcher.
// Re-encoding a JPEG through Skia drops the original EXIF/JFIF headers, so the
// DPI stamp ("72 dpi") is lost or defaulted. DPI is header metadata only — pixel
// dimensions never change — but print pipelines read it, so this rewrites the
// JFIF density (and EXIF XResolution/YResolution when present) to the requested
// value. All writes are same-length, so no segment offsets move.
//
// If the JPEG carries neither JFIF nor EXIF, a minimal JFIF APP0 segment is
// inserted right after SOI.
function u16(bytes: Uint8Array, p: number): number {
return (bytes[p] << 8) | bytes[p + 1];
}
function setU16(bytes: Uint8Array, p: number, v: number): void {
bytes[p] = (v >> 8) & 0xff;
bytes[p + 1] = v & 0xff;
}
// Patches `bytes` in place for JFIF; returns the (possibly rebuilt) array.
export function patchJpegDpi(input: Uint8Array, dpi: number): Uint8Array {
if (input.length < 2 || input[0] !== 0xff || input[1] !== 0xd8) return input;
const bytes = input.slice(); // work on a copy; insertion path rebuilds anyway
const dpiLo = dpi & 0xff;
const dpiHi = (dpi >> 8) & 0xff;
let sawJfif = false;
let sawExif = false;
let i = 2;
while (i + 1 < bytes.length && bytes[i] === 0xff) {
while (bytes[i] === 0xff) i++;
const marker = bytes[i];
i++;
if (marker === 0xd9 || marker === 0xda) break; // EOI / SOS — headers only
if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) continue;
if (i + 2 > bytes.length) break;
const len = u16(bytes, i);
if (len < 2 || i + len > bytes.length) break;
const dataStart = i + 2;
const payloadLen = len - 2;
// APP0 JFIF: [FF E0] len [5x id "JFIF\0"][2 ver][1 units][2 Xd][2 Yd][1 thW][1 thH]...
if (marker === 0xe0 && payloadLen >= 14 && bytes[dataStart] === 0x4a &&
bytes[dataStart + 1] === 0x46 && bytes[dataStart + 2] === 0x49 &&
bytes[dataStart + 3] === 0x46 && bytes[dataStart + 4] === 0x00) {
sawJfif = true;
bytes[dataStart + 7] = 1; // units = dots per inch
bytes[dataStart + 8] = dpiHi;
bytes[dataStart + 9] = dpiLo;
bytes[dataStart + 10] = dpiHi;
bytes[dataStart + 11] = dpiLo;
}
// APP1 EXIF: [FF E1] len "Exif\0\0" then TIFF
if (marker === 0xe1 && payloadLen >= 14 &&
bytes[dataStart] === 0x45 && bytes[dataStart + 1] === 0x78 &&
bytes[dataStart + 2] === 0x69 && bytes[dataStart + 3] === 0x66 &&
bytes[dataStart + 4] === 0x00 && bytes[dataStart + 5] === 0x00) {
sawExif = true;
const tiff = dataStart + 6;
const le = bytes[tiff] === 0x49 && bytes[tiff + 1] === 0x49;
const be = bytes[tiff] === 0x4d && bytes[tiff + 1] === 0x4d;
if ((le || be) && tiff + 8 <= bytes.length) {
const read32 = (p: number): number =>
le
? bytes[p] | (bytes[p + 1] << 8) | (bytes[p + 2] << 16) | (bytes[p + 3] << 24)
: ((bytes[p] << 24) | (bytes[p + 1] << 16) | (bytes[p + 2] << 8) | bytes[p + 3]) >>> 0;
const write32 = (p: number, v: number): void => {
if (le) {
bytes[p] = v & 0xff; bytes[p + 1] = (v >> 8) & 0xff;
bytes[p + 2] = (v >> 16) & 0xff; bytes[p + 3] = (v >> 24) & 0xff;
} else {
bytes[p] = (v >> 24) & 0xff; bytes[p + 1] = (v >> 16) & 0xff;
bytes[p + 2] = (v >> 8) & 0xff; bytes[p + 3] = v & 0xff;
}
};
// TIFF integer fields (tag/type/count/values) follow the file byte order
const u16r = (p: number): number =>
le ? bytes[p] | (bytes[p + 1] << 8) : (bytes[p] << 8) | bytes[p + 1];
const u16w = (p: number, v: number): void => {
if (le) { bytes[p] = v & 0xff; bytes[p + 1] = (v >> 8) & 0xff; }
else { bytes[p] = (v >> 8) & 0xff; bytes[p + 1] = v & 0xff; }
};
const ifdOff = read32(tiff + 4);
if (ifdOff + 2 <= bytes.length - tiff) {
const ifd = tiff + ifdOff;
const count = u16r(ifd);
for (let e = 0; e < count; e++) {
const entry = ifd + 2 + e * 12;
if (entry + 12 > bytes.length) break;
const tag = u16r(entry);
const type = u16r(entry + 2);
const cnt = read32(entry + 4);
if ((tag === 0x011a || tag === 0x011b) && type === 5 && cnt === 1) {
// RATIONAL: entry+8 holds offset to (num, den), relative to TIFF start
const rat = tiff + read32(entry + 8);
if (rat + 8 <= bytes.length) write32(rat, dpi); // keep denominator
} else if (tag === 0x0128 && type === 3 && cnt === 1) {
// ResolutionUnit (2 = inch): normalize to inch so the new density reads correctly
u16w(entry + 8, 2);
}
}
}
}
}
i += len;
}
if (sawJfif || sawExif) return bytes;
// Neither segment exists — insert a minimal JFIF APP0 (density = dpi inch) after SOI.
const app0 = new Uint8Array([
0xff, 0xe0, 0x00, 0x10,
0x4a, 0x46, 0x49, 0x46, 0x00, // "JFIF\0"
0x01, 0x01, // version 1.01
0x01, // units: inch
dpiHi, dpiLo, dpiHi, dpiLo,
0x00, 0x00, // no thumbnail
]);
const out = new Uint8Array(bytes.length + app0.length);
out[0] = 0xff; out[1] = 0xd8;
out.set(app0, 2);
out.set(bytes.subarray(2), 2 + app0.length);
return out;
}
+202
View File
@@ -0,0 +1,202 @@
import { ColorAdjustments } from '../types';
// Static metadata for the continuous (slider) adjustments of the IQ/WB/FILTERS
// tabs — shared between AdjustmentPanel (chips + slider rows) and App (which
// needs the open row's bounds to drive drag-on-the-image adjustment). The get
// /set accessors are stateless so one table serves every render.
export interface ContinuousParamDef {
key: string;
label: string;
min: number;
max: number;
step: number;
defaultValue: number;
display: (v: number) => string;
get: (a: ColorAdjustments) => number;
set: (v: number) => Partial<ColorAdjustments>;
// PRO-only knob: the LITE build greys the chip out (PRO corner tag) and
// cannot open its row, so the value stays at its default there.
pro?: boolean;
}
const sign = (v: number) => (v > 0 ? `+${v}` : String(v));
const oneEv = (v: number) => `${v > 0 ? '+' : ''}${v.toFixed(1)} EV`;
export const PARAM_DEFS: {
iq: ContinuousParamDef[];
wb: ContinuousParamDef[];
filters: ContinuousParamDef[];
} = {
iq: [
{
key: 'exposure',
label: 'EXPOSURE',
min: -10,
max: 10,
step: 1,
defaultValue: 0,
display: sign,
get: (a) => a.exposure,
set: (v) => ({ exposure: v }),
},
{
key: 'ev',
label: 'EV',
min: -3,
max: 3,
step: 0.5,
defaultValue: 0,
display: oneEv,
get: (a) => a.exposureCompensation ?? 0,
set: (v) => ({ exposureCompensation: v }),
},
{
key: 'contrast',
label: 'CONTRAST',
min: -10,
max: 10,
step: 1,
defaultValue: 0,
display: sign,
get: (a) => a.contrast,
set: (v) => ({ contrast: v }),
},
{
key: 'color',
label: 'COLOR',
min: -10,
max: 10,
step: 1,
defaultValue: 0,
display: sign,
get: (a) => a.saturation,
set: (v) => ({ saturation: v }),
},
{
key: 'vibrance',
label: 'VIBRANCE',
min: -10,
max: 10,
step: 1,
defaultValue: 0,
display: sign,
get: (a) => a.vibrance ?? 0,
set: (v) => ({ vibrance: v }),
},
{
key: 'highlight',
label: 'HIGHLIGHT',
min: -10,
max: 10,
step: 1,
defaultValue: 0,
display: sign,
get: (a) => a.highlight ?? 0,
set: (v) => ({ highlight: v }),
},
{
key: 'shadow',
label: 'SHADOW',
min: -10,
max: 10,
step: 1,
defaultValue: 0,
display: sign,
get: (a) => a.shadow ?? 0,
set: (v) => ({ shadow: v }),
},
],
wb: [
{
key: 'temperature',
label: 'COLOR TEMP',
min: 2500,
max: 10000,
step: 100,
defaultValue: 5500,
display: (v) => `${v}K`,
get: (a) => a.temperature,
set: (v) => ({ temperature: v }),
},
{
key: 'tint',
label: 'TINT',
min: -10,
max: 10,
step: 1,
defaultValue: 0,
display: sign,
get: (a) => a.tint ?? 0,
set: (v) => ({ tint: v }),
},
],
filters: [
{
key: 'denoise',
label: 'NOISE REDUCTION',
min: -10,
max: 10,
step: 1,
defaultValue: 0,
display: String,
get: (a) => a.denoise,
set: (v) => ({ denoise: v }),
},
{
key: 'clarity',
label: 'CLARITY',
min: -10,
max: 10,
step: 1,
defaultValue: 0,
display: sign,
get: (a) => a.clarity,
set: (v) => ({ clarity: v }),
},
{
key: 'sharpening',
label: 'SHARPENING',
min: -10,
max: 10,
step: 1,
defaultValue: 0,
display: String,
get: (a) => a.sharpening ?? 0,
set: (v) => ({ sharpening: v }),
},
{
key: 'grain',
label: 'MONOCHROME GRAIN',
min: 0,
max: 10,
step: 1,
defaultValue: 0,
display: String,
get: (a) => a.grain,
set: (v) => ({ grain: v }),
},
{
key: 'hdf',
label: 'HDF EFFECT',
pro: true,
min: 0,
max: 10,
step: 1,
defaultValue: 0,
display: String,
get: (a) => a.hdf ?? 0,
set: (v) => ({ hdf: v }),
},
{
key: 'vignette',
label: 'VIGNETTING',
min: 0,
max: 10,
step: 1,
defaultValue: 0,
display: String,
get: (a) => a.vignette ?? 0,
set: (v) => ({ vignette: v }),
},
],
};
+89
View File
@@ -0,0 +1,89 @@
import { Skia, type SkImage } from '@shopify/react-native-skia';
// Turn a decoded image 90deg clockwise into a fresh SkImage. Used by the FRAME
// tab so a library photo can go landscape <-> portrait before it enters a frame:
// every consumer reads width()/height(), so the pixel-dimension swap carries the
// new orientation through the polaroid window aspect, the cover crop and the
// watermark areas without a single extra layout branch.
// ponytail: offscreen surface, same fallback as exportEngine.createSurface; a
// matrix-based draw would avoid the copy if this ever shows up in a profile.
export function rotateSkImage90(image: SkImage | null): SkImage | null {
if (!image) return null;
const w = image.width();
const h = image.height();
if (!(w > 0 && h > 0)) return null;
// CPU-backed on purpose: a GPU (MakeOffscreen) snapshot keeps its pixels in a
// context texture that is gone by the time the <Canvas> draws a frame later —
// the preview then renders the photo completely black. Raster pixels survive
// the trip to the render thread.
const surface = Skia.Surface.Make(h, w);
if (!surface) return image; // keep the unrotated photo on allocation failure
try {
const canvas = surface.getCanvas();
canvas.clear(Skia.Color('transparent'));
canvas.translate(h, 0);
canvas.rotate(90, 0, 0);
canvas.drawImage(image, 0, 0);
return surface.makeImageSnapshot() ?? image;
} finally {
// The raster snapshot refcounts the pixels, so the surface can go now
// instead of leaving one full-size copy behind per rotation.
surface.dispose();
}
}
// Fine rotation about the centre (the FRAME tab's STRAIGHTEN), keeping the
// original pixel dimensions: the content is scaled up just enough that the
// rotated rectangle still covers the frame, so no empty corner shows. The
// scale is the axis-aligned wrap of the counter-rotated frame — slightly
// conservative, which crops a hair more rather than leaking a corner.
// ponytail: one offscreen surface per call, same trade as rotateSkImage90.
export function rotateSkImageBy(image: SkImage | null, degrees: number): SkImage | null {
if (!image) return null;
const w = image.width();
const h = image.height();
if (!(w > 0 && h > 0)) return null;
if (!degrees) return image;
const rad = (degrees * Math.PI) / 180;
const cos = Math.abs(Math.cos(rad));
const sin = Math.abs(Math.sin(rad));
const k = Math.max((w * cos + h * sin) / w, (w * sin + h * cos) / h);
const surface = Skia.Surface.Make(w, h);
if (!surface) return image; // keep the unrotated photo on allocation failure
try {
const canvas = surface.getCanvas();
canvas.clear(Skia.Color('transparent'));
canvas.translate(w / 2, h / 2);
canvas.rotate(degrees, 0, 0);
canvas.scale(k, k);
canvas.drawImage(image, -w / 2, -h / 2);
return surface.makeImageSnapshot() ?? image;
} finally {
surface.dispose(); // see rotateSkImage90
}
}
// The whole FRAME-tab photo orientation in one call: the quarter turn first
// (rotateSkImage90 swaps the pixel dimensions, which then propagates through
// every consumer unchanged), then the fine STRAIGHTEN on top of it.
export function applyPhotoRotation(
image: SkImage | null,
quarter = 0,
straighten = 0
): SkImage | null {
let out = image;
const turns = Math.round(quarter / 90) % 4;
for (let i = 0; i < turns; i++) {
const next = rotateSkImage90(out);
// A 180/270 turn allocates one full-size copy per turn: the intermediate is
// dead as soon as the next one exists (never the caller's input image).
if (next && next !== out && i > 0) out.dispose();
out = next ?? out;
}
if (straighten) {
const s = rotateSkImageBy(out, straighten);
if (s && s !== out && out !== image) out.dispose(); // see above
out = s ?? out;
}
return out;
}
+254
View File
@@ -0,0 +1,254 @@
import { BaseFilter, ColorAdjustments } from '../types';
// Tone-domain adjustments (Fuji-style Dynamic Range + Highlight/Shadow).
// SkSL runtime effect over a child image shader.
//
// Lightness/chroma split: the curve moves the luma and the colour difference
// (rgb - luma) carries the hue through with most of its chroma. Scaling R,G,B
// by one gain keeps the *ratio* but crushes absolute chroma — that is what
// turned saturated blues black under -SH and bright colours grey under -HL.
//
// Wide, soft knees so the knobs reach like a tone curve instead of biting only
// at the very ends: HL rides only the top (0.65..1.00) so it leaves the greys
// and midtones alone (a 0.45 knee dragged a mid-grey down) while SH rides the
// lower half (0.00..0.55), and the 0.50 midpoint never moves. Earlier narrow
// knees (0.80..1.00 / 0.00..0.30) made both sliders look dead on any photo
// without true whites or blacks.
//
// Both knobs are pure additive shifts of the luma. That keeps the curve
// monotonic (steepest knee slope 4.29/unit * 0.22 = 0.94, and the two knees
// never overlap), so a brighter input can never come out darker. The earlier
// multiplicative form was NOT monotonic: with hl=-1 a grey 0.73 came out
// darker than 0.80.
//
// dr - DR strength 0..1: lifts shadows slightly and rolls highlights
// (Fuji extended DR); 0/auto/DR100 = no extra curve.
// hl - highlight -1..1: + lifts toward white, - rolls the bright side down.
// sh - shadow -1..1: + lifts the dark side, - deepens it.
// vib - vibrance -1..1: chroma-masked saturation. It rides along in this shader
// (rather than the colour matrix) because it needs per-pixel chroma:
// already-vivid pixels move least, so skins/skies deepen without the neon
// clip a plain Saturation boost causes.
// shT/hlT - split tone (per-channel RGB bias, -1..1 each): a fixed cast applied
// to the shadows and/or the highlights only. A 4x5 colour matrix cannot do
// this — it is one linear map, so any cast it applies must also hit the
// midtones and the opposite end. Classic Neg wants green/cyan shadows with
// warm highlights at once, so the stock ships these values and the pass
// stays active for it even when every user knob is 0. All-zero still = no
// pass.
export const TONE_SKSL = `
uniform shader src;
uniform float dr;
uniform float hl;
uniform float sh;
uniform float vib;
uniform float shTr;
uniform float shTg;
uniform float shTb;
uniform float hlTr;
uniform float hlTg;
uniform float hlTb;
uniform float cc;
uniform float ccb;
vec4 main(vec2 xy) {
vec4 c = src.eval(xy);
vec3 rgb = clamp(c.rgb, 0.0, 1.0);
float t = clamp(dot(rgb, vec3(0.2126, 0.7152, 0.0722)), 0.0, 1.0);
float hlMask = smoothstep(0.65, 1.00, t);
float shMask = 1.0 - smoothstep(0.00, 0.55, t);
// NOTE: never name a local 'out' — it is a reserved SkSL qualifier.
float o = t + hl * 0.22 * hlMask + sh * 0.34 * shMask;
// Dynamic range: gentle shadow lift + highlight roll (protect brights).
o += dr * 0.12 * shMask * (1.0 - t);
o -= dr * 0.18 * hlMask * t;
o = clamp(o, 0.0, 1.0);
// Lightness takes the curve, hue stays: the colour difference is gained
// only part-way so darkening cannot collapse a colour to black and lifting
// cannot blow a dark saturated colour out to white.
float cg = clamp(o / max(t, 0.0004), 0.55, 1.35);
rgb = clamp(vec3(o) + (rgb - vec3(t)) * cg, 0.0, 1.0);
// Split tone (stock look): the shadows and the highlights may each carry
// their own tint, so the two ends of the curve can drift opposite ways
// (Classic Neg: green-cyan darks, warm brights) without touching mid-greys.
rgb = clamp(rgb + vec3(shTr, shTg, shTb) * shMask + vec3(hlTr, hlTg, hlTb) * hlMask, 0.0, 1.0);
// Color Chrome / Color Chrome FX Blue: the two stock-dialed colour effects
// DEEPEN what is already chromatic and leave neutrals exactly where they are
// (Fuji: "deeper tone in highly saturated colour"; FX Blue does it for the
// blue/cyan side only). Both therefore need per-pixel chroma — a 4x5 colour
// matrix is one linear map, so any gain it applies also moves greys, and a
// blue-only gain drags the whole white point.
float mxc = max(max(rgb.r, rgb.g), rgb.b);
float mnc = min(min(rgb.r, rgb.g), rgb.b);
// Chroma ratio with a small floor: a near-black pixel with a hair of cast
// has ratio 1.0 but no colour to deepen, and must stay put.
float ccChroma = (mxc - mnc) / max(mxc, 0.10);
// Color Chrome rides the chroma itself: a muted colour barely moves, a vivid
// one gains density. The 0.25 knee keeps skin, haze and pastels untouched.
float ccMask = cc * smoothstep(0.25, 0.85, ccChroma);
// FX Blue: only where blue clearly leads red AND green (so magenta/purple
// stay out), and richest in a bright blue — a dark blue has no tonality left
// to deepen.
float ccbBlue = clamp((rgb.b - rgb.r) * 2.0, 0.0, 1.0) * clamp((rgb.b - 0.5 * (rgb.r + rgb.g) + 0.05) * 3.0, 0.0, 1.0);
float ccbMask = ccb * ccbBlue * smoothstep(0.15, 0.60, ccChroma) * smoothstep(0.20, 0.70, t);
float deep = clamp(ccMask + ccbMask, 0.0, 1.0);
// Density = lightness down with the colour difference riding along, so hue is
// preserved and the colour cannot collapse toward black (same reason the tone
// curve above keeps chroma). A touch of chroma is given up as it deepens.
float l3 = dot(rgb, vec3(0.2126, 0.7152, 0.0722));
rgb = clamp(vec3(l3 * (1.0 - 0.28 * deep)) + (rgb - vec3(l3)) * (1.0 - 0.10 * deep), 0.0, 1.0);
// Vibrance: push the LESS-saturated pixels harder than the vivid ones.
float l2 = dot(rgb, vec3(0.2126, 0.7152, 0.0722));
float mx = max(max(rgb.r, rgb.g), rgb.b);
float mn = min(min(rgb.r, rgb.g), rgb.b);
float chroma = mx > 0.0001 ? (mx - mn) / mx : 0.0;
float kv = 1.0 + vib * 0.75 * (1.0 - chroma);
return vec4(clamp(mix(vec3(l2), rgb, kv), 0.0, 1.0), c.a);
}
`;
// Bright Pass Filter for the HDF EFFECT pass (HDF), SkSL over a child image
// shader — the pattern TONE_SKSL above already proved on device.
//
// Per channel the old 2.5*in-1.5 curve only zeroed a channel that was dark
// *itself*: a saturated blue (B = 1.0) came out of it fully lit, so a dark blue
// shadow bloomed and a dark saturated colour smeared its hue into the darks.
// Photoshop's Bright Pass filters on the LUMINANCE instead: one knee decides how
// much light a pixel carries, and one gain scales all three channels, so below
// the knee the output is exactly 0.0 (Screen against black = no-op, the shadows
// are untouched) and above it every channel keeps its ratio — the hue cannot
// drift, only the brightness blooms.
//
// Knee t0..t1 = 0.55..0.85: the soft end of the old hard 0.6 cut (2.5*0.6-1.5 =
// 0), wide enough that a specular ramps in instead of clipping on/off.
export const GLOW_T0 = 0.55;
export const GLOW_T1 = 0.85;
export const GLOW_SKSL = `
uniform shader src;
uniform float t0;
uniform float t1;
vec4 main(vec2 xy) {
vec4 c = src.eval(xy);
float luma = dot(clamp(c.rgb, 0.0, 1.0), vec3(0.2126, 0.7152, 0.0722));
return vec4(c.rgb * smoothstep(t0, t1, luma), c.a);
}
`;
// Flat uniform buffer for `makeShaderWithChildren` — same order as GLOW_SKSL's
// declarations (t0, t1).
export function glowUniformArray(): number[] {
'worklet';
return [GLOW_T0, GLOW_T1];
}
// Same values for the declarative <Shader> path, which indexes uniforms by
// NAME (a flat array is only valid for the JS makeShaderWithChildren API).
export const GLOW_UNIFORMS = { t0: GLOW_T0, t1: GLOW_T1 };
// CLARITY (positive): unsharp 3x3 with epsilon 0 — the kernel export pass 4
// builds with MakeMatrixConvolution, re-expressed as a plain shader because RN
// Skia 2.6 exposes no convolution image filter to the declarative JSX writer.
// `px` is one ORIGINAL image pixel expressed in the caller's canvas units, so
// the preview, the camera worklet and the file all sharpen at the same radius.
export const CLARITY_SKSL = `
uniform shader src;
uniform float a;
uniform float2 px;
vec4 main(vec2 xy) {
vec4 c = src.eval(xy);
vec4 s = src.eval(xy + float2(0.0, -px.y))
+ src.eval(xy + float2(0.0, px.y))
+ src.eval(xy + float2(-px.x, 0.0))
+ src.eval(xy + float2( px.x, 0.0));
return vec4(clamp(c.rgb * (1.0 + 4.0 * a) - a * s.rgb, 0.0, 1.0), c.a);
}
`;
// Named uniforms for <Shader uniforms>, same names as CLARITY_SKSL declares.
export function clarityUniforms(a: number, pxX: number, pxY: number) {
return { a, px: [pxX, pxY] };
}
export interface ToneUniforms {
// All zero → no tone adjustment needed (caller can skip the shader pass).
dr: number; // 0..1
hl: number; // -1..1 (adjustments.highlight / 10)
sh: number; // -1..1 (adjustments.shadow / 10)
vib: number; // -1..1 (adjustments.vibrance / 10)
shT: [number, number, number]; // shadow split-tone RGB bias, -1..1
hlT: [number, number, number]; // highlight split-tone RGB bias, -1..1
cc: number; // 0..1 Color Chrome depth (0 = 'none')
ccb: number; // 0..1 Color Chrome FX Blue depth (0 = 'none')
}
// Per-stock tone pass. Fuji's Classic stocks are not a plain colour matrix:
// Classic Neg splits its tone (green-cyan darks / warm brights) and Classic
// Chrome crushes the shadows hard while muting colour. Those two parts live
// here instead of in the 4x5 matrix, which cannot move one end of the curve
// without also moving the other.
const FILM_TONE: Partial<Record<BaseFilter, Partial<ToneUniforms>>> = {
'classic-chrome': { sh: -0.28 },
'classic-neg': { shT: [-0.018, 0.009, 0.013], hlT: [0.024, 0.008, -0.012] },
// Acros. A black-and-white stock IS its grey ramp, so this entry only shapes
// the two ENDS and leaves the middle an identity: a smooth shadow toe that
// reaches a true black (no film-base lift, no flat grey wash) and a highlight
// shoulder that stops just short of white instead of clipping a cloud to
// paper. Mid-tones see neither mask, so the ramp keeps every step the matrix
// handed over — which is what 'deep black' costs in a colour stock and does
// not have to cost here.
// Gains are TONE_SKSL's own (sh * 0.34, hl * 0.22), so -0.12 puts the toe at
// ~5% and -0.05 trims the top ~1%.
monochrome: { sh: -0.12, hl: -0.05 },
};
export function getToneUniforms(adj: ColorAdjustments, baseFilter?: BaseFilter): ToneUniforms {
const drRaw = adj.dynamicRange ?? 'auto';
const dr = drRaw === 'auto' || drRaw === 100 ? 0 : (drRaw - 100) / 300;
const hl = Math.max(-1, Math.min(1, (adj.highlight ?? 0) / 10));
const sh = Math.max(-1, Math.min(1, (adj.shadow ?? 0) / 10));
const vib = Math.max(-1, Math.min(1, (adj.vibrance ?? 0) / 10));
const film = (baseFilter && FILM_TONE[baseFilter]) || {};
const shT: [number, number, number] = film.shT ?? [0, 0, 0];
const hlT: [number, number, number] = film.hlT ?? [0, 0, 0];
// Color Chrome depth per stop of the UI's none/weak/strong. A chrome set is a
// monochrome look, so both are forced off there: the effect is colour-only
// (the preview/export matrix skips them for monochrome for the same reason).
const colour = baseFilter !== 'monochrome';
const chromeDepth = (v: ColorAdjustments['colorChrome'] | undefined) =>
!colour || v === 'none' || v == null ? 0 : v === 'strong' ? 0.9 : 0.45;
const blueDepth = (v: ColorAdjustments['colorChromeBlue'] | undefined) =>
!colour || v === 'none' || v == null ? 0 : v === 'strong' ? 1.0 : 0.5;
return {
dr,
hl: hl + (film.hl ?? 0),
sh: sh + (film.sh ?? 0),
vib,
shT,
hlT,
cc: chromeDepth(adj.colorChrome),
ccb: blueDepth(adj.colorChromeBlue),
};
}
// Flat uniform buffer for `makeShaderWithChildren` / `<Shader uniforms>` — the
// order must match TONE_SKSL's declarations.
export function toneUniformArray(u: ToneUniforms): number[] {
return [u.dr, u.hl, u.sh, u.vib, u.shT[0], u.shT[1], u.shT[2], u.hlT[0], u.hlT[1], u.hlT[2], u.cc, u.ccb];
}
export function toneIsActive(u: ToneUniforms): boolean {
return (
u.dr !== 0 ||
u.hl !== 0 ||
u.sh !== 0 ||
u.vib !== 0 ||
u.shT[0] !== 0 ||
u.shT[1] !== 0 ||
u.shT[2] !== 0 ||
u.hlT[0] !== 0 ||
u.hlT[1] !== 0 ||
u.hlT[2] !== 0 ||
u.cc !== 0 ||
u.ccb !== 0
);
}