From 588a36d05ab459da6631d21f2102b2e8988a5f47 Mon Sep 17 00:00:00 2001 From: 3dtours Date: Tue, 8 Sep 2026 11:38:27 +0700 Subject: [PATCH] feat: cinematic seasonal atmosphere presets (spring/summer/autumn/winter) - enhances existing light with season tints, sun glow, fog veil --- src/components/Viewfinder.tsx | 125 ++++++++++++++++++++++-------- src/types/index.ts | 6 ++ src/utils/cinemaShader.ts | 142 ++++++++++++++++++++++++++++++++++ src/utils/defaultRecipes.ts | 101 ++++++++++++++++++++++++ src/utils/exportEngine.ts | 28 ++++++- 5 files changed, 368 insertions(+), 34 deletions(-) create mode 100644 src/utils/cinemaShader.ts diff --git a/src/components/Viewfinder.tsx b/src/components/Viewfinder.tsx index 3820d78..f86c0c0 100644 --- a/src/components/Viewfinder.tsx +++ b/src/components/Viewfinder.tsx @@ -24,6 +24,7 @@ import { Lock } from 'lucide-react-native'; import { Recipe, GPSInfo, FrameId } from '../types'; import { getSkiaColorMatrix, applyExposureGain } from '../utils/colorUtils'; import { TONE_SKSL, getToneUniforms } from '../utils/toneShader'; +import { CINEMA_SKSL, getCinemaUniforms, cinemaOffFlat } from '../utils/cinemaShader'; interface ViewfinderProps { mode: 'camera' | 'library'; @@ -61,6 +62,15 @@ const toneEffect = (() => { } })(); +const cinemaEffect = (() => { + try { + return Skia.RuntimeEffect.Make(CINEMA_SKSL); + } catch (e) { + console.error('[cinema] CINEMA_SKSL compile failed: ' + e); + return null; + } +})(); + // Plain numeric primitives (captured by value into the preview worklet — the // worklet runtime can't read RNSkia's enum namespace objects directly). const T_CLAMP = TileMode.Clamp; @@ -127,10 +137,18 @@ export default function Viewfinder({ // eslint-disable-next-line react-hooks/exhaustive-deps [] ); + // Cinema seasonal grade params (16 flat floats; all-zero = off). + const cinemaParams = recipe.cinema ? getCinemaUniforms(recipe.cinema) : null; + const cinemaSync = useMemo( + () => createSynchronizable(cinemaOffFlat()), + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); useEffect(() => { colorMatrixSync.setBlocking(colorMatrix); toneSync.setBlocking(toneParams); - }, [colorMatrix, toneParams, colorMatrixSync, toneSync]); + cinemaSync.setBlocking(cinemaParams ? cinemaParams.flat : cinemaOffFlat()); + }, [colorMatrix, toneParams, cinemaParams, colorMatrixSync, toneSync, cinemaSync]); // Exposure compensation → hardware AE bias. Passed imperatively (never as a // declarative prop): v5 calls setExposureBias while the session is still @@ -293,18 +311,27 @@ export default function Viewfinder({ const paint = Skia.Paint(); paint.setColorFilter(Skia.ColorFilter.MakeMatrix(colorMatrixSync.getBlocking())); const tone = toneSync.getBlocking(); + const cinema = cinemaSync.getBlocking(); const hasTone = toneEffect != null && (tone[0] !== 0 || tone[1] !== 0 || tone[2] !== 0); - if (hasTone) { + const hasCinema = cinemaEffect != null && cinema[0] > 0; + if (hasTone || hasCinema) { try { const imageShader = frameTexture.makeShaderOptions(T_CLAMP, T_CLAMP, F_LINEAR, M_NONE); - const shader = toneEffect.makeShaderWithChildren(tone, [imageShader]); - if (shader != null) { - // drawRect, not drawImage: paint shaders only apply to geometry - // fills — drawImage ignores the paint shader, which is why the - // DR/Highlight/Shadow tone never showed on the live preview. + // drawRect, not drawImage: paint shaders only apply to geometry + // fills — drawImage ignores the paint shader, which is why the + // DR/Highlight/Shadow tone never showed on the live preview. + let final = null; + if (hasTone) { + final = toneEffect.makeShaderWithChildren(tone, [imageShader]); + } + if (hasCinema) { + const cinemaShader = cinemaEffect.makeShaderWithChildren(cinema, [final ?? imageShader]); + if (cinemaShader != null) final = cinemaShader; + } + if (final != null) { const w = frameTexture.width(); const h = frameTexture.height(); - paint.setShader(shader); + paint.setShader(final); canvas.drawRect(Skia.XYWHRect(0, 0, w, h), paint); return; } @@ -319,7 +346,7 @@ export default function Viewfinder({ }); frame.dispose(); }, - [colorMatrixSync, toneSync, toneFailLogged] + [colorMatrixSync, toneSync, cinemaSync, toneFailLogged] ); const grainOpacity = adjustments.grain / 20; @@ -646,6 +673,8 @@ export default function Viewfinder({ // Library mode — fullscreen, full-photo (contain), pinch/double-tap zoom. const toneOn = toneParams[0] !== 0 || toneParams[1] !== 0 || toneParams[2] !== 0; + const cinemaOn = cinemaEffect != null && !!cinemaParams; + const useShaderPass = !!imageFitRect && ((toneEffect != null && toneOn) || cinemaOn); return ( {libraryImageUri && skiaImage ? ( @@ -653,32 +682,62 @@ export default function Viewfinder({ - {toneEffect && toneOn && imageFitRect ? ( - // DR/Highlight/Shadow tone curve. The Rect must cover exactly - // the contain-fitted photo area (not the full canvas): a - // fullscreen ImageShader with fit="contain" clamps/smears the - // photo edges across the black letterbox (user-visible as "the - // background is a zoomed copy of the photo"). - - - - + {useShaderPass ? ( + // DR/Highlight/Shadow tone + Cinema seasonal grade, both live + // over the photo. The Rect must cover exactly the contain-fitted + // photo area (not the full canvas): a fullscreen ImageShader + // with fit="contain" clamps/smears the photo edges across the + // black letterbox (user-visible as "the background is a zoomed + // copy of the photo"). Cinema samples the tone output when both + // are on (chain: cinema -> tone -> image), matrix applies after. + + {cinemaOn ? ( + + {toneEffect && toneOn ? ( + + + + ) : ( + + )} + + ) : toneEffect && toneOn ? ( + + + + ) : null} ) : ( - + )} {grainOpacity > 0 && noiseEffect && ( diff --git a/src/types/index.ts b/src/types/index.ts index 2a8629a..ad2bc74 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,5 +1,10 @@ export type FrameId = 'none' | 'polaroid' | 'classic-white' | 'cinematic'; +// Cinematic seasonal atmosphere grade. Not a fake re-light: it reads the +// photo's EXISTING exposure — warm tint + soft glow on areas already lit, +// cool tint in shade, plus a seasonal atmospheric haze/fog veil. +export type CinemaSeason = 'spring' | 'summer' | 'autumn' | 'winter'; + export interface ColorAdjustments { exposure: number; // -10 to +10 (mapped to matrix multiplier or offset) contrast: number; // -10 to +10 @@ -25,6 +30,7 @@ export interface Recipe { baseFilter: 'classic-neg' | 'provia' | 'velvia' | 'monochrome' | 'none'; adjustments: ColorAdjustments; frameId?: FrameId; // e.g. 'none', 'polaroid', 'classic-white', 'cinematic' + cinema?: CinemaSeason | null; // seasonal atmosphere grade (optional) useGeotag: boolean; } diff --git a/src/utils/cinemaShader.ts b/src/utils/cinemaShader.ts new file mode 100644 index 0000000..d20b960 --- /dev/null +++ b/src/utils/cinemaShader.ts @@ -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 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 form. + uniforms: Record; +} + +const ZERO: Record = {}; + +const SEASONS: Record = { + // 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 = {}; + 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; +} diff --git a/src/utils/defaultRecipes.ts b/src/utils/defaultRecipes.ts index 2e8c789..101c46c 100644 --- a/src/utils/defaultRecipes.ts +++ b/src/utils/defaultRecipes.ts @@ -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, + }, ]; diff --git a/src/utils/exportEngine.ts b/src/utils/exportEngine.ts index 6e9edba..ec2a518 100644 --- a/src/utils/exportEngine.ts +++ b/src/utils/exportEngine.ts @@ -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);