fix(camera): matrix-based zoom for pinch/double-tap, tap-to-focus AE/AF lock, library letterbox smear fix, export raw JPEG bytes q95, resume isActive fixes, dark theme polish
This commit is contained in:
+47
-11
@@ -1,3 +1,7 @@
|
||||
import { ColorAdjustments } from '../types';
|
||||
|
||||
export type BaseFilter = 'classic-neg' | 'provia' | 'velvia' | 'monochrome' | 'none';
|
||||
|
||||
// Kelvin to RGB compensation for White Balance.
|
||||
// In photography, when WB is set HIGH (e.g. 10000K), the camera understands
|
||||
// the scene light is COLD and compensates with WARM tones (red/orange).
|
||||
@@ -23,16 +27,21 @@ export function kelvinToRGB(kelvin: number): { r: number; g: number; b: number }
|
||||
return { r, g, b };
|
||||
}
|
||||
|
||||
// Generate a 4x5 ColorMatrix (array of 20 floats) based on base style and adjustments
|
||||
export function getSkiaColorMatrix(
|
||||
baseFilter: 'classic-neg' | 'provia' | 'velvia' | 'monochrome' | 'none',
|
||||
exposure: number, // -10 to +10
|
||||
contrast: number, // -10 to +10
|
||||
saturation: number, // -10 to +10
|
||||
temperature: number, // 2500 to 10000
|
||||
tint: number, // -10 to +10
|
||||
colorChrome: 'none' | 'weak' | 'strong'
|
||||
): number[] {
|
||||
// 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;
|
||||
const colorChrome = adj.colorChrome || 'none';
|
||||
const colorChromeBlue = adj.colorChromeBlue || 'none';
|
||||
|
||||
// 1. Start with Identity Matrix
|
||||
let matrix = [
|
||||
1, 0, 0, 0, 0,
|
||||
@@ -110,6 +119,18 @@ export function getSkiaColorMatrix(
|
||||
matrix = multiplyMatrices(chromeMat, matrix);
|
||||
}
|
||||
|
||||
// 4b. Color Chrome Effect Blue — deep-blue richness boost (blue channel only)
|
||||
if (baseFilter !== 'monochrome' && colorChromeBlue !== 'none') {
|
||||
const factor = colorChromeBlue === 'strong' ? 0.35 : 0.15;
|
||||
const blueMat = [
|
||||
1, 0, 0, 0, 0,
|
||||
0, 1, 0, 0, 0,
|
||||
0, 0, 1 + factor, 0, 0,
|
||||
0, 0, 0, 1, 0,
|
||||
];
|
||||
matrix = multiplyMatrices(blueMat, matrix);
|
||||
}
|
||||
|
||||
// 5. White Balance (Kelvin Temperature and Tint) — skip for monochrome to prevent color leakage
|
||||
if (baseFilter !== 'monochrome') {
|
||||
const rgbTemp = kelvinToRGB(temperature);
|
||||
@@ -146,6 +167,21 @@ export function getSkiaColorMatrix(
|
||||
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);
|
||||
}
|
||||
|
||||
// Compute a tint color + opacity from the matrix for camera overlay preview
|
||||
// Applies the matrix to white (1,1,1,1) to see what color cast the filter creates
|
||||
export function computeMatrixTint(matrix: number[]): { r: number; g: number; b: number; a: number } {
|
||||
@@ -177,7 +213,7 @@ function multiplyMatrices(a: number[], b: number[]): number[] {
|
||||
a[r * 5 + 2] * b[2 * 5 + c] +
|
||||
a[r * 5 + 3] * b[3 * 5 + c];
|
||||
}
|
||||
// Handle the 5th column translation translation
|
||||
// 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] +
|
||||
|
||||
@@ -1,4 +1,25 @@
|
||||
import { Recipe } from '../types';
|
||||
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,
|
||||
highlight: 0,
|
||||
shadow: 0,
|
||||
denoise: 0,
|
||||
clarity: 0,
|
||||
grain: 0,
|
||||
colorChrome: 'none',
|
||||
colorChromeBlue: 'none',
|
||||
dynamicRange: 'auto',
|
||||
sharpening: 0,
|
||||
exposureCompensation: 0,
|
||||
};
|
||||
|
||||
export const DEFAULT_RECIPES: Recipe[] = [
|
||||
{
|
||||
@@ -17,6 +38,10 @@ export const DEFAULT_RECIPES: Recipe[] = [
|
||||
clarity: 2,
|
||||
grain: 4,
|
||||
colorChrome: 'weak',
|
||||
colorChromeBlue: 'none',
|
||||
dynamicRange: 'auto',
|
||||
sharpening: 0,
|
||||
exposureCompensation: 0,
|
||||
},
|
||||
frameId: 'none',
|
||||
useGeotag: true,
|
||||
@@ -37,6 +62,10 @@ export const DEFAULT_RECIPES: Recipe[] = [
|
||||
clarity: 4,
|
||||
grain: 1,
|
||||
colorChrome: 'strong',
|
||||
colorChromeBlue: 'none',
|
||||
dynamicRange: 'auto',
|
||||
sharpening: 0,
|
||||
exposureCompensation: 0,
|
||||
},
|
||||
frameId: 'none',
|
||||
useGeotag: true,
|
||||
@@ -57,6 +86,10 @@ export const DEFAULT_RECIPES: Recipe[] = [
|
||||
clarity: 1,
|
||||
grain: 2,
|
||||
colorChrome: 'none',
|
||||
colorChromeBlue: 'none',
|
||||
dynamicRange: 'auto',
|
||||
sharpening: 0,
|
||||
exposureCompensation: 0,
|
||||
},
|
||||
frameId: 'none',
|
||||
useGeotag: true,
|
||||
@@ -77,6 +110,10 @@ export const DEFAULT_RECIPES: Recipe[] = [
|
||||
clarity: 3,
|
||||
grain: 6,
|
||||
colorChrome: 'none',
|
||||
colorChromeBlue: 'none',
|
||||
dynamicRange: 'auto',
|
||||
sharpening: 0,
|
||||
exposureCompensation: 0,
|
||||
},
|
||||
frameId: 'none',
|
||||
useGeotag: true,
|
||||
@@ -97,6 +134,10 @@ export const DEFAULT_RECIPES: Recipe[] = [
|
||||
clarity: -2,
|
||||
grain: 5,
|
||||
colorChrome: 'strong',
|
||||
colorChromeBlue: 'none',
|
||||
dynamicRange: 'auto',
|
||||
sharpening: 0,
|
||||
exposureCompensation: 0,
|
||||
},
|
||||
frameId: 'classic-white',
|
||||
useGeotag: true,
|
||||
|
||||
+102
-29
@@ -1,12 +1,14 @@
|
||||
import { Skia, ImageFormat, BlendMode, TileMode, type SkImage } from '@shopify/react-native-skia';
|
||||
import { Skia, ImageFormat, BlendMode, TileMode, FilterMode, MipmapMode, type SkImage, type SkShader, type SkSurface } from '@shopify/react-native-skia';
|
||||
import { File } from 'expo-file-system';
|
||||
import * as FileSystem from 'expo-file-system/legacy';
|
||||
import * as MediaLibrary from 'expo-media-library/legacy';
|
||||
import { Asset } from 'expo-asset';
|
||||
import { Recipe, GPSInfo, FrameId } from '../types';
|
||||
import { getSkiaColorMatrix } from './colorUtils';
|
||||
import { getSkiaColorMatrix, applyExposureGain } from './colorUtils';
|
||||
import { TONE_SKSL, getToneUniforms, toneIsActive } from './toneShader';
|
||||
import { drawFrameOnCanvas } from './frameUtils';
|
||||
import { formatCoordinate } from './locationUtils';
|
||||
import { base64ToBytes, bytesToBase64, patchJpegDpi } from './jpegDpi';
|
||||
import { patchJpegDpi } from './jpegDpi';
|
||||
|
||||
export interface ExportResult {
|
||||
uri: string;
|
||||
@@ -19,12 +21,24 @@ export interface ExportOptions {
|
||||
// Apply a subtle Lightroom-style "Screen" output sharpen (unsharp mask) to the
|
||||
// final pixels before encoding. Pixels already export at full resolution.
|
||||
sharpen?: boolean;
|
||||
// True for photos captured through the live camera: exposure compensation was
|
||||
// already applied as a hardware AE bias at capture, so no 2^EV matrix gain
|
||||
// should be added on top. Library stills omit this and get the matrix gain.
|
||||
evFromCamera?: boolean;
|
||||
}
|
||||
|
||||
// Lightroom-style "Screen" output sharpen: subtle unsharp mask. A 3x3
|
||||
// convolution whose kernel sums to 1 (brightness preserved), amount ~0.5.
|
||||
// ponytail: fixed subtle preset tuned for full-res desktop/screen output; turn
|
||||
// into an amount/radius UI control if users ask for adjustable strength.
|
||||
// GPU-backed surface when the Skia GPU context is available (convolutions/sharpen
|
||||
// run on the Adreno shader cores instead of the CPU — big win on 50MP captures),
|
||||
// falling back to the CPU raster surface. MakeOffscreen returns null when no GPU
|
||||
// context exists yet, so the fallback keeps this safe in library-only cold starts.
|
||||
function createSurface(width: number, height: number): SkSurface | null {
|
||||
return Skia.Surface.MakeOffscreen(width, height) ?? Skia.Surface.Make(width, height);
|
||||
}
|
||||
|
||||
function screenSharpenImage(image: SkImage, amount = 0.5): SkImage {
|
||||
const w = image.width();
|
||||
const h = image.height();
|
||||
@@ -34,7 +48,7 @@ function screenSharpenImage(image: SkImage, amount = 0.5): SkImage {
|
||||
[0, -amount, 0, -amount, center, -amount, 0, -amount, 0],
|
||||
1, 0, 1, 1, TileMode.Clamp, false, null, null
|
||||
);
|
||||
const surf = Skia.Surface.Make(w, h);
|
||||
const surf = createSurface(w, h);
|
||||
if (!surf) return image; // fall back to unsharpened on allocation failure
|
||||
const paint = Skia.Paint();
|
||||
paint.setImageFilter(filter);
|
||||
@@ -58,6 +72,10 @@ export async function processAndExportPhoto(
|
||||
gpsInfo: GPSInfo | null,
|
||||
options?: ExportOptions
|
||||
): Promise<ExportResult | null> {
|
||||
const t0 = Date.now();
|
||||
// Direct console.error templates (user log captures are E-level only). Steps
|
||||
// use full static prefixes — release minification dropped step literals when
|
||||
// they were passed through a wrapper, so no indirection here.
|
||||
try {
|
||||
// 1. Read source image file into Skia
|
||||
const skiaData = await Skia.Data.fromURI(sourceUri);
|
||||
@@ -71,12 +89,14 @@ export async function processAndExportPhoto(
|
||||
console.error('Failed to parse image from URI');
|
||||
return null;
|
||||
}
|
||||
console.error(`[export] decode ${Date.now() - t0}ms`);
|
||||
|
||||
const width = skImage.width();
|
||||
const height = skImage.height();
|
||||
console.error(`[export] BEGIN ${width}x${height} ev=${recipe.adjustments.exposureCompensation ?? 0} evFromCamera=${options?.evFromCamera ?? false}`);
|
||||
|
||||
// 2. Create offscreen canvas
|
||||
const surface = Skia.Surface.Make(width, height);
|
||||
// 2. Create offscreen canvas (GPU-backed when available — see createSurface)
|
||||
const surface = createSurface(width, height);
|
||||
if (!surface) {
|
||||
console.error('Failed to create Skia surface');
|
||||
return null;
|
||||
@@ -87,19 +107,48 @@ export async function processAndExportPhoto(
|
||||
|
||||
const adjustments = recipe.adjustments;
|
||||
|
||||
// 3. Build Color Matrix Filter
|
||||
const matrix = getSkiaColorMatrix(
|
||||
recipe.baseFilter,
|
||||
adjustments.exposure,
|
||||
adjustments.contrast,
|
||||
adjustments.saturation,
|
||||
adjustments.temperature,
|
||||
adjustments.tint,
|
||||
adjustments.colorChrome
|
||||
);
|
||||
const colorFilter = Skia.ColorFilter.MakeMatrix(matrix);
|
||||
// 3. Build Color Matrix Filter (+2^EV gain for library stills; camera
|
||||
// captures already got the bias at exposure time → evFromCamera skips it).
|
||||
const evStops = adjustments.exposureCompensation ?? 0;
|
||||
const evMatrix = options?.evFromCamera
|
||||
? getSkiaColorMatrix(recipe.baseFilter, adjustments)
|
||||
: applyExposureGain(getSkiaColorMatrix(recipe.baseFilter, adjustments), evStops);
|
||||
const colorFilter = Skia.ColorFilter.MakeMatrix(evMatrix);
|
||||
paint.setColorFilter(colorFilter);
|
||||
|
||||
// 3b. Tone shader — DR / Highlight / Shadow curve on top of the matrix.
|
||||
// Keep the built shader in its own variable: only drawRect (which samples
|
||||
// through the shader) when a real shader exists. If the RuntimeEffect fails
|
||||
// to compile / makeShaderWithChildren returns null, paint has no shader and a
|
||||
// drawRect would fill solid opaque color → black/blank output.
|
||||
const tone = getToneUniforms(adjustments);
|
||||
const hasTone = toneIsActive(tone);
|
||||
let toneShader: SkShader | null = null;
|
||||
if (hasTone) {
|
||||
let toneEffect: ReturnType<typeof Skia.RuntimeEffect.Make> = null;
|
||||
try {
|
||||
toneEffect = Skia.RuntimeEffect.Make(TONE_SKSL);
|
||||
} catch (e) {
|
||||
console.error('[export] TONE_SKSL compile failed - falling back to matrix-only: ' + e);
|
||||
}
|
||||
if (toneEffect) {
|
||||
const imageShader = skImage.makeShaderOptions(
|
||||
TileMode.Clamp,
|
||||
TileMode.Clamp,
|
||||
FilterMode.Linear,
|
||||
MipmapMode.None
|
||||
);
|
||||
toneShader = toneEffect.makeShaderWithChildren([tone.dr, tone.hl, tone.sh], [imageShader]);
|
||||
if (toneShader) {
|
||||
paint.setShader(toneShader);
|
||||
} else {
|
||||
console.error('[export] tone shader build failed - falling back to matrix-only');
|
||||
}
|
||||
} else {
|
||||
console.error('[export] TONE_SKSL compile failed - falling back to matrix-only');
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Denoise / Clarity Image Filters (export-only enhancements)
|
||||
let imageFilter = null;
|
||||
|
||||
@@ -145,8 +194,16 @@ export async function processAndExportPhoto(
|
||||
paint.setImageFilter(imageFilter);
|
||||
}
|
||||
|
||||
// 5. Draw primary image with color filters & enhancements
|
||||
canvas.drawImage(skImage, 0, 0, paint);
|
||||
// 5. Draw primary image with color filters & enhancements. Tone path samples
|
||||
// the image through the paint shader (child imageShader) with a rect so the
|
||||
// whole chain (tone → matrix → denoise/clarity) applies — but only when a
|
||||
// real shader was built; otherwise plain drawImage (never drawRect on a
|
||||
// shader-less paint, which paints solid color).
|
||||
if (toneShader) {
|
||||
canvas.drawRect(Skia.XYWHRect(0, 0, width, height), paint);
|
||||
} else {
|
||||
canvas.drawImage(skImage, 0, 0, paint);
|
||||
}
|
||||
|
||||
// 6. Add Monochrome Grain Overlay
|
||||
if (adjustments.grain > 0) {
|
||||
@@ -215,27 +272,42 @@ export async function processAndExportPhoto(
|
||||
}
|
||||
|
||||
// 9. Snapshot & Encode to JPEG (base64 string, no SkData round-trip)
|
||||
console.error(`[export] composite ${Date.now() - t0}ms`);
|
||||
let resultImage = surface.makeImageSnapshot();
|
||||
if (options?.sharpen) {
|
||||
resultImage = screenSharpenImage(resultImage);
|
||||
// Sharpening knob (0..10) overrides the legacy boolean sharpen flag:
|
||||
// legacy camera captures keep sharpen:true → 0.5 fallback when knob is 0.
|
||||
const sharpKnob = adjustments.sharpening ?? 0;
|
||||
const sharpenAmount = sharpKnob > 0 ? (sharpKnob / 10) * 0.8 : options?.sharpen ? 0.5 : 0;
|
||||
if (sharpenAmount > 0) {
|
||||
resultImage = screenSharpenImage(resultImage, sharpenAmount);
|
||||
}
|
||||
let base64 = resultImage.encodeToBase64(ImageFormat.JPEG, 95);
|
||||
console.error(`[export] sharpen ${Date.now() - t0}ms`);
|
||||
// Encode straight to raw JPEG bytes — no base64. The old path round-tripped
|
||||
// the whole 12MP frame through JS base64 decode/encode for the DPI patch and
|
||||
// then writeAsStringAsync, which alone took ~6s of the render.
|
||||
let bytes = resultImage.encodeToBytes(ImageFormat.JPEG, 95);
|
||||
|
||||
if (!base64) {
|
||||
if (!bytes || bytes.length === 0) {
|
||||
console.error('Failed to encode image to JPEG');
|
||||
return null;
|
||||
}
|
||||
console.error(`[export] encode ${Date.now() - t0}ms`);
|
||||
|
||||
// 9b. Patch DPI metadata (JFIF density / EXIF resolution) when requested
|
||||
// 9b. Patch DPI metadata (JFIF density / EXIF resolution) when requested —
|
||||
// in-place Uint8Array work, no base64 round-trip.
|
||||
if (options?.dpi && options.dpi > 0) {
|
||||
const patched = patchJpegDpi(base64ToBytes(base64), options.dpi);
|
||||
base64 = bytesToBase64(patched);
|
||||
bytes = patchJpegDpi(bytes, options.dpi);
|
||||
}
|
||||
|
||||
// 10. Write binary bytes to temporary local file (overwrites previous export)
|
||||
await FileSystem.writeAsStringAsync(EXPORT_FILE, base64, {
|
||||
encoding: FileSystem.EncodingType.Base64,
|
||||
});
|
||||
try {
|
||||
new File(EXPORT_FILE).write(bytes);
|
||||
} catch (e) {
|
||||
console.error('Failed to write export file: ' + e);
|
||||
return null;
|
||||
}
|
||||
console.error(`[export] write ${Date.now() - t0}ms`);
|
||||
console.error(`[export] total-done ${Date.now() - t0}ms`);
|
||||
|
||||
// 11. Request Media Library permission & Save to device gallery
|
||||
const mediaPermission = await MediaLibrary.requestPermissionsAsync();
|
||||
@@ -249,6 +321,7 @@ export async function processAndExportPhoto(
|
||||
} else {
|
||||
console.warn('Media Library permission denied. Image saved to temporary cache only.');
|
||||
}
|
||||
console.error(`[export] gallery-save ${Date.now() - t0}ms`);
|
||||
|
||||
return { uri: EXPORT_FILE, savedToLibrary: false };
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { Recipe } from '../types';
|
||||
import { DEFAULT_RECIPES } from './defaultRecipes';
|
||||
import { DEFAULT_RECIPES, DEFAULT_ADJUSTMENTS } from './defaultRecipes';
|
||||
|
||||
const CUSTOM_RECIPES_KEY = '@camrecipe_pro:custom_recipes';
|
||||
|
||||
@@ -17,7 +17,9 @@ export async function getCustomRecipes(): Promise<Recipe[]> {
|
||||
|
||||
export async function getAllRecipes(): Promise<Recipe[]> {
|
||||
const custom = await getCustomRecipes();
|
||||
return [...DEFAULT_RECIPES, ...custom];
|
||||
// Backfill newer adjustment fields onto recipes persisted before they existed.
|
||||
const fill = (r: Recipe): Recipe => ({ ...r, adjustments: { ...DEFAULT_ADJUSTMENTS, ...r.adjustments } });
|
||||
return [...DEFAULT_RECIPES.map(fill), ...custom.map(fill)];
|
||||
}
|
||||
|
||||
export async function saveCustomRecipe(recipe: Omit<Recipe, 'id' | 'isCustom'>): Promise<Recipe> {
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { ColorAdjustments } from '../types';
|
||||
|
||||
// Tone-domain adjustments (Fuji-style Dynamic Range + Highlight/Shadow).
|
||||
// SkSL runtime effect over a child image shader, driven by luma so hue is
|
||||
// preserved (single multiplicative factor per pixel).
|
||||
//
|
||||
// Soft-knee masks keep the knobs independent, like camera tone curves:
|
||||
// hl affects ONLY the bright end (knee 0.80..1.00),
|
||||
// sh affects ONLY the dark end (knee 0.00..0.30),
|
||||
// leaving the whole 0.30..0.80 mid/upper band untouched. Earlier knees
|
||||
// (0.40..0.95 / 0.05..0.55, then 0.55..1.0 / 0.0..0.45, then 0.70..1.0) still
|
||||
// reached mid/upper grays, so HL read as a global gray/brightness change
|
||||
// instead of isolating the bright end — on-device testing confirmed it.
|
||||
//
|
||||
// HL is multiplicative on BOTH signs so +/− act on the same region:
|
||||
// + scales 0.85+ up toward white (natural clip at 1.0), − scales them down.
|
||||
// A + additive lift using (1-t) dies at t→1.0 and never touched the specular
|
||||
// end — that asymmetry made +HL look like a mid-tone push, not a highlight
|
||||
// change. (SH keeps its original form; additive + worked there.)
|
||||
//
|
||||
// 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.
|
||||
export const TONE_SKSL = `
|
||||
uniform shader src;
|
||||
uniform float dr;
|
||||
uniform float hl;
|
||||
uniform float sh;
|
||||
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);
|
||||
float hlMask = smoothstep(0.80, 1.0, t);
|
||||
float shMask = 1.0 - smoothstep(0.0, 0.30, t);
|
||||
// NOTE: never name a local 'out' — it is a reserved SkSL qualifier.
|
||||
float o = t;
|
||||
// Highlight: bright end only, multiplicative on both signs (symmetric region).
|
||||
if (hl >= 0.0) {
|
||||
o *= 1.0 + hl * 0.30 * hlMask;
|
||||
} else {
|
||||
o *= 1.0 + hl * 0.45 * hlMask;
|
||||
}
|
||||
// Shadow: dark end only.
|
||||
if (sh >= 0.0) {
|
||||
o += sh * 0.80 * shMask * (1.0 - t);
|
||||
} else {
|
||||
o *= 1.0 + sh * 0.80 * shMask;
|
||||
}
|
||||
// Dynamic range: gentle shadow lift + highlight roll (protect brights).
|
||||
o += dr * 0.12 * shMask * (1.0 - t);
|
||||
o -= dr * 0.18 * hlMask * t;
|
||||
float f = clamp(o / max(t, 0.0004), 0.0, 4.0);
|
||||
return vec4(clamp(c.rgb * f, 0.0, 1.0), c.a);
|
||||
}
|
||||
`;
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
export function getToneUniforms(adj: ColorAdjustments): 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));
|
||||
return { dr, hl, sh };
|
||||
}
|
||||
|
||||
export function toneIsActive(u: ToneUniforms): boolean {
|
||||
return u.dr !== 0 || u.hl !== 0 || u.sh !== 0;
|
||||
}
|
||||
Reference in New Issue
Block a user