feat: cinematic seasonal atmosphere presets (spring/summer/autumn/winter) - enhances existing light with season tints, sun glow, fog veil

This commit is contained in:
2026-09-08 11:38:27 +07:00
parent ea281d7974
commit 588a36d05a
5 changed files with 368 additions and 34 deletions
+142
View File
@@ -0,0 +1,142 @@
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 cinemaSeasonName(season: CinemaSeason | null | undefined): string | null {
return season ? SEASONS[season]?.name ?? null : null;
}
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;
}
+101
View File
@@ -142,4 +142,105 @@ export const DEFAULT_RECIPES: Recipe[] = [
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,
},
];
+27 -1
View File
@@ -6,6 +6,7 @@ import { Asset } from 'expo-asset';
import { Recipe, GPSInfo, FrameId } from '../types';
import { getSkiaColorMatrix, applyExposureGain } from './colorUtils';
import { TONE_SKSL, getToneUniforms, toneIsActive } from './toneShader';
import { CINEMA_SKSL, getCinemaUniforms, cinemaIsActive } from './cinemaShader';
import { drawFrameOnCanvas } from './frameUtils';
import { patchJpegDpi } from './jpegDpi';
@@ -152,6 +153,31 @@ export async function processAndExportPhoto(
}
}
// 3c. Cinema seasonal atmosphere grade (chain: cinema -> tone -> image).
// All-zero uniforms (no recipe.cinema) → skipped entirely.
const cinema = getCinemaUniforms(recipe.cinema);
let paintShader = toneShader;
if (cinema && cinemaIsActive(cinema.flat)) {
try {
const cinemaEffect = Skia.RuntimeEffect.Make(CINEMA_SKSL);
if (cinemaEffect) {
const child = toneShader ?? skImage.makeShaderOptions(
TileMode.Clamp,
TileMode.Clamp,
FilterMode.Linear,
MipmapMode.None
);
const cs = cinemaEffect.makeShaderWithChildren(cinema.flat, [child]);
if (cs) {
paintShader = cs;
paint.setShader(paintShader);
}
}
} catch (e) {
console.error('[export] CINEMA_SKSL failed - skipped: ' + e);
}
}
// 4. Denoise / Clarity Image Filters (export-only enhancements)
let imageFilter = null;
@@ -202,7 +228,7 @@ export async function processAndExportPhoto(
// 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) {
if (paintShader) {
canvas.drawRect(Skia.XYWHRect(0, 0, width, height), paint);
} else {
canvas.drawImage(skImage, 0, 0, paint);