mirror of
https://github.com/samvallad33/vestige.git
synced 2026-07-02 22:01:01 +02:00
Memory Cinema storm color/shape overhaul (the crown-jewel pillar): - Fix the white-out root cause: emissiveNode was NEVER set, so the selective MRT bloom had no color to bloom and washed the frame white. Route the shared iridescent rainbow to BOTH colorNode and emissiveNode. - Rim glow (fresnel-style): bright glowing edges, dim readable center — the shareable luminous-shell / hollow-torus look. - Morphing geometry: the home target cycles sphere → torus → galaxy spiral → cube lattice → wave sheet, drifting continuously and snapping per beat. - Hyper-saturated full-spectrum palette (per-particle phase + radial shells + spatial bands + time) so the whole rainbow is present at once. - Spread the initial spawn across a wide hollow shell (was a tiny dense ball that boot-flashed white). - Act/beat-aware brightness: beats 0/1 fade in soft, Act I held calm, Acts II/III blaze at full. No white-out regressions. Gate: svelte-check 0/0, 937/937 tests pass (cinema auteur/pathfinder green), verified live in browser. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
610 lines
19 KiB
Svelte
610 lines
19 KiB
Svelte
<script lang="ts">
|
|
// Memory Cinema — the orchestration layer (Phase 4).
|
|
//
|
|
// Ties the three tiers together into one fullscreen experience:
|
|
// Tier 3 (always): BFS pathfinder + camera director drive the flythrough.
|
|
// Tier 2 (default): local structured captions from real node/edge data.
|
|
// Tier 1 (opt-in / when available): richer narration from a backend LLM
|
|
// endpoint, or the opt-in on-device model (lazy-loaded only on click).
|
|
// WebGPU storm renders if supported; otherwise camera + captions still play.
|
|
//
|
|
// Launches in an isolated overlay with its own canvas — the WebGL graph
|
|
// underneath is never touched.
|
|
|
|
import { onDestroy } from 'svelte';
|
|
import * as THREE from 'three';
|
|
import type { GraphNode, GraphEdge } from '$types';
|
|
import { planCinemaPath, type CinemaPath, type CinemaBeat } from '$lib/graph/cinema/pathfinder';
|
|
import { CinemaDirector } from '$lib/graph/cinema/director';
|
|
import {
|
|
resolveNarration,
|
|
localCaptions,
|
|
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';
|
|
|
|
interface Props {
|
|
nodes: GraphNode[];
|
|
edges: GraphEdge[];
|
|
centerId: string;
|
|
/** Optional Tier-1 backend narration fetcher (passed when backend supports it). */
|
|
fetchBackendNarration?: () => Promise<BeatNarration[] | null>;
|
|
}
|
|
let { nodes, edges, centerId, fetchBackendNarration }: Props = $props();
|
|
|
|
let open = $state(false);
|
|
let stage = $state<'idle' | 'planning' | 'playing' | 'done'>('idle');
|
|
let caption = $state('');
|
|
let chip = $state('');
|
|
let progress = $state(0);
|
|
let beatIndex = $state(0);
|
|
let totalBeats = $state(0);
|
|
let narrationSource = $state<CinemaNarration['source'] | null>(null);
|
|
let webgpuActive = $state(false);
|
|
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<DirectorPlan | null>(null);
|
|
|
|
let canvasHost = $state<HTMLDivElement | undefined>(undefined);
|
|
let sandbox: CinemaSandbox | null = null;
|
|
let director: CinemaDirector | null = null;
|
|
let path: CinemaPath | null = null;
|
|
let narration: CinemaNarration | null = null;
|
|
let rafId = 0;
|
|
let lastFrame = 0;
|
|
let typeTimer: ReturnType<typeof setInterval> | null = null;
|
|
let renderFailures = 0;
|
|
|
|
const reducedMotion =
|
|
typeof window !== 'undefined' &&
|
|
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
|
|
// Deterministic layout: spread path nodes on a gentle spiral so the camera
|
|
// has distinct world positions to fly between (independent of the WebGL
|
|
// graph's internal coordinates — keeps the sandbox isolated).
|
|
// Lay the beat nodes out on a TIGHT, BOUNDED shell centered on the origin —
|
|
// fixed radius, no per-beat growth. Earlier this grew (22 + i*6) so each beat
|
|
// sat farther out and the camera+storm marched off into space ("flying off").
|
|
// A bounded shell keeps the whole composition centered; cinematic variety
|
|
// comes from the camera angle/move/standoff, not from translating across a
|
|
// huge volume. The focused node is always re-centered by recenterOn() below.
|
|
const SHELL_RADIUS = 14;
|
|
function layoutPositions(p: CinemaPath): Map<string, THREE.Vector3> {
|
|
const pos = new Map<string, THREE.Vector3>();
|
|
const n = p.beats.length;
|
|
for (let i = 0; i < n; i++) {
|
|
// Distribute beats evenly on a sphere (golden-angle spiral) so they
|
|
// never clump and never exceed SHELL_RADIUS from center.
|
|
const t = n > 1 ? i / (n - 1) : 0.5;
|
|
const y = 1 - t * 2; // 1..-1
|
|
const r = Math.sqrt(Math.max(0, 1 - y * y));
|
|
const theta = i * 2.399963; // golden angle
|
|
pos.set(
|
|
p.beats[i].nodeId,
|
|
new THREE.Vector3(
|
|
Math.cos(theta) * r * SHELL_RADIUS,
|
|
y * SHELL_RADIUS * 0.5,
|
|
Math.sin(theta) * r * SHELL_RADIUS
|
|
)
|
|
);
|
|
}
|
|
return pos;
|
|
}
|
|
|
|
|
|
function speak(text: string) {
|
|
if (!voiceOn || typeof speechSynthesis === 'undefined') return;
|
|
try {
|
|
speechSynthesis.cancel();
|
|
const u = new SpeechSynthesisUtterance(text);
|
|
u.rate = 0.98;
|
|
u.pitch = 1.0;
|
|
speechSynthesis.speak(u);
|
|
} catch {
|
|
/* voice unavailable — captions carry it */
|
|
}
|
|
}
|
|
|
|
// Typewriter caption stream so text "arrives" with the camera.
|
|
function streamCaption(text: string) {
|
|
if (typeTimer) clearInterval(typeTimer);
|
|
caption = '';
|
|
if (reducedMotion) {
|
|
caption = text;
|
|
return;
|
|
}
|
|
let i = 0;
|
|
typeTimer = setInterval(() => {
|
|
caption = text.slice(0, ++i);
|
|
if (i >= text.length && typeTimer) {
|
|
clearInterval(typeTimer);
|
|
typeTimer = null;
|
|
}
|
|
}, 18);
|
|
}
|
|
|
|
// 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) {
|
|
const mode: StormMode = shot?.stormMode ?? 'connection';
|
|
// Pass act + 0-based beat index so the storm holds Act I dimmer AND
|
|
// fades in extra-soft on beats 0/1 (which otherwise wash to white).
|
|
sandbox.transitionTo(stormRole(mode), wp, shot?.act ?? 'I', index);
|
|
}
|
|
}
|
|
}
|
|
|
|
let currentPositions: Map<string, THREE.Vector3> | null = null;
|
|
|
|
async function launch() {
|
|
// Tear down any prior run so Replay never inherits stale state.
|
|
cancelAnimationFrame(rafId);
|
|
if (typeTimer) clearInterval(typeTimer);
|
|
director?.stop();
|
|
sandbox?.dispose();
|
|
sandbox = null;
|
|
director = null;
|
|
narration = null;
|
|
renderFailures = 0;
|
|
directorNote = '';
|
|
logline = '';
|
|
plan = null;
|
|
act = 'I';
|
|
tension = 0;
|
|
|
|
open = true;
|
|
stage = 'planning';
|
|
statusLine = 'Planning a path through your memory…';
|
|
caption = '';
|
|
chip = '';
|
|
progress = 0;
|
|
beatIndex = 0;
|
|
|
|
// Tier 3: plan the path (always works).
|
|
path = planCinemaPath(nodes, edges, centerId, 7);
|
|
totalBeats = path.beats.length;
|
|
if (totalBeats === 0) {
|
|
statusLine = 'Not enough memory to compose a tour yet.';
|
|
stage = 'done';
|
|
return;
|
|
}
|
|
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;
|
|
|
|
// Try the WebGPU storm; fall back silently to camera + captions.
|
|
webgpuActive = false;
|
|
if (canvasHost) {
|
|
try {
|
|
const { CinemaSandbox, isWebGPUSupported } = await import('$lib/graph/cinema/sandbox');
|
|
if (isWebGPUSupported()) {
|
|
sandbox = new CinemaSandbox(canvasHost);
|
|
await sandbox.boot();
|
|
webgpuActive = true;
|
|
}
|
|
} catch (e) {
|
|
console.warn('[cinema] WebGPU sandbox unavailable, camera-only mode:', e);
|
|
sandbox = null;
|
|
webgpuActive = false;
|
|
}
|
|
}
|
|
|
|
// Director drives the camera (sandbox camera if WebGPU, else a virtual one).
|
|
const fallbackAspect =
|
|
canvasHost && canvasHost.clientHeight > 0
|
|
? canvasHost.clientWidth / canvasHost.clientHeight
|
|
: 16 / 9;
|
|
const cam = sandbox?.cameraRef ?? new THREE.PerspectiveCamera(60, fallbackAspect, 0.1, 2000);
|
|
const target = sandbox?.target ?? new THREE.Vector3();
|
|
director = new CinemaDirector(cam, target, currentPositions, path, {
|
|
onBeat,
|
|
onProgress: (t) => (progress = t),
|
|
onComplete: () => {
|
|
stage = 'done';
|
|
statusLine = 'End of tour.';
|
|
},
|
|
}, { reducedMotion, shots, centerOnOrigin: webgpuActive });
|
|
|
|
stage = 'playing';
|
|
statusLine = webgpuActive
|
|
? 'Rendering 150k-particle semantic storm on WebGPU…'
|
|
: 'Cinematic flythrough (captions mode).';
|
|
lastFrame = performance.now();
|
|
director.start();
|
|
loop();
|
|
}
|
|
|
|
function loop() {
|
|
rafId = requestAnimationFrame(loop);
|
|
const now = performance.now();
|
|
const dt = Math.max(0, Math.min(0.05, (now - lastFrame) / 1000));
|
|
lastFrame = now;
|
|
// The camera director is the bulletproof core — it must advance every
|
|
// frame regardless of whether the WebGPU render succeeds.
|
|
try {
|
|
director?.update(dt);
|
|
} catch (e) {
|
|
console.warn('[cinema] director error:', e);
|
|
}
|
|
// Snapshot the sandbox so the async catch can't act on a sandbox that
|
|
// close() nulled out while the render promise was in flight.
|
|
const sb = sandbox;
|
|
if (sb && webgpuActive) {
|
|
sb.render(dt).catch((e) => {
|
|
// A render failure must never stall the tour. After a few
|
|
// consecutive failures, drop to camera-only (captions still play).
|
|
if (++renderFailures >= 3 && sandbox === sb) {
|
|
console.warn('[cinema] WebGPU render failing, dropping to camera-only:', e);
|
|
webgpuActive = false;
|
|
sb.dispose();
|
|
sandbox = null;
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
function close() {
|
|
cancelAnimationFrame(rafId);
|
|
if (typeTimer) clearInterval(typeTimer);
|
|
if (typeof speechSynthesis !== 'undefined') speechSynthesis.cancel();
|
|
director?.stop();
|
|
sandbox?.dispose();
|
|
sandbox = null;
|
|
director = null;
|
|
open = false;
|
|
stage = 'idle';
|
|
webgpuActive = false;
|
|
}
|
|
|
|
// a11y: Escape closes the fullscreen overlay; the close button auto-focuses
|
|
// on open so keyboard users land inside the dialog.
|
|
let closeBtn = $state<HTMLButtonElement | undefined>(undefined);
|
|
function onOverlayKeydown(e: KeyboardEvent) {
|
|
if (e.key === 'Escape') {
|
|
e.preventDefault();
|
|
close();
|
|
}
|
|
}
|
|
$effect(() => {
|
|
if (open && closeBtn) closeBtn.focus();
|
|
});
|
|
|
|
// Opt-in on-device narration. Lazy-loads @huggingface/transformers ONLY when
|
|
// the user enables "Local AI" and launches — never downloads a model
|
|
// unprompted. Runs a small instruction model in-browser on WebGPU to rewrite
|
|
// each beat's structured caption into richer prose. Returns null (→ Tier-2
|
|
// local captions) ONLY if the package is absent or generation genuinely fails
|
|
// — a real implementation with a real fallback, not a placeholder.
|
|
type TransformersPipeline = (
|
|
input: string,
|
|
opts?: Record<string, unknown>
|
|
) => Promise<Array<{ generated_text?: string }>>;
|
|
function localAiFetcher(): () => Promise<BeatNarration[] | null> {
|
|
return async () => {
|
|
if (!path) return null;
|
|
try {
|
|
statusLine = 'Loading on-device model (first run downloads weights)…';
|
|
// Computed specifier so TS/Vite don't resolve the optional,
|
|
// un-bundled package at build time.
|
|
const pkg = '@huggingface/transformers';
|
|
const mod = (await import(/* @vite-ignore */ pkg).catch(() => null)) as {
|
|
pipeline?: (task: string, model: string, opts?: Record<string, unknown>) => Promise<TransformersPipeline>;
|
|
} | null;
|
|
if (!mod?.pipeline) return null;
|
|
|
|
const generate = await mod.pipeline(
|
|
'text-generation',
|
|
'onnx-community/Qwen2.5-0.5B-Instruct',
|
|
{ device: 'webgpu', dtype: 'q4' }
|
|
);
|
|
|
|
// Seed from the deterministic local captions, then enrich each beat.
|
|
const base = localCaptions(path);
|
|
statusLine = 'Narrating with the on-device model…';
|
|
const out: BeatNarration[] = [];
|
|
for (const b of base.beats) {
|
|
const prompt =
|
|
`You are narrating a cinematic tour of an AI's memory graph. ` +
|
|
`In one vivid sentence, narrate this beat: "${b.text}"`;
|
|
const res = await generate(prompt, { max_new_tokens: 48, temperature: 0.7, do_sample: true });
|
|
const text = res?.[0]?.generated_text?.replace(prompt, '').trim();
|
|
out.push({ nodeId: b.nodeId, chip: b.chip, text: text && text.length > 4 ? text : b.text });
|
|
}
|
|
return out;
|
|
} catch (e) {
|
|
console.warn('[cinema] on-device narration failed, using local captions:', e);
|
|
return null;
|
|
}
|
|
};
|
|
}
|
|
|
|
onDestroy(close);
|
|
</script>
|
|
|
|
<button
|
|
class="cinema-launch glass rounded-full px-4 py-2 text-sm text-bright flex items-center gap-2 hover:scale-[1.03] transition"
|
|
onclick={launch}
|
|
aria-label="Start Memory Cinema — an AI-narrated flythrough of your memory"
|
|
>
|
|
<span aria-hidden="true">🎬</span> Memory Cinema
|
|
</button>
|
|
|
|
{#if open}
|
|
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
|
<div
|
|
class="cinema-overlay"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-label="Memory Cinema"
|
|
tabindex="-1"
|
|
onkeydown={onOverlayKeydown}
|
|
>
|
|
<div class="cinema-canvas" bind:this={canvasHost}></div>
|
|
|
|
<!-- Top bar: status + close -->
|
|
<div class="cinema-top glass-subtle">
|
|
<div class="flex items-center gap-2 text-xs text-dim">
|
|
<span class="cinema-dot" class:active={stage === 'playing'}></span>
|
|
<span>{statusLine}</span>
|
|
{#if plan}
|
|
<span class="cinema-badge" title="Who directed this film">
|
|
{plan.source === 'deterministic' ? 'Auteur (local)' : 'Auteur (AI)'}
|
|
</span>
|
|
{/if}
|
|
{#if narrationSource}
|
|
<span class="cinema-badge">{narrationSource === 'backend-llm' ? 'AI narration' : 'Live captions'}</span>
|
|
{/if}
|
|
{#if webgpuActive}<span class="cinema-badge cinema-badge-gpu">WebGPU</span>{/if}
|
|
{#if stage === 'playing'}<span class="cinema-act">Act {act}</span>{/if}
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<label class="cinema-toggle" title="Speak narration aloud">
|
|
<input type="checkbox" bind:checked={voiceOn} /> Voice
|
|
</label>
|
|
<label class="cinema-toggle" title="Use an on-device model for narration (downloads weights on first use)">
|
|
<input type="checkbox" bind:checked={localAiOn} /> Local AI
|
|
</label>
|
|
<button bind:this={closeBtn} class="cinema-close" onclick={close} aria-label="Close Memory Cinema (Esc)">✕</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Pre-roll DIRECTOR'S PLAN card: the AI states its film before rolling. -->
|
|
{#if stage === 'planning' && logline}
|
|
<div class="cinema-plan-card glass-panel">
|
|
<div class="cinema-plan-kicker">Director's plan</div>
|
|
<p class="cinema-plan-logline">{logline}</p>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Bottom: narration captions + director's note + progress -->
|
|
<div class="cinema-caption-wrap">
|
|
{#if chip}<div class="cinema-chip">{chip}</div>{/if}
|
|
<p class="cinema-caption">{caption}</p>
|
|
{#if directorNote && stage === 'playing'}
|
|
<p class="cinema-note" title="Why the director chose this shot">▸ {directorNote}</p>
|
|
{/if}
|
|
<div class="cinema-progress" aria-hidden="true">
|
|
<div
|
|
class="cinema-progress-fill"
|
|
style="width:{progress * 100}%; --tension:{tension}"
|
|
></div>
|
|
</div>
|
|
<div class="cinema-beatcount text-dim text-xs">
|
|
{#if totalBeats > 0}Beat {beatIndex} / {totalBeats}{/if}
|
|
{#if stage === 'done'}<button class="cinema-replay" onclick={launch}>↻ Replay</button>{/if}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<style>
|
|
.cinema-overlay {
|
|
position: fixed;
|
|
inset: 0;
|
|
z-index: 90;
|
|
background: radial-gradient(circle at 50% 40%, #05050f 0%, #010108 100%);
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
.cinema-canvas {
|
|
position: absolute;
|
|
inset: 0;
|
|
z-index: 0;
|
|
}
|
|
.cinema-top {
|
|
position: relative;
|
|
z-index: 2;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 1rem;
|
|
padding: max(0.75rem, env(safe-area-inset-top)) 1rem 0.75rem;
|
|
flex-wrap: wrap;
|
|
}
|
|
.cinema-badge {
|
|
font-size: 0.65rem;
|
|
padding: 0.1rem 0.45rem;
|
|
border-radius: 999px;
|
|
border: 1px solid rgba(129, 140, 248, 0.4);
|
|
color: var(--color-synapse-glow);
|
|
}
|
|
.cinema-badge-gpu {
|
|
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;
|
|
border-radius: 50%;
|
|
background: var(--color-muted);
|
|
}
|
|
.cinema-dot.active {
|
|
background: #14e8c6;
|
|
box-shadow: 0 0 10px #14e8c6;
|
|
}
|
|
.cinema-toggle {
|
|
font-size: 0.7rem;
|
|
color: var(--color-dim);
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.3rem;
|
|
cursor: pointer;
|
|
}
|
|
.cinema-close {
|
|
background: transparent;
|
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
|
color: var(--color-text);
|
|
border-radius: 8px;
|
|
width: 32px;
|
|
height: 32px;
|
|
cursor: pointer;
|
|
}
|
|
.cinema-caption-wrap {
|
|
position: relative;
|
|
z-index: 2;
|
|
margin-top: auto;
|
|
padding: 1rem 1.25rem max(1.25rem, env(safe-area-inset-bottom));
|
|
max-width: 720px;
|
|
}
|
|
.cinema-chip {
|
|
display: inline-block;
|
|
font-size: 0.65rem;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.08em;
|
|
color: var(--color-dream-glow);
|
|
margin-bottom: 0.35rem;
|
|
}
|
|
.cinema-caption {
|
|
font-size: clamp(1.05rem, 2.4vw, 1.6rem);
|
|
line-height: 1.45;
|
|
color: var(--color-bright);
|
|
text-shadow: 0 2px 24px rgba(0, 0, 0, 0.9);
|
|
min-height: 2.6em;
|
|
margin: 0 0 0.75rem;
|
|
}
|
|
.cinema-progress {
|
|
height: 3px;
|
|
background: rgba(255, 255, 255, 0.1);
|
|
border-radius: 3px;
|
|
overflow: hidden;
|
|
}
|
|
.cinema-progress-fill {
|
|
height: 100%;
|
|
/* 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;
|
|
display: flex;
|
|
gap: 0.75rem;
|
|
align-items: center;
|
|
}
|
|
.cinema-replay {
|
|
background: transparent;
|
|
border: 1px solid rgba(129, 140, 248, 0.4);
|
|
color: var(--color-synapse-glow);
|
|
border-radius: 999px;
|
|
padding: 0.15rem 0.7rem;
|
|
cursor: pointer;
|
|
font-size: 0.75rem;
|
|
}
|
|
@media (prefers-reduced-motion: reduce) {
|
|
.cinema-progress-fill {
|
|
transition: none;
|
|
}
|
|
}
|
|
</style>
|