From d37bb71e5d0f453035a0249491756a5504f13f39 Mon Sep 17 00:00:00 2001 From: Indiana Date: Thu, 30 Jul 2026 02:08:02 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20the=20lens=20=E2=80=94=20the=20camera?= =?UTF-8?q?=20as=20a=20channel=20the=20dead=20look=20through?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new séance mode. The seeker opens their camera, presses "let it look", and the entity speaks about what is ACTUALLY in the room — the configured chat model (minicpm-v4.5:8b) is vision-capable, so this is real perception, not invented description. Same principle as every other channel here: real measurement first, interpretation second. Verified live end-to-end through the real WebSocket: given a synthetic room (pale doorway, red flame on dark boards), "Bessie L. Carter" reported the gray rectangle and red square on a dark surface with faint shadows, then misread it as her pen feeling heavy the night before Mr. Edgerton's birdseed arrived. Accuracy followed by wrongness, which is the whole effect. Privacy is the load-bearing design constraint, not a footnote: - "Camera open" and "the entity saw something" are deliberately separate states. Opening the lens transmits NOTHING; only an explicit press sends one still. There is no timer and no background capture path. - Frames are downscaled to 768px and JPEG-compressed client-side, then passed to the model and dropped. Never written to disk, never logged, never attached to an event row — only the resulting utterance is stored, exactly like any other thing a spirit says. - The prompt forbids describing faces or guessing anyone's identity, age or appearance; a person present is spoken of only as a presence. - A closed lens is covered by an opaque veil in the UI, so there is never ambiguity about whether the camera is live. Robustness: - CameraEye carries the same generation guard the EVP listener needed: closing during the permission prompt releases the late-arriving stream instead of letting the camera go live after teardown. - Failures are classified (denied / insecure / absent / busy / unknown) rather than always blaming the seeker for a refusal. - Scrying is the heaviest request this app makes of a CPU-only Ollama box, so it gets the tightest limiter of any channel (4/min/user, 8/min/IP). - Frames are size-capped BEFORE reaching the queue, and a vision failure emits an error frame instead of killing the socket — both covered by tests asserting the model was never called. 10 new frontend tests, 5 new backend tests. 385 frontend + backend suites pass; i18n parity holds across both languages. Co-Authored-By: Claude Opus 5 --- backend/app/ws.py | 86 +++++++++++- backend/tests/test_ws_session.py | 136 +++++++++++++++++++ frontend/src/components/CameraPanel.css | 105 +++++++++++++++ frontend/src/components/CameraPanel.tsx | 167 ++++++++++++++++++++++++ frontend/src/i18n/coverage-check.mjs | 5 +- frontend/src/i18n/en.json | 28 +++- frontend/src/i18n/es.json | 28 +++- frontend/src/lib/camera.test.ts | 167 ++++++++++++++++++++++++ frontend/src/lib/types.ts | 14 +- frontend/src/pages/SeancePage.css | 20 +++ frontend/src/pages/SeancePage.tsx | 6 +- frontend/src/state/seance.tsx | 8 ++ 12 files changed, 759 insertions(+), 11 deletions(-) create mode 100644 frontend/src/components/CameraPanel.css create mode 100644 frontend/src/components/CameraPanel.tsx create mode 100644 frontend/src/lib/camera.test.ts 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, ], )