feat: the lens — the camera as a channel the dead look through
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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']],
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user