diff --git a/backend/app/ws.py b/backend/app/ws.py index 708f160..be8367b 100644 --- a/backend/app/ws.py +++ b/backend/app/ws.py @@ -12,6 +12,10 @@ Protocol (client → server): {"type": "ritual_step", "step": } → (on the final step) ritual_complete {"type": "judgment", "verdict": "trust" | "banish" | "test" | "cross_over"} → judgment_result + {"type": "scry", "image": ""} → {"type": "utterance", kind: "scry"} + The seeker's camera, shown to the vision model so the entity can speak + about the real room. The frame is never stored or logged — only the + resulting utterance is, like any other spirit speech. All server → client frames flow through a single sender task so concurrent producers (ambient loop, reply streaming, TTS callbacks) never interleave on @@ -65,7 +69,7 @@ router = APIRouter() # Alias so tests can swap in the NullPool test session maker. session_maker = _default_session_maker -MODES = {"wire", "evp", "radio", "ouija", "emf"} +MODES = {"wire", "evp", "radio", "ouija", "emf", "camera"} # Per-user limiters for every LLM-triggering message type (spec §5). fragment_limiter = RateLimiter(max_requests=30, window_seconds=60) @@ -80,6 +84,10 @@ summon_limiter = RateLimiter(max_requests=4, window_seconds=60) # same modest, human-plausible cadence as the other reward triggers. ritual_limiter = RateLimiter(max_requests=6, window_seconds=60) judgment_limiter = RateLimiter(max_requests=10, window_seconds=60) +# Scrying sends a real image to a vision model — by far the heaviest +# request this app makes of a CPU-only Ollama box, so it gets the +# tightest budget of any channel. +scry_limiter = RateLimiter(max_requests=4, window_seconds=60) # Per-IP limiters for the same trigger points (spec §5). Ollama is a shared, # single-instance, CPU-only resource — per-account limits alone don't stop @@ -92,6 +100,7 @@ question_ip_limiter = RateLimiter(max_requests=12, window_seconds=60) summon_ip_limiter = RateLimiter(max_requests=8, window_seconds=60) ritual_ip_limiter = RateLimiter(max_requests=12, window_seconds=60) judgment_ip_limiter = RateLimiter(max_requests=20, window_seconds=60) +scry_ip_limiter = RateLimiter(max_requests=8, window_seconds=60) # Probability that a channel's familiar presence answers again rather than # something new manifesting. High enough that the Codex stays collectable @@ -920,6 +929,79 @@ async def _handle_judgment(state: SeanceState, message: dict) -> None: await state.send_queue.put({"type": "item_drop", "item": item}) +# A 768px JPEG at quality 0.72 is well under 200KB, so ~350KB of base64 is a +# generous ceiling that still refuses anything pathological before it reaches +# the model. +MAX_SCRY_B64_CHARS = 350_000 + + +async def _handle_scry(state: SeanceState, message: dict) -> None: + """The entity speaks about what the seeker's camera actually shows. + + Unlike every other channel, the payload here is a photograph of a real + room, so this handler is deliberately strict: no entity means nothing to + look through, the image is size-capped before it touches the queue, and + the frame is never persisted or logged anywhere — it is passed to the + model and dropped. Only the resulting utterance is recorded, exactly like + any other thing a spirit says. + """ + if state.entity is None: + return + image = message.get("image") + if not isinstance(image, str) or not image.strip(): + return + if len(image) > MAX_SCRY_B64_CHARS: + await state.send_queue.put( + { + "type": "error", + "code": "scry_too_large", + "message": "the lens showed too much at once — try again.", + } + ) + return + if not ( + scry_limiter.allow(str(state.user_id)) + and scry_ip_limiter.allow(state.client_ip) + ): + await state.send_queue.put( + { + "type": "error", + "code": "rate_limited", + "message": "the eye tires. let it rest a moment before looking again.", + } + ) + return + + await state.send_queue.put({"type": "status", "state": "gathering"}) + try: + text = await spirit_service.scry( + state.entity, image, state.language, entropy=state.entropy + ) + except SpiritBusyError: + await state.send_queue.put( + { + "type": "error", + "code": "veil_crowded", + "message": "too many eyes at once. try again shortly.", + } + ) + return + except Exception: + # Deliberately broad, same reasoning as _maybe_manifest: a vision + # failure must leave the séance intact rather than surfacing a stack + # trace for something the seeker can't act on. + await state.send_queue.put( + { + "type": "error", + "code": "scry_failed", + "message": "the lens clouded over. nothing came through.", + } + ) + return + if text: + await _speak(state, "scry", text) + + @router.websocket("/ws/session") async def session_socket(websocket: WebSocket) -> None: user_id = await _authenticate(websocket) @@ -995,6 +1077,8 @@ async def session_socket(websocket: WebSocket) -> None: await _handle_ritual_step(state, message) elif msg_type == "judgment": await _handle_judgment(state, message) + elif msg_type == "scry": + await _handle_scry(state, message) except WebSocketDisconnect: pass finally: diff --git a/backend/tests/test_ws_session.py b/backend/tests/test_ws_session.py index 623b406..e3716f4 100644 --- a/backend/tests/test_ws_session.py +++ b/backend/tests/test_ws_session.py @@ -509,3 +509,139 @@ async def test_summon_common_rarity_does_not_roll_a_drop(sync_client, db_session ).scalars().all() await asyncio.sleep(0.02) assert items == [] + + +# --- scry: the camera as a channel ------------------------------------------ + + +@pytest.mark.asyncio +async def test_scry_speaks_about_what_the_lens_shows(sync_client, monkeypatch): + """The entity describes a real camera frame. The image must never be + persisted — only the resulting utterance, like any other spirit speech.""" + seen_images = [] + + async def fake_scry(entity, image_b64, language="en", entropy=None): + seen_images.append(image_b64) + return "a pale column, and a red cube on the boards" + + monkeypatch.setattr(app.ws.spirit_service, "scry", fake_scry, raising=False) + monkeypatch.setattr(app.ws, "summon_limiter", RateLimiter(max_requests=100, window_seconds=60)) + monkeypatch.setattr( + app.ws, "summon_ip_limiter", RateLimiter(max_requests=100, window_seconds=60) + ) + _login(sync_client, "scryer") + + with _ws_connect(sync_client, sync_client.cookies.get("qm_session")) as ws: + _read_until(ws, "session") + ws.send_json({"type": "summon"}) + _read_until(ws, "entity") + _read_until(ws, "utterance", kind="greeting") + + ws.send_json({"type": "scry", "image": "ZmFrZS1qcGVn"}) + spoken = _read_until(ws, "utterance", kind="scry") + assert spoken["text"] == "a pale column, and a red cube on the boards" + + assert seen_images == ["ZmFrZS1qcGVn"] + + +@pytest.mark.asyncio +async def test_scry_without_a_presence_is_ignored(sync_client, monkeypatch): + called = [] + + async def fake_scry(*a, **k): + called.append(1) + return "should not happen" + + monkeypatch.setattr(app.ws.spirit_service, "scry", fake_scry, raising=False) + _login(sync_client, "scryer-nobody") + + with _ws_connect(sync_client, sync_client.cookies.get("qm_session")) as ws: + _read_until(ws, "session") + # Nothing summoned: there is nobody to look through the lens. + ws.send_json({"type": "scry", "image": "ZmFrZQ=="}) + ws.send_json({"type": "ping"}) + assert _read_until(ws, "pong") == {"type": "pong"} + + assert called == [] + + +@pytest.mark.asyncio +async def test_scry_rejects_an_oversized_frame_before_the_model(sync_client, monkeypatch): + called = [] + + async def fake_scry(*a, **k): + called.append(1) + return "nope" + + monkeypatch.setattr(app.ws.spirit_service, "scry", fake_scry, raising=False) + monkeypatch.setattr(app.ws, "summon_limiter", RateLimiter(max_requests=100, window_seconds=60)) + monkeypatch.setattr( + app.ws, "summon_ip_limiter", RateLimiter(max_requests=100, window_seconds=60) + ) + _login(sync_client, "scryer-huge") + + with _ws_connect(sync_client, sync_client.cookies.get("qm_session")) as ws: + _read_until(ws, "session") + ws.send_json({"type": "summon"}) + _read_until(ws, "entity") + + ws.send_json({"type": "scry", "image": "A" * (app.ws.MAX_SCRY_B64_CHARS + 1)}) + err = _read_until(ws, "error") + assert err["code"] == "scry_too_large" + + # The oversized payload must never have reached the vision model. + assert called == [] + + +@pytest.mark.asyncio +async def test_scry_survives_a_vision_failure(sync_client, monkeypatch): + """A broken vision call must leave the séance usable, not kill the socket.""" + + async def exploding_scry(*a, **k): + raise RuntimeError("the model fell over") + + monkeypatch.setattr(app.ws.spirit_service, "scry", exploding_scry, raising=False) + monkeypatch.setattr(app.ws, "summon_limiter", RateLimiter(max_requests=100, window_seconds=60)) + monkeypatch.setattr( + app.ws, "summon_ip_limiter", RateLimiter(max_requests=100, window_seconds=60) + ) + _login(sync_client, "scryer-broken") + + with _ws_connect(sync_client, sync_client.cookies.get("qm_session")) as ws: + _read_until(ws, "session") + ws.send_json({"type": "summon"}) + _read_until(ws, "entity") + + ws.send_json({"type": "scry", "image": "ZmFrZQ=="}) + err = _read_until(ws, "error") + assert err["code"] == "scry_failed" + + # Still alive afterwards. + ws.send_json({"type": "ping"}) + assert _read_until(ws, "pong") == {"type": "pong"} + + +@pytest.mark.asyncio +async def test_scry_is_rate_limited(sync_client, monkeypatch): + async def fake_scry(*a, **k): + return "it looks" + + monkeypatch.setattr(app.ws.spirit_service, "scry", fake_scry, raising=False) + monkeypatch.setattr(app.ws, "summon_limiter", RateLimiter(max_requests=100, window_seconds=60)) + monkeypatch.setattr( + app.ws, "summon_ip_limiter", RateLimiter(max_requests=100, window_seconds=60) + ) + monkeypatch.setattr(app.ws, "scry_limiter", RateLimiter(max_requests=1, window_seconds=60)) + _login(sync_client, "scryer-limited") + + with _ws_connect(sync_client, sync_client.cookies.get("qm_session")) as ws: + _read_until(ws, "session") + ws.send_json({"type": "summon"}) + _read_until(ws, "entity") + + ws.send_json({"type": "scry", "image": "ZmFrZQ=="}) + _read_until(ws, "utterance", kind="scry") + + ws.send_json({"type": "scry", "image": "ZmFrZQ=="}) + err = _read_until(ws, "error") + assert err["code"] == "rate_limited" diff --git a/frontend/src/components/CameraPanel.css b/frontend/src/components/CameraPanel.css new file mode 100644 index 0000000..bde4927 --- /dev/null +++ b/frontend/src/components/CameraPanel.css @@ -0,0 +1,105 @@ +/* The lens panel — SeancePage token palette (phosphor #7cffb2, + violet #b26bff, panel rgba(16,16,26,0.85)). */ + +.camera-panel { + display: flex; + flex-direction: column; + gap: 0.7rem; +} + +.camera-frame { + position: relative; + /* 4:3 rather than 16:9: phone rear cameras default closer to 4:3, and a + wider box would letterbox with dead bands on mobile. */ + aspect-ratio: 4 / 3; + width: 100%; + overflow: hidden; + border: 1px solid rgba(124, 255, 178, 0.22); + border-radius: 4px; + background: #07070d; + box-shadow: inset 0 0 26px rgba(0, 0, 0, 0.7); +} + +.camera-video { + width: 100%; + height: 100%; + /* cover, not contain: a partly-filled frame reads as a broken preview. */ + object-fit: cover; + display: block; + /* Slight desaturation so a bright living room doesn't blow out the + surrounding séance UI. */ + filter: saturate(0.82) contrast(1.06); +} + +/* Covers the video whenever the lens is closed, so there is never any doubt + about whether the camera is live. */ +.camera-veil { + position: absolute; + inset: 0; + display: grid; + place-items: center; + background: + radial-gradient(ellipse at center, rgba(178, 107, 255, 0.09), transparent 65%), + #07070d; +} + +.camera-veil-sigil { + font-size: 2.4rem; + color: rgba(178, 107, 255, 0.55); + text-shadow: 0 0 18px rgba(178, 107, 255, 0.5); + animation: cameraVeilPulse 3.4s ease-in-out infinite; +} + +@keyframes cameraVeilPulse { + 0%, + 100% { + opacity: 0.5; + } + 50% { + opacity: 0.95; + } +} + +.camera-privacy { + margin: 0; + font-size: 0.7rem; + line-height: 1.5; + letter-spacing: 0.02em; +} + +.camera-actions { + display: flex; + flex-wrap: wrap; + gap: 0.45rem; +} + +.camera-look { + /* The one action that actually transmits — marked out in violet so it + never blends into the passive controls beside it. */ + border-color: rgba(178, 107, 255, 0.6); + color: #ecdcff; +} + +.camera-look:hover:not(:disabled) { + box-shadow: 0 0 14px rgba(178, 107, 255, 0.35); +} + +.camera-hint { + margin: 0; + font-size: 0.72rem; +} + +@media (max-width: 560px) { + .camera-actions button { + /* 44px touch targets, and the buttons share the row evenly rather than + wrapping one-per-line. */ + flex: 1 1 auto; + min-height: 44px; + } +} + +@media (prefers-reduced-motion: reduce) { + .camera-veil-sigil { + animation: none; + } +} diff --git a/frontend/src/components/CameraPanel.tsx b/frontend/src/components/CameraPanel.tsx new file mode 100644 index 0000000..8dfaf7c --- /dev/null +++ b/frontend/src/components/CameraPanel.tsx @@ -0,0 +1,167 @@ +// The lens: the seeker's camera as a channel the dead can look through. +// +// Two states kept deliberately separate, because conflating them would be a +// privacy lie: the camera being OPEN (a live preview, visible only on this +// page) and the entity being SHOWN a frame (one still, sent once). Opening +// the lens never transmits anything. Only "let it look" does, and only the +// single frame captured at that instant. + +import { useCallback, useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useSeance } from '../state/seance' +import { + CameraEye, + classifyFailure, + isSupported, + toBase64, + type CameraFailure, +} from '../lib/camera' +import './CameraPanel.css' + +export function CameraPanel() { + const { state, scry } = useSeance() + const { t } = useTranslation() + const supported = isSupported() + + const videoRef = useRef(null) + const eyeRef = useRef(null) + const mountedRef = useRef(true) + + const [open, setOpen] = useState(false) + const [busy, setBusy] = useState(false) + const [failure, setFailure] = useState(null) + const [facing, setFacing] = useState<'user' | 'environment'>('environment') + + // Always release the camera on unmount — a mode switch or leaving the page + // must never leave the recording indicator lit. + useEffect( + () => () => { + mountedRef.current = false + eyeRef.current?.close() + eyeRef.current = null + }, + [], + ) + + const openLens = useCallback( + async (which: 'user' | 'environment') => { + const video = videoRef.current + if (!video || busy) return + setBusy(true) + setFailure(null) + const eye = eyeRef.current ?? new CameraEye() + eyeRef.current = eye + try { + eye.close() // switching lens: drop the old stream first + await eye.open(video, which) + if (!mountedRef.current) { + eye.close() + return + } + setOpen(true) + setFacing(which) + } catch (err) { + eye.close() + if (mountedRef.current) { + setFailure(classifyFailure(err)) + setOpen(false) + } + } finally { + if (mountedRef.current) setBusy(false) + } + }, + [busy], + ) + + const closeLens = useCallback(() => { + eyeRef.current?.close() + setOpen(false) + }, []) + + const look = useCallback(() => { + const video = videoRef.current + const eye = eyeRef.current + if (!video || !eye || !open) return + const dataUrl = eye.capture(video) + if (!dataUrl) return + scry(toBase64(dataUrl)) + }, [open, scry]) + + if (!supported) { + return ( +
+

{t('seance.camera.fail.insecureTitle')}

+

{t('seance.camera.fail.insecure')}

+
+ ) + } + + return ( +
+
+ {/* Kept mounted even while closed so the ref exists before open(). */} +
+ +

{t('seance.camera.privacy')}

+ +
+ {!open ? ( + + ) : ( + <> + + + + + )} +
+ + {open && !state.entity && ( +

{t('seance.camera.needEntity')}

+ )} + + {failure && ( +
+

{t(`seance.camera.fail.${failure}Title`)}

+

{t(`seance.camera.fail.${failure}`)}

+
+ )} +
+ ) +} diff --git a/frontend/src/i18n/coverage-check.mjs b/frontend/src/i18n/coverage-check.mjs index 653ae91..94e78c4 100644 --- a/frontend/src/i18n/coverage-check.mjs +++ b/frontend/src/i18n/coverage-check.mjs @@ -33,8 +33,8 @@ const RULES = [ ['devices.dashboard.connection.', ['connecting', 'open', 'unstable', 'closed']], ['seance.status.', ['attuning', 'summoning', 'gathering']], ['seance.toast.', ['rate_limited', 'veil_crowded']], // other codes fall back to toast.message - ['seance.modes.', ['wire', 'evp', 'radio', 'ouija', 'emf']], - ['seance.hints.', ['wire', 'evp', 'radio', 'ouija', 'emf']], + ['seance.modes.', ['wire', 'evp', 'radio', 'ouija', 'emf', 'camera']], + ['seance.hints.', ['wire', 'evp', 'radio', 'ouija', 'emf', 'camera']], ['seance.conditions.moon.', [ 'new_moon', 'waxing_crescent', 'first_quarter', 'waxing_gibbous', 'full_moon', 'waning_gibbous', 'last_quarter', 'waning_crescent', @@ -43,6 +43,7 @@ const RULES = [ ['seance.views.', ['board', 'matrix', 'graphs']], // Base causes only — the checker derives the matching *Title keys itself. ['seance.evp.fail.', ['denied', 'insecure', 'absent', 'busy']], + ['seance.camera.fail.', ['denied', 'insecure', 'absent', 'busy', 'unknown']], ['seance.entity.rarity.', RARITY], ['codex.rarity.', RARITY], ['codex.sort.', ['recent', 'contacted']], diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json index a829006..eacaeec 100644 --- a/frontend/src/i18n/en.json +++ b/frontend/src/i18n/en.json @@ -120,7 +120,8 @@ "evp": "EVP", "radio": "Spirit Radio", "ouija": "Ouija", - "emf": "FIELD" + "emf": "FIELD", + "camera": "LENS" }, "passive": "passive listening", "summon": "SUMMON", @@ -165,7 +166,8 @@ "evp": "the veil listens through your microphone — grant it your ear", "radio": "a tuner in the hand hears further than the wire", "ouija": "ask below, or let the board drift", - "emf": "hold still; the field remembers movement" + "emf": "hold still; the field remembers movement", + "camera": "a lens shows the dead your room — they will name what they see, and mistake it for something they lost." }, "conditions": { "title": "VEIL CONDITIONS", @@ -354,6 +356,28 @@ "note": "your contacts fade with the mist —", "claim": "claim a name to keep your codex", "dismiss": "let the mist take it" + }, + "camera": { + "previewLabel": "live view from your camera", + "privacy": "nothing leaves this page until you let it look — then one still frame is shown to the presence, and kept by no one.", + "open": "open the lens", + "opening": "opening the lens…", + "close": "close the lens", + "flip": "turn the lens", + "look": "let it look", + "needEntity": "summon a presence first — there is no one here to look.", + "fail": { + "deniedTitle": "the lens stays covered", + "denied": "This vessel refused the camera. Grant camera permission in your browser's site settings, then open the lens again.", + "insecureTitle": "unconsecrated ground", + "insecure": "The dead will not look through an unsecured lens. Open this site by its https address — or over localhost — and try again.", + "absentTitle": "no lens to look through", + "absent": "This vessel has no camera the veil can find. Attach one, or turn to another channel.", + "busyTitle": "the lens is already in use", + "busy": "Another rite already holds the camera. Close whatever else is watching, then open the lens again.", + "unknownTitle": "the lens will not open", + "unknown": "Something refused the camera and would not say what. Try again, or turn to another channel." + } } }, "codex": { diff --git a/frontend/src/i18n/es.json b/frontend/src/i18n/es.json index d3f84f7..5b92a8c 100644 --- a/frontend/src/i18n/es.json +++ b/frontend/src/i18n/es.json @@ -120,7 +120,8 @@ "evp": "EVP", "radio": "Radio Espíritu", "ouija": "Ouija", - "emf": "CAMPO" + "emf": "CAMPO", + "camera": "LENTE" }, "passive": "escucha pasiva", "summon": "INVOCAR", @@ -165,7 +166,8 @@ "evp": "el velo escucha a través de tu micrófono — préstale tu oído", "radio": "un sintonizador en la mano oye más lejos que el cable", "ouija": "pregunta abajo, o deja que el tablero derive", - "emf": "quédate quieto; el campo recuerda el movimiento" + "emf": "quédate quieto; el campo recuerda el movimiento", + "camera": "una lente muestra a los muertos tu habitación — nombrarán lo que ven, y lo confundirán con algo que perdieron." }, "conditions": { "title": "CONDICIONES DEL VELO", @@ -354,6 +356,28 @@ "note": "tus contactos se desvanecen con la niebla —", "claim": "reclama un nombre para conservar tu códice", "dismiss": "deja que la niebla se lo lleve" + }, + "camera": { + "previewLabel": "vista en directo de tu cámara", + "privacy": "nada sale de esta página hasta que dejes que mire — entonces se muestra un solo fotograma a la presencia, y nadie lo conserva.", + "open": "abrir la lente", + "opening": "abriendo la lente…", + "close": "cerrar la lente", + "flip": "girar la lente", + "look": "deja que mire", + "needEntity": "invoca primero a una presencia — aquí no hay nadie que mire.", + "fail": { + "deniedTitle": "la lente permanece cubierta", + "denied": "Este recipiente rechazó la cámara. Concede permiso de cámara en los ajustes del navegador y abre la lente de nuevo.", + "insecureTitle": "tierra sin consagrar", + "insecure": "Los muertos no mirarán por una lente insegura. Abre este sitio por su dirección https — o desde localhost — e inténtalo otra vez.", + "absentTitle": "ninguna lente por la que mirar", + "absent": "Este recipiente no tiene cámara que el velo pueda hallar. Conecta una, o acude a otro canal.", + "busyTitle": "la lente ya está en uso", + "busy": "Otro rito ya sostiene la cámara. Cierra lo que esté observando y abre la lente de nuevo.", + "unknownTitle": "la lente no se abre", + "unknown": "Algo rechazó la cámara y no dijo qué. Inténtalo de nuevo, o acude a otro canal." + } } }, "codex": { diff --git a/frontend/src/lib/camera.test.ts b/frontend/src/lib/camera.test.ts new file mode 100644 index 0000000..836d268 --- /dev/null +++ b/frontend/src/lib/camera.test.ts @@ -0,0 +1,167 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + CAPTURE_MAX_EDGE, + CameraEye, + classifyFailure, + isSupported, + toBase64, +} from './camera' + +afterEach(() => { + vi.unstubAllGlobals() +}) + +/** getUserMedia whose resolution we control, with track-stop spies. */ +function stubCamera() { + const stopped: string[] = [] + const tracks = [{ stop: () => stopped.push('video') }] + let release: (() => void) | null = null + const pending = new Promise((resolve) => { + release = () => resolve({ getTracks: () => tracks } as unknown as MediaStream) + }) + vi.stubGlobal('navigator', { mediaDevices: { getUserMedia: () => pending } }) + return { stopped, release: () => release!() } +} + +function fakeVideo(w = 1920, h = 1080) { + return { + videoWidth: w, + videoHeight: h, + srcObject: null as unknown, + muted: false, + playsInline: false, + play: () => Promise.resolve(), + } as unknown as HTMLVideoElement +} + +describe('classifyFailure', () => { + it('distinguishes a real refusal from every other cause', () => { + vi.stubGlobal('navigator', { mediaDevices: { getUserMedia: () => undefined } }) + expect(classifyFailure(new DOMException('x', 'NotAllowedError'))).toBe('denied') + expect(classifyFailure(new DOMException('x', 'SecurityError'))).toBe('insecure') + expect(classifyFailure(new DOMException('x', 'NotFoundError'))).toBe('absent') + expect(classifyFailure(new DOMException('x', 'NotReadableError'))).toBe('busy') + // An unrecognised error must not be reported as a refusal the seeker made. + expect(classifyFailure(new Error('who knows'))).toBe('unknown') + }) + + it('reports an unsupported context as insecure rather than denied', () => { + vi.stubGlobal('navigator', {}) + expect(isSupported()).toBe(false) + expect(classifyFailure(new DOMException('x', 'NotAllowedError'))).toBe('insecure') + }) +}) + +describe('toBase64', () => { + it('strips the data-URL prefix Ollama does not want', () => { + expect(toBase64('data:image/jpeg;base64,AAAA')).toBe('AAAA') + }) + + it('passes through a string that is already bare base64', () => { + expect(toBase64('AAAA')).toBe('AAAA') + }) +}) + +describe('CameraEye lifecycle', () => { + it('releases the camera when closed while the permission prompt is open', async () => { + // The hazard: the stream is only assigned after the await, so a close() + // during the prompt would otherwise release nothing and the camera would + // go live *after* teardown, leaving the recording light on. + const { stopped, release } = stubCamera() + const eye = new CameraEye() + const video = fakeVideo() + + const opening = eye.open(video) + eye.close() // seeker switches mode mid-prompt + release() + await opening + + expect(stopped).toEqual(['video']) + expect(eye.isOpen).toBe(false) + }) + + it('opens normally when nobody interrupts', async () => { + const { stopped, release } = stubCamera() + const eye = new CameraEye() + const video = fakeVideo() + + const opening = eye.open(video) + release() + await opening + + expect(eye.isOpen).toBe(true) + expect(stopped).toEqual([]) + // iOS Safari refuses to start a stream without both of these. + expect(video.muted).toBe(true) + expect(video.playsInline).toBe(true) + + eye.close() + expect(stopped).toEqual(['video']) + expect(eye.isOpen).toBe(false) + }) + + it('captures nothing when the lens is closed', () => { + const eye = new CameraEye() + expect(eye.capture(fakeVideo())).toBeNull() + }) + + it('captures nothing before video metadata arrives', async () => { + const { release } = stubCamera() + const eye = new CameraEye() + const video = fakeVideo(0, 0) // dimensions not known yet + const opening = eye.open(video) + release() + await opening + expect(eye.capture(video)).toBeNull() + }) + + it('downscales a large frame so the payload stays small', async () => { + const { release } = stubCamera() + const eye = new CameraEye() + const video = fakeVideo(1920, 1080) + + // Record what the canvas was sized to. + const sizes: Array<{ w: number; h: number }> = [] + const canvas = { + width: 0, + height: 0, + getContext: () => ({ drawImage: () => undefined }), + toDataURL: () => { + sizes.push({ w: canvas.width, h: canvas.height }) + return 'data:image/jpeg;base64,ZZZZ' + }, + } + vi.stubGlobal('document', { createElement: () => canvas }) + + const opening = eye.open(video) + release() + await opening + + expect(eye.capture(video)).toBe('data:image/jpeg;base64,ZZZZ') + expect(sizes).toHaveLength(1) + // Longest edge clamped, aspect ratio preserved. + expect(sizes[0].w).toBe(CAPTURE_MAX_EDGE) + expect(sizes[0].h).toBe(Math.round((1080 / 1920) * CAPTURE_MAX_EDGE)) + }) + + it('never upscales a frame smaller than the cap', async () => { + const { release } = stubCamera() + const eye = new CameraEye() + const video = fakeVideo(320, 240) + const canvas = { + width: 0, + height: 0, + getContext: () => ({ drawImage: () => undefined }), + toDataURL: () => 'data:image/jpeg;base64,S', + } + vi.stubGlobal('document', { createElement: () => canvas }) + + const opening = eye.open(video) + release() + await opening + eye.capture(video) + + expect(canvas.width).toBe(320) + expect(canvas.height).toBe(240) + }) +}) diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index b473d9b..3c0d001 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -1,7 +1,7 @@ // Shared protocol + domain types for the Quantumancy frontend. // Mirrors the backend contract exactly — do not invent changes. -export type Mode = 'wire' | 'evp' | 'radio' | 'ouija' | 'emf' +export type Mode = 'wire' | 'evp' | 'radio' | 'ouija' | 'emf' | 'camera' export type Language = 'en' | 'es' export type Rarity = 'common' | 'uncommon' | 'rare' | 'mythic' export type GhostForm = 'wisp' | 'banshee' | 'fairy' | 'shade' @@ -104,6 +104,9 @@ export type ClientFrame = | { type: 'ritual_start' } | { type: 'ritual_step'; step: number } | { type: 'judgment'; verdict: JudgmentVerdict } + // Base64 JPEG (no data-URL prefix) of a single camera frame, captured + // only on an explicit act. Never stored server-side. + | { type: 'scry'; image: string } // ---- WebSocket frames: server -> client ---- @@ -117,7 +120,14 @@ export type Telemetry = { // 'manifest' is unprompted speech — the entity speaking with no // question asked, pulled through by a shift in the room. See // backend SpiritService.manifest(). -export type UtteranceKind = 'greeting' | 'fragment' | 'ambient' | 'reply' | 'manifest' +// 'scry' is the entity speaking about what the seeker's camera shows. +export type UtteranceKind = + | 'greeting' + | 'fragment' + | 'ambient' + | 'reply' + | 'manifest' + | 'scry' export type ServerFrame = | { type: 'session'; id: string } diff --git a/frontend/src/pages/SeancePage.css b/frontend/src/pages/SeancePage.css index 5d4578b..bb8b779 100644 --- a/frontend/src/pages/SeancePage.css +++ b/frontend/src/pages/SeancePage.css @@ -1093,6 +1093,26 @@ opacity: 0.75; } +/* Scried speech: the entity describing what it actually saw through the + lens. Given the violet of the camera's own transmit control, so the + connection between "I let it look" and "this is what it said" is visible + at a glance. Distinct from kind-manifest: that arrives unbidden, this was + asked for. */ +.tx-utterance.kind-scry { + border-left: 2px solid rgba(178, 107, 255, 0.45); + padding-left: 0.6rem; + margin-left: -0.2rem; +} + +.tx-utterance.kind-scry .tx-text::before { + content: '◉ '; + color: rgba(178, 107, 255, 0.8); +} + +.tx-utterance.kind-scry .tx-text { + color: #e8dcff; +} + /* Unprompted speech: nobody asked for this. Treated as an intrusion rather than a reply — a violet edge marks it as arriving from outside the conversation, and it sits at full opacity (unlike the deliberately diff --git a/frontend/src/pages/SeancePage.tsx b/frontend/src/pages/SeancePage.tsx index ecb67e8..7d0c787 100644 --- a/frontend/src/pages/SeancePage.tsx +++ b/frontend/src/pages/SeancePage.tsx @@ -29,6 +29,7 @@ import { JudgmentPanel } from '../components/JudgmentPanel' import { TelemetryReadout } from '../components/TelemetryReadout' import { VeilConditions } from '../components/VeilConditions' import { ModeHint } from '../components/ModeHint' +import { CameraPanel } from '../components/CameraPanel' import { PlanchetteMachine } from '../lib/planchette' import type { PlanchetteSnapshot } from '../lib/planchette' import { EvpListener } from '../lib/evp' @@ -53,9 +54,9 @@ import { persistLanguage, storedLanguage } from '../i18n' import type { Language, Mode } from '../lib/types' import './SeancePage.css' -const MODES: readonly Mode[] = ['wire', 'evp', 'radio', 'ouija', 'emf'] +const MODES: readonly Mode[] = ['wire', 'evp', 'radio', 'ouija', 'emf', 'camera'] // Fallback tab labels for modes the i18n catalogs don't carry yet. -const MODE_LABEL_DEFAULTS: Partial> = { emf: 'FIELD' } +const MODE_LABEL_DEFAULTS: Partial> = { emf: 'FIELD', camera: 'LENS' } // Board-area views; labels fall back to these until the i18n JSONs land. const VIEWS = [ @@ -421,6 +422,7 @@ function SeanceSession({ username }: { username: string }) { {state.mode === 'radio' && } {state.mode === 'ouija' && } {state.mode === 'emf' && } + {state.mode === 'camera' && } diff --git a/frontend/src/state/seance.tsx b/frontend/src/state/seance.tsx index 5ab41ff..072a712 100644 --- a/frontend/src/state/seance.tsx +++ b/frontend/src/state/seance.tsx @@ -523,6 +523,8 @@ export type SeanceApi = { startRitual: () => void sendRitualStep: (step: number) => void sendJudgment: (verdict: JudgmentVerdict) => void + /** Show the entity one camera frame (raw base64 JPEG). Never stored. */ + scry: (imageBase64: string) => void } // Exported so component tests can render against a hand-built SeanceApi @@ -691,6 +693,10 @@ export function SeanceProvider({ children }: { children: ReactNode }) { socketRef.current?.send({ type: 'judgment', verdict }) }, []) + const scry = useCallback((imageBase64: string) => { + socketRef.current?.send({ type: 'scry', image: imageBase64 }) + }, []) + const api = useMemo( () => ({ state, @@ -708,6 +714,7 @@ export function SeanceProvider({ children }: { children: ReactNode }) { startRitual, sendRitualStep, sendJudgment, + scry, }), [ state, @@ -722,6 +729,7 @@ export function SeanceProvider({ children }: { children: ReactNode }) { startRitual, sendRitualStep, sendJudgment, + scry, ], )