/** * Lighting + grade register — the named lighting presets and colour-grade * presets with their quantified spec emitters (Kelvin / key angle / ratio, * shadow-highlight hue-sat) and their prose twins for the models that read * physical description better than numerals. PURE and deterministic: no I/O, * no Date, no Math.random. Extracted verbatim from `cinematography.ts` * (roadmap Phase 3a), which re-exports every public name, so importers are * unchanged. */ import type { DetailLevel } from './cinematography.js'; interface LightingPreset { kelvin: number; keyDeg: number; ratio: string; } interface GradePreset { shadowHue: number; shadowSat: number; highlightHue: number; highlightSat: number; lift?: number; gamma?: number; gain?: number; } const LIGHTING: Record = { 'hard-dawn': { kelvin: 4200, keyDeg: 25, ratio: '4:1' }, 'golden-hour': { kelvin: 3200, keyDeg: 15, ratio: '3:1' }, 'neutral-studio': { kelvin: 5600, keyDeg: 45, ratio: '2:1' }, 'night-fire': { kelvin: 2000, keyDeg: 30, ratio: '8:1' }, moonlight: { kelvin: 7000, keyDeg: 35, ratio: '6:1' }, overcast: { kelvin: 6500, keyDeg: 60, ratio: '1.5:1' }, 'neon-split': { kelvin: 4500, keyDeg: 40, ratio: '3:1' }, chiaroscuro: { kelvin: 3400, keyDeg: 20, ratio: '12:1' }, silhouette: { kelvin: 5000, keyDeg: 10, ratio: '16:1' }, fluorescent: { kelvin: 4300, keyDeg: 70, ratio: '1.2:1' }, 'night-practical': { kelvin: 2800, keyDeg: 25, ratio: '7:1' }, 'night-urban-neon': { kelvin: 5200, keyDeg: 30, ratio: '5:1' }, 'night-canyon': { kelvin: 3200, keyDeg: 10, ratio: '12:1' }, 'rembrandt-gray': { kelvin: 5200, keyDeg: 40, ratio: '3:1' }, }; const LIGHTING_WORDS: Record = { 'hard-dawn': 'crisp directional dawn light, long raking shadows', 'golden-hour': 'warm low golden-hour glow, soft long shadows', 'neutral-studio': 'clean balanced studio light, even and neutral', 'night-fire': 'flickering warm firelight against deep night shadow', moonlight: 'cool blue moonlight, soft and directional with deep shadows', overcast: 'flat soft overcast daylight, low contrast and even', 'neon-split': 'split warm/cool neon key, magenta-and-cyan separation', chiaroscuro: 'extreme chiaroscuro, a single hard source carving light from darkness', silhouette: 'strong backlight rendering the subject as a near-silhouette', fluorescent: 'flat green-tinged overhead fluorescent, institutional and even', 'night-practical': 'warm practical pools against deep night, motivated sources only', 'night-urban-neon': 'wet-street urban neon, mixed signage color spill at night', 'night-canyon': 'practical-only exterior night — headlight beams punch through haze, everything beyond them near-black', 'rembrandt-gray': 'lean single-source Rembrandt close on a gray plate, matte and warm', }; const GRADE: Record = { 'desaturated-earth': { shadowHue: 30, shadowSat: 18, highlightHue: 45, highlightSat: 22 }, 'teal-orange': { shadowHue: 190, shadowSat: 45, highlightHue: 30, highlightSat: 55 }, 'noir-bw': { shadowHue: 0, shadowSat: 0, highlightHue: 0, highlightSat: 0 }, 'warm-nostalgia': { shadowHue: 35, shadowSat: 25, highlightHue: 40, highlightSat: 30, gamma: 1.05 }, 'cool-isolation': { shadowHue: 210, shadowSat: 30, highlightHue: 205, highlightSat: 20 }, 'cyberpunk-neon': { shadowHue: 280, shadowSat: 60, highlightHue: 320, highlightSat: 65 }, 'bleach-bypass': { shadowHue: 0, shadowSat: 6, highlightHue: 0, highlightSat: 4, lift: 0.12, gamma: 1.1, gain: 0.92 }, 'mono-accent': { shadowHue: 0, shadowSat: 0, highlightHue: 0, highlightSat: 8 }, }; const GRADE_WORDS: Record = { 'desaturated-earth': 'muted earthy palette, dusty and restrained', 'teal-orange': 'cinematic teal-and-orange contrast', 'noir-bw': 'high-contrast monochrome noir', 'warm-nostalgia': 'warm faded nostalgia, soft amber memory tone', 'cool-isolation': 'cool desaturated isolation, blue-grey distance', 'cyberpunk-neon': 'saturated magenta-and-cyan cyberpunk neon', 'bleach-bypass': 'low-saturation high-density bleach-bypass with lifted blacks', 'mono-accent': 'near-monochrome with a single restrained accent hue', }; /** * Build a lighting prompt fragment at the requested detail level. * Unknown ids fall back to a neutral string rather than throwing. */ export function lightingSpec(id: string, d: DetailLevel): string { const preset = LIGHTING[id]; if (!preset) { if (d === 'terse') { return 'soft neutral lighting'; } return '5600K key at 45°, 2:1 ratio, neutral fill'; } const words = LIGHTING_WORDS[id] ?? id; if (d === 'terse') { return words; } if (id === 'rembrandt-gray') { const lean = 'one broad diffused source from camera-left and slightly above, a soft triangle of light on the shadow cheek, ' + 'no hard shadow edges, no rim light, no hair light, no kicker; skin matte and velvety, warmth preserved and natural, never pale or cool-shifted'; return d === 'standard' ? '5200K key at 40°, 3:1 ratio' : `5200K key at 40°, 3:1 ratio, ${lean}`; } const core = `${preset.kelvin}K key at ${preset.keyDeg}°, ${preset.ratio} ratio`; if (d === 'standard') { return core; } return `${core}, gentle fill and crisp rim light, ${words}`; } /** * The camera-bible "two-temperature technique": a warm key against a cool fill * (or the reverse). Two temperatures in one shot read as depth, complexity, and * dimension; a single temperature reads flat. Resolves the warm/cool Kelvin * from the {@link LIGHTING} registry (unknown ids fall back to a warm * golden-hour key / cool moonlight fill rather than throwing). Pure. */ export function twoTemperatureClause(warmId: string, coolId: string, d: DetailLevel): string { const warm = LIGHTING[warmId] ?? LIGHTING['golden-hour']; const cool = LIGHTING[coolId] ?? LIGHTING.moonlight; if (d === 'terse') { return 'warm key against cool fill, two-temperature separation'; } if (d === 'standard') { return `warm key ~${warm.kelvin}K against cool fill ~${cool.kelvin}K`; } return ( `two-temperature lighting: warm key ~${warm.kelvin}K (${warm.ratio}) against ` + `cool fill ~${cool.kelvin}K — depth, complexity, dimension` ); } /** * Build a color-grade prompt fragment at the requested detail level. * Unknown ids fall back to a neutral string rather than throwing. */ export function gradeSpec(id: string, d: DetailLevel): string { const preset = GRADE[id]; if (!preset) { if (d === 'terse') { return 'neutral grade'; } if (d === 'standard') { return 'balanced neutral grade, natural contrast'; } return 'shadows 0° 0% neutral; highlights 0° 0% neutral, natural contrast'; } const words = GRADE_WORDS[id] ?? id; if (d === 'terse') { return words; } if (d === 'standard') { return `${words} grade`; } const richBase = `shadows ${preset.shadowHue}° ${preset.shadowSat}% tint; ` + `highlights ${preset.highlightHue}° ${preset.highlightSat}% tint, ${words}`; const curve = [ preset.lift !== undefined ? `lift ${preset.lift}` : '', preset.gamma !== undefined ? `gamma ${preset.gamma}` : '', preset.gain !== undefined ? `gain ${preset.gain}` : '', ].filter(Boolean).join(', '); return curve ? `${richBase}; ${curve}` : richBase; } /* ------------------------------------------------------------------------- * * PROSE register — the Joey 2.0 "behavior-not-brand" emitters. * * The numeric register above (cameraSpec/lightingSpec/gradeSpec) stays for the * provider numeric handoff and the storyboard-grid Style line. This PROSE * register is the sibling Joey 2.0 wording: evocative *physical* phrasing that * describes what the light/grade/lens DOES, with NO Kelvin, key-angle degrees, * contrast ratio, hue°, sat°, or lift/gamma/gain numerals — but it KEEPS the * real optical numerals (focal length mm, fps, shutter) that Joey 2.0 keeps, * because those are genuine camera facts, not synthetic colour math. * * All emitters are pure/deterministic (no Date, no Math.random, no I/O). * ------------------------------------------------------------------------- */ /** * Joey 2.0 lighting *behaviour* descriptions, keyed by the same ids as * {@link LIGHTING}. Each describes the source, the shadow shape, and what is * deliberately absent (rim / hair light / kicker) — never a Kelvin or angle. */ const LIGHTING_PROSE: Record = { 'rembrandt-gray': 'one broad diffused source from camera-left and slightly above, a soft triangle of light on the shadow cheek, ' + 'gentle wrap, no rim, no hair light, no kicker', 'hard-dawn': 'a single hard low source raking in from the side, long crisp shadows stretching across the frame, ' + 'a clean edge where light meets dark', 'golden-hour': 'warm low sun grazing the subject from behind and to the side, soft long shadows, a gentle amber wrap on the skin', 'neutral-studio': 'one broad even diffused source filling the frame, soft balanced shadows, nothing harsh and nothing carved', 'night-fire': 'a warm flickering source from below and to one side, deep shadow on the far cheek, light dancing across the face', moonlight: 'a cool soft source from high and to the side, deep gentle shadows, a quiet blue wash with no warmth', overcast: 'flat soft light from a wide grey sky, shadows almost gone, even and undirectional across the whole frame', 'neon-split': 'a warm source on one cheek and a cool source on the other, the two colours meeting down the centre line of the face', chiaroscuro: 'a single hard source carving the subject out of near-darkness, most of the frame falling away into shadow', silhouette: 'a strong source behind the subject, the front left almost unlit, the figure read as shape against light', fluorescent: 'a flat even overhead wash, soft shadows under the brow and chin, an institutional cool-green cast', 'night-practical': 'warm pools from motivated practicals only, deep shadow between them, light falling off fast into the dark', 'night-urban-neon': 'mixed coloured spill from off-screen signage, wet-street bounce, light pooling and shifting across the subject', 'night-canyon': 'light coming only from practicals inside the scene — headlight beams catching suspended haze as visible volumetric cones, ' + 'a faint contained horizon glow at deep distance too weak to lift anything, everything outside the beam throw ' + 'falling into deep near-black shadow that still holds detail, subjects reading as silhouettes with lit forward edges', }; /** * Build a PROSE lighting fragment at the requested detail level. Carries NO * Kelvin / degrees / ratio numerals (use {@link lightingSpec} for those). * Unknown ids fall back to a neutral description rather than throwing. */ export function lightingProse(id: string, d: DetailLevel): string { const prose = LIGHTING_PROSE[id]; if (!prose) { const words = LIGHTING_WORDS[id]; return words ?? 'one soft diffused source, gentle even shadows, naturally lit'; } if (d === 'terse') { // condense to the leading clause (up to the first comma) for the terse form const head = prose.split(',')[0]; return head; } return prose; } /** * Joey 2.0 grade *behaviour* descriptions, keyed by the same ids as * {@link GRADE}. Each describes how shadows and highlights are shaped in * physical terms — never a hue°, sat%, or lift/gamma numeral. */ const GRADE_PROSE: Record = { 'teal-orange': 'contemporary teal-amber cinema grade, warm key meeting cool fill, shadows lifted gently into deep blue-grey ' + 'never crushed, highlights rolled off softly never clipping', 'desaturated-earth': 'muted earthy grade, colour pulled back toward dust and stone, shadows lifted gently never crushed, ' + 'highlights rolled off softly never clipping', 'noir-bw': 'rich black-and-white grade, deep luminous shadows holding detail never crushed, bright highlights rolled off never clipping', 'warm-nostalgia': 'warm faded grade with a soft amber memory tone, shadows lifted gently never crushed, highlights rolled off softly never clipping', 'cool-isolation': 'cool desaturated grade leaning blue-grey, shadows lifted into distance never crushed, highlights rolled off softly never clipping', 'cyberpunk-neon': 'saturated magenta-and-cyan neon grade, colour pushed in the highlights, shadows lifted into deep blue never crushed, ' + 'highlights rolled off before clipping', 'bleach-bypass': 'low-saturation high-density bleach-bypass grade, blacks lifted and milky never crushed, highlights held back from clipping', 'mono-accent': 'near-monochrome grade with a single restrained accent colour, shadows lifted gently never crushed, highlights rolled off softly never clipping', }; /** * Build a PROSE grade fragment at the requested detail level. Carries NO * hue° / sat% / lift-gamma numerals (use {@link gradeSpec} for those). * Unknown ids fall back to a neutral description rather than throwing. */ export function gradeProse(id: string, d: DetailLevel): string { const prose = GRADE_PROSE[id]; if (!prose) { const words = GRADE_WORDS[id]; if (words) { return d === 'terse' ? words : `${words}, shadows lifted gently never crushed, highlights rolled off softly never clipping`; } return 'natural balanced grade, shadows lifted gently never crushed, highlights rolled off softly never clipping'; } if (d === 'terse') { return prose.split(',').slice(0, 2).join(','); } return prose; }