From 5e8a22a427eb9750bbd75f5fe8ede8e4deb46484 Mon Sep 17 00:00:00 2001 From: Sam Valladares Date: Mon, 22 Jun 2026 00:58:35 -0500 Subject: [PATCH] =?UTF-8?q?feat(auteur):=20Phase=202=20=E2=80=94=20directo?= =?UTF-8?q?r=20executes=20the=20screenplay=20(shippable=20hero)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit director.ts: optional shots:ResolvedShot[] in DirectorOptions; per-beat flight/dwell timing; framePosition now reads move (push_in/pull_back/crane scale standoff) + angle (low=look-up, high=look-down) + standoff; orbit shots revolve the camera during dwell; Dutch roll via camera.up; hard/match cuts snap (editorial cut). With NO shots the camera is byte-identical to before (all values fall back to the existing constants + easeInOutCubic lerp). MemoryCinema.svelte: build computeSignals + planShotsDeterministic + resolveShots on launch, pass shots to the director; onBeat drives storm mode + director's note + Act + tension from the shot. New UI: pre-roll DIRECTOR'S PLAN card (logline naming real memories), per-beat 'why this shot' note, Act I/II/III badge, tension-tinted progress bar, Auteur source badge. The deterministic auteur ships the hero film with zero LLM. 937 tests + build green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/lib/components/MemoryCinema.svelte | 131 ++++++++++++++++-- .../src/lib/graph/cinema/director.ts | 117 +++++++++++++--- 2 files changed, 216 insertions(+), 32 deletions(-) diff --git a/apps/dashboard/src/lib/components/MemoryCinema.svelte b/apps/dashboard/src/lib/components/MemoryCinema.svelte index 547f9fd..38712d3 100644 --- a/apps/dashboard/src/lib/components/MemoryCinema.svelte +++ b/apps/dashboard/src/lib/components/MemoryCinema.svelte @@ -22,6 +22,14 @@ type CinemaNarration, type BeatNarration, } from '$lib/graph/cinema/narrator'; + import { computeSignals } from '$lib/graph/cinema/topology'; + import { + planShotsDeterministic, + resolveShots, + type DirectorPlan, + type ResolvedShot, + type StormMode, + } from '$lib/graph/cinema/auteur'; import type { SemanticRole } from '$lib/graph/cinema/storm'; import type { CinemaSandbox } from '$lib/graph/cinema/sandbox'; @@ -46,6 +54,12 @@ let voiceOn = $state(false); let localAiOn = $state(false); let statusLine = $state(''); + // Auteur (director) state surfaced in the overlay. + let directorNote = $state(''); // the current shot's "why" (cites a real metric) + let act = $state<'I' | 'II' | 'III'>('I'); + let tension = $state(0); // 0..1 for the tension sparkline + let logline = $state(''); + let plan = $state(null); let canvasHost = $state(undefined); let sandbox: CinemaSandbox | null = null; @@ -82,11 +96,6 @@ return pos; } - function roleFor(beat: CinemaBeat): SemanticRole { - if (beat.kind === 'origin') return 'anchor'; - if (beat.kind === 'contradiction') return 'contradiction'; - return 'connection'; - } function speak(text: string) { if (!voiceOn || typeof speechSynthesis === 'undefined') return; @@ -119,15 +128,30 @@ }, 18); } - function onBeat(beat: CinemaBeat, index: number) { + // Map the director's StormMode to the storm runtime's SemanticRole. 'surprise' + // is a Phase-3 storm mode; until then it reads as 'connection'. + function stormRole(mode: StormMode): SemanticRole { + return mode === 'surprise' ? 'connection' : mode; + } + + function onBeat(beat: CinemaBeat, index: number, shot: ResolvedShot | null) { beatIndex = index + 1; const text = narration?.beats[index]?.text ?? beat.node.label ?? ''; chip = narration?.beats[index]?.chip ?? ''; streamCaption(text); speak(text); + // Surface the director's intent for this shot — the "why", act, tension. + if (shot) { + directorNote = shot.why; + act = shot.act; + tension = shot.tension; + } if (sandbox && webgpuActive) { const wp = currentPositions?.get(beat.nodeId); - if (wp) sandbox.transitionTo(roleFor(beat), wp); + if (wp) { + const mode: StormMode = shot?.stormMode ?? 'connection'; + sandbox.transitionTo(stormRole(mode), wp); + } } } @@ -143,6 +167,11 @@ director = null; narration = null; renderFailures = 0; + directorNote = ''; + logline = ''; + plan = null; + act = 'I'; + tension = 0; open = true; stage = 'planning'; @@ -162,6 +191,15 @@ } currentPositions = layoutPositions(path); + // THE AUTEUR: read the graph's dramatic structure and direct the film. + // Tier 2 (deterministic) ships the hero; Tier 1 (LLM) lands in Phase 4. + const signals = computeSignals(nodes, edges); + plan = planShotsDeterministic(path, signals); + logline = plan.logline; + const shots = resolveShots(plan, path); + act = shots[0]?.act ?? 'I'; + tension = shots[0]?.tension ?? 0; + // Tiers 1/2: resolve narration (backend LLM → local captions). narration = await resolveNarration(path, localAiOn ? localAiFetcher() : fetchBackendNarration); narrationSource = narration.source; @@ -197,7 +235,7 @@ stage = 'done'; statusLine = 'End of tour.'; }, - }, { reducedMotion }); + }, { reducedMotion, shots }); stage = 'playing'; statusLine = webgpuActive @@ -340,10 +378,16 @@
{statusLine} + {#if plan} + + {plan.source === 'deterministic' ? 'Auteur (local)' : 'Auteur (AI)'} + + {/if} {#if narrationSource} {narrationSource === 'backend-llm' ? 'AI narration' : 'Live captions'} {/if} {#if webgpuActive}WebGPU{/if} + {#if stage === 'playing'}Act {act}{/if}
- + + {#if stage === 'planning' && logline} +
+
Director's plan
+

{logline}

+
+ {/if} + +
{#if chip}
{chip}
{/if}

{caption}

+ {#if directorNote && stage === 'playing'} +

▸ {directorNote}

+ {/if}
{#if totalBeats > 0}Beat {beatIndex} / {totalBeats}{/if} @@ -406,6 +464,50 @@ border-color: rgba(20, 232, 198, 0.5); color: #14e8c6; } + .cinema-act { + font-size: 0.6rem; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--color-dream-glow); + opacity: 0.85; + } + /* Pre-roll director's plan card — centered, the AI's statement of intent. */ + .cinema-plan-card { + position: absolute; + z-index: 3; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + max-width: 520px; + padding: 1.5rem 1.75rem; + border-radius: 16px; + text-align: center; + animation: cinema-plan-in 0.5s ease both; + } + @keyframes cinema-plan-in { + from { opacity: 0; transform: translate(-50%, -46%); } + to { opacity: 1; transform: translate(-50%, -50%); } + } + .cinema-plan-kicker { + font-size: 0.65rem; + letter-spacing: 0.18em; + text-transform: uppercase; + color: var(--color-synapse-glow); + margin-bottom: 0.5rem; + } + .cinema-plan-logline { + font-size: clamp(1.05rem, 2.2vw, 1.4rem); + line-height: 1.5; + color: var(--color-bright); + margin: 0; + } + .cinema-note { + font-size: 0.78rem; + color: var(--color-synapse-glow); + opacity: 0.85; + margin: 0 0 0.6rem; + font-style: italic; + } .cinema-dot { width: 8px; height: 8px; @@ -464,8 +566,13 @@ } .cinema-progress-fill { height: 100%; - background: linear-gradient(90deg, var(--color-synapse), var(--color-dream)); - transition: width 0.2s linear; + /* Tint shifts toward crimson as the shot's tension rises (--tension 0..1). */ + background: linear-gradient( + 90deg, + var(--color-synapse), + color-mix(in oklch, var(--color-dream), #ff2d55 calc(var(--tension, 0) * 100%)) + ); + transition: width 0.2s linear, background 0.4s ease; } .cinema-beatcount { margin-top: 0.4rem; diff --git a/apps/dashboard/src/lib/graph/cinema/director.ts b/apps/dashboard/src/lib/graph/cinema/director.ts index 7a3d3c3..8681f88 100644 --- a/apps/dashboard/src/lib/graph/cinema/director.ts +++ b/apps/dashboard/src/lib/graph/cinema/director.ts @@ -11,10 +11,12 @@ import * as THREE from 'three'; import type { CinemaPath, CinemaBeat } from './pathfinder'; +import type { ResolvedShot } from './auteur'; export interface DirectorCallbacks { - /** Fired once when the camera arrives at (or cuts to) a beat. */ - onBeat?: (beat: CinemaBeat, index: number) => void; + /** Fired once when the camera arrives at (or cuts to) a beat. The resolved + * shot for the beat is passed so consumers can drive storm/score/captions. */ + onBeat?: (beat: CinemaBeat, index: number, shot: ResolvedShot | null) => void; /** Fired when the whole tour finishes. */ onComplete?: () => void; /** Fired every frame with overall progress 0..1 (for a scrubber/progress bar). */ @@ -30,6 +32,11 @@ export interface DirectorOptions { standoff?: number; /** Instant cuts instead of flights (prefers-reduced-motion). */ reducedMotion?: boolean; + /** Optional per-beat director's plan (one ResolvedShot per beat, aligned by + * index). When ABSENT the camera behaves byte-identically to the pre-Auteur + * director — every value falls back to the constants above. When present, + * each shot's move/angle/dutch/standoff/flight/dwell/cut directs that beat. */ + shots?: ResolvedShot[]; } type Phase = 'idle' | 'flying' | 'dwelling' | 'done'; @@ -73,9 +80,25 @@ export class CinemaDirector { dwellSeconds: opts.dwellSeconds ?? 3.2, standoff: opts.standoff ?? 26, reducedMotion: opts.reducedMotion ?? false, + shots: opts.shots ?? [], }; } + /** The resolved shot directing a beat, or null when no plan was supplied + * (→ the camera uses the constant defaults = pre-Auteur behavior). */ + private shotAt(index: number): ResolvedShot | null { + return this.opts.shots[index] ?? null; + } + + /** Per-beat flight duration: the shot's value, else the global default. A + * hard/match cut has zero flight (handled in beginFlightTo). */ + private flightSecondsAt(index: number): number { + return this.shotAt(index)?.flightSeconds ?? this.opts.flightSeconds; + } + private dwellSecondsAt(index: number): number { + return this.shotAt(index)?.dwellSeconds ?? this.opts.dwellSeconds; + } + get totalBeats(): number { return this.path.beats.length; } @@ -99,39 +122,61 @@ export class CinemaDirector { this.phase = 'done'; } - /** Compute the camera stand-off position for a beat's node. */ - private framePosition(beat: CinemaBeat, out: THREE.Vector3): THREE.Vector3 { + /** Compute the camera stand-off position for a beat's node, directed by its + * shot (move / angle / standoff). With no shot, reproduces the original + * framing exactly: standoff = opts.standoff, +0.35 up-bias (filmic tilt). */ + private framePosition(beat: CinemaBeat, index: number, out: THREE.Vector3): THREE.Vector3 { const nodePos = this.positions.get(beat.nodeId); if (!nodePos) { // Node has no resolved position yet — keep current framing. return out.copy(this.camera.position); } - // Offset back + up from the node along the current view direction so the - // node sits centered with a cinematic slightly-above angle. + const shot = this.shotAt(index); + _tmpDir.copy(this.camera.position).sub(nodePos); if (_tmpDir.lengthSq() < 1e-4) _tmpDir.set(0, 0.4, 1); _tmpDir.normalize(); - // Bias the approach vector upward a touch for a filmic tilt. - _tmpDir.addScaledVector(_tmpUp, 0.35).normalize(); - return out.copy(nodePos).addScaledVector(_tmpDir, this.opts.standoff); + + // Vertical bias = the camera angle. Default +0.35 (slightly above, the + // original filmic tilt). low = look UP at the node (power), high = look + // DOWN (decay/fading). + let upBias = 0.35; + if (shot) { + if (shot.angle === 'low') upBias = -0.45; + else if (shot.angle === 'high') upBias = 0.7; + } + _tmpDir.addScaledVector(_tmpUp, upBias).normalize(); + + // Stand-off = how close: push_in tightens, pull_back/crane widen. + let standoff = shot?.standoff ?? this.opts.standoff; + if (shot) { + if (shot.move === 'push_in') standoff *= 0.7; + else if (shot.move === 'pull_back') standoff *= 1.5; + else if (shot.move === 'crane') standoff *= 1.8; + } + return out.copy(nodePos).addScaledVector(_tmpDir, standoff); } private beginFlightTo(index: number): void { const beat = this.path.beats[index]; const nodePos = this.positions.get(beat.nodeId); + const shot = this.shotAt(index); this.fromPos.copy(this.camera.position); this.fromTarget.copy(this.target); - this.framePosition(beat, this.toPos); + this.framePosition(beat, index, this.toPos); this.toTarget.copy(nodePos ?? this.target); this.phaseElapsed = 0; - if (this.opts.reducedMotion) { - // Jump-cut: snap, fire the beat, go straight to dwelling. + // A directed hard/match cut snaps instantly (like reduced-motion), so the + // editorial "cut" reads as an edit, not a fly. reduced-motion forces this + // for every beat regardless of shot. + const snap = this.opts.reducedMotion || shot?.cut === 'hard_cut' || shot?.cut === 'match_cut'; + if (snap) { this.camera.position.copy(this.toPos); this.target.copy(this.toTarget); this.phase = 'dwelling'; - this.cb.onBeat?.(beat, index); + this.cb.onBeat?.(beat, index, shot); } else { this.phase = 'flying'; } @@ -144,23 +189,33 @@ export class CinemaDirector { const dt = Math.max(0, Math.min(deltaSeconds, 0.05)); this.phaseElapsed += dt; + const flightSecs = this.flightSecondsAt(this.beatIndex); + const dwellSecs = this.dwellSecondsAt(this.beatIndex); + if (this.phase === 'flying') { - const t = Math.min(1, this.phaseElapsed / this.opts.flightSeconds); + const t = Math.min(1, this.phaseElapsed / flightSecs); const e = easeInOutCubic(t); this.camera.position.lerpVectors(this.fromPos, this.toPos, e); this.target.lerpVectors(this.fromTarget, this.toTarget, e); + this.applyDutch(this.beatIndex, e); if (t >= 1) { this.phase = 'dwelling'; this.phaseElapsed = 0; - this.cb.onBeat?.(this.path.beats[this.beatIndex], this.beatIndex); + this.cb.onBeat?.(this.path.beats[this.beatIndex], this.beatIndex, this.shotAt(this.beatIndex)); } } else if (this.phase === 'dwelling') { - // Gentle drift during the dwell keeps the shot alive (skipped if reduced). if (!this.opts.reducedMotion) { const nodePos = this.positions.get(this.path.beats[this.beatIndex].nodeId); - if (nodePos) this.target.lerp(nodePos, 0.02); + if (nodePos) { + this.target.lerp(nodePos, 0.02); // gentle settle keeps the shot alive + // An orbit shot slowly revolves the camera around the node + // during the dwell — the signature "reverent" move for keystones. + if (this.shotAt(this.beatIndex)?.move === 'orbit') { + this.orbitAround(nodePos, dt * 0.35); + } + } } - if (this.phaseElapsed >= this.opts.dwellSeconds) { + if (this.phaseElapsed >= dwellSecs) { const nextIndex = this.beatIndex + 1; if (nextIndex >= this.path.beats.length) { this.phase = 'done'; @@ -178,10 +233,32 @@ export class CinemaDirector { const per = this.path.beats.length > 0 ? 1 / this.path.beats.length : 0; const intra = this.phase === 'flying' - ? Math.min(1, this.phaseElapsed / this.opts.flightSeconds) * 0.5 - : 0.5 + Math.min(1, this.phaseElapsed / this.opts.dwellSeconds) * 0.5; + ? Math.min(1, this.phaseElapsed / flightSecs) * 0.5 + : 0.5 + Math.min(1, this.phaseElapsed / dwellSecs) * 0.5; this.cb.onProgress?.(Math.min(1, this.beatIndex * per + intra * per)); } + + /** Revolve the camera around a node by `angle` radians (orbit shots). */ + private orbitAround(center: THREE.Vector3, angle: number): void { + _tmpDir.copy(this.camera.position).sub(center); + const cos = Math.cos(angle); + const sin = Math.sin(angle); + const x = _tmpDir.x * cos - _tmpDir.z * sin; + const z = _tmpDir.x * sin + _tmpDir.z * cos; + _tmpDir.x = x; + _tmpDir.z = z; + this.camera.position.copy(center).add(_tmpDir); + } + + /** Roll the camera (Dutch angle) toward the shot's target roll over the + * flight, easing back to upright for non-Dutch shots. */ + private applyDutch(index: number, t: number): void { + const targetRoll = this.shotAt(index)?.dutch ?? 0; + const roll = targetRoll * t; + // camera.up = rotate world-up around the camera's forward axis by `roll`. + _tmpDir.set(0, 0, -1).applyQuaternion(this.camera.quaternion); // forward + this.camera.up.set(0, 1, 0).applyAxisAngle(_tmpDir, roll); + } } function easeInOutCubic(t: number): number {