/** * Detail-leveled camera / lighting / grade emitters. * * Pure, deterministic prompt-fragment builders. No I/O, no network. * Each emitter turns a structured spec into a human/provider-readable * string whose density scales with the requested {@link DetailLevel}: * - terse: evocative words only, no numbers * - standard: key numeric anchors (lens, Kelvin, ratio) * - rich: full numeric detail (velocity, fill/rim, hue/sat splits) */ export type DetailLevel = 'terse' | 'standard' | 'rich'; export type CameraMovement = | 'push-in' | 'pull-out' | 'dolly' | 'orbit' | 'pan' | 'tilt' | 'track' | 'handheld' | 'locked-off'; export interface CameraMove { shot: string; lens: number; angle: string; movement: CameraMovement; velocityFtPerSec?: number; } // Registers extracted in roadmap Phase 3a; every public name stays importable from here. export { lightingSpec, twoTemperatureClause, gradeSpec, lightingProse, gradeProse } from './lighting-grade-register.js'; export { HOOK_PATTERN_IDS, resolveHookPattern, hookBeat } from './hook-register.js'; export type { HookPatternId } from './hook-register.js'; export { specularKillClause, subsurfaceScatteringClause, strandHairClause, contrastCurveClause, moistureMatteClause, flatteringRealismClause, volumetricHaze, captureRealismBlock, phoneCaptureBlock, volumetricHazeThreePlane, backgroundPlate } from './realism-register.js'; export type { HazeDensity, CaptureRealismOpts, PhoneCaptureOpts, ThreePlaneHazeOpts, PlateKind } from './realism-register.js'; export { DYNAMIC_REGISTER_IDS, dynamicRegister, dynamicRegisterClause, strobeBlock } from './dynamic-register.js'; export type { DynamicRegisterId, DynamicRegister } from './dynamic-register.js'; const DEFAULT_VELOCITY: Record = { 'push-in': 2, 'pull-out': 2, dolly: 3, orbit: 4, pan: 5, tilt: 4, track: 6, handheld: 3, 'locked-off': 0, }; const MOVEMENT_QUALIFIER: Record = { 'push-in': 'slow push-in', 'pull-out': 'slow pull-out', dolly: 'smooth dolly', orbit: 'steady orbit', pan: 'controlled pan', tilt: 'controlled tilt', track: 'tracking move', handheld: 'loose handheld', 'locked-off': 'locked-off, no movement', }; /** * Build a camera-move prompt fragment at the requested detail level. */ export function cameraSpec(m: CameraMove, d: DetailLevel): string { const base = `${m.shot}, ${m.angle} angle, ${m.movement}`; if (d === 'terse') { return base; } if (d === 'standard') { return `${m.shot}, ${m.angle} angle, ${m.lens}mm, ${MOVEMENT_QUALIFIER[m.movement]}`; } const velocity = m.velocityFtPerSec ?? DEFAULT_VELOCITY[m.movement]; return ( `${m.shot}, ${m.angle} angle, ${m.lens}mm, ` + `${m.movement} at ${velocity} ft/s, subtle lens breathing` ); } /** * Joey 2.0 camera/optics *behaviour* descriptions, keyed by {@link CameraMovement}. * Each KEEPS the real optical numerals (focal length mm) Joey keeps, while * describing the glass and operator in physical prose — no Kelvin / degrees / ratio. */ const CAMERA_PROSE: Record = { dolly: 'wide-latitude cinema capture, vintage 75mm 2x anamorphic at a wide aperture, oval bokeh, soft diffusion bloom, ' + 'smooth dolly with natural operator breath, color-negative film rendition with fine 35mm grain', track: 'wide-latitude cinema capture, vintage 75mm 2x anamorphic at a wide aperture, oval bokeh, soft diffusion bloom, ' + 'a tracking move with natural operator breath, color-negative film rendition with fine 35mm grain', 'push-in': 'wide-latitude cinema capture, 50mm spherical prime at a wide aperture, soft diffusion bloom, ' + 'a slow push-in with natural operator breath, color-negative film rendition with fine 35mm grain', 'pull-out': 'wide-latitude cinema capture, 40mm spherical prime at a wide aperture, soft diffusion bloom, ' + 'a slow pull-out with natural operator breath, color-negative film rendition with fine 35mm grain', orbit: 'wide-latitude cinema capture, vintage 75mm 2x anamorphic at a wide aperture, oval bokeh, soft diffusion bloom, ' + 'a steady orbit with natural operator breath, color-negative film rendition with fine 35mm grain', pan: 'wide-latitude cinema capture, 35mm spherical prime at a wide aperture, soft diffusion bloom, ' + 'a controlled pan with natural operator breath, color-negative film rendition with fine 35mm grain', tilt: 'wide-latitude cinema capture, 35mm spherical prime at a wide aperture, soft diffusion bloom, ' + 'a controlled tilt with natural operator breath, color-negative film rendition with fine 35mm grain', handheld: 'wide-latitude cinema capture, vintage 75mm 2x anamorphic at a wide aperture, oval bokeh, soft diffusion bloom, ' + 'handheld with natural operator breath, color-negative film rendition with fine 35mm grain', 'locked-off': 'wide-latitude cinema capture, 50mm spherical prime at a wide aperture, soft diffusion bloom, ' + 'locked-off with no movement, color-negative film rendition with fine 35mm grain', }; /** * Build a PROSE camera fragment for a {@link CameraMovement} at the requested * detail level. KEEPS focal length mm (and any fps/shutter the caller adds) — * those are real optical numerals — but carries NO Kelvin / key-angle degrees / * contrast ratio. Unknown movements fall back to a handheld description. */ export function cameraProse(move: CameraMovement, d: DetailLevel): string { const prose = CAMERA_PROSE[move] ?? CAMERA_PROSE.handheld; if (d === 'terse') { // keep the lens clause + the movement clause; drop the grade/grain tail const parts = prose.split(', '); return [parts[1], parts[parts.length - 2]].filter(Boolean).join(', '); } return prose; } /** * A fully-specified cinema mode: the camera-worldbuilder backbone. * * Each field is a self-contained prompt fragment describing one axis of * the look, so a caller can assemble a mode into a coherent shot recipe. */ export interface ModeSpec { camera: string; lens: string; movement: string; filtration: string; grade: string; } /** * The five canonical cinema modes. Order is intentional (narrative first, * as the safe fallback); tests assert the sorted set. */ export const CINEMA_MODE_IDS = ['narrative', 'studio', 'action', 'performance', 'atmospheric'] as const; export type CinemaModeId = (typeof CINEMA_MODE_IDS)[number]; const CINEMA_MODES: Record = { narrative: { camera: 'lived-in real-world coverage, motivated framing, naturalistic eyelines', lens: '35mm spherical primes, shallow-to-medium depth', movement: 'grounded handheld and dolly, motivated reframes', filtration: 'light black pro-mist 1/8, subtle halation on practicals', grade: 'natural skin, gentle filmic contrast, true-to-life palette', }, studio: { camera: 'crafted void backdrop, controlled tabletop framing, precise composition', lens: '50mm macro-capable primes, deep controlled focus', movement: 'locked-off and motorized slider, perfectly repeatable moves', filtration: 'clean uncoated glass, no diffusion, crisp specular highlights', grade: 'clean neutral base, controlled contrast, accurate product color', }, action: { camera: 'kinetic handheld with whip-pans, fast aggressive coverage, dynamic angles', lens: '24mm wide primes, fast apertures for snap focus', movement: 'fast handheld, whip-pan and crash-zoom, rapid tracking', filtration: 'minimal diffusion, hard contrast, occasional anamorphic flare', grade: 'punchy high-contrast teal-and-orange, crushed shadows', }, performance: { camera: 'pit-photographer documentary framing, long-lens isolation, candid energy', lens: '85–135mm telephoto, compressed perspective, creamy bokeh', movement: 'shoulder-rig follow and long-lens pans, reactive not planned', filtration: 'glimmerglass 1/4 for stage glow, gentle bloom on lights', grade: 'saturated stage color, warm highlights, rich contrast', }, atmospheric: { camera: 'slow environmental wides, mood-led negative space, patient framing', lens: '40mm primes, soft falloff, atmospheric depth', movement: 'slow creeping push-in and drifting glide, near-static', filtration: 'heavy black pro-mist 1/4, volumetric haze, soft bloom', grade: 'desaturated moody palette, cool shadows, low-key contrast', }, }; /** * Resolve a cinema mode by id, falling back to `narrative` for unknown * ids rather than throwing. */ export function cinemaMode(id: CinemaModeId): ModeSpec { return CINEMA_MODES[id] ?? CINEMA_MODES.narrative; } const ORBIT_MODE: ModeSpec = { camera: 'orbiting hero framing, subject centered as the camera arcs around it', lens: '50mm primes, medium depth holding the subject sharp through the arc', movement: 'smooth 360° orbit, steady circular tracking around the subject', filtration: 'light black pro-mist 1/8, clean specular highlights', grade: 'rich contrast, controlled color, hero-product polish', }; const VOCAB_TO_MODE: Record = { cinematic: 'narrative', 'handheld-social': 'action', macro: 'studio', glide: 'atmospheric', stylized: 'performance', }; /** * Map a {@link CategoryDescriptor} `cameraVocab` token onto a {@link ModeSpec}. * * `orbit` resolves to a synthesized orbit spec; other known tokens map to a * canonical mode. Unknown tokens fall back to `narrative` rather than throwing. */ export function resolveCameraVocab(vocab: string): ModeSpec { if (vocab === 'orbit') { return ORBIT_MODE; } const mode = VOCAB_TO_MODE[vocab]; return mode ? CINEMA_MODES[mode] : CINEMA_MODES.narrative; } /** * One stacked shot in a multi-world intercut sequence: a single shot that * carries its OWN cinema-mode {@link ModeSpec} and a rendered camera `block`. */ export interface StackedShot { modeId: CinemaModeId; spec: ModeSpec; block: string; } /** * Render a {@link ModeSpec} as a single-line camera block string. The * `spec.camera` fragment is preserved verbatim so callers (and tests) can * locate it inside the block. */ function renderModeBlock(spec: ModeSpec): string { return `CAM: ${spec.camera} | ${spec.lens} | ${spec.movement} | ${spec.filtration} | ${spec.grade}`; } /** * Stack cinema modes for a multi-world intercut sequence. * * Returns one {@link StackedShot} per input mode id, preserving input order * AND duplicates. Each shot keeps its OWN {@link cinemaMode} spec and rendered * camera block — adjacent modes are never averaged, merged, or collapsed into * a single register, so intercutting between worlds stays visually distinct. */ export function stackModes(modeIds: CinemaModeId[]): StackedShot[] { return modeIds.map((modeId) => { const spec = cinemaMode(modeId); return { modeId, spec, block: renderModeBlock(spec) }; }); } /** * Layered sound-design recipes keyed on a category's `audioProfile`. Density * scales with {@link DetailLevel}. `diegetic` stays score-free (natural ambience * + foley); `ad-mix` layers a music bed, product foley, hook/CTA accents, and a * VO pocket. */ const SOUND_DESIGN: Record<'diegetic' | 'ad-mix', Record> = { diegetic: { terse: 'Diegetic ambience and foley only.', standard: 'Diegetic only — a natural ambient bed, close environmental foley, and subject-driven sound; no score.', rich: 'Diegetic only — a natural ambient bed under close environmental foley, with subject-driven movement and contact sounds up front; no musical score and no narration.', }, 'ad-mix': { terse: 'Ad mix — music bed with foley accents.', standard: 'Ad mix — a music bed under crisp product foley, with a punch-in SFX accent on the hook and a clean VO pocket.', rich: 'Ad mix — a driving music bed, crisp close-up product foley, a punch-in SFX accent on the hook beat, a resolve sting on the CTA, and a clean VO pocket in the midrange.', }, }; /** * Resolve a category audio profile to a layered sound-design line. Pure and * deterministic; defaults to the `standard` detail level. */ export function soundDesign( profile: 'diegetic' | 'ad-mix', detail: DetailLevel = 'standard', ): string { return SOUND_DESIGN[profile][detail]; } /** * Per-genre look defaults: concrete color / lighting / cut-rate anchors a * caller can seed a shot plan with before any per-shot overrides. * * `keyLightId` references an id understood by {@link lightingSpec} * (e.g. `'neutral-studio'`, `'golden-hour'`, `'hard-dawn'`, `'night-fire'`). */ export interface GenreDefaults { paletteHue: number; saturationPct: number; cutRatePerSec: number; keyLightId: string; } const NEUTRAL_GENRE_DEFAULTS: GenreDefaults = { paletteHue: 30, saturationPct: 45, cutRatePerSec: 0.4, keyLightId: 'neutral-studio', }; const GENRE_DEFAULTS: Record = { 'live-action': { paletteHue: 30, saturationPct: 50, cutRatePerSec: 0.4, keyLightId: 'golden-hour' }, pixar: { paletteHue: 45, saturationPct: 80, cutRatePerSec: 0.5, keyLightId: 'neutral-studio' }, anime: { paletteHue: 210, saturationPct: 75, cutRatePerSec: 0.7, keyLightId: 'hard-dawn' }, noir: { paletteHue: 220, saturationPct: 10, cutRatePerSec: 0.3, keyLightId: 'night-fire' }, influencer: { paletteHue: 25, saturationPct: 65, cutRatePerSec: 0.8, keyLightId: 'neutral-studio' }, action: { paletteHue: 200, saturationPct: 70, cutRatePerSec: 1.2, keyLightId: 'hard-dawn' }, 'music-video': { paletteHue: 280, saturationPct: 85, cutRatePerSec: 1.0, keyLightId: 'night-fire' }, }; /** * Resolve per-genre look defaults. Case-insensitive; unknown genres fall * back to a neutral default rather than throwing. */ export function genreDefaults(genre: string): GenreDefaults { return GENRE_DEFAULTS[genre.toLowerCase()] ?? NEUTRAL_GENRE_DEFAULTS; } /** * A single ordered beat in a structured shot timeline. Beats are contiguous: * the first `start` is 0 and the last `end` is the clip duration, with no gaps. */ export interface Beat { start: number; end: number; label: string; direction: string; } /** * The beat-structure templates a shot plan can be scaffolded from. Mirrors the * `BeatTemplate` union in {@link ../category-registry}. */ export type BeatTemplateId = | 'three-act' | 'ad-hook-feature-cta' | 'turntable' | 'lookbook' | 'song-structure' | 'tension-release' | 'social-2s-hook' | 'panel-sequence'; interface BeatStep { label: string; direction: string; weight: number; } /** * Lay out a sequence of weighted steps as contiguous beats spanning * `[startOffset, durationSeconds]`. The last beat's `end` is pinned exactly to * `durationSeconds` so rounding never leaves a gap or overshoot. */ function layoutSteps(steps: BeatStep[], durationSeconds: number, startOffset: number): Beat[] { const totalWeight = steps.reduce((sum, step) => sum + step.weight, 0) || 1; const span = durationSeconds - startOffset; const result: Beat[] = []; let cursor = startOffset; steps.forEach((step, index) => { const isLast = index === steps.length - 1; const end = isLast ? durationSeconds : Math.round((cursor + (span * step.weight) / totalWeight) * 100) / 100; result.push({ start: cursor, end, label: step.label, direction: step.direction }); cursor = end; }); return result; } /** * Generate an ordered, contiguous set of {@link Beat}s for a beat template. * * The first beat always starts at 0 and the last beat always ends at * `durationSeconds`, with no gaps between adjacent beats. * * - `three-act`: setup → inciting → rising → climax → resolve. * - `ad-hook-feature-cta`: a HOOK beat `[0, hookSeconds]` (defaulting to a short * 2s hook, clamped below the duration, when `hookSeconds` is 0), then * feature/benefit beats, ending with a CTA beat. * - `turntable`: a "Hero angle" open and a "Hero angle (return)" close bracketing * rotation beats. * - `lookbook`: a sequence of pose-change beats. */ export function beats( template: BeatTemplateId, durationSeconds: number, hookSeconds: number, ): Beat[] { switch (template) { case 'three-act': return layoutSteps( [ { label: 'Setup', direction: 'establish the subject, place, and tone', weight: 1 }, { label: 'Inciting', direction: 'introduce the disruption that sets the story in motion', weight: 1 }, { label: 'Rising', direction: 'escalate stakes and momentum toward the peak', weight: 2 }, { label: 'Climax', direction: 'land the highest-energy payoff beat', weight: 1 }, { label: 'Resolve', direction: 'settle the frame and leave a lingering final image', weight: 1 }, ], durationSeconds, 0, ); case 'ad-hook-feature-cta': { const hookEnd = hookSeconds > 0 ? Math.min(hookSeconds, durationSeconds) : Math.min(2, Math.max(0, durationSeconds - 1)); const hook: Beat = { start: 0, end: hookEnd, label: 'Hook', direction: 'scroll-stopping opening beat that earns the next second', }; const rest = layoutSteps( [ { label: 'Feature', direction: 'show the product or idea in clear, confident detail', weight: 1 }, { label: 'Benefit', direction: 'translate the feature into a felt payoff for the viewer', weight: 1 }, { label: 'CTA', direction: 'direct call to action with a clear next step', weight: 1 }, ], durationSeconds, hookEnd, ); return [hook, ...rest]; } case 'turntable': return layoutSteps( [ { label: 'Hero angle', direction: 'open on the hero three-quarter angle, locked and clean', weight: 1 }, { label: 'Rotation', direction: 'smooth quarter-turn revealing form and surface', weight: 1 }, { label: 'Rotation (back)', direction: 'continue the orbit through the rear profile', weight: 1 }, { label: 'Hero angle (return)', direction: 'settle back on the hero three-quarter angle to close', weight: 1 }, ], durationSeconds, 0, ); case 'lookbook': return layoutSteps( [ { label: 'Look 1', direction: 'first pose and styling, full-length establishing frame', weight: 1 }, { label: 'Look 2', direction: 'pose change with a fresh angle and energy', weight: 1 }, { label: 'Look 3', direction: 'final pose and styling, signature closing frame', weight: 1 }, ], durationSeconds, 0, ); case 'song-structure': return layoutSteps( [ { label: 'Intro', direction: 'cold-open establishing image before the first vocal lands', weight: 1 }, { label: 'Verse', direction: 'verse groove — performance and narrative build, steady cutting', weight: 2 }, { label: 'Chorus', direction: 'chorus payoff — peak energy, the hook visual, widest motion', weight: 2 }, { label: 'Bridge', direction: 'bridge — change of texture, tempo, or location for contrast', weight: 1 }, { label: 'Outro', direction: 'outro — final held image as the track resolves', weight: 1 }, ], durationSeconds, 0, ); case 'tension-release': return layoutSteps( [ { label: 'Standoff', direction: 'combatants square up — stillness and held breath before contact', weight: 1 }, { label: 'Build', direction: 'feints, footwork, and rising kinetic pressure', weight: 1 }, { label: 'Clash', direction: 'the peak exchange at full speed and impact', weight: 2 }, { label: 'Aftermath', direction: 'the hit lands, momentum bleeds out, the frame settles', weight: 1 }, ], durationSeconds, 0, ); case 'social-2s-hook': return layoutSteps( [ { label: 'Hook', direction: 'instant scroll-stopping pattern-interrupt visual in the first beat', weight: 1 }, { label: 'Curiosity', direction: 'open a curiosity gap that withholds the payoff', weight: 1 }, { label: 'Escalate', direction: 'raise the stakes or reveal in rising steps', weight: 2 }, { label: 'Payoff', direction: 'deliver the promised reveal and a loop-back final beat', weight: 1 }, ], durationSeconds, 0, ); case 'panel-sequence': return layoutSteps( [ { label: 'Panel 1', direction: 'open on the first comic panel, holding its key composition', weight: 1 }, { label: 'Panel 2', direction: 'cut to the next panel, advancing the action with a match on motion', weight: 1 }, { label: 'Turn', direction: 'the turn panel — the beat that flips the situation', weight: 1 }, { label: 'Splash', direction: 'the splash panel — the widest, highest-impact frame', weight: 1 }, ], durationSeconds, 0, ); default: { const exhaustive: never = template; throw new Error(`unknown beat template: ${String(exhaustive)}`); } } } /** * Precise orbit/turntable camera grammar. Product-360 categories need exact * terms — a generic "orbit" conflates three distinct motions: * - `product-rotation`: the object spins; the camera stays locked/static. * - `camera-orbit`: the camera circles a static subject. * - `parallax-orbit`: the camera arcs with foreground/background depth parallax. * * Order is intentional and stable; tests assert the sorted set. */ export const ORBIT_KINDS = ['product-rotation', 'camera-orbit', 'parallax-orbit'] as const; export type OrbitKind = (typeof ORBIT_KINDS)[number]; const ORBIT_GRAMMAR: Record = { 'product-rotation': 'Camera locked off and static; the object rotates in place on a motorized turntable, spinning a smooth 360° to reveal every surface while the frame stays perfectly still.', 'camera-orbit': 'Camera arcs in a smooth circle around a static subject, orbiting on a fixed radius so the subject holds dead-center while the background sweeps behind it.', 'parallax-orbit': 'Camera arcs around the subject with pronounced depth parallax — foreground elements sweep past faster than the distant background, layering the planes for a strong sense of dimensional depth.', }; /** * Resolve a precise camera-direction string for an {@link OrbitKind}. Unknown * kinds fall back to the `camera-orbit` grammar rather than throwing. */ export function orbitGrammar(kind: OrbitKind): string { return ORBIT_GRAMMAR[kind] ?? ORBIT_GRAMMAR['camera-orbit']; } /** * Build an audio-mix prompt fragment at the requested detail level. * - terse: evocative words only, no numbers * - standard: brief layer naming * - rich: an explicit dB hierarchy with a silence/re-entry beat */ export function audioMix(d: DetailLevel): string { if (d === 'terse') { return 'natural ambience, grounded foley, present dialogue'; } if (d === 'standard') { return 'ambience bed under foley, dialogue forward, music supportive'; } return ( 'ambient -4 dB, foley -1 dB, dialogue 0 dB ref, music -2 dB; ' + '1.5–2.5s silence then sudden re-entry' ); } /** * Beat-aligned audio direction for music videos. Positive tempo phrasing only * (negative direction like "no slow motion" does not work on these models). */ export function musicSyncLine(bpm: number | undefined, d: DetailLevel): string { if (d === 'terse') { return 'cuts and motion land on the beat'; } const tempo = bpm ? ` at ${bpm} BPM` : ''; const core = `cuts, accents, and subject motion land on the downbeat${tempo}, edited to the music's rhythm`; if (d === 'standard') { return core; } return `${core}; energy builds into each drop and holds through the bar`; } /** * FOV degree anchor ladder (Joey 3.0 Worldbuilder). Seedance latches onto FOV * in DEGREES as a discrete snap value — degrees read as instruction where a * bare millimetre reads as suggestion, and multishot sequences that only name * mm drift lens character between beats. Only these anchor steps exist; an * off-ladder value (e.g. 23°) is a hard error, never rounded silently. */ export interface FovAnchor { /** FOV in degrees — the value the model actually snaps to. */ deg: number; /** Canonical full-frame mm equivalent, written as a reader aid only. */ mm: number; /** The lens feel, written into the prompt after the degree token. */ feel: string; /** What the step is for (operator guidance; not emitted). */ useFor: string; } export const FOV_ANCHORS: readonly FovAnchor[] = [ { deg: 180, mm: 8, feel: 'fisheye spherical bulge', useFor: 'POV, dream-state, hallucination' }, { deg: 107, mm: 15, feel: 'architectural ultra-wide', useFor: 'vast interior scale, epic establishing' }, { deg: 84, mm: 22, feel: 'wide', useFor: 'full-body group blocking, environmental establishing' }, { deg: 63, mm: 32, feel: 'reportage wide', useFor: 'observational walking-alongside documentary feel' }, { deg: 47, mm: 50, feel: 'eye-level neutral', useFor: 'universal medium, dialogue two-shot, waist-up' }, { deg: 29, mm: 80, feel: 'portrait compression', useFor: 'isolated bust, tight dialogue coverage' }, { deg: 18, mm: 120, feel: 'portrait tight', useFor: 'identity-hold close-up, held emotional beat' }, { deg: 12, mm: 190, feel: 'tele detail', useFor: 'hand insert, object close, jewelry, texture' }, { deg: 8, mm: 350, feel: 'extreme long-lens', useFor: 'anchored-far observation, broadcast, watchtower feel' }, ]; export const FOV_ANCHOR_DEGREES: readonly number[] = FOV_ANCHORS.map((a) => a.deg); /** * The short FOV anchor token for a ladder degree — `"47° (50mm) eye-level * neutral"`. Throws on any off-ladder degree (the ladder is the contract; a * non-anchor value drifts, so it is refused rather than rounded). */ export function fovAnchor(deg: number): string { const anchor = FOV_ANCHORS.find((a) => a.deg === deg); if (!anchor) { throw new Error( `off-ladder FOV: ${deg}°. Pick an anchor step: ${FOV_ANCHOR_DEGREES.map((v) => `${v}°`).join(', ')}.`, ); } return `${anchor.deg}° (${anchor.mm}mm) ${anchor.feel}`; } /** * Full prompt line for the FOV anchor, with the hold-the-lens clause that stops * multishot lens drift (the failure the degree ladder exists to fix). */ export function fovAnchorLine(deg: number): string { return `${fovAnchor(deg)}, the FOV held across every shot with no drift mid-segment`; } /** * Cuts & timing precision scale (Joey 3.0 Worldbuilder). Four registers, * most-to-least precise; pick the tightest level the shot actually requires. * Whenever cuts exist, the clause closes the door on unintended edits. */ export const CUTS_PRECISION_IDS = ['oner', 'sequential', 'timed', 'freestyle'] as const; export type CutsPrecision = (typeof CUTS_PRECISION_IDS)[number]; /** Cut-trigger vocabulary these models recognise inside Movement beats. */ export const CUT_VOCABULARY = ['HARD CUT', 'SMASH CUT', 'MATCH CUT', 'INSERT CUT', 'REVERSE CUT', 'WHIP CUT'] as const; const CUTS_CLAUSES: Record = { oner: 'one uninterrupted shot, no internal cuts, the camera never breaks the take', sequential: 'beats land at the labeled CUT 1 → CUT 2 → CUT 3 marks; the camera does not add any additional cuts — ' + 'edits happen only at the marks written above', timed: 'every cut lands on its declared second value with HARD CUT written at each transition; the camera does not add ' + 'any additional cuts — edits happen only at the marks written above. A whip pan holds at least 0.8 seconds of ' + 'motion to render as a blur; one speed per beat, with a hard cut at every speed change, never a blended ramp', freestyle: 'the camera and editor explore freely, cutting where the energy demands', }; /** * The edit-precision clause for a cuts register. Continuity across any internal * cut is the caller's job (same subject set, same left/right geometry, same * eyeline, light, wardrobe, and prop states) — this clause locks the edit * COUNT and timing, not the continuity. */ export function cutsClause(precision: CutsPrecision): string { return CUTS_CLAUSES[precision]; } /** * The LOCKED FLAT GRADE close for character plates and sheets (Joey 3.0 Banana * Pro). A character plate is a reference, not a finished frame: any shadow * baked into it — a cheek triangle, a nose shadow, a contact shadow, a backdrop * falloff — is inherited and amplified by every downstream generation and * fights whatever lighting the actual scene wants. So the plate carries ZERO * lighting information. Three mandatory elements, always together: a flat * backdrop (one uniform value), shadowless illumination (matched fill on all * sides), and zero cast shadow. Distinct from {@link backgroundPlate} (the * lean plate clause) — this is the full relight-from-scratch close. Black has * no flat-grade variant (a flat black plate cannot hold the no-falloff * contract), so the kind is narrowed to mid-gray | white. */ export function flatGradeClose(kind: 'mid-gray' | 'white', d: DetailLevel): string { const backdrop = kind === 'mid-gray' ? 'an even 18% neutral mid-gray seamless, completely flat — one single uniform value corner to corner, no seam line, no gradient, no hotspot, no vignette, no falloff anywhere in the frame' : 'a pure white seamless, completely flat — no gradient, no seam line, perfectly even corner to corner'; if (d === 'terse') { return `${backdrop}; flat shadowless illumination with matched fill from every side; zero cast shadow anywhere in the frame`; } const core = `Background is ${backdrop}. ` + 'Relight from scratch overriding any reference lighting: completely flat shadowless illumination — one enormous ' + 'soft frontal source at camera position wrapping the subject evenly, matched equal fill from camera-left and ' + 'camera-right at identical intensity, matched fill from above and below, so both sides of the face and body read ' + 'at exactly the same brightness. No key-and-fill ratio, no modelling, no shadow side, no nose shadow, no ' + 'under-chin shadow, no rim light, no hair light, no kicker, no specular hotspot. Zero shadow cast onto the ' + 'background, no contact shadow, no drop shadow, no ambient occlusion anywhere in the frame. Extremely low ' + 'contrast, even, catalogue-flat. Form is described by bone structure, hair strands, and fabric folds alone, not ' + 'by light and shadow. Skin and fabric read matte and velvety, no shine, no gloss, rendering at their true ' + 'natural tone, warmth preserved and natural, never pale or washed-out or cool-shifted by the background'; if (d === 'standard') { return core; } return ( `${core}. Real peach fuzz at the jaw and hairline, real soft fine even pore texture, subsurface scattering ` + 'reading as semi-translucent biology, never plastic, never waxy. Photographed on a 50mm prime, even sharpness, ' + 'soft natural film grain. Photographed not generated' ); }